diff --git a/htmx/README.md b/htmx/README.md new file mode 100644 index 0000000..c029605 --- /dev/null +++ b/htmx/README.md @@ -0,0 +1,26 @@ +This is a demo project for htmx, flask and babel, so this readme contains +mostly random notes about its construction and maintenance. + +Localization +============ + +I followed the tutorial at https://phrase.com/blog/posts/python-localization-flask-applications/ + +To extract messages, use + +``` +pybabel extract -F babel.cfg -o messages.pot . +``` + +The following creates a new translation file and may be a bad idea if you +already have one: + +``` +pybabel init -i messages.pot -d translations -l de +``` + +Compile the translation file(s): + +``` +pybabel compile -d translations +``` diff --git a/htmx/app.py b/htmx/app.py new file mode 100644 index 0000000..57ed5b5 --- /dev/null +++ b/htmx/app.py @@ -0,0 +1,197 @@ +import logging +import uuid +import psycopg2 +import psycopg2.extras + +from flask import Flask, request, render_template, render_template_string, session, g +from flask_babel import Babel, _ + +logging.basicConfig(format="%(asctime)s %(levelname)s %(name)s %(lineno)d | %(message)s", level=logging.DEBUG) + +app = Flask(__name__) +babel = Babel(app) +app.secret_key = b"1234" # XXX - Should be in config (not in source, but stable across restarts) + +log = logging.getLogger(__name__) + +# Routes +@app.route("/") +def home(): + log.info("in home") + app.logger.info("in home (via app.logger)") + log.info("greeting = %s", _("hello")) + user_public_id = None + if "uuid" not in session: + session["uuid"], user_public_id = new_user() + if not user_public_id: + csr = get_cursor() + csr.execute("select * from users where uuid = %s", (session["uuid"],)) + user_public_id = csr.fetchone().public_id # Can this fail? + + log.info("session = %s", session) + return render_template("home.html", public_id=user_public_id) + +@app.route("/entry_form", methods=["POST"]) +def entry_form(): + log.info("session = %s", session) + user_uuid = session["uuid"] + log.info("user_uuid = %s", user_uuid) + csr = get_cursor() + csr.execute( + "select * from country_names natural join countries where lang=%s order by population desc", + (get_locale(),)) + f = "" + for r in csr.fetchall(): + csr.execute( + "select * from entries where uuid = %s and country = %s", + (user_uuid, r.code,)) + f += render_template("entry_row.html", code=r.code, name=r.name, entries=csr.fetchall()) + return f + +@app.route("/update_entries", methods=["POST"]) +def update_entries(): + csr = get_cursor() + log.debug(request.form) + csr = get_cursor() + r = "" + for k, v in request.form.items(): + (band, code, id) = k.split("_") + if id == "new": + csr.execute( + "insert into entries(country, uuid, band) values(%s, %s, %s) returning id", + (code, session["uuid"], v,)) + id = csr.fetchone().id + extra = render_template_string( + """""", + code=code) + else: + csr.execute( + "update entries set band = %s where id = %s and uuid = %s", + (v, id, session["uuid"],)) + extra = "" + r += render_template_string( + """""", + code=code, id=id, band=v) + r += extra + return r + +@app.route("/result/") +def result(public_id): + + lang = get_locale() + with get_dict_cursor() as csr: + csr.execute( + """ + select country, name, + band, + count(*) as total, + count(*) filter(where public_id = %s) as by_user + from entries e + join countries c on c.code = e.country + join country_names cn on e.country = cn.code and cn.lang = %s + join users u using (uuid) + group by country, name, population, band + order by population desc, name, band + """, + (public_id, lang,)) + country_stats = [] + for r in csr: + if not country_stats or country_stats[-1]["code"] != r["country"]: + country_stats.append({"code": r["country"], + "name": r["name"], + "bands": []}) + country_stats[-1]["bands"].append({"name": r["band"], + "total_count": r["total"], + "by_user": bool(r["by_user"])}) + + stats = { "countries": country_stats } + csr.execute( + """ + with a as ( + select public_id, count(distinct(country)) as c + from entries join users using (uuid) + group by 1) + select public_id, c, rank() over (order by c desc) from a; + """ + ) + for r in csr: + if r["public_id"] == public_id: + stats["country_rank"] = r["rank"] + stats["country_count"] = r["c"] + stats["user_count"] = csr.rowcount + + csr.execute( + """ + with a as ( + select public_id, count(*) as c + from entries join users using (uuid) + group by 1) + select public_id, c, rank() over (order by c desc) from a; + """ + ) + for r in csr: + if r["public_id"] == public_id: + # XXX - clobbering previous results? + stats["band_rank"] = r["rank"] + stats["band_count"] = r["c"] + + ask_mail = session["uuid"].endswith(public_id) + log.debug("uuid = %s, public_id=%s, ask_mail=%s", session["uuid"], public_id, ask_mail) + return render_template("result.html", public_id=public_id, stats=stats, ask_mail=ask_mail) + +@app.route("/add-email", methods=["POST"]) +def add_email(): + csr = get_cursor() + csr.execute("update users set email=%s where uuid = %s", (request.form["email"], session["uuid"],)) + if csr.rowcount: + return render_template_string(_("Saved. Expect mail at {{email}}"), + email=request.form["email"]) + else: + return _("You don't exist. Go away!") + +# Middleware + +@babel.localeselector +def get_locale(): + lang = request.accept_languages.best_match(["de", "en"]) + log.info("get_locale: best language is %s", lang) + return lang + +# Helpers + +def get_dict_cursor(): + db = get_db() + csr = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + return csr + +def get_cursor(): + db = get_db() + csr = db.cursor(cursor_factory=psycopg2.extras.NamedTupleCursor) + return csr + +def get_db(): + if "db" not in g: + g.db = psycopg2.connect("dbname=internationale") + return g.db + +@app.teardown_appcontext +def teardown_db(exception): + log.info("in teardown_db: exception = %s", exception) + db = g.pop('db', None) + if db is not None: + if not exception: + try: + db.commit() + except: + pass + db.rollback() + db.close() + +def new_user(): + user_uuid = str(uuid.uuid4()) + user_public_id = user_uuid[-12:] + csr = get_cursor() + csr.execute("insert into users(uuid, public_id) values(%s, %s)", (user_uuid, user_public_id,)) + return user_uuid, user_public_id + +# vim: tw=99 diff --git a/htmx/babel.cfg b/htmx/babel.cfg new file mode 100644 index 0000000..f0234b3 --- /dev/null +++ b/htmx/babel.cfg @@ -0,0 +1,3 @@ +[python: **.py] +[jinja2: **/templates/**.html] +extensions=jinja2.ext.autoescape,jinja2.ext.with_ diff --git a/htmx/messages.pot b/htmx/messages.pot new file mode 100644 index 0000000..7da1d0c --- /dev/null +++ b/htmx/messages.pot @@ -0,0 +1,65 @@ +# Translations template for PROJECT. +# Copyright (C) 2022 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2022. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2022-03-20 23:24+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" + +#: app.py:22 +msgid "hello" +msgstr "" + +#: app.py:147 +msgid "Saved. Expect mail at {{email}}" +msgstr "" + +#: app.py:150 +msgid "You don't exist. Go away!" +msgstr "" + +#: templates/home.html:6 templates/result.html:6 +msgid "The Musical Internatiionale" +msgstr "" + +#: templates/home.html:20 +msgid "" +"All the countries in the world — which bands or solo musicians from these" +" countries do you know?" +msgstr "" + +#: templates/home.html:32 +msgid "Show" +msgstr "" + +#: templates/result.html:15 +#, python-format +msgid "" +"You have entered %(band_count)s bands or artists from %(country_count)s " +"countries." +msgstr "" + +#: templates/result.html:18 +#, python-format +msgid "" +"This puts you at rank %(band_rank)s and %(country_rank)s of " +"%(user_count)s, respectively." +msgstr "" + +#: templates/result.html:37 +msgid "" +"You can leave your email address here if you want to be informed about " +"future developments" +msgstr "" + diff --git a/htmx/requirements.txt b/htmx/requirements.txt new file mode 100644 index 0000000..d05211a --- /dev/null +++ b/htmx/requirements.txt @@ -0,0 +1,2 @@ +Flask +Flask-Babel diff --git a/htmx/static/MontserratAlternates-Bold.ttf b/htmx/static/MontserratAlternates-Bold.ttf new file mode 100644 index 0000000..6ed08dc Binary files /dev/null and b/htmx/static/MontserratAlternates-Bold.ttf differ diff --git a/htmx/static/MontserratAlternates-Light.ttf b/htmx/static/MontserratAlternates-Light.ttf new file mode 100644 index 0000000..75c08f0 Binary files /dev/null and b/htmx/static/MontserratAlternates-Light.ttf differ diff --git a/htmx/static/MontserratAlternates-LightItalic.ttf b/htmx/static/MontserratAlternates-LightItalic.ttf new file mode 100644 index 0000000..a5080ce Binary files /dev/null and b/htmx/static/MontserratAlternates-LightItalic.ttf differ diff --git a/htmx/static/MontserratAlternates-Medium.ttf b/htmx/static/MontserratAlternates-Medium.ttf new file mode 100644 index 0000000..5cf21e6 Binary files /dev/null and b/htmx/static/MontserratAlternates-Medium.ttf differ diff --git a/htmx/static/MontserratAlternates-MediumItalic.ttf b/htmx/static/MontserratAlternates-MediumItalic.ttf new file mode 100644 index 0000000..78afa6a Binary files /dev/null and b/htmx/static/MontserratAlternates-MediumItalic.ttf differ diff --git a/htmx/static/MontserratAlternates-Regular.ttf b/htmx/static/MontserratAlternates-Regular.ttf new file mode 100644 index 0000000..d3163e7 Binary files /dev/null and b/htmx/static/MontserratAlternates-Regular.ttf differ diff --git a/htmx/static/guitar.png b/htmx/static/guitar.png new file mode 100644 index 0000000..882c2cd Binary files /dev/null and b/htmx/static/guitar.png differ diff --git a/htmx/static/style.css b/htmx/static/style.css new file mode 100644 index 0000000..f3ada21 --- /dev/null +++ b/htmx/static/style.css @@ -0,0 +1,205 @@ +cite { + display: block; +} + +cite::before { + content: "—"; +} + +th { + text-align: right; +} + +main { + display: grid; +} + +#the-quote { + grid-column: 2; +} + +.intro { + grid-column: 1; + grid-row: 1; +} + +#country-list { + grid-column: 1 / 3; + grid-row: 2; + justify-self: left; +} + +#permalink { + grid-column: 2; + grid-row: 1; + margin-left: auto; +} + +#summary { + grid-column: 1; + grid-row: 1; +} + +@media (max-width: 40rem) { + #the-quote { + grid-column: 2; + } + + .intro { + grid-column: 1 / 3; + grid-row: 2; + } + + #country-list { + grid-column: 1 / 3; + grid-row: 3; + } + + #permalink { + grid-column: 2; + grid-row: 1; + margin-left: auto; + } + + #summary { + grid-column: 1 /3; + grid-row: 2; + } +} + +.intro { + margin: 3em; + font-family: MontserratAlternates; + font-weight: 300; + font-size: 1.2rem; + line-height: 1.7rem; +} + +#country-list input { + margin: 0.2em 0.5em; +} + +input:focus { + box-shadow: 0 0 0.2em 0.2em hsl(240 100% 70%); +} + +@font-face { + font-family: MontserratAlternates; + src: url(MontserratAlternates-Light.ttf); + font-weight: 300; +} + +@font-face { + font-family: MontserratAlternates; + src: url(MontserratAlternates-Regular.ttf); + font-weight: 400; +} + +@font-face { + font-family: MontserratAlternates; + src: url(MontserratAlternates-Medium.ttf); + font-weight: 500; +} + +@font-face { + font-family: MontserratAlternates; + src: url(MontserratAlternates-Bold.ttf); + font-weight: 700; +} + +@font-face { + font-family: MontserratAlternates; + src: url(MontserratAlternates-LightItalic.ttf); + font-weight: 300; + font-style: italic; +} + +@font-face { + font-family: MontserratAlternates; + src: url(MontserratAlternates-MediumItalic.ttf); + font-weight: 500; + font-style: italic; +} + +th { + vertical-align: baseline; + font-family: MontserratAlternates; + font-weight: 400; + font-color: #444; +} + +td { + vertical-align: baseline; +} + +body { + background-image: url("guitar.png"); + background-size: contain; + background-repeat: no-repeat; + background-attachment: fixed; + text-shadow: 0 0 1.0em #FFFFFF, + 0 0 0.2em #FFFFFF; + font-family: MontserratAlternates; + font-weight: 300; + line-height: 1.4; +} + +.band { + font-family: MontserratAlternates; + font-weight: 300; + white-space: nowrap; +} +.band.by_user { + font-weight: 500; + font-style: italic; +} + +table { + border-spacing: 1em 0.3em; +} + +#permalink a { + text-decoration: none; + color: #444; + padding: 0.5em; + border: 1px dotted black; +} + +#summary { + margin-left: 3em; +} + +.buttonrow { + display: flex; + justify-content: space-around; +} + +.btn { + margin-top: 2em; + margin-left: auto; + margin-right: auto; + text-decoration: none; + color: #000; + border: solid 1px #888; + background-color: #CCD; + padding: 1em; + border-radius: 0.5em; + box-shadow: 0 0 0.3em 0.2em hsla(0, 0%, 70%, 0.5); + font-weight: 700; + display: inline-block; +} + +.btn:focus, .btn:hover { + background-color: #DDF; + border: solid 1px #88F; + box-shadow: 0 0 0.3em 0.4em hsla(240, 30%, 70%, 0.5); +} + +p { + background-color: #FFFFFF80; +} + +/* + vim: sw=4 + */ + diff --git a/htmx/templates/entry_row.html b/htmx/templates/entry_row.html new file mode 100644 index 0000000..10e08de --- /dev/null +++ b/htmx/templates/entry_row.html @@ -0,0 +1,11 @@ + + + {{name}} + + + {% for e in entries %} + + {% endfor %} + + + diff --git a/htmx/templates/home.html b/htmx/templates/home.html new file mode 100644 index 0000000..649484d --- /dev/null +++ b/htmx/templates/home.html @@ -0,0 +1,41 @@ + + + + + + {{ _("The Musical Internatiionale") }} + + + + + +
+
+ I don't need a rationale
+ to sing the Internationale + + They might be giants + +
+
{{ _("All the countries in the world — which bands or solo musicians from these countries do you know?") }}
+ + + + + + + + + + +
+ + + + {{ _("Show") }} + +
+
+ + + diff --git a/htmx/templates/result.html b/htmx/templates/result.html new file mode 100644 index 0000000..7e787bd --- /dev/null +++ b/htmx/templates/result.html @@ -0,0 +1,44 @@ + + + + + + {{ _("The Musical Internatiionale") }} + + + + + +
+
+

