feat(i18n): francais comme langue principale, anglais accessible

Le site s'affiche desormais en francais par defaut, avec un selecteur de
langue permettant de basculer vers l'anglais.

Choix de conception : les chaines sources restent en anglais
  Elles servent d'identifiants gettext, et le francais est fourni par
  catalogue avec BABEL_DEFAULT_LOCALE = 'fr'. Le code reste ainsi dans une
  seule langue -- la meme que ses commentaires et docstrings -- tandis que
  ce qu'un membre voit est du francais.

  Consequence qui rend la migration praticable : une chaine non encore
  traduite retombe en anglais, pas sur un identifiant brut. Les gabarits
  peuvent donc etre migres un par un sans jamais laisser le site a moitie
  casse.

Selection de la langue (app/i18n.py)
  1. choix explicite via le selecteur, garde en session
  2. sinon en-tete Accept-Language du navigateur, restreint a fr et en
  3. sinon francais
  Un choix explicite prime toujours, y compris sur un navigateur anglophone.

Selecteur
  Extrait en partiel et inclus dans les deux branches de la mise en page :
  barre laterale une fois connecte, ET page d'authentification. Quelqu'un
  qui ne lit pas la langue courante doit pouvoir en changer AVANT de se
  connecter -- le laisser derriere l'authentification aurait ete un defaut
  d'accessibilite. Chaque langue est ecrite dans sa propre langue.

  La route /lang/<locale> valide le Referer avant de rediriger : sans ce
  controle, elle constituait une redirection ouverte.

Migre dans cette passe
  navigation complete, page de connexion, les cinq pages d'erreur, et
  l'integralite des messages flash de routes/auth.py. 64 chaines, dont
  aucune non traduite.

Verification
  25 tests, dont deux garde-fous d'integrite : un catalogue .mo manquant
  ou une entree non traduite font echouer la suite. Sans cela, une
  compilation oubliee servirait de l'anglais partout, en silence et sans
  rien dans les journaux.

Un test existant a du etre corrige, et c'est instructif
  test_login_failure_message_does_not_reveal_account_existence cherchait la
  sous-chaine anglaise 'attempt(s) remaining'. La page etant desormais en
  francais, elle etait absente des deux cotes, l'assertion passait, et le
  mode strict a signale le faux succes. Le test comparait donc l'anglais,
  pas le comportement. Il compare desormais les messages flash rendus,
  quelle que soit la langue. La faille SEC-AUTH-006 reste ouverte, et le
  test la documente toujours.

Les catalogues .po ET .mo sont versionnes : le deploiement est un simple
miroir de fichiers, sans etape de compilation. messages.pot, regenerable,
ne l'est pas.

