feat(i18n): traduire les messages flash et de validation

198 appels flash dans les sept modules de routes, plus les 23 messages de
validation de app/validators.py. Le catalogue compte desormais 631 chaines,
aucune non traduite.

validators.py utilise lazy_gettext : les champs de schema sont construits a
l'import, donc avant qu'une requete existe. Un gettext ordinaire s'y
resoudrait une seule fois, dans la langue active au demarrage.

Un bug introduit par la conversion, puis corrige
  Le convertisseur automatique ne voyait que le premier litteral d'un appel
  flash, ce qui a casse deux chaines concatenees sur plusieurs lignes dans
  users.py -- le resultat n'etait meme pas du Python valide. Ma premiere
  verification ne l'a pas vu : elle enchainait py_compile sur head, or head
  reussit toujours, donc le "OK" s'affichait quoi qu'il arrive. Les deux
  appels sont reecrits et la verification refaite correctement.

Un bug plus interessant, revele par le test de fumee
  La langue choisie ne survivait pas a la connexion. login() et logout()
  appellent tous deux session.clear() -- l'un contre la fixation de session,
  l'autre pour terminer la session -- et le choix de langue partait avec le
  reste. Concretement : quelqu'un qui lisait la page de connexion en anglais
  se retrouvait en francais des qu'il se connectait.

  La langue est une preference d'affichage, pas un etat appartenant au
  compte. Les deux endroits la reportent maintenant explicitement, a cote du
  jeton CSRF. Quatre tests couvrent le cas, dont un qui verifie que corriger
  une cle preservee n'a pas fait tomber l'autre.

Detail de nommage : le convertisseur avait genere %(value)s pour une
expression conditionnelle, ce qui n'aide pas un traducteur. Renomme en
%(player)s.

Les 14 traductions ecrites avec une apostrophe droite sont normalisees en
apostrophe typographique. Sans consequence en HTML, ou ' s'affiche
correctement -- mais les blocs <script> ne decodent pas les entites, et
autant que le catalogue soit homogene.