+ {{_("You have entered %(band_count)s bands or artists from %(country_count)s countries.", band_count=stats.band_count, country_count=stats.country_count)}} +

+

+ {{_("This puts you at rank %(band_rank)s and %(country_rank)s of %(user_count)s, respectively.", band_rank=stats.band_rank, country_rank=stats.country_rank, user_count=stats.user_count)}} +

+ + {% for c in stats.countries %} + + + + + {% endfor %} +
+ {{ c.name }} + + {% for b in c.bands %} + {{b.name}}{%if not loop.last%},{%endif%} + {% endfor %} +
+
+ {% if ask_mail %} +

+ {{_("You can leave your email address here if you want to be informed about future developments")}} +
+ +

+ {% endif %} +
+ + diff --git a/htmx/translations/de/LC_MESSAGES/messages.mo b/htmx/translations/de/LC_MESSAGES/messages.mo new file mode 100644 index 0000000..2826bad Binary files /dev/null and b/htmx/translations/de/LC_MESSAGES/messages.mo differ diff --git a/htmx/translations/de/LC_MESSAGES/messages.po b/htmx/translations/de/LC_MESSAGES/messages.po new file mode 100644 index 0000000..cf57a26 --- /dev/null +++ b/htmx/translations/de/LC_MESSAGES/messages.po @@ -0,0 +1,68 @@ +# German translations for PROJECT. +# Copyright (C) 2022 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2022. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2022-02-27 01:46+0100\n" +"PO-Revision-Date: 2022-02-27 01:47+0100\n" +"Last-Translator: Peter J. Holzer \n" +"Language: de\n" +"Language-Team: de \n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" + +#: app.py:23 +msgid "hello" +msgstr "Hallo" + +#: app.py:147 +msgid "Saved. Expect mail at {{email}}" +msgstr "Gespeichert. Du wirst Mail an {{email}} bekommen." + +#: app.py:150 +msgid "You don't exist. Go away!" +msgstr "Du existierst nicht. Verschwinde!" + +#: templates/home.html:6 +msgid "The Musical Internatiionale" +msgstr "Die Musik-Internationale" + +#: templates/home.html:21 +msgid "" +"All the countries in the world — which bands or solo musicians from these" +" countries do you know?" +msgstr "Alle Länder dieser Erde — welche Bands oder Solo-Musiker aus diesen Ländern kennst Du?" + +#: templates/home.html:24 +msgid "Show" +msgstr "Aufdecken" + +#: templates/result.html:15 +#, python-format +msgid "" +"You have entered %(band_count)s bands or artists from %(country_count)s " +"countries." +msgstr "Du hast %(band_count)s Bands oder Solo-Musiker aus " +"%(country_count)s Ländern eingegeben." + +#: templates/result.html:18 +#, python-format +msgid "" +"This puts you at rank %(band_rank)s and %(country_rank)s of " +"%(user_count)s, respectively." +msgstr "Damit bist Du an %(band_rank)s. bzw. %(country_rank)s. Stelle von %(user_count)s." + +#: templates/result.html:37 +msgid "" +"You can leave your email address here if you want to be informed about " +"future developments" +msgstr "Wenn Du über zukünftige Entwicklungen informiert werden willst, " +"kannst Du Deine Mail-Adresse hier hinterlassen:" +