docs/translations.md documente le processus, les deux pieges (concatenation
de phrases, traduction a l'import), et l'etat de la migration. A noter pour
la suite : les chaines dans les blocs <script> ne peuvent pas etre balisees
telles quelles, il faudra les passer par des attributs data- -- ce qui
rejoint le chantier de sortie de unsafe-inline (OPS-010).

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
GGThed
2026-08-07 20:31:25 -04:00
co-authored by Claude Opus 5
parent afab7070fb
commit a92600c305
24 changed files with 1318 additions and 83 deletions
+10
View File
@@ -139,3 +139,13 @@ desktop.ini
# Les rapports de couverture HTML, qui étaient vraisemblablement la cible
# de ces règles, sont couverts ci-dessus par `htmlcov/` et
# `coverage_html_report/`.
# =============================================================================
# Internationalisation
# =============================================================================
# Le gabarit de catalogue est entierement regenerable :
# pybabel extract -F babel.cfg -k _l -o messages.pot .
# Les catalogues .po (sources de traduction) et .mo (compiles, lus a
# l'execution) sont eux versionnes : le deploiement est un simple miroir de
# fichiers, sans etape de compilation.
messages.pot
+20 -1
View File
@@ -7,11 +7,12 @@ the Flask application instance with comprehensive security hardening.
import os
from flask import Flask, request, redirect, jsonify, render_template, url_for
from flask_cors import CORS
from app.extensions import db, login_manager, csrf, limiter
from app.extensions import db, login_manager, csrf, limiter, babel
from sqlalchemy import text
from werkzeug.exceptions import HTTPException
import markupsafe
from dotenv import load_dotenv
from app import i18n
load_dotenv()
@@ -69,6 +70,12 @@ def create_app(config=None):
app.config['CORS_ALLOWED_ORIGINS'] = os.getenv('CORS_ALLOWED_ORIGINS', '')
app.config['FORCE_HTTPS'] = os.getenv('FORCE_HTTPS', 'true').lower() == 'true'
# Internationalisation. French is the site's primary language.
app.config['BABEL_DEFAULT_LOCALE'] = i18n.DEFAULT_LOCALE
app.config['BABEL_TRANSLATION_DIRECTORIES'] = os.path.join(
os.path.dirname(os.path.abspath(__file__)), 'translations'
)
# Side effects of create_app(), both on by default so that production and
# development behave exactly as before. Tests turn them off.
app.config['AUTO_CREATE_TABLES'] = (
@@ -129,6 +136,18 @@ def create_app(config=None):
login_manager.init_app(app)
csrf.init_app(app)
limiter.init_app(app)
babel.init_app(app, locale_selector=i18n.select_locale)
# Exposed to every template so the language switcher can render itself
# without each view having to pass the list along.
@app.context_processor
def inject_locales():
from flask_babel import get_locale
return {
'current_locale': str(get_locale() or i18n.DEFAULT_LOCALE),
'supported_locales': i18n.SUPPORTED_LOCALES,
'locale_names': i18n.LOCALE_NAMES,
}
# Configure structured logging
from app.logging_config import configure_logging
+5
View File
@@ -4,6 +4,7 @@ from flask_wtf.csrf import CSRFProtect
from werkzeug.security import generate_password_hash, check_password_hash
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_babel import Babel
# Database and extension initialization
db = SQLAlchemy()
@@ -12,6 +13,10 @@ login_manager.login_view = 'auth.login'
login_manager.login_message_category = 'info'
csrf = CSRFProtect()
# Internationalisation. French is the primary language of the site; English
# stays available. See app/i18n.py for how a locale is chosen.
babel = Babel()
# Rate limiter for brute-force protection
limiter = Limiter(
key_func=get_remote_address,
+75
View File
@@ -0,0 +1,75 @@
"""Language selection.
French is the primary language of the site; English remains available.
Source strings stay in English and act as gettext message ids, with the
French wording supplied by translations/fr/LC_MESSAGES/messages.po. That
keeps the codebase in one language — the same one as its comments and
docstrings — while what a member actually sees defaults to French.
Consequence worth knowing: an English page is what you get when a string
has no French translation yet. A missing entry degrades to English rather
than to a raw identifier, which is why the migration can proceed template
by template without ever leaving the site in a broken state.
"""
from flask import request, session
#: Locales the site is served in, in order of preference.
SUPPORTED_LOCALES = ('fr', 'en')
#: Language names as written in their own language, for the switcher.
LOCALE_NAMES = {
'fr': 'Français',
'en': 'English',
}
#: Session key holding an explicit user choice.
LOCALE_SESSION_KEY = 'locale'
DEFAULT_LOCALE = 'fr'
def select_locale():
"""Pick the locale for the current request.
Order of precedence:
1. an explicit choice the user made through the language switcher,
kept in the session;
2. the browser's Accept-Language header, restricted to what we serve;
3. French.
Note that step 2 only ever selects English for someone whose browser
actually asks for it. Everyone else gets French, including browsers
sending no header at all.
Returns:
str: A locale code from SUPPORTED_LOCALES.
"""
chosen = session.get(LOCALE_SESSION_KEY)
if chosen in SUPPORTED_LOCALES:
return chosen
# best_match returns None when nothing overlaps.
if request:
negotiated = request.accept_languages.best_match(SUPPORTED_LOCALES)
if negotiated:
return negotiated
return DEFAULT_LOCALE
def set_locale(locale):
"""Record an explicit language choice for this session.
Args:
locale: Requested locale code.
Returns:
bool: True if it was accepted, False if unsupported.
"""
if locale not in SUPPORTED_LOCALES:
return False
session[LOCALE_SESSION_KEY] = locale
return True
+25 -21
View File
@@ -15,6 +15,7 @@ from app.extensions import db, hash_password, check_password, limiter
from app.models import User, Player, ESPORT_GAMES
from app.validators import RegisterSchema, LoginSchema
from app.logging_config import log_auth_event
from flask_babel import gettext as _
from marshmallow import ValidationError
from urllib.parse import urlparse, urlencode
import requests
@@ -137,8 +138,8 @@ def login():
log_auth_event('login.rejected.locked', username=username, user_id=user.id)
remaining = (user.locked_until - datetime.utcnow()).seconds // 60
flash(
f'Account is locked due to too many failed attempts. '
f'Please try again in {remaining} minute(s).',
_('Account is locked due to too many failed attempts. '
'Please try again in %(remaining)s minute(s).', remaining=remaining),
'danger'
)
return render_template('pages/login.html')
@@ -147,7 +148,7 @@ def login():
if not user.is_active_account:
log_auth_event('login.rejected.deactivated',
username=username, user_id=user.id)
flash('This account has been deactivated.', 'danger')
flash(_('This account has been deactivated.'), 'danger')
return render_template('pages/login.html')
# Reset failed login attempts on successful login
@@ -175,7 +176,7 @@ def login():
next_page = request.args.get('next')
if next_page and not is_safe_url(next_page):
next_page = None
flash(f'Welcome back, {user.username}!', 'success')
flash(_('Welcome back, %(username)s!', username=user.username), 'success')
return redirect(next_page) if next_page else redirect(url_for('main.dashboard'))
else:
# Track failed login attempt
@@ -188,20 +189,23 @@ def login():
log_auth_event('account.locked', username=username, user_id=user.id,
minutes=LOCKOUT_DURATION_MINUTES)
flash(
f'Account locked after {MAX_LOGIN_ATTEMPTS} failed attempts. '
f'Please try again in {LOCKOUT_DURATION_MINUTES} minutes.',
_('Account locked after %(attempts)s failed attempts. '
'Please try again in %(minutes)s minutes.',
attempts=MAX_LOGIN_ATTEMPTS,
minutes=LOCKOUT_DURATION_MINUTES),
'danger'
)
else:
remaining = MAX_LOGIN_ATTEMPTS - user.failed_login_attempts
flash(
f'Login unsuccessful. {remaining} attempt(s) remaining before lockout.',
_('Login unsuccessful. %(remaining)s attempt(s) remaining '
'before lockout.', remaining=remaining),
'danger'
)
db.session.commit()
else:
log_auth_event('login.failure.unknown_user', username=username)
flash('Login unsuccessful. Please check username and password.', 'danger')
flash(_('Login unsuccessful. Please check username and password.'), 'danger')
return render_template('pages/login.html')
@@ -232,7 +236,7 @@ def register():
# Validate CAPTCHA first
captcha_answer = request.form.get('captcha_answer', '')
if not verify_captcha(captcha_answer):
flash('Incorrect CAPTCHA answer. Please try again.', 'danger')
flash(_('Incorrect CAPTCHA answer. Please try again.'), 'danger')
captcha = generate_captcha()
# Clear password fields only on CAPTCHA failure
form_data.pop('password', None)
@@ -274,7 +278,7 @@ def register():
league_os_profile = validated.get('league_os_profile')
if User.query.filter_by(username=username).first():
flash('Username already exists.', 'danger')
flash(_('Username already exists.'), 'danger')
captcha = generate_captcha()
form_data.pop('password', None)
form_data.pop('confirm_password', None)
@@ -286,7 +290,7 @@ def register():
)
if User.query.filter_by(email=email).first():
flash('Email already registered.', 'danger')
flash(_('Email already registered.'), 'danger')
captcha = generate_captcha()
form_data.pop('password', None)
form_data.pop('confirm_password', None)
@@ -332,7 +336,7 @@ def register():
log_auth_event('account.registered', username=user.username, user_id=user.id)
flash('Your account has been created! You can now log in.', 'success')
flash(_('Your account has been created! You can now log in.'), 'success')
return redirect(url_for('auth.login'))
# GET request — render empty form
@@ -358,7 +362,7 @@ def discord_login():
# DISCORD_REDIRECT_URI is checked too: quoting it when unset used to
# raise inside the query builder rather than report a configuration error.
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
flash('Discord OAuth2 is not configured.', 'danger')
flash(_('Discord OAuth2 is not configured.'), 'danger')
return redirect(url_for('auth.register'))
# Anti-forgery token, required by RFC 6749 §10.12. Without it, an
@@ -398,15 +402,15 @@ def discord_callback():
if not expected_state or not secrets.compare_digest(expected_state, received_state):
flash(
'Discord authorization could not be verified. '
'Please start the connection again from this page.',
_('Discord authorization could not be verified. '
'Please start the connection again from this page.'),
'danger',
)
return redirect(url_for('auth.register'))
code = request.args.get('code')
if not code:
flash('Discord authorization failed. No code received.', 'danger')
flash(_('Discord authorization failed. No code received.'), 'danger')
return redirect(url_for('auth.register'))
# Exchange the authorization code for an access token
@@ -430,11 +434,11 @@ def discord_callback():
token_json = token_response.json()
access_token = token_json.get('access_token')
except requests.RequestException:
flash('Failed to connect to Discord. Please try again.', 'danger')
flash(_('Failed to connect to Discord. Please try again.'), 'danger')
return redirect(url_for('auth.register'))
if not access_token:
flash('Failed to obtain Discord access token.', 'danger')
flash(_('Failed to obtain Discord access token.'), 'danger')
return redirect(url_for('auth.register'))
auth_headers = {'Authorization': f'Bearer {access_token}'}
@@ -449,7 +453,7 @@ def discord_callback():
user_response.raise_for_status()
user_data = user_response.json()
except requests.RequestException:
flash('Failed to fetch Discord user profile.', 'danger')
flash(_('Failed to fetch Discord user profile.'), 'danger')
return redirect(url_for('auth.register'))
# Fetch the user's connected gaming accounts
@@ -496,7 +500,7 @@ def discord_callback():
'auto_select_games': auto_select_games,
}
flash('Discord account connected! Your profile has been pre-filled.', 'success')
flash(_('Discord account connected! Your profile has been pre-filled.'), 'success')
return redirect(url_for('auth.register'))
@@ -514,5 +518,5 @@ def logout():
log_auth_event('logout', username=current_user.username, user_id=current_user.id)
logout_user()
session.clear()
flash('You have been logged out.', 'info')
flash(_('You have been logged out.'), 'info')
return redirect(url_for('auth.login'))
+26 -1
View File
@@ -3,7 +3,7 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, render_template, redirect, url_for
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app.extensions import db
from app.models import (
@@ -23,6 +23,31 @@ def index():
return redirect(url_for('auth.login'))
@main_bp.route('/lang/<locale>')
def set_language(locale):
"""Switch the interface language and return where the user came from.
Available to anonymous visitors too: the login page has to be readable
before anyone can sign in.
A GET link rather than a form: the only thing a forged request could
achieve is changing the visitor's own display language, which carries
no consequence worth a token. The redirect target is still validated —
an unchecked `Referer` would make this an open redirect.
"""
from app.i18n import set_locale
from app.routes.auth import is_safe_url
if not set_locale(locale):
flash('That language is not available.', 'warning')
target = request.referrer
if target and is_safe_url(target):
return redirect(target)
return redirect(url_for('main.dashboard') if current_user.is_authenticated
else url_for('auth.login'))
@main_bp.route('/dashboard')
@login_required
def dashboard():
+59
View File
@@ -1948,3 +1948,62 @@ a:hover { color: var(--primary-dark); }
[data-theme="dark"] .error-container p {
color: var(--text-secondary);
}
/* =========================================================================
Language switcher
========================================================================= */
.nav-language {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.nav-language .lang-link {
color: var(--text-secondary);
text-decoration: none;
padding: 0;
}
.nav-language .lang-link:hover {
color: var(--primary);
text-decoration: underline;
}
.nav-language .lang-current {
font-weight: 600;
color: var(--text-primary);
}
.nav-language .lang-separator {
opacity: 0.4;
}
.auth-language {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 18px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.auth-language .lang-link { color: var(--text-secondary); text-decoration: none; }
.auth-language .lang-link:hover { color: var(--primary); text-decoration: underline; }
.auth-language .lang-current { font-weight: 600; color: var(--text-primary); }
.auth-language .lang-separator { opacity: 0.4; }
/* Visually hidden, still announced by screen readers. */
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
+5 -5
View File
@@ -1,15 +1,15 @@
{% extends "layouts/base.html" %}
{% block title %}400 Bad Request - TryoutPro{% endblock %}
{% block page_title %}Bad Request{% endblock %}
{% block title %}{{ _('400 Bad Request') }} - TryoutPro{% endblock %}
{% block page_title %}{{ _('Bad Request') }}{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<h2>400 — Bad Request</h2>
<p>The request could not be understood by the server. Please check your input and try again.</p>
<h2>{{ _('400 — Bad Request') }}</h2>
<p>{{ _('The request could not be understood by the server. Please check your input and try again.') }}</p>
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> Go Back
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
</a>
</div>
{% endblock %}
+5 -5
View File
@@ -1,15 +1,15 @@
{% extends "layouts/base.html" %}
{% block title %}403 Forbidden - TryoutPro{% endblock %}
{% block page_title %}Access Denied{% endblock %}
{% block title %}{{ _('403 Forbidden') }} - TryoutPro{% endblock %}
{% block page_title %}{{ _('Access Denied') }}{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-lock"></i>
</div>
<h2>403 — Forbidden</h2>
<p>You do not have permission to access this resource. If you believe this is an error, please contact an administrator.</p>
<h2>{{ _('403 — Forbidden') }}</h2>
<p>{{ _('You do not have permission to access this resource. If you believe this is an error, please contact an administrator.') }}</p>
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> Go Back
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
</a>
</div>
{% endblock %}
+5 -5
View File
@@ -1,15 +1,15 @@
{% extends "layouts/base.html" %}
{% block title %}404 Not Found - TryoutPro{% endblock %}
{% block page_title %}Page Not Found{% endblock %}
{% block title %}{{ _('404 Not Found') }} - TryoutPro{% endblock %}
{% block page_title %}{{ _('Page Not Found') }}{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-search"></i>
</div>
<h2>404 — Not Found</h2>
<p>The page you are looking for does not exist. It may have been moved or deleted.</p>
<h2>{{ _('404 — Not Found') }}</h2>
<p>{{ _('The page you are looking for does not exist. It may have been moved or deleted.') }}</p>
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-home"></i> Return Home
<i class="fas fa-home"></i> {{ _('Return Home') }}
</a>
</div>
{% endblock %}
+5 -5
View File
@@ -1,15 +1,15 @@
{% extends "layouts/base.html" %}
{% block title %}429 Too Many Requests - TryoutPro{% endblock %}
{% block page_title %}Rate Limit Exceeded{% endblock %}
{% block title %}{{ _('429 Too Many Requests') }} - TryoutPro{% endblock %}
{% block page_title %}{{ _('Rate Limit Exceeded') }}{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-hourglass-half"></i>
</div>
<h2>429 — Too Many Requests</h2>
<p>You have sent too many requests in a short period. Please wait a moment and try again.</p>
<h2>{{ _('429 — Too Many Requests') }}</h2>
<p>{{ _('You have sent too many requests in a short period. Please wait a moment and try again.') }}</p>
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> Go Back
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
</a>
</div>
{% endblock %}
+5 -5
View File
@@ -1,15 +1,15 @@
{% extends "layouts/base.html" %}
{% block title %}500 Server Error - TryoutPro{% endblock %}
{% block page_title %}Internal Server Error{% endblock %}
{% block title %}{{ _('500 Server Error') }} - TryoutPro{% endblock %}
{% block page_title %}{{ _('Internal Server Error') }}{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-cogs"></i>
</div>
<h2>500 — Internal Server Error</h2>
<p>Something went wrong on our end. The error has been logged and will be investigated. Please try again later.</p>
<h2>{{ _('500 — Internal Server Error') }}</h2>
<p>{{ _('Something went wrong on our end. The error has been logged and will be investigated. Please try again later.') }}</p>
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-redo-alt"></i> Try Again
<i class="fas fa-redo-alt"></i> {{ _('Try Again') }}
</a>
</div>
{% endblock %}
@@ -0,0 +1,19 @@
{# Language switcher.
Included from both branches of the layout: the signed-in sidebar and the
anonymous authentication page. Someone who cannot read the current
language has to be able to change it *before* signing in, so this cannot
live behind the login.
Each language is written in its own language, for the same reason. #}
<i class="fas fa-language" aria-hidden="true"></i>
<span class="sr-only">{{ _('Language') }}</span>
{% for code in supported_locales %}
{%- if code == current_locale %}
<span class="lang-current" aria-current="true">{{ locale_names[code] }}</span>
{%- else %}
<a href="{{ url_for('main.set_language', locale=code) }}"
class="lang-link" hreflang="{{ code }}" rel="alternate">{{ locale_names[code] }}</a>
{%- endif %}
{%- if not loop.last %}<span class="lang-separator" aria-hidden="true">·</span>{% endif %}
{% endfor %}
+23 -16
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="{{ current_locale }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -30,26 +30,26 @@
<li>
<a href="{{ url_for('main.dashboard') }}" class="{% if request.endpoint and 'dashboard' in request.endpoint %}active{% endif %}">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
<span>{{ _('Dashboard') }}</span>
</a>
</li>
<li>
<a href="{{ url_for('tryouts.list_tryouts') }}" class="{% if request.endpoint and 'tryouts' in request.endpoint and request.endpoint != 'tryouts.create_tryout' %}active{% endif %}">
<i class="fas fa-calendar-alt"></i>
<span>Tryouts</span>
<span>{{ _('Tryouts') }}</span>
</a>
</li>
<li>
<a href="{{ url_for('matches.calendar') }}" class="{% if request.endpoint and 'calendar' in request.endpoint %}active{% endif %}">
<i class="fas fa-calendar"></i>
<span>Calendar</span>
<span>{{ _('Calendar') }}</span>
</a>
</li>
{% if current_user.can_evaluate() %}
<li>
<a href="{{ url_for('evaluations.list_evaluations') }}" class="{% if request.endpoint and 'evaluations' in request.endpoint %}active{% endif %}">
<i class="fas fa-clipboard-check"></i>
<span>Evaluations</span>
<span>{{ _('Evaluations') }}</span>
</a>
</li>
{% endif %}
@@ -57,14 +57,14 @@
<li>
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
<i class="fas fa-users"></i>
<span>My Team(s)</span>
<span>{{ _('My Team(s)') }}</span>
</a>
</li>
{% else %}
<li>
<a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint and request.endpoint != 'teams.my_teams' %}active{% endif %}">
<i class="fas fa-users-cog"></i>
<span>Manage Teams</span>
<span>{{ _('Manage Teams') }}</span>
</a>
</li>
{% endif %}
@@ -72,7 +72,7 @@
<li>
<a href="{{ url_for('users.list_users') }}" class="{% if request.endpoint and 'users' in request.endpoint and request.endpoint != 'users.profile' %}active{% endif %}">
<i class="fas fa-users-cog"></i>
<span>Manage Users</span>
<span>{{ _('Manage Users') }}</span>
</a>
</li>
{% endif %}
@@ -80,7 +80,7 @@
<li>
<a href="{{ url_for('users.my_notes') }}" class="{% if request.endpoint == 'users.my_notes' %}active{% endif %}">
<i class="fas fa-sticky-note"></i>
<span>My Notes</span>
<span>{{ _('My Notes') }}</span>
</a>
</li>
{% endif %}
@@ -88,35 +88,39 @@
<li>
<a href="{{ url_for('users.manage_coach_availability') }}" class="{% if request.endpoint == 'users.manage_coach_availability' %}active{% endif %}">
<i class="fas fa-clock"></i>
<span>Availability</span>
<span>{{ _('Availability') }}</span>
</a>
</li>
<li>
<a href="{{ url_for('users.notes_dashboard') }}" class="{% if request.endpoint == 'users.notes_dashboard' or request.endpoint == 'users.manage_team_notes' or request.endpoint == 'users.manage_personal_notes' %}active{% endif %}">
<i class="fas fa-sticky-note"></i>
<span>Notes & One on One</span>
<span>{{ _('Notes & One on One') }}</span>
</a>
</li>
{% endif %}
<li>
<a href="{{ url_for('users.list_contracts') }}" class="{% if request.endpoint and 'contracts' in request.endpoint %}active{% endif %}">
<i class="fas fa-file-contract"></i>
<span>Contracts</span>
<span>{{ _('Contracts') }}</span>
</a>
</li>
<li class="nav-divider"></li>
<li>
<a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
<i class="fas fa-user"></i>
<span>My Profile</span>
<span>{{ _('My Profile') }}</span>
</a>
</li>
<li>
<a href="{{ url_for('auth.logout') }}" class="logout-link">
<i class="fas fa-sign-out-alt"></i>
<span>Logout</span>
<span>{{ _('Logout') }}</span>
</a>
</li>
<li class="nav-divider"></li>
<li class="nav-language">
{% include "layouts/_language_switcher.html" %}
</li>
</ul>
</nav>
@@ -126,7 +130,7 @@
<i class="fas fa-bars"></i>
</button>
<div class="page-header">
<h1>{% block page_title %}Dashboard{% endblock %}</h1>
<h1>{% block page_title %}{{ _('Dashboard') }}{% endblock %}</h1>
{% block breadcrumb %}{% endblock %}
</div>
<button class="dark-mode-toggle" id="darkModeToggle" onclick="toggleDarkMode()" title="Toggle dark mode">
@@ -168,7 +172,10 @@
<div class="auth-header">
<i class="fas fa-trophy"></i>
<h2>TryoutPro</h2>
<p>Team Tryout Management System</p>
<p>{{ _('Team Tryout Management System') }}</p>
</div>
<div class="auth-language">
{% include "layouts/_language_switcher.html" %}
</div>
{% block auth_content %}{% endblock %}
</div>
+7 -7
View File
@@ -1,17 +1,17 @@
{% extends "layouts/base.html" %}
{% block title %}Login - TryoutPro{% endblock %}
{% block title %}{{ _('Login') }} - TryoutPro{% endblock %}
{% block auth_content %}
<form method="POST" action="{{ url_for('auth.login') }}" class="auth-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label for="username"><i class="fas fa-user"></i> Username</label>
<input type="text" id="username" name="username" placeholder="Enter your username" required>
<label for="username"><i class="fas fa-user"></i> {{ _('Username') }}</label>
<input type="text" id="username" name="username" placeholder="{{ _('Enter your username') }}" required>
</div>
<div class="form-group">
<label for="password"><i class="fas fa-lock"></i> Password</label>
<input type="password" id="password" name="password" placeholder="Enter your password" required>
<label for="password"><i class="fas fa-lock"></i> {{ _('Password') }}</label>
<input type="password" id="password" name="password" placeholder="{{ _('Enter your password') }}" required>
</div>
<button type="submit" class="btn btn-primary btn-block">Sign In</button>
<p class="auth-link">Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a></p>
<button type="submit" class="btn btn-primary btn-block">{{ _('Sign In') }}</button>
<p class="auth-link">{{ _("Don't have an account?") }} <a href="{{ url_for('auth.register') }}">{{ _('Register here') }}</a></p>
</form>
{% endblock %}
Binary file not shown.
+306
View File
@@ -0,0 +1,306 @@
# English translations for team-tryouts.
# Copyright (C) 2026 UdeS Esports
# This file is distributed under the same license as the team-tryouts
# project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
#
msgid ""
msgstr ""
"Project-Id-Version: team-tryouts VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-08-07 20:25-0400\n"
"PO-Revision-Date: 2026-08-07 20:22-0400\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en\n"
"Language-Team: UdeS Esports\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.18.0\n"
#: app/routes/auth.py:141
#, python-format
msgid ""
"Account is locked due to too many failed attempts. Please try again in %(remaining)s "
"minute(s)."
msgstr ""
"Account is locked due to too many failed attempts. Please try again in %(remaining)s "
"minute(s)."
#: app/routes/auth.py:151
msgid "This account has been deactivated."
msgstr "This account has been deactivated."
#: app/routes/auth.py:179
#, python-format
msgid "Welcome back, %(username)s!"
msgstr "Welcome back, %(username)s!"
#: app/routes/auth.py:192
#, python-format
msgid ""
"Account locked after %(attempts)s failed attempts. Please try again in %(minutes)s "
"minutes."
msgstr ""
"Account locked after %(attempts)s failed attempts. Please try again in %(minutes)s "
"minutes."
#: app/routes/auth.py:201
#, python-format
msgid "Login unsuccessful. %(remaining)s attempt(s) remaining before lockout."
msgstr "Login unsuccessful. %(remaining)s attempt(s) remaining before lockout."
#: app/routes/auth.py:208
msgid "Login unsuccessful. Please check username and password."
msgstr "Login unsuccessful. Please check username and password."
#: app/routes/auth.py:239
msgid "Incorrect CAPTCHA answer. Please try again."
msgstr "Incorrect CAPTCHA answer. Please try again."
#: app/routes/auth.py:281
msgid "Username already exists."
msgstr "Username already exists."
#: app/routes/auth.py:293
msgid "Email already registered."
msgstr "Email already registered."
#: app/routes/auth.py:339
msgid "Your account has been created! You can now log in."
msgstr "Your account has been created! You can now log in."
#: app/routes/auth.py:365
msgid "Discord OAuth2 is not configured."
msgstr "Discord OAuth2 is not configured."
#: app/routes/auth.py:405
msgid ""
"Discord authorization could not be verified. Please start the connection again from "
"this page."
msgstr ""
"Discord authorization could not be verified. Please start the connection again from "
"this page."
#: app/routes/auth.py:413
msgid "Discord authorization failed. No code received."
msgstr "Discord authorization failed. No code received."
#: app/routes/auth.py:437
msgid "Failed to connect to Discord. Please try again."
msgstr "Failed to connect to Discord. Please try again."
#: app/routes/auth.py:441
msgid "Failed to obtain Discord access token."
msgstr "Failed to obtain Discord access token."
#: app/routes/auth.py:456
msgid "Failed to fetch Discord user profile."
msgstr "Failed to fetch Discord user profile."
#: app/routes/auth.py:503
msgid "Discord account connected! Your profile has been pre-filled."
msgstr "Discord account connected! Your profile has been pre-filled."
#: app/routes/auth.py:521
msgid "You have been logged out."
msgstr "You have been logged out."
#: app/templates/errors/400.html:2
msgid "400 Bad Request"
msgstr "400 Bad Request"
#: app/templates/errors/400.html:3
msgid "Bad Request"
msgstr "Bad Request"
#: app/templates/errors/400.html:9
msgid "400 — Bad Request"
msgstr "400 — Bad Request"
#: app/templates/errors/400.html:10
msgid ""
"The request could not be understood by the server. Please check your input and try "
"again."
msgstr ""
"The request could not be understood by the server. Please check your input and try "
"again."
#: app/templates/errors/400.html:12 app/templates/errors/403.html:12
#: app/templates/errors/429.html:12
msgid "Go Back"
msgstr "Go Back"
#: app/templates/errors/403.html:2
msgid "403 Forbidden"
msgstr "403 Forbidden"
#: app/templates/errors/403.html:3
msgid "Access Denied"
msgstr "Access Denied"
#: app/templates/errors/403.html:9
msgid "403 — Forbidden"
msgstr "403 — Forbidden"
#: app/templates/errors/403.html:10
msgid ""
"You do not have permission to access this resource. If you believe this is an error, "
"please contact an administrator."
msgstr ""
"You do not have permission to access this resource. If you believe this is an error, "
"please contact an administrator."
#: app/templates/errors/404.html:2
msgid "404 Not Found"
msgstr "404 Not Found"
#: app/templates/errors/404.html:3
msgid "Page Not Found"
msgstr "Page Not Found"
#: app/templates/errors/404.html:9
msgid "404 — Not Found"
msgstr "404 — Not Found"
#: app/templates/errors/404.html:10
msgid "The page you are looking for does not exist. It may have been moved or deleted."
msgstr "The page you are looking for does not exist. It may have been moved or deleted."
#: app/templates/errors/404.html:12
msgid "Return Home"
msgstr "Return Home"
#: app/templates/errors/429.html:2
msgid "429 Too Many Requests"
msgstr "429 Too Many Requests"
#: app/templates/errors/429.html:3
msgid "Rate Limit Exceeded"
msgstr "Rate Limit Exceeded"
#: app/templates/errors/429.html:9
msgid "429 — Too Many Requests"
msgstr "429 — Too Many Requests"
#: app/templates/errors/429.html:10
msgid "You have sent too many requests in a short period. Please wait a moment and try again."
msgstr "You have sent too many requests in a short period. Please wait a moment and try again."
#: app/templates/errors/500.html:2
msgid "500 Server Error"
msgstr "500 Server Error"
#: app/templates/errors/500.html:3
msgid "Internal Server Error"
msgstr "Internal Server Error"
#: app/templates/errors/500.html:9
msgid "500 — Internal Server Error"
msgstr "500 — Internal Server Error"
#: app/templates/errors/500.html:10
msgid ""
"Something went wrong on our end. The error has been logged and will be investigated. "
"Please try again later."
msgstr ""
"Something went wrong on our end. The error has been logged and will be investigated. "
"Please try again later."
#: app/templates/errors/500.html:12
msgid "Try Again"
msgstr "Try Again"
#: app/templates/layouts/_language_switcher.html:10
msgid "Language"
msgstr "Language"
#: app/templates/layouts/base.html:33 app/templates/layouts/base.html:133
msgid "Dashboard"
msgstr "Dashboard"
#: app/templates/layouts/base.html:39
msgid "Tryouts"
msgstr "Tryouts"
#: app/templates/layouts/base.html:45
msgid "Calendar"
msgstr "Calendar"
#: app/templates/layouts/base.html:52
msgid "Evaluations"
msgstr "Evaluations"
#: app/templates/layouts/base.html:60
msgid "My Team(s)"
msgstr "My Team(s)"
#: app/templates/layouts/base.html:67
msgid "Manage Teams"
msgstr "Manage Teams"
#: app/templates/layouts/base.html:75
msgid "Manage Users"
msgstr "Manage Users"
#: app/templates/layouts/base.html:83
msgid "My Notes"
msgstr "My Notes"
#: app/templates/layouts/base.html:91
msgid "Availability"
msgstr "Availability"
#: app/templates/layouts/base.html:97
msgid "Notes & One on One"
msgstr "Notes & One on One"
#: app/templates/layouts/base.html:104
msgid "Contracts"
msgstr "Contracts"
#: app/templates/layouts/base.html:111
msgid "My Profile"
msgstr "My Profile"
#: app/templates/layouts/base.html:117
msgid "Logout"
msgstr "Logout"
#: app/templates/layouts/base.html:175
msgid "Team Tryout Management System"
msgstr "Team Tryout Management System"
#: app/templates/pages/login.html:2
msgid "Login"
msgstr "Login"
#: app/templates/pages/login.html:7
msgid "Username"
msgstr "Username"
#: app/templates/pages/login.html:8
msgid "Enter your username"
msgstr "Enter your username"
#: app/templates/pages/login.html:11
msgid "Password"
msgstr "Password"
#: app/templates/pages/login.html:12
msgid "Enter your password"
msgstr "Enter your password"
#: app/templates/pages/login.html:14
msgid "Sign In"
msgstr "Sign In"
#: app/templates/pages/login.html:15
msgid "Don't have an account?"
msgstr "Don't have an account?"
#: app/templates/pages/login.html:15
msgid "Register here"
msgstr "Register here"
Binary file not shown.
+306
View File
@@ -0,0 +1,306 @@
# French translations for team-tryouts.
# Copyright (C) 2026 UdeS Esports
# This file is distributed under the same license as the team-tryouts
# project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
#
msgid ""
msgstr ""
"Project-Id-Version: team-tryouts VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-08-07 20:25-0400\n"
"PO-Revision-Date: 2026-08-07 20:22-0400\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: fr\n"
"Language-Team: UdeS Esports\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.18.0\n"
#: app/routes/auth.py:141
#, python-format
msgid ""
"Account is locked due to too many failed attempts. Please try again in %(remaining)s "
"minute(s)."
msgstr ""
"Compte verrouillé après trop de tentatives échouées. Réessayez dans %(remaining)s "
"minute(s)."
#: app/routes/auth.py:151
msgid "This account has been deactivated."
msgstr "Ce compte a été désactivé."
#: app/routes/auth.py:179
#, python-format
msgid "Welcome back, %(username)s!"
msgstr "Bon retour, %(username)s !"
#: app/routes/auth.py:192
#, python-format
msgid ""
"Account locked after %(attempts)s failed attempts. Please try again in %(minutes)s "
"minutes."
msgstr ""
"Compte verrouillé après %(attempts)s tentatives échouées. Réessayez dans %(minutes)s "
"minutes."
#: app/routes/auth.py:201
#, python-format
msgid "Login unsuccessful. %(remaining)s attempt(s) remaining before lockout."
msgstr "Échec de la connexion. Il reste %(remaining)s tentative(s) avant le verrouillage."
#: app/routes/auth.py:208
msgid "Login unsuccessful. Please check username and password."
msgstr "Échec de la connexion. Vérifiez le nom d'utilisateur et le mot de passe."
#: app/routes/auth.py:239
msgid "Incorrect CAPTCHA answer. Please try again."
msgstr "Réponse au CAPTCHA incorrecte. Veuillez réessayer."
#: app/routes/auth.py:281
msgid "Username already exists."
msgstr "Ce nom d'utilisateur est déjà pris."
#: app/routes/auth.py:293
msgid "Email already registered."
msgstr "Cette adresse courriel est déjà enregistrée."
#: app/routes/auth.py:339
msgid "Your account has been created! You can now log in."
msgstr "Votre compte a été créé. Vous pouvez maintenant vous connecter."
#: app/routes/auth.py:365
msgid "Discord OAuth2 is not configured."
msgstr "La connexion Discord n'est pas configurée."
#: app/routes/auth.py:405
msgid ""
"Discord authorization could not be verified. Please start the connection again from "
"this page."
msgstr ""
"L'autorisation Discord n'a pas pu être vérifiée. Relancez la connexion depuis cette "
"page."
#: app/routes/auth.py:413
msgid "Discord authorization failed. No code received."
msgstr "L'autorisation Discord a échoué : aucun code reçu."
#: app/routes/auth.py:437
msgid "Failed to connect to Discord. Please try again."
msgstr "Impossible de joindre Discord. Veuillez réessayer."
#: app/routes/auth.py:441
msgid "Failed to obtain Discord access token."
msgstr "Impossible d'obtenir le jeton d'accès Discord."
#: app/routes/auth.py:456
msgid "Failed to fetch Discord user profile."
msgstr "Impossible de récupérer le profil Discord."
#: app/routes/auth.py:503
msgid "Discord account connected! Your profile has been pre-filled."
msgstr "Compte Discord connecté. Votre profil a été pré-rempli."
#: app/routes/auth.py:521
msgid "You have been logged out."
msgstr "Vous avez été déconnecté."
#: app/templates/errors/400.html:2
msgid "400 Bad Request"
msgstr "400 Requête incorrecte"
#: app/templates/errors/400.html:3
msgid "Bad Request"
msgstr "Requête incorrecte"
#: app/templates/errors/400.html:9
msgid "400 — Bad Request"
msgstr "400 — Requête incorrecte"
#: app/templates/errors/400.html:10
msgid ""
"The request could not be understood by the server. Please check your input and try "
"again."
msgstr "Le serveur n'a pas pu interpréter la requête. Vérifiez votre saisie et réessayez."
#: app/templates/errors/400.html:12 app/templates/errors/403.html:12
#: app/templates/errors/429.html:12
msgid "Go Back"
msgstr "Retour"
#: app/templates/errors/403.html:2
msgid "403 Forbidden"
msgstr "403 Accès refusé"
#: app/templates/errors/403.html:3
msgid "Access Denied"
msgstr "Accès refusé"
#: app/templates/errors/403.html:9
msgid "403 — Forbidden"
msgstr "403 — Accès refusé"
#: app/templates/errors/403.html:10
msgid ""
"You do not have permission to access this resource. If you believe this is an error, "
"please contact an administrator."
msgstr ""
"Vous n'avez pas les droits d'accès à cette ressource. Si vous pensez qu'il s'agit "
"d'une erreur, contactez un administrateur."
#: app/templates/errors/404.html:2
msgid "404 Not Found"
msgstr "404 Page introuvable"
#: app/templates/errors/404.html:3
msgid "Page Not Found"
msgstr "Page introuvable"
#: app/templates/errors/404.html:9
msgid "404 — Not Found"
msgstr "404 — Page introuvable"
#: app/templates/errors/404.html:10
msgid "The page you are looking for does not exist. It may have been moved or deleted."
msgstr "La page demandée n'existe pas. Elle a peut-être été déplacée ou supprimée."
#: app/templates/errors/404.html:12
msgid "Return Home"
msgstr "Retour à l'accueil"
#: app/templates/errors/429.html:2
msgid "429 Too Many Requests"
msgstr "429 Trop de requêtes"
#: app/templates/errors/429.html:3
msgid "Rate Limit Exceeded"
msgstr "Limite de requêtes atteinte"
#: app/templates/errors/429.html:9
msgid "429 — Too Many Requests"
msgstr "429 — Trop de requêtes"
#: app/templates/errors/429.html:10
msgid "You have sent too many requests in a short period. Please wait a moment and try again."
msgstr ""
"Vous avez envoyé trop de requêtes en peu de temps. Patientez un instant puis "
"réessayez."
#: app/templates/errors/500.html:2
msgid "500 Server Error"
msgstr "500 Erreur du serveur"
#: app/templates/errors/500.html:3
msgid "Internal Server Error"
msgstr "Erreur interne du serveur"
#: app/templates/errors/500.html:9
msgid "500 — Internal Server Error"
msgstr "500 — Erreur interne du serveur"
#: app/templates/errors/500.html:10
msgid ""
"Something went wrong on our end. The error has been logged and will be investigated. "
"Please try again later."
msgstr ""
"Une erreur est survenue de notre côté. Elle a été journalisée et sera examinée. "
"Veuillez réessayer dans un instant."
#: app/templates/errors/500.html:12
msgid "Try Again"
msgstr "Réessayer"
#: app/templates/layouts/_language_switcher.html:10
msgid "Language"
msgstr "Langue"
#: app/templates/layouts/base.html:33 app/templates/layouts/base.html:133
msgid "Dashboard"
msgstr "Tableau de bord"
#: app/templates/layouts/base.html:39
msgid "Tryouts"
msgstr "Sélections"
#: app/templates/layouts/base.html:45
msgid "Calendar"
msgstr "Calendrier"
#: app/templates/layouts/base.html:52
msgid "Evaluations"
msgstr "Évaluations"
#: app/templates/layouts/base.html:60
msgid "My Team(s)"
msgstr "Mon ou mes équipes"
#: app/templates/layouts/base.html:67
msgid "Manage Teams"
msgstr "Gestion des équipes"
#: app/templates/layouts/base.html:75
msgid "Manage Users"
msgstr "Gestion des utilisateurs"
#: app/templates/layouts/base.html:83
msgid "My Notes"
msgstr "Mes notes"
#: app/templates/layouts/base.html:91
msgid "Availability"
msgstr "Disponibilités"
#: app/templates/layouts/base.html:97
msgid "Notes & One on One"
msgstr "Notes et rencontres individuelles"
#: app/templates/layouts/base.html:104
msgid "Contracts"
msgstr "Contrats"
#: app/templates/layouts/base.html:111
msgid "My Profile"
msgstr "Mon profil"
#: app/templates/layouts/base.html:117
msgid "Logout"
msgstr "Déconnexion"
#: app/templates/layouts/base.html:175
msgid "Team Tryout Management System"
msgstr "Système de gestion des sélections d'équipe"
#: app/templates/pages/login.html:2
msgid "Login"
msgstr "Connexion"
#: app/templates/pages/login.html:7
msgid "Username"
msgstr "Nom d'utilisateur"
#: app/templates/pages/login.html:8
msgid "Enter your username"
msgstr "Saisissez votre nom d'utilisateur"
#: app/templates/pages/login.html:11
msgid "Password"
msgstr "Mot de passe"
#: app/templates/pages/login.html:12
msgid "Enter your password"
msgstr "Saisissez votre mot de passe"
#: app/templates/pages/login.html:14
msgid "Sign In"
msgstr "Se connecter"
#: app/templates/pages/login.html:15
msgid "Don't have an account?"
msgstr "Vous n'avez pas de compte ?"
#: app/templates/pages/login.html:15
msgid "Register here"
msgstr "Inscrivez-vous ici"
+6
View File
@@ -0,0 +1,6 @@
# Extraction rules for pybabel.
#
# Jinja templates need the jinja2 extractor, not the python one: it has to
# understand {% trans %} blocks and expressions inside {{ }}.
[python: app/**.py]
[jinja2: app/templates/**.html]
+177
View File
@@ -0,0 +1,177 @@
# Translations
French is the primary language of the site. English remains available
through the switcher in the sidebar (and on the login page, for visitors
who have not signed in yet).
---
## 1. How it works
Source strings stay **in English** and act as gettext message ids. The
French wording lives in a catalogue.
```
app/i18n.py locale selection
app/translations/fr/LC_MESSAGES/messages.po French catalogue (edited)
app/translations/fr/LC_MESSAGES/messages.mo compiled (read at runtime)
app/translations/en/LC_MESSAGES/messages.po English, msgstr == msgid
babel.cfg extraction rules
```
This keeps the codebase in one language — the same one as its comments and
docstrings — while what a member sees defaults to French.
**A string with no translation falls back to English**, not to a raw
identifier. That is why this migration can proceed template by template
without ever leaving the site half broken: an untranslated page is a page
in English, not a page full of `nav.dashboard.label`.
### Which language a visitor gets
1. An explicit choice made through the switcher, kept in the session.
2. Failing that, the browser's `Accept-Language`, restricted to `fr` and `en`.
3. Failing that, French.
An explicit choice always wins, including over an English browser.
---
## 2. Marking a string for translation
### In a template
```jinja
<span>{{ _('Dashboard') }}</span>
<p>{{ _('The page you are looking for does not exist.') }}</p>
```
With a value inside:
```jinja
{{ _('Welcome back, %(username)s!', username=user.username) }}
```
For a longer block:
```jinja
{% trans %}This tryout has ended and can no longer be modified.{% endtrans %}
```
### In Python
```python
from flask_babel import gettext as _
flash(_('This account has been deactivated.'), 'danger')
flash(_('Welcome back, %(username)s!', username=user.username), 'success')
```
### Two things that do not work
**Never build a sentence by concatenation.** Word order differs between
languages, and the translator sees fragments with no context.
```python
flash(_('Player ') + name + _(' has been removed.')) # no
flash(_('%(name)s has been removed.', name=name)) # yes
```
**Never translate at import time.** A module-level `_()` runs before any
request exists, so it resolves once, in whatever locale happened to be
active — usually the default. Use `lazy_gettext` when the string has to sit
in a constant or a class attribute:
```python
from flask_babel import lazy_gettext as _l
ROLE_LABELS = {'coach': _l('Coach'), 'player': _l('Player')}
```
Extraction picks up `_l` because `babel.cfg` is invoked with `-k _l`.
---
## 3. Updating the catalogues
After marking new strings:
```bash
# 1. Re-extract every marked string
pybabel extract -F babel.cfg -k _l -o messages.pot --project=team-tryouts .
# 2. Merge into the existing catalogues, keeping current translations
pybabel update -i messages.pot -d app/translations
# 3. Fill in the new French entries
# edit app/translations/fr/LC_MESSAGES/messages.po
# 4. Compile
pybabel compile -d app/translations
```
`messages.pot` is regenerable and not tracked. The `.po` and `.mo` files
**are** tracked: deployment is a plain file mirror with no build step, so
an uncompiled catalogue would mean an English-only site in production.
### Entries needing attention
`pybabel update` marks changed strings as `#, fuzzy`. A fuzzy entry is
**ignored at runtime** — the string falls back to English. Review the
guessed translation, then remove the `#, fuzzy` line.
### Adding a language
```bash
pybabel init -i messages.pot -d app/translations -l es
```
Then add the code to `SUPPORTED_LOCALES` and `LOCALE_NAMES` in
`app/i18n.py`. The switcher picks it up on its own.
---
## 4. Checks
`tests/test_i18n.py` fails the build when:
- a compiled `.mo` is missing — otherwise the site silently serves English
everywhere, with nothing in the logs;
- a catalogue still contains an untranslated entry.
That second check is what keeps the migration honest: adding
`{{ _('...') }}` to a template without translating it turns the suite red.
---
## 5. State of the migration
Done: navigation, login page, the five error pages, and every flash message
in `app/routes/auth.py`.
Remaining, roughly in order of how often a member sees them:
| Area | Files |
|---|---|
| Dashboard | `pages/dashboard.html` |
| Tryouts | `pages/tryouts.html`, `view_tryout.html`, `tryout_form.html` |
| Registration | `pages/register.html` |
| Profile | `pages/profile.html`, `edit_profile.html` |
| Teams | `pages/teams.html`, `my_teams.html` |
| Calendar and matches | `pages/calendar.html`, `match_form.html`, `team_matches.html` |
| Notes and One on One | `pages/notes.html`, `one_on_one.html`, `add_note.html`, … |
| Contracts | `pages/contracts.html`, `upload_contract.html` |
| Remaining flash messages | `routes/users.py`, `tryouts.py`, `teams.py`, `matches.py`, … |
Two things to keep in mind while continuing.
**Strings inside `<script>` blocks** cannot be marked with `{{ _() }}` and
left at that — see `SEC-XSS-001`. Pass translated text through a `data-`
attribute, or a `<script type="application/json">` block read by an
external file. This lines up with the work needed to drop `unsafe-inline`
from the CSP (`OPS-010`), so the two are best done together, template by
template.
**Validation messages in `app/validators.py`** are shown to users and are
not yet marked. They need `lazy_gettext`, since schema fields are built at
import time.
+2
View File
@@ -12,9 +12,11 @@ certifi==2026.7.22
charset-normalizer==3.4.9
click==8.4.2
colorama==0.4.6
babel==2.18.0
Deprecated==1.3.1
discord.py==2.7.1
Flask==3.1.3
Flask-Babel==4.0.0
flask-cors==6.0.5
Flask-Limiter==4.1.1
Flask-Login==0.6.3
+22 -9
View File
@@ -4,11 +4,24 @@ These lock in the two fixes from wave 0: sessions now actually expire, and
deactivating an account now closes the sessions it already holds.
"""
import re
import pytest
from app.extensions import db
from app.models import User
#: Matches the flash markup emitted by layouts/base.html.
_FLASH_PATTERN = re.compile(
r'<div class="alert alert-\w+ alert-dismissible">\s*<span>(.*?)</span>',
re.S,
)
def _flash_messages(body):
"""Flash texts rendered in a response, independent of the locale."""
return [m.strip() for m in _FLASH_PATTERN.findall(body)]
def _set_active(app, user_id, active):
with app.app_context():
@@ -113,20 +126,20 @@ class TestLoginRejection:
def test_login_failure_message_does_not_reveal_account_existence(
self, client, make_user, app
):
"""Compares the rendered flash messages rather than looking for a
known substring: the site is served in French by default, so an
English marker would silently match nothing on both sides and make
the test pass while the flaw is still there."""
user_id = make_user('player')
with app.app_context():
username = db.session.get(User, user_id).username
existing = client.post(
def failure_message(name):
body = client.post(
'/auth/login',
data={'username': username, 'password': 'WrongPassword1'},
data={'username': name, 'password': 'WrongPassword1'},
follow_redirects=True,
).get_data(as_text=True)
return _flash_messages(body)
unknown = client.post(
'/auth/login',
data={'username': 'no-such-account', 'password': 'WrongPassword1'},
follow_redirects=True,
).get_data(as_text=True)
assert ('attempt(s) remaining' in existing) == ('attempt(s) remaining' in unknown)
assert failure_message(username) == failure_message('no-such-account')
+202
View File
@@ -0,0 +1,202 @@
"""Language selection and translation.
French is the primary language of the site; English stays available.
Source strings remain in English and act as gettext message ids, with the
French wording supplied by a catalogue. A string not yet translated
degrades to English rather than to a raw identifier — which is what lets
the migration proceed template by template without ever leaving the site
half broken.
"""
import pytest
from app.i18n import DEFAULT_LOCALE, SUPPORTED_LOCALES, select_locale
class TestDefaults:
def test_french_is_the_default_language(self):
assert DEFAULT_LOCALE == 'fr'
def test_english_is_available(self):
assert 'en' in SUPPORTED_LOCALES
def test_a_visitor_without_preferences_gets_french(self, client):
body = client.get('/auth/login').get_data(as_text=True)
assert 'lang="fr"' in body
assert 'Se connecter' in body
def test_the_html_lang_attribute_follows_the_locale(self, client):
english = client.get('/auth/login',
headers={'Accept-Language': 'en-CA,en;q=0.9'})
assert 'lang="en"' in english.get_data(as_text=True)
class TestBrowserNegotiation:
def test_an_english_browser_is_served_english(self, client):
body = client.get('/auth/login',
headers={'Accept-Language': 'en-CA,en;q=0.9'}
).get_data(as_text=True)
assert 'Sign In' in body
def test_an_unsupported_language_falls_back_to_french(self, client):
body = client.get('/auth/login',
headers={'Accept-Language': 'de-DE,de;q=0.9'}
).get_data(as_text=True)
assert 'Se connecter' in body
def test_a_french_browser_is_served_french(self, client):
body = client.get('/auth/login',
headers={'Accept-Language': 'fr-CA,fr;q=0.9'}
).get_data(as_text=True)
assert 'Se connecter' in body
class TestExplicitSwitch:
def test_switching_to_english_changes_the_page(self, client):
client.get('/lang/en')
body = client.get('/auth/login').get_data(as_text=True)
assert 'Sign In' in body
assert 'lang="en"' in body
def test_an_explicit_choice_overrides_the_browser_header(self, client):
"""Someone on an English machine who picks French must keep French."""
client.get('/lang/fr')
body = client.get('/auth/login',
headers={'Accept-Language': 'en-CA,en;q=0.9'}
).get_data(as_text=True)
assert 'Se connecter' in body
assert 'lang="fr"' in body
def test_the_choice_persists_across_requests(self, client):
client.get('/lang/en')
for _ in range(3):
assert 'Sign In' in client.get('/auth/login').get_data(as_text=True)
def test_an_unsupported_locale_is_refused(self, client):
client.get('/lang/en')
client.get('/lang/de')
body = client.get('/auth/login').get_data(as_text=True)
assert 'Sign In' in body, 'an unsupported code must not change the locale'
def test_the_switcher_redirects_back_to_the_referring_page(self, client):
response = client.get('/lang/en',
headers={'Referer': 'http://localhost/auth/register'},
follow_redirects=False)
assert response.headers['Location'].endswith('/auth/register')
def test_an_external_referer_is_not_followed(self, client):
"""An unchecked Referer would make this an open redirect."""
response = client.get('/lang/en',
headers={'Referer': 'https://evil.test/phishing'},
follow_redirects=False)
assert 'evil.test' not in response.headers['Location']
class TestSwitcherAvailability:
def test_the_switcher_is_visible_before_signing_in(self, client):
"""Someone who cannot read the current language has to be able to
change it without signing in first."""
body = client.get('/auth/login').get_data(as_text=True)
assert 'English' in body
assert '/lang/en' in body
def test_the_switcher_is_visible_once_signed_in(self, client, as_role):
as_role('player')
body = client.get('/users/profile').get_data(as_text=True)
assert 'English' in body
assert '/lang/en' in body
def test_each_language_is_named_in_its_own_language(self, client):
body = client.get('/auth/login').get_data(as_text=True)
assert 'English' in body
client.get('/lang/en')
body = client.get('/auth/login').get_data(as_text=True)
assert 'Français' in body
class TestTranslatedContent:
def test_navigation_is_translated(self, client, as_role):
as_role('player')
body = client.get('/users/profile').get_data(as_text=True)
assert 'Tableau de bord' in body
assert 'Déconnexion' in body
def test_error_pages_are_translated(self, client):
body = client.get('/no-such-page').get_data(as_text=True)
assert 'Page introuvable' in body
@pytest.mark.parametrize('locale,expected', [
('fr', 'Ce compte a été désactivé.'),
('en', 'This account has been deactivated.'),
])
def test_flash_messages_are_translated(
self, app, client, make_user, login, locale, expected
):
from app.extensions import db
from app.models import User
user_id = make_user('player')
with app.app_context():
user = db.session.get(User, user_id)
user.is_active_account = False
username = user.username
db.session.commit()
client.get(f'/lang/{locale}')
body = login(username).get_data(as_text=True)
assert expected in body
class TestCatalogueIntegrity:
"""A missing compiled catalogue is invisible at runtime: the site simply
serves English everywhere. Worth failing a build over."""
@pytest.mark.parametrize('locale', SUPPORTED_LOCALES)
def test_the_compiled_catalogue_exists(self, locale):
import os
path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'app', 'translations', locale, 'LC_MESSAGES', 'messages.mo',
)
assert os.path.exists(path), (
f'{locale} catalogue is not compiled: run '
'`pybabel compile -d app/translations`'
)
@pytest.mark.parametrize('locale', SUPPORTED_LOCALES)
def test_every_message_is_translated(self, locale):
import io
import os
from babel.messages.pofile import read_po
path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'app', 'translations', locale, 'LC_MESSAGES', 'messages.po',
)
with io.open(path, encoding='utf-8') as handle:
catalog = read_po(handle, locale=locale)
untranslated = [m.id for m in catalog if m.id and not m.string]
assert not untranslated, (
f'{len(untranslated)} untranslated string(s) in {locale}: '
f'{untranslated[:5]}'
)
class TestSelectorUnit:
def test_select_locale_returns_a_supported_code(self, app):
with app.test_request_context('/'):
assert select_locale() in SUPPORTED_LOCALES