200 tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
GGThed
2026-08-07 22:12:27 -04:00
co-authored by Claude Opus 5
parent 80bc1413f1
commit 37f70c89e3
15 changed files with 1639 additions and 249 deletions
+72 -71
View File
@@ -7,6 +7,7 @@ import os
import uuid
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
from flask_login import login_required, current_user
from flask_babel import gettext as _
from app.extensions import db, hash_password
from app.models import (
Admin, Manager, Coach, Player, Scout,
@@ -42,7 +43,7 @@ def _flash_validation_errors(err):
"""Surface marshmallow errors the same way auth.py already does."""
for field, messages in err.messages.items():
for msg in messages:
flash(f'{field}: {msg}', 'danger')
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
def _form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('password',)):
@@ -112,7 +113,7 @@ _USER_CLASS_MAP = {
def list_users():
"""List all users for management (Admin only)."""
if not isinstance(current_user, Admin):
flash('Only the president can manage users.', 'danger')
flash(_('Only the president can manage users.'), 'danger')
return redirect(url_for('main.dashboard'))
users = User.query.order_by(User.role, User.username).all()
@@ -124,7 +125,7 @@ def list_users():
def edit_user(user_id):
"""Edit an existing user (Admin only)."""
if not isinstance(current_user, Admin):
flash('Only the president can edit users.', 'danger')
flash(_('Only the president can edit users.'), 'danger')
return redirect(url_for('main.dashboard'))
user = User.query.get_or_404(user_id)
@@ -164,15 +165,15 @@ def edit_user(user_id):
# address surfaced as an IntegrityError, i.e. a 500.
clash = User.query.filter(User.email == email, User.id != user.id).first()
if clash:
flash('Email already in use by another account.', 'danger')
flash(_('Email already in use by another account.'), 'danger')
return _rerender()
if user.role != role:
# Two ways to lock everyone out of administration, neither of
# which any interface can undo afterwards.
if user.id == actor_id:
flash('You cannot change your own role. Ask another president '
'to do it.', 'danger')
flash(_('You cannot change your own role. Ask another '
'president to do it.'), 'danger')
return _rerender()
if user.role == 'admin':
@@ -182,8 +183,8 @@ def edit_user(user_id):
User.id != user.id,
).count()
if remaining_admins == 0:
flash('This is the last active president. Promote another '
'account before changing this one.', 'danger')
flash(_('This is the last active president. Promote '
'another account before changing this one.'), 'danger')
return _rerender()
# Change role via raw SQL to avoid polymorphic identity corruption.
@@ -228,7 +229,7 @@ def edit_user(user_id):
log_auth_event('account.updated',
actor=actor_name, actor_id=actor_id,
target=user.username, target_id=user.id, active=is_active)
flash(f'User {user.username} updated successfully!', 'success')
flash(_('User %(username)s updated successfully!', username=user.username), 'success')
return redirect(url_for('users.list_users'))
user_gamertags = {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform}
@@ -243,11 +244,11 @@ def edit_user(user_id):
def delete_user(user_id):
"""Delete a user (Admin only)."""
if not isinstance(current_user, Admin):
flash('Only the president can delete users.', 'danger')
flash(_('Only the president can delete users.'), 'danger')
return redirect(url_for('main.dashboard'))
if current_user.id == user_id:
flash('You cannot delete your own account.', 'danger')
flash(_('You cannot delete your own account.'), 'danger')
return redirect(url_for('users.list_users'))
user = User.query.get_or_404(user_id)
@@ -284,7 +285,7 @@ def delete_user(user_id):
log_auth_event('account.deleted',
actor=current_user.username, actor_id=current_user.id,
target=deleted_username, target_id=user_id, role=deleted_role)
flash(f'User {deleted_username} has been removed.', 'success')
flash(_('User %(deleted_username)s has been removed.', deleted_username=deleted_username), 'success')
return redirect(url_for('users.list_users'))
@@ -293,7 +294,7 @@ def delete_user(user_id):
def create_user():
"""Create a new user (Admin only). Uses the correct polymorphic subclass."""
if not isinstance(current_user, Admin):
flash('Only the president can create users.', 'danger')
flash(_('Only the president can create users.'), 'danger')
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
@@ -313,11 +314,11 @@ def create_user():
role = validated['role']
if User.query.filter_by(username=username).first():
flash('Username already exists.', 'danger')
flash(_('Username already exists.'), 'danger')
return render_template('pages/create_user.html', roles=USER_TYPES)
if User.query.filter_by(email=email).first():
flash('Email already registered.', 'danger')
flash(_('Email already registered.'), 'danger')
return render_template('pages/create_user.html', roles=USER_TYPES)
hashed_password = hash_password(password)
@@ -332,7 +333,7 @@ def create_user():
log_auth_event('account.created_by_admin',
actor=current_user.username, actor_id=current_user.id,
target=user.username, target_id=user.id, role=role)
flash(f'User {full_name} created as {role}!', 'success')
flash(_('User %(full_name)s created as %(role)s!', full_name=full_name, role=role), 'success')
return redirect(url_for('users.list_users'))
return render_template('pages/create_user.html', roles=USER_TYPES)
@@ -389,13 +390,13 @@ def edit_profile():
league_os_profile = validated.get('league_os_profile')
if username != current_user.username and User.query.filter_by(username=username).first():
flash('Username already taken.', 'danger')
flash(_('Username already taken.'), 'danger')
return render_template('pages/edit_profile.html', user=current_user,
esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags())
if email != current_user.email and User.query.filter_by(email=email).first():
flash('Email already in use.', 'danger')
flash(_('Email already in use.'), 'danger')
return render_template('pages/edit_profile.html', user=current_user,
esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags())
@@ -420,7 +421,7 @@ def edit_profile():
username=current_user.username, user_id=current_user.id)
db.session.commit()
flash('Profile updated successfully!', 'success')
flash(_('Profile updated successfully!'), 'success')
return redirect(url_for('users.profile'))
return render_template('pages/edit_profile.html', user=current_user,
@@ -625,7 +626,7 @@ def list_contracts():
def upload_contract():
"""Upload a contract for a player."""
if not isinstance(current_user, (Admin, Manager, Coach)):
flash('Only presidents, managers, and coaches can upload contracts.', 'danger')
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
return redirect(url_for('users.list_contracts'))
if isinstance(current_user, Coach):
@@ -645,26 +646,26 @@ def upload_contract():
except ValidationError as err:
for field, messages in err.messages.items():
for msg in messages:
flash(f'{field}: {msg}', 'danger')
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
return render_template('pages/upload_contract.html', players=players)
player_id = validated['player_id']
notes = validated.get('notes')
if not can_manage_player_contract(current_user, player_id):
flash('You do not have permission to upload a contract for this player.', 'danger')
flash(_('You do not have permission to upload a contract for this player.'), 'danger')
return redirect(url_for('users.upload_contract'))
if 'contract_file' not in request.files:
flash('No file selected.', 'danger')
flash(_('No file selected.'), 'danger')
return redirect(url_for('users.upload_contract'))
file = request.files['contract_file']
if file.filename == '':
flash('No file selected.', 'danger')
flash(_('No file selected.'), 'danger')
return redirect(url_for('users.upload_contract'))
if not file.filename.lower().endswith('.pdf'):
flash('Only PDF files are allowed for contracts.', 'danger')
flash(_('Only PDF files are allowed for contracts.'), 'danger')
return redirect(url_for('users.upload_contract'))
upload_dir = os.path.join(os.getcwd(), 'documents', 'contrats signés')
@@ -697,7 +698,7 @@ def upload_contract():
)
db.session.add(contract)
db.session.commit()
flash(f'Contract uploaded successfully for {player.username}!', 'success')
flash(_('Contract uploaded successfully for %(username)s!', username=player.username), 'success')
return redirect(url_for('users.list_contracts'))
return render_template('pages/upload_contract.html', players=players)
@@ -709,16 +710,16 @@ def upload_signed_contract(contract_id):
"""Upload a signed contract (player only)."""
contract = Contract.query.get_or_404(contract_id)
if not contract.can_upload_signed(current_user):
flash('Only the player can upload their signed contract.', 'danger')
flash(_('Only the player can upload their signed contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
if 'signed_file' not in request.files:
flash('No file selected.', 'danger')
flash(_('No file selected.'), 'danger')
return redirect(url_for('users.list_contracts'))
file = request.files['signed_file']
if file.filename == '':
flash('No file selected.', 'danger')
flash(_('No file selected.'), 'danger')
return redirect(url_for('users.list_contracts'))
signed_filename = f"signed_{contract.stored_filename}"
@@ -729,7 +730,7 @@ def upload_signed_contract(contract_id):
contract.status = 'signed'
contract.signed_at = datetime.utcnow()
db.session.commit()
flash('Signed contract uploaded successfully!', 'success')
flash(_('Signed contract uploaded successfully!'), 'success')
return redirect(url_for('users.list_contracts'))
@@ -739,7 +740,7 @@ def download_contract(contract_id):
"""Download a contract file."""
contract = Contract.query.get_or_404(contract_id)
if not contract.can_view(current_user):
flash('You do not have permission to download this contract.', 'danger')
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(contract.file_path, as_attachment=True, download_name=contract.original_filename)
@@ -750,10 +751,10 @@ def download_signed_contract(contract_id):
"""Download a signed contract file."""
contract = Contract.query.get_or_404(contract_id)
if not contract.can_view(current_user):
flash('You do not have permission to download this contract.', 'danger')
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
if not contract.signed_file_path:
flash('No signed contract available.', 'danger')
flash(_('No signed contract available.'), 'danger')
return redirect(url_for('users.list_contracts'))
return send_file(contract.signed_file_path, as_attachment=True, download_name=contract.signed_filename)
@@ -820,7 +821,7 @@ def send_discord_notification(player_name, points, date_str, start_time_str, end
def one_on_one():
"""One on One request page for players."""
if not isinstance(current_user, Player):
flash('Only players can request One on One sessions.', 'danger')
flash(_('Only players can request One on One sessions.'), 'danger')
return redirect(url_for('main.dashboard'))
org_teams = current_user.get_org_teams()
@@ -828,7 +829,7 @@ def one_on_one():
coach = User.query.get(org_team.coach_id) if org_team and org_team.coach_id else None
if not coach:
flash('You do not have a coach assigned to your team.', 'info')
flash(_('You do not have a coach assigned to your team.'), 'info')
team_notes = []
if org_team:
@@ -855,7 +856,7 @@ def one_on_one():
points = request.form.get('points', '').strip()
if not coach:
flash('Cannot request One on One - no coach assigned.', 'danger')
flash(_('Cannot request One on One - no coach assigned.'), 'danger')
return redirect(url_for('users.one_on_one'))
try:
@@ -863,7 +864,7 @@ def one_on_one():
start_time = datetime.strptime(start_time_str, '%H:%M').time()
end_time = datetime.strptime(end_time_str, '%H:%M').time()
except (ValueError, TypeError):
flash('Invalid date or time format.', 'danger')
flash(_('Invalid date or time format.'), 'danger')
return redirect(url_for('users.one_on_one'))
check_date = datetime.strptime(date_str, '%Y-%m-%d')
@@ -877,7 +878,7 @@ def one_on_one():
)
if not is_available:
flash("The requested time is not within the coach's availability.", 'danger')
flash(_("The requested time is not within the coach's availability."), 'danger')
return redirect(url_for('users.one_on_one'))
request_obj = OneOnOneRequest(
@@ -900,7 +901,7 @@ def one_on_one():
request_id=request_obj.id,
)
flash('Your One on One request has been submitted!', 'success')
flash(_('Your One on One request has been submitted!'), 'success')
return redirect(url_for('users.one_on_one'))
# Build list of upcoming dates that have coach availability
@@ -935,17 +936,17 @@ def one_on_one():
def accept_one_on_one(request_id):
"""Coach accepts a One on One request."""
if not isinstance(current_user, Coach):
flash('Only coaches can accept One on One requests.', 'danger')
flash(_('Only coaches can accept One on One requests.'), 'danger')
return redirect(url_for('main.dashboard'))
request_obj = OneOnOneRequest.query.get_or_404(request_id)
if request_obj.coach_id != current_user.id:
flash('This request is not for you.', 'danger')
flash(_('This request is not for you.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if request_obj.status != 'pending':
flash('This request has already been processed.', 'info')
flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard'))
player = request_obj.player
@@ -967,7 +968,7 @@ def accept_one_on_one(request_id):
approved=True,
)
flash(f'One on One request from {player.username if player else "Unknown"} has been approved!', 'success')
flash(_('One on One request from %(player)s has been approved!', player=player.username if player else 'Unknown'), 'success')
return redirect(url_for('users.notes_dashboard'))
@@ -976,17 +977,17 @@ def accept_one_on_one(request_id):
def reject_one_on_one(request_id):
"""Coach rejects a One on One request."""
if not isinstance(current_user, Coach):
flash('Only coaches can reject One on One requests.', 'danger')
flash(_('Only coaches can reject One on One requests.'), 'danger')
return redirect(url_for('main.dashboard'))
request_obj = OneOnOneRequest.query.get_or_404(request_id)
if request_obj.coach_id != current_user.id:
flash('This request is not for you.', 'danger')
flash(_('This request is not for you.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if request_obj.status != 'pending':
flash('This request has already been processed.', 'info')
flash(_('This request has already been processed.'), 'info')
return redirect(url_for('users.notes_dashboard'))
rejection_reason = request.form.get('rejection_reason', '').strip()
@@ -1013,7 +1014,7 @@ def reject_one_on_one(request_id):
refusal_note=rejection_reason or None,
)
flash(f'One on One request from {player.username if player else "Unknown"} has been rejected.', 'info')
flash(_('One on One request from %(player)s has been rejected.', player=player.username if player else 'Unknown'), 'info')
return redirect(url_for('users.notes_dashboard'))
@@ -1026,7 +1027,7 @@ def reject_one_on_one(request_id):
def my_notes():
"""View personal and team notes for the current player."""
if not isinstance(current_user, Player):
flash('This page is for players only.', 'info')
flash(_('This page is for players only.'), 'info')
return redirect(url_for('main.dashboard'))
org_teams = current_user.get_org_teams()
@@ -1057,7 +1058,7 @@ def my_notes():
def manage_coach_availability():
"""Manage coach availability for One on One sessions."""
if not isinstance(current_user, Coach):
flash('Only coaches can manage availability.', 'danger')
flash(_('Only coaches can manage availability.'), 'danger')
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
@@ -1119,7 +1120,7 @@ def clear_coach_availability():
def notes_dashboard():
"""Notes and One on One dashboard for coaches."""
if not isinstance(current_user, Coach):
flash('Only coaches can access the notes dashboard.', 'danger')
flash(_('Only coaches can access the notes dashboard.'), 'danger')
return redirect(url_for('main.dashboard'))
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
@@ -1180,12 +1181,12 @@ def notes_dashboard():
def manage_team_notes():
"""Create or update team notes for the coach's org team."""
if not isinstance(current_user, Coach):
flash('Only coaches can manage team notes.', 'danger')
flash(_('Only coaches can manage team notes.'), 'danger')
return redirect(url_for('main.dashboard'))
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
if not org_team:
flash('You are not assigned to a team.', 'danger')
flash(_('You are not assigned to a team.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
content = request.form.get('content', '').strip()
@@ -1197,7 +1198,7 @@ def manage_team_notes():
)
db.session.add(note)
db.session.commit()
flash('Team notes saved successfully!', 'success')
flash(_('Team notes saved successfully!'), 'success')
return redirect(url_for('users.notes_dashboard'))
@@ -1211,23 +1212,23 @@ def manage_team_notes():
def manage_personal_notes():
"""Create a personal note for a player (coach only, simple form)."""
if not isinstance(current_user, Coach):
flash('Only coaches can manage personal notes.', 'danger')
flash(_('Only coaches can manage personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
player_id = request.form.get('player_id', type=int)
content = request.form.get('content', '').strip()
if not player_id or not content:
flash('Player and content are required.', 'danger')
flash(_('Player and content are required.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
player = User.query.get_or_404(player_id)
if not isinstance(player, Player):
flash('Can only add notes for players.', 'danger')
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not coach_can_access_player(current_user, player_id):
flash('You can only write notes about players you work with.', 'danger')
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
note = PersonalNote(
@@ -1237,7 +1238,7 @@ def manage_personal_notes():
)
db.session.add(note)
db.session.commit()
flash(f'Note added for {player.username}.', 'success')
flash(_('Note added for %(username)s.', username=player.username), 'success')
return redirect(url_for('users.notes_dashboard'))
@@ -1250,7 +1251,7 @@ def manage_personal_notes():
def add_personal_note():
"""Create a personal note for a player with optional context (coach only)."""
if not isinstance(current_user, Coach):
flash('Only coaches can add personal notes.', 'danger')
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
player_id = request.form.get('player_id', type=int)
@@ -1260,16 +1261,16 @@ def add_personal_note():
team_id_str = request.form.get('team_id')
if not player_id or not content:
flash('Player and content are required.', 'danger')
flash(_('Player and content are required.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
player = User.query.get_or_404(player_id)
if not isinstance(player, Player):
flash('Can only add notes for players.', 'danger')
flash(_('Can only add notes for players.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
if not coach_can_access_player(current_user, player_id):
flash('You can only write notes about players you work with.', 'danger')
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.notes_dashboard'))
note = PersonalNote(
@@ -1282,7 +1283,7 @@ def add_personal_note():
)
db.session.add(note)
db.session.commit()
flash(f'Note added for {player.username}.', 'success')
flash(_('Note added for %(username)s.', username=player.username), 'success')
return redirect(url_for('users.notes_dashboard'))
@@ -1295,7 +1296,7 @@ def add_personal_note():
def add_note_from_tryout(tryout_id):
"""Add a personal note for a player in the context of a tryout."""
if not isinstance(current_user, Coach):
flash('Only coaches can add personal notes.', 'danger')
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
@@ -1310,11 +1311,11 @@ def add_note_from_tryout(tryout_id):
content = request.form.get('content', '').strip()
if not player_id or not content:
flash('Player and content are required.', 'danger')
flash(_('Player and content are required.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
if not coach_can_access_player(current_user, player_id):
flash('You can only write notes about players you work with.', 'danger')
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
note = PersonalNote(
@@ -1325,7 +1326,7 @@ def add_note_from_tryout(tryout_id):
)
db.session.add(note)
db.session.commit()
flash('Note added successfully.', 'success')
flash(_('Note added successfully.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_template('pages/add_note.html',
@@ -1345,7 +1346,7 @@ def add_note_from_tryout(tryout_id):
def add_note_from_match(match_id):
"""Add a personal note for a player in the context of a match."""
if not isinstance(current_user, Coach):
flash('Only coaches can add personal notes.', 'danger')
flash(_('Only coaches can add personal notes.'), 'danger')
return redirect(url_for('main.dashboard'))
match_obj = Match.query.get_or_404(match_id)
@@ -1361,11 +1362,11 @@ def add_note_from_match(match_id):
content = request.form.get('content', '').strip()
if not player_id or not content:
flash('Player and content are required.', 'danger')
flash(_('Player and content are required.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
if not coach_can_access_player(current_user, player_id):
flash('You can only write notes about players you work with.', 'danger')
flash(_('You can only write notes about players you work with.'), 'danger')
return redirect(url_for('users.add_note_from_match', match_id=match_id))
note = PersonalNote(
@@ -1376,7 +1377,7 @@ def add_note_from_match(match_id):
)
db.session.add(note)
db.session.commit()
flash('Note added successfully.', 'success')
flash(_('Note added successfully.'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=match_obj.tryout_id))
return render_template('pages/add_note.html',