Compare commits

...

6 Commits

Author SHA1 Message Date
Samuel Ortion c9d4c335ab Update 2022-05-27 10:08:25 +02:00
Samuel Ortion 3e80e68da7 Add level 2022-05-26 16:15:21 +02:00
Samuel Ortion a760e2fabc Fix translation 2022-05-26 14:59:28 +02:00
Samuel Ortion 899487245d Update model & translations' 2022-05-26 13:58:46 +02:00
Samuel Ortion 3a8faf6d5f Add migration utility & score table 2022-05-26 10:59:23 +02:00
Samuel Ortion 13e7c35a1a Add base game functionnality 2022-05-26 10:27:26 +02:00
31 changed files with 712 additions and 32 deletions

4
.gitignore vendored
View File

@ -1,3 +1,5 @@
venv
config.py
__pycache__
__pycache__
src_audio/
node_modules

View File

@ -33,11 +33,6 @@ cp config.py.example config.py
## Database migration
```bash
python3
```
```python3
from app import *
app.app_context().push()
db.create_all()
flask db migrate -m "Migration message."
flask db upgrade # Perform migration (after script verification in ./migrations/versions/)
```

95
app.py
View File

@ -4,11 +4,16 @@ from flask import session
from flask import request
from flask import redirect
from flask import url_for
from flask import g
from flask_migrate import Migrate
from flask_babel import Babel, gettext
from werkzeug.security import generate_password_hash, check_password_hash
from config import secret_key, database_uri
from model import db, User
import game as Game
app = Flask(__name__)
app.secret_key = secret_key
@ -16,8 +21,38 @@ app.secret_key = secret_key
app.config['SQLALCHEMY_DATABASE_URI'] = database_uri
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['BABEL_DEFAULT_LOCALE'] = 'en'
app.config['BABEL_TRANSLATION_DIRECTORIES'] = "./language/translations"
babel = Babel(app)
db.init_app(app)
@babel.localeselector
def get_locale():
if not session.get('lang') is None:
return session['lang']
# otherwise try to guess the language from the user accept
# header the browser transmits. We support fr/en in this
# example. The best match wins.
return request.accept_languages.best_match(['fr', 'en'])
@babel.timezoneselector
def get_timezone():
user = getattr(g, 'user', None)
if user is not None:
return user.timezone
app.jinja_env.globals['get_locale'] = get_locale
@app.route('/lang')
def get_lang():
return render_template('lang.html')
@app.route('/lang/<locale>')
def set_lang(locale='en'):
session['lang'] = locale
return redirect('/')
@app.route("/")
def home():
if 'username' in session:
@ -28,17 +63,29 @@ def home():
@app.route("/signup", methods=["GET", "POST"])
def signup():
if request.method == "POST":
message = ""
username = request.form['username']
if username == "":
message += gettext("Username empty. Try to find one.")
email = request.form['email']
if email == "":
message += gettext("Email empty. Please give me one.")
password = request.form['password']
registered_user = User.query.filter_by(username=username).first()
if registered_user is None:
if password == "":
message += gettext("You should not use an empty password")
registered_user_by_username = User.query.filter_by(user_name=username).first()
registered_user_by_email = User.query.filter_by(user_email=email).first()
if registered_user_by_username is None and registered_user_by_email is None:
password_hash = generate_password_hash(password)
registered_user = User(username=username, email=email, password=password_hash)
registered_user = User(user_name=username, user_email=email, user_password=password_hash)
db.session.add(registered_user)
db.session.commit()
else:
return render_template("auth/signup.html", message="Username already used. Try with an other.")
if not registered_user_by_email is None:
message += gettext("Email already used by a user.")
else:
message += gettext("Username already used by a user.")
return render_template("auth/signup.html", message=message)
return redirect(url_for("login"))
elif request.method == "GET":
return render_template("auth/signup.html")
@ -48,11 +95,11 @@ def login():
if request.method == "POST":
username = request.form['username']
password = request.form['password']
user = User.query.filter_by(username=username).first()
user = User.query.filter_by(user_name=username).first()
if user is None:
return render_template("auth/login.html", message="No user with this username already registered")
else:
password_hash = user.password
password_hash = user.user_password
if check_password_hash(password_hash, password):
session["username"] = username
return redirect(url_for("home"))
@ -65,4 +112,38 @@ def login():
def logout():
# Remove username from the session if it's there
session.pop("username", None)
return redirect(url_for("home"))
return redirect(url_for("home"))
# Game routes
@app.route("/game")
def game():
return redirect(url_for('new_game'))
@app.route("/game/new")
def new_game():
if not "username" in session:
return redirect(url_for('login'))
user = User.query.filter_by(user_name = session['username']).first()
if user is None:
return redirect(url_for('login'))
else:
level = str(user.user_level)
question = Game.new_question(level)
session["question"] = question
return render_template("game/question.html", question=question)
@app.route("/game/answer", methods=["POST", "GET"])
def game_answer():
if request.method == "POST":
answer = request.form["answer"]
if answer == session["question"]["species"]:
message = "You are correct !"
else:
message = "You are not correct !"
return render_template("game/answer.html", message=message, question=session["question"])
elif request.method == "GET":
return render_template("game/new.html")
migrate = Migrate(app, db)

