diff --git a/.gitignore b/.gitignore index f308b32..624e58b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/app.py b/app/app.py index 1252f0a..8c02398 100644 --- a/app/app.py +++ b/app/app.py @@ -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 diff --git a/app/extensions.py b/app/extensions.py index c342d63..e49214c 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -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, diff --git a/app/i18n.py b/app/i18n.py new file mode 100644 index 0000000..8573d9f --- /dev/null +++ b/app/i18n.py @@ -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 diff --git a/app/routes/auth.py b/app/routes/auth.py index 93c9c40..247df7a 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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')) \ No newline at end of file diff --git a/app/routes/main.py b/app/routes/main.py index d69d85c..b119eaf 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -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/') +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(): diff --git a/app/static/css/style.css b/app/static/css/style.css index 847abf1..44fad61 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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; +} diff --git a/app/templates/errors/400.html b/app/templates/errors/400.html index 2e2ad56..237ea11 100644 --- a/app/templates/errors/400.html +++ b/app/templates/errors/400.html @@ -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 %}
-

400 — Bad Request

-

The request could not be understood by the server. Please check your input and try again.

+

{{ _('400 — Bad Request') }}

+

{{ _('The request could not be understood by the server. Please check your input and try again.') }}

- Go Back + {{ _('Go Back') }}
{% endblock %} \ No newline at end of file diff --git a/app/templates/errors/403.html b/app/templates/errors/403.html index 7309d3b..a415853 100644 --- a/app/templates/errors/403.html +++ b/app/templates/errors/403.html @@ -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 %}
-

403 — Forbidden

-

You do not have permission to access this resource. If you believe this is an error, please contact an administrator.

+

{{ _('403 — Forbidden') }}

+

{{ _('You do not have permission to access this resource. If you believe this is an error, please contact an administrator.') }}

- Go Back + {{ _('Go Back') }}
{% endblock %} \ No newline at end of file diff --git a/app/templates/errors/404.html b/app/templates/errors/404.html index 4c80ff3..e17536a 100644 --- a/app/templates/errors/404.html +++ b/app/templates/errors/404.html @@ -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 %}
-

404 — Not Found

-

The page you are looking for does not exist. It may have been moved or deleted.

+

{{ _('404 — Not Found') }}

+

{{ _('The page you are looking for does not exist. It may have been moved or deleted.') }}

- Return Home + {{ _('Return Home') }}
{% endblock %} \ No newline at end of file diff --git a/app/templates/errors/429.html b/app/templates/errors/429.html index 72e9beb..92bed44 100644 --- a/app/templates/errors/429.html +++ b/app/templates/errors/429.html @@ -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 %}
-

429 — Too Many Requests

-

You have sent too many requests in a short period. Please wait a moment and try again.

+

{{ _('429 — Too Many Requests') }}

+

{{ _('You have sent too many requests in a short period. Please wait a moment and try again.') }}

- Go Back + {{ _('Go Back') }}
{% endblock %} \ No newline at end of file diff --git a/app/templates/errors/500.html b/app/templates/errors/500.html index b57aeaa..ffaab59 100644 --- a/app/templates/errors/500.html +++ b/app/templates/errors/500.html @@ -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 %}
-

500 — Internal Server Error

-

Something went wrong on our end. The error has been logged and will be investigated. Please try again later.

+

{{ _('500 — Internal Server Error') }}

+

{{ _('Something went wrong on our end. The error has been logged and will be investigated. Please try again later.') }}

- Try Again + {{ _('Try Again') }}
{% endblock %} \ No newline at end of file diff --git a/app/templates/layouts/_language_switcher.html b/app/templates/layouts/_language_switcher.html new file mode 100644 index 0000000..c958107 --- /dev/null +++ b/app/templates/layouts/_language_switcher.html @@ -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. #} + +{{ _('Language') }} +{% for code in supported_locales %} + {%- if code == current_locale %} + {{ locale_names[code] }} + {%- else %} + {{ locale_names[code] }} + {%- endif %} + {%- if not loop.last %}{% endif %} +{% endfor %} diff --git a/app/templates/layouts/base.html b/app/templates/layouts/base.html index 22676c3..83f5dde 100644 --- a/app/templates/layouts/base.html +++ b/app/templates/layouts/base.html @@ -1,5 +1,5 @@ - + @@ -30,26 +30,26 @@
  • - Dashboard + {{ _('Dashboard') }}
  • - Tryouts + {{ _('Tryouts') }}
  • - Calendar + {{ _('Calendar') }}
  • {% if current_user.can_evaluate() %}
  • - Evaluations + {{ _('Evaluations') }}
  • {% endif %} @@ -57,14 +57,14 @@
  • - My Team(s) + {{ _('My Team(s)') }}
  • {% else %}
  • - Manage Teams + {{ _('Manage Teams') }}
  • {% endif %} @@ -72,7 +72,7 @@
  • - Manage Users + {{ _('Manage Users') }}
  • {% endif %} @@ -80,7 +80,7 @@
  • - My Notes + {{ _('My Notes') }}
  • {% endif %} @@ -88,35 +88,39 @@
  • - Availability + {{ _('Availability') }}
  • - Notes & One on One + {{ _('Notes & One on One') }}
  • {% endif %}
  • - Contracts + {{ _('Contracts') }}
  • - My Profile + {{ _('My Profile') }}
  • - Logout + {{ _('Logout') }}
  • + + @@ -126,7 +130,7 @@ - + + {% endblock %} \ No newline at end of file diff --git a/app/translations/en/LC_MESSAGES/messages.mo b/app/translations/en/LC_MESSAGES/messages.mo new file mode 100644 index 0000000..2b4f6ec Binary files /dev/null and b/app/translations/en/LC_MESSAGES/messages.mo differ diff --git a/app/translations/en/LC_MESSAGES/messages.po b/app/translations/en/LC_MESSAGES/messages.po new file mode 100644 index 0000000..71c9008 --- /dev/null +++ b/app/translations/en/LC_MESSAGES/messages.po @@ -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 , 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 \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" + diff --git a/app/translations/fr/LC_MESSAGES/messages.mo b/app/translations/fr/LC_MESSAGES/messages.mo new file mode 100644 index 0000000..dfa46ba Binary files /dev/null and b/app/translations/fr/LC_MESSAGES/messages.mo differ diff --git a/app/translations/fr/LC_MESSAGES/messages.po b/app/translations/fr/LC_MESSAGES/messages.po new file mode 100644 index 0000000..20d0ac5 --- /dev/null +++ b/app/translations/fr/LC_MESSAGES/messages.po @@ -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 , 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 \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" + diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..6cbc1ac --- /dev/null +++ b/babel.cfg @@ -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] diff --git a/docs/translations.md b/docs/translations.md new file mode 100644 index 0000000..9011b5b --- /dev/null +++ b/docs/translations.md @@ -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 +{{ _('Dashboard') }} +

    {{ _('The page you are looking for does not exist.') }}

    +``` + +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 `