3
babel.cfg Normal file
View File

@ -0,0 +1,3 @@
[python: **.py]
[jinja2: templates/**.html]
encoding = utf-8

View File

@ -0,0 +1,120 @@
{
"1": [
"Buse variable",
"Merle noir",
"Pic vert",
"Rougegorge familier",
"Mouette rieuse",
"Canard colvert",
"Pinson des arbres",
"Moineau domestique",
"Pie bavarde",
"Corneille noire",
"Faisan de Colchide"
],
"2": [
"Grive draine",
"Grande Aigrette",
"Grand Cormoran",
"Foulque macroule",
"Hirondelle rustique",
"Fauvette grisette",
"Troglodyte mignon",
"Bergeronnette grise",
"Choucas des tours",
"Pigeon ramier",
"Rougequeue noir",
"Tourterelle turque"
],
"3": [
"Aigrette garzette",
"Grive litorne",
"Accenteur mouchet",
"Bernache du Canada",
"Pipit farlouse",
"Tourterelle des bois",
"Sittelle torchepot",
"Alouette des champs",
"Coucou gris",
"Corbeau freux",
"Martinet noir",
"Bruant zizi"
],
"4": [
"Grimpereau des jardins",
"Grive musicienne",
"Bruant jaune",
"Bergeronnette des ruisseaux",
"Sterne pierregarin",
"Bruant des roseaux",
"Milan noir",
"Grand Corbeau",
"Fuligule morillon"
],
"5": [
"Gobemouche gris",
"Cigogne blanche",
"Alouette lulu",
"Grive mauvis",
"Tarin des aulnes",
"Chouette hulotte",
"Traquet motteux",
"Milan royal",
"Monticole bleu",
"Canard chipeau"
],
"6": [
"Fuligule milouin",
"Chevalier guignette",
"Bouscarle de Cetti",
"Tadorne de Belon",
"Cisticole des joncs",
"Pic noir",
"Nette rousse",
"Petit Gravelot",
"Fauvette des jardins",
"Serin cini"
],
"7": [
"Pic mar",
"Perdrix grise",
"Pinson du Nord",
"Bruant proyer",
"Phragmite des joncs",
"Rousserolle effarvatte",
"Pouillot fitis",
"Faucon hobereau"
],
"8": [
"Fauvette pitchou",
"Busard des roseaux",
"Canard mandarin",
"Spatule blanche",
"Bernache nonnette",
"Torcol fourmilier",
"Effraie des clochers",
"Sittelle corse",
"Perdrix rouge",
"Hirondelle de rochers",
"Hirondelle de rivage"
],
"9": [
"Moineau friquet",
"Bernache cravant",
"Plongeon imbrin",
"Fuligule nyroca",
"Pigeon colombin",
"Grimpereau des bois"
],
"10": [
"Petit-duc scops",
"Chevalier sylvain",
"Chevalier gambette",
"Pipit spioncelle",
"Canard souchet",
"Accenteur alpin",
"Sterne naine",
"Chevalier aboyeur",
"Gobemouche noir"
]
}

33
game.py Normal file
View File

@ -0,0 +1,33 @@
import random
import os
from glob import glob
import json
with open("./data/level_species_cleaned.json", "r") as f:
LEVEL_SPECIES_LIST = json.load(f)
format_name = lambda folder_name : folder_name.replace('_', ' ')
to_folder_name = lambda species_name : species_name.replace(' ', '_').replace('\'', '').lower()
def get_proposals(question_species_name, available_species, n):
proposals = [question_species_name]
for i in range(n):
proposition = random.choice(available_species)
while proposition == question_species_name:
proposition = random.choice(available_species)
proposals.append(proposition)
random.shuffle(proposals)
return proposals
def new_question(level):
available_species = LEVEL_SPECIES_LIST[level]
question_species_name = random.choice(available_species)
question_species_folder = to_folder_name(question_species_name)
question = {}
audio_paths = list(map(os.path.basename, glob(f"static/data/src_audio/{question_species_folder}/*.mp3")))
audio_path = random.choice(audio_paths)
question["species"] = question_species_name
question["species_folder"] = question_species_folder
question["audio_path"] = audio_path
question["proposals"] = get_proposals(question_species_name, available_species, 5)
return question

51
language/message.pot Normal file
View File

@ -0,0 +1,51 @@
# Translations template for PROJECT.
# Copyright (C) 2022 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2022.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2022-05-27 10:03+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.10.1\n"
#: templates/base.html:17
msgid "Welcome"
msgstr ""
#: templates/index.html:3
msgid "Welcome to BirdQuizz !"
msgstr ""
#: templates/menu.html:7
msgid "Home"
msgstr ""
#: templates/menu.html:8
msgid "Game"
msgstr ""
#: templates/menu.html:9
msgid "About"
msgstr ""
#: templates/menu.html:11
msgid "Logout"
msgstr ""
#: templates/menu.html:13
msgid "Login"
msgstr ""
#: templates/menu.html:15
msgid "Identify bird song"
msgstr ""

Binary file not shown.

View File

@ -0,0 +1,79 @@
# French translations for PROJECT.
# Copyright (C) 2022 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2022.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2022-05-27 10:03+0200\n"
"PO-Revision-Date: 2022-05-26 13:10+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: fr\n"
"Language-Team: fr <LL@li.org>\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.10.1\n"
#: templates/base.html:17
msgid "Welcome"
msgstr "Bienvenue"
#: templates/index.html:3
msgid "Welcome to BirdQuizz !"
msgstr "Bienvenue au BirdQuizz !"
#: templates/menu.html:7
msgid "Home"
msgstr "Accueil"
#: templates/menu.html:8
msgid "Game"
msgstr "Jeu"
#: templates/menu.html:9
msgid "About"
msgstr "À propos"
#: templates/menu.html:11
msgid "Logout"
msgstr "Déconnexion"
#: templates/menu.html:13
msgid "Login"
msgstr "Connexion"
#: templates/menu.html:15
msgid "Identify bird song"
msgstr "Indentifie les son d'oiseaux"
#~ msgid "Welcome"
#~ msgstr "Bienvenue"
#~ msgid "Welcome to BirdQuizz !"
#~ msgstr "Bienvenue au BirdQuizz !"
#~ msgid "Home"
#~ msgstr "Accueil"
#~ msgid "Game"
#~ msgstr "Jeu"
#~ msgid "Username empty. Try to find one."
#~ msgstr "Nom d'utilisateur vide. Essayez d'en trouver un."
#~ msgid "Email empty. Please give me one."
#~ msgstr "Email non fourmi. Merci de m'en envoyez un."
#~ msgid "You should not use an empty password"
#~ msgstr "Vous ne devriez pas utiliser un mot de passe vide."
#~ msgid "Email already used by a user."
#~ msgstr "Cet email a déjà été utilisé par quelqu'un."
#~ msgid "Username already used by a user."
#~ msgstr "Ce nom d'utilisateur a déjà été pris."

2
make_migration.sh Normal file
View File

@ -0,0 +1,2 @@
flask db migrate -m "Initial migration."
flask db upgrade

1
migrations/README Normal file
View File

@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View File

@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

91
migrations/env.py Normal file
View File

@ -0,0 +1,91 @@
from __future__ import with_statement
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option(
'sqlalchemy.url',
str(current_app.extensions['migrate'].db.get_engine().url).replace(
'%', '%%'))
target_metadata = current_app.extensions['migrate'].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
connectable = current_app.extensions['migrate'].db.get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -1,11 +1,14 @@
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import declarative_base, relationship
db = SQLAlchemy()
class User(db.Model):
__tablename__ = 'user'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True)
email = db.Column(db.String(64), unique=True, index=True)
password = db.Column(db.String(128))
user_id = db.Column(db.Integer, primary_key=True)
user_name = db.Column(db.String(64), unique=True)
user_email = db.Column(db.String(64), unique=True, index=True)
user_password = db.Column(db.String(128))
user_level = db.Column(db.Integer, default=1)
user_score = db.Column(db.Integer, default=0)

View File

@ -1,12 +1,18 @@
alembic==1.7.7
Babel==2.10.1
click==8.1.3
Flask==2.1.2
Flask-Babel==2.0.0
Flask-Migrate==3.1.0
Flask-SQLAlchemy==2.5.1
greenlet==1.1.2
itsdangerous==2.1.2
Jinja2==3.1.2
Mako==1.2.0
MarkupSafe==2.1.1
mysql-connector-python==8.0.29
protobuf==3.20.1
PyMySQL==1.0.2
pytz==2022.1
SQLAlchemy==1.4.36
Werkzeug==2.1.2

0
static/scripts/app.js Normal file
View File

View File

@ -1,4 +1,7 @@
body {
background-color: black;
color: white;
main {
min-height: 100vh;
}
footer {
padding: 1em;
}

View File

@ -9,11 +9,13 @@
{% endif %}
<form action="/login" method="POST">
<label for="username">Username</label>
<label for="username">{{ _('Username') }}</label>
<input type="text" name="username" id="username">
<label for="password">Password</label>
<label for="password">{{ _('Password') }}</label>
<input type="password" name="password" id="password">
<input type="submit" value="Login">
<input type="submit" value="{{ _('Login') }}">
</form>
<a href="/signup">{{ _('Sign up') }}</a>
{% endblock %}

View File

@ -9,13 +9,13 @@
{% endif %}
<form action="/signup" method="POST">
<label for="username">Username</label>
<label for="username">{{ _('Username') }}</label>
<input type="text" name="username" id="username">
<label for="email">Email</label>
<label for="email">{{ _('Email') }}</label>
<input type="email" name="email" id="email">
<label for="password">Password</label>
<label for="password">{{ _('Password') }}</label>
<input type="password" name="password" id="password">
<input type="submit" value="Sign up">
<input type="submit" value="{{ _('Sign up') }}">
</form>
{% endblock %}

View File

@ -9,9 +9,10 @@
</head>
<body>
<header>
{% include 'menu.html' %}
<h1>BirdQuizz</h1>
{% if username is defined %}
<p>Welcome <span class="username">{{ username }}</span></p>
<p>{{ _('Welcome') }} <span class="username">{{ username }}</span></p>
{% endif %}
</header>
<main>

View File

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block content %}
<p>{{ message }}</p>
<p>
<a href="/game/new">{{ _('New Question') }}</a>
</p>
{% endblock %}

5
templates/game/new.html Normal file
View File

@ -0,0 +1,5 @@
{% extends "base.html" %}
<a href="/game/new" class="button">
{{ _('New Game') }}
</a>

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<audio src="/static/data/src_audio/{{ question['species_folder'] }}/{{ question['audio_path'] }}" controls autoplay></audio>
<form action="/game/answer" method="POST">
<select name="answer" id="answer">
{% for item in question['proposals'] %}
<option value="{{ item }}">{{ item }}</option>
{% endfor %}
</select>
<input type="submit" value="{{ _('Send Answer') }}">
</form>
{% endblock %}

View File

@ -1,5 +1,4 @@
{% extends "base.html" %}
{% block content %}
Welcome to BirdQuizz !
{{ _('Welcome to BirdQuizz !') }}
{% endblock %}

11
templates/lang.html Normal file
View File

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<h2>{{ _('Select your language !') }}</h2>
<form action="/lang" method="POST">
<select name="lang" id="lang">
<option value="fr">Français</option>
<option value="en">English</option>
</select>
<input type="submit" value="{{ _('Translate') }}">
</form>
{% endblock %}

11
templates/menu.html Normal file
View File

@ -0,0 +1,11 @@
<nav>
<ul>
<li><a href="/">{{ _('Home') }}</a></li>
<li><a href="/game">{{ _('Game') }}</a></li>
{% if username is defined %}
<li><a href="/logout">{{ _('Logout') }}</a></li>
{% else %}
<li><a href="/login">{{ _('Login') }}</a></li>
{% endif %}
</ul>
</nav>

33
utils/convert_levels.py Normal file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Update levels species folowing the folder names"""
import json
import glob
import os
SPECIES_FOLDER_NAMES = list(map(os.path.basename, glob.glob("./static/data/src_audio/*")))
def filter(species, condition):
keeped = []
for name in species:
if condition(name):
keeped.append(name)
return keeped
def only_matched_folder(species_name):
folder_like_name = species_name.replace(' ', '_').replace('\'', '')
folder_like_name = folder_like_name.lower()
return folder_like_name in SPECIES_FOLDER_NAMES
def main():
with open("./data/level_species.json", "r") as f:
data = json.load(f)
for level in data:
species_list = data[level]
species_list = filter(species_list, only_matched_folder)
data[level] = species_list
with open("./data/level_species_cleaned.json", "w") as f:
json.dump(data, f, ensure_ascii=False, indent=4)
if __name__ == "__main__":
main()

46
utils/generate_levels.py Normal file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Generate species list for each levels"""
import os
import glob
import json
import math
LEVELS = 10
def load_species_sightings(file):
species_sightings = {}
with open(file, "r") as f:
data = json.load(f)
sightings = data['data']['sightings']
counter = 0
for sighting in sightings:
species = sighting["species"]["name"]
if species in species_sightings:
species_sightings[species] += 1
else:
species_sightings[species] = 1
counter += 1
for species in species_sightings:
species_sightings[species] /= counter * 0.01
return species_sightings
def split_species_list(species_sightings):
level_lists = { level: [] for level in range(1, LEVELS + 1)}
species_sorted = sorted(species_sightings, key = lambda species: -species_sightings[species])
species_number = len(species_sorted)
species_per_level = species_number // LEVELS
species_splitted = [species_sorted[i:i+species_per_level] for i in range(0, species_number, species_per_level)]
for level in range(1, LEVELS + 1):
level_lists[level] = species_splitted[level - 1]
return level_lists
def main():
file = "./data/export_26052022_150619.json"
species_frequency = load_species_sightings(file)
level_species = split_species_list(species_frequency)
with open("./data/level_species.json", "w") as f:
json.dump(level_species, f, ensure_ascii=False, indent=4)
if __name__ == "__main__":
main()

4
utils/make_migration.sh Normal file
View File

@ -0,0 +1,4 @@
#!/bin/bash
flask db migrate -m "Initial migration."
flask db upgrade

View File

@ -0,0 +1,4 @@
#!/bin/sh
pybabel extract -F babel.cfg -o ./language/message.pot ./templates/**
pybabel update -i message.pot -d translations
pybabel compile -d translations