From 9647003c3f378916010ff29e46b739490a5f7ee0 Mon Sep 17 00:00:00 2001 From: GGThed Date: Sun, 16 Aug 2026 23:36:30 -0400 Subject: [PATCH 1/5] fix(auth): garder l identite Discord du cote verifie --- README.md | 7 +- app/routes/auth.py | 108 ++++++- app/routes/users/accounts.py | 10 + app/routes/users/profile.py | 2 - app/supporting_scripts/schema_report.py | 63 +++- app/templates/pages/edit_profile.html | 11 +- app/templates/pages/register.html | 2 - app/translations/en/LC_MESSAGES/messages.mo | Bin 45460 -> 45760 bytes app/translations/en/LC_MESSAGES/messages.po | 311 ++++++++++--------- app/translations/fr/LC_MESSAGES/messages.mo | Bin 49787 -> 50084 bytes app/translations/fr/LC_MESSAGES/messages.po | 315 +++++++++++--------- app/validators.py | 11 - docs/database-schema.md | 17 +- tests/test_discord_identity.py | 222 ++++++++++++++ tests/test_i18n.py | 61 ++++ tests/test_schema_report.py | 33 ++ 16 files changed, 833 insertions(+), 340 deletions(-) create mode 100644 tests/test_discord_identity.py diff --git a/README.md b/README.md index 6fff8c0..01036bd 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,11 @@ Ce qui **n'est pas** fait, pour que personne ne s'y fie : - **Les comptes créés par le formulaire d'inscription sont actifs immédiatement** : il n'y a pas d'étape de validation par le staff. Décision de produit en attente, voir `docs/roles-and-permissions.md`. -- **L'identité Discord** transite encore par un champ caché du formulaire - d'inscription : elle n'est pas prouvée par le passage OAuth2. +- **L'unicité Discord n'est pas encore garantie par PostgreSQL.** L'identité + OAuth reste désormais côté serveur, le profil ne peut plus réécrire le + snowflake et l'application refuse les nouvelles collisions. Les doublons + historiques doivent être relevés puis corrigés avant la contrainte + `UNIQUE` (`schema_report.py --check-discord-identities`). `docs/security-checklist.md` détaille la liste avant mise en production. diff --git a/app/routes/auth.py b/app/routes/auth.py index 0bff41d..d74a68d 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -21,10 +21,14 @@ from app.extensions import check_password, db, hash_password, limiter from app.i18n import LOCALE_SESSION_KEY from app.logging_config import log_auth_event from app.models import ESPORT_GAMES, Player, User -from app.validators import LoginSchema, RegisterSchema +from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id #: Session key holding the pending OAuth2 anti-forgery token. DISCORD_STATE_KEY = 'discord_oauth_state' +#: Whether the OAuth result should create a registration draft or relink the +#: signed-in account. Kept server-side and covered by the same signed session +#: as the anti-forgery state. +DISCORD_PURPOSE_KEY = 'discord_oauth_purpose' # Failed-attempt tracking. The tally is kept for the audit trail and for the # cool-off marker below; it no longer refuses a correct password (SEC-018). @@ -354,6 +358,15 @@ def register(): form_data = dict(request.form) form_data['games'] = request.form.getlist('games') + # Once Discord has authenticated the identity, neither its display + # name nor its snowflake is input data anymore. Remove any client + # copies before validation as well as before persistence: otherwise a + # forged, malformed hidden value can still make the verified flow fail. + discord_oauth = session.get('discord_oauth') or {} + if discord_oauth.get('id'): + form_data.pop('discord_username', None) + form_data.pop('discord_user_id', None) + refusal = check_registration_challenge(request.form) if refusal is not None: # Logged, because this is the only place abuse of the sign-up @@ -380,8 +393,18 @@ def register(): full_name = validated['full_name'] phone = validated.get('phone') selected_games = validated.get('games', []) - discord_username = validated.get('discord_username') - discord_user_id = validated.get('discord_user_id') + # The OAuth identity is server-side state. It used to be copied into + # hidden inputs and read back from request.form, which let anyone + # replace the verified Discord account before submitting (SEC-AUTH-005). + # A manual registration may still provide a display name, but never a + # Discord snowflake: that identifier is an authentication factor for + # bot reactions and must come from Discord itself. + discord_user_id = discord_oauth.get('id') + if discord_user_id: + discord_user_id = str(discord_user_id) + discord_username = ( + discord_oauth.get('username') if discord_user_id else validated.get('discord_username') + ) league_os_profile = validated.get('league_os_profile') if User.query.filter_by(username=username).first(): @@ -392,6 +415,13 @@ def register(): flash(_('Email already registered.'), 'danger') return _rerender_registration(form_data) + # The database constraint belongs to DB-002, after production has + # been backed up and deduplicated. Refuse new duplicates now instead + # of leaving the critical impersonation path open until then. + if discord_user_id and User.query.filter_by(discord_user_id=discord_user_id).first(): + flash(_('This Discord account is already linked to another account.'), 'danger') + return _rerender_registration(form_data) + hashed_password = hash_password(password) user = Player( username=username, @@ -451,11 +481,15 @@ def discord_login(): Returns: Response: Redirect to Discord authorization URL. """ + purpose = 'profile' if current_user.is_authenticated else 'registration' + session[DISCORD_PURPOSE_KEY] = purpose + return_endpoint = 'users.edit_profile' if purpose == 'profile' else 'auth.register' + # 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') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) # Anti-forgery token, required by RFC 6749 §10.12. Without it, an # attacker could have the victim's browser consume an authorization code @@ -479,16 +513,24 @@ def discord_login(): def discord_callback(): """Handle the OAuth2 callback from Discord. - Exchanges the authorization code for an access token, then fetches - the user's profile (/users/@me) and connections (/users/@me/connections). - Results are stored in the session and the user is redirected back to - the registration form where fields will be pre-filled. + Exchanges the authorization code for an access token, then fetches the + user's profile. During registration, connected game accounts are also + loaded into server-side draft state. For a signed-in profile relink, the + verified identity is written directly without passing through a form. Returns: - Response: Redirect to registration page. + Response: Redirect to the registration form or profile editor. """ # The state is consumed whatever happens next: a token is single-use, and # leaving it in the session would allow a replay. + purpose = session.pop(DISCORD_PURPOSE_KEY, 'registration') + if purpose == 'profile' and current_user.is_authenticated: + return_endpoint = 'users.edit_profile' + elif purpose == 'profile': + return_endpoint = 'auth.login' + else: + return_endpoint = 'auth.register' + expected_state = session.pop(DISCORD_STATE_KEY, None) received_state = request.args.get('state', '') @@ -500,12 +542,12 @@ def discord_callback(): ), 'danger', ) - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) code = request.args.get('code') if not code: flash(_('Discord authorization failed. No code received.'), 'danger') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) # Exchange the authorization code for an access token token_data = { @@ -529,11 +571,11 @@ def discord_callback(): access_token = token_json.get('access_token') except requests.RequestException: flash(_('Failed to connect to Discord. Please try again.'), 'danger') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) if not access_token: flash(_('Failed to obtain Discord access token.'), 'danger') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) auth_headers = {'Authorization': f'Bearer {access_token}'} @@ -548,7 +590,43 @@ def discord_callback(): user_data = user_response.json() except requests.RequestException: flash(_('Failed to fetch Discord user profile.'), 'danger') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) + + discord_user_id = user_data.get('id') + try: + if not discord_user_id: + raise ValidationError('missing Discord user id') + discord_user_id = str(discord_user_id) + validate_discord_user_id(discord_user_id) + except ValidationError: + flash(_('Failed to fetch Discord user profile.'), 'danger') + return redirect(url_for(return_endpoint)) + + if purpose == 'profile': + # If the session expired while Discord was open, do not turn a profile + # relink into registration state for an anonymous browser. + if not current_user.is_authenticated: + flash(_('Please log in to connect your Discord account.'), 'danger') + return redirect(url_for('auth.login')) + + clash = User.query.filter( + User.discord_user_id == discord_user_id, + User.id != current_user.id, + ).first() + if clash: + flash(_('This Discord account is already linked to another account.'), 'danger') + return redirect(url_for('users.edit_profile')) + + current_user.discord_user_id = discord_user_id + current_user.discord_username = user_data.get('username') or None + db.session.commit() + log_auth_event( + 'account.discord_linked', + username=current_user.username, + user_id=current_user.id, + ) + flash(_('Discord account connected!'), 'success') + return redirect(url_for('users.edit_profile')) # Fetch the user's connected gaming accounts connections = [] @@ -587,7 +665,7 @@ def discord_callback(): # Store in session for the registration form to use session['discord_oauth'] = { - 'id': user_data.get('id'), + 'id': discord_user_id, 'username': user_data.get('username'), 'avatar': user_data.get('avatar'), 'gamertag_suggestions': gamertag_suggestions, diff --git a/app/routes/users/accounts.py b/app/routes/users/accounts.py index 7942525..ef39cd8 100644 --- a/app/routes/users/accounts.py +++ b/app/routes/users/accounts.py @@ -111,6 +111,16 @@ def edit_user(user_id): flash(_('Email already in use by another account.'), 'danger') return _rerender() + discord_clash = None + if discord_user_id: + discord_clash = User.query.filter( + User.discord_user_id == discord_user_id, + User.id != user.id, + ).first() + if discord_clash: + flash(_('This Discord account is already linked to another account.'), 'danger') + return _rerender() + role_changed = user.role != role previous_role = user.role diff --git a/app/routes/users/profile.py b/app/routes/users/profile.py index 2898431..39cfeda 100644 --- a/app/routes/users/profile.py +++ b/app/routes/users/profile.py @@ -76,7 +76,6 @@ def edit_profile(): phone = validated.get('phone') selected_games = validated.get('games', []) discord_username = validated.get('discord_username') - discord_user_id = validated.get('discord_user_id') league_os_profile = validated.get('league_os_profile') if username != current_user.username and User.query.filter_by(username=username).first(): @@ -105,7 +104,6 @@ def edit_profile(): current_user.phone = phone current_user.games = ','.join(selected_games) if selected_games else None current_user.discord_username = discord_username or None - current_user.discord_user_id = discord_user_id or None current_user.league_os_profile = league_os_profile or None update_user_gamertags(current_user, selected_games) diff --git a/app/supporting_scripts/schema_report.py b/app/supporting_scripts/schema_report.py index 7942786..d9952f2 100644 --- a/app/supporting_scripts/schema_report.py +++ b/app/supporting_scripts/schema_report.py @@ -29,10 +29,13 @@ Usage # Also look for the seeded admin/password account (SEC-003) python app/supporting_scripts/schema_report.py --check-seed-accounts + # Find Discord identities that must be reconciled before UNIQUE (SEC-012) + python app/supporting_scripts/schema_report.py --check-discord-identities + Exit codes ---------- 0 the live schema matches the models - 1 drift found — the report says what + 1 drift or requested data risk found — the report says what 2 could not connect or read the catalogue Reading the output @@ -315,6 +318,42 @@ def find_seed_accounts(engine): return results +def find_duplicate_discord_identities(engine): + """Discord snowflakes claimed by more than one account (SEC-012). + + New links are now refused in application code, but existing production + rows predate that guard. These groups must be reconciled before Alembic + can add the database-level UNIQUE constraint. + + Returns: + list[tuple]: (discord_user_id, comma-separated usernames, count). + """ + from sqlalchemy import text + + with engine.connect() as connection: + rows = connection.execute( + text( + 'SELECT discord_user_id, COUNT(*) AS account_count ' + 'FROM users ' + "WHERE discord_user_id IS NOT NULL AND discord_user_id <> '' " + 'GROUP BY discord_user_id HAVING COUNT(*) > 1 ' + 'ORDER BY discord_user_id' + ) + ).fetchall() + + duplicates = [] + for discord_user_id, account_count in rows: + usernames = connection.execute( + text( + 'SELECT username FROM users ' + 'WHERE discord_user_id = :discord_user_id ORDER BY username' + ), + {'discord_user_id': discord_user_id}, + ).scalars() + duplicates.append((discord_user_id, ', '.join(usernames), account_count)) + return duplicates + + def main(argv=None): parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) parser.add_argument( @@ -327,6 +366,11 @@ def main(argv=None): action='store_true', help='Also look for the admin/password account seeded by clear_db.py (SEC-003).', ) + parser.add_argument( + '--check-discord-identities', + action='store_true', + help='Find duplicate Discord IDs that block the SEC-012 UNIQUE constraint.', + ) args = parser.parse_args(argv) if not args.url: @@ -375,9 +419,24 @@ def main(argv=None): ) print(f' {username} ({role}): {verdict}') + duplicate_discord_identities = [] + if args.check_discord_identities: + print('\n' + '=' * 78) + print('Duplicate Discord identities (SEC-012)') + print('=' * 78) + try: + duplicate_discord_identities = find_duplicate_discord_identities(engine) + except SQLAlchemyError as exc: + print(f'Could not check: {exc}') + else: + if not duplicate_discord_identities: + print('No Discord identity is shared by multiple accounts.') + for discord_user_id, usernames, account_count in duplicate_discord_identities: + print(f' {discord_user_id}: {account_count} accounts ({usernames})') + blocking = sum(1 for f in findings if f.severity == BLOCKING) print(f'\n{len(findings)} finding(s), {blocking} blocking.') - return 1 if findings else 0 + return 1 if findings or duplicate_discord_identities else 0 if __name__ == '__main__': # pragma: no cover diff --git a/app/templates/pages/edit_profile.html b/app/templates/pages/edit_profile.html index c9977e7..656970c 100644 --- a/app/templates/pages/edit_profile.html +++ b/app/templates/pages/edit_profile.html @@ -79,14 +79,13 @@
-
+
-
-
- - - {{ _('Enable Developer Mode in Discord → Right-click profile → Copy ID') }} + + + {% if user.discord_user_id %}{{ _('Reconnect') }}{% else %}{{ _('Connect Discord Account') }}{% endif %} +
diff --git a/app/templates/pages/register.html b/app/templates/pages/register.html index 6bcc23d..b1a90c1 100644 --- a/app/templates/pages/register.html +++ b/app/templates/pages/register.html @@ -57,8 +57,6 @@ {{ _('Reconnect') }}
- - {{ _('Discord connected. Game connections have been used to pre-fill your profile below.') }} diff --git a/app/translations/en/LC_MESSAGES/messages.mo b/app/translations/en/LC_MESSAGES/messages.mo index 23c3123b5ef362bd05882722c601ce3d82729c27..8fc3943a946fc204fe799869613b37189ae7fc6d 100644 GIT binary patch delta 10851 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 LgZ~>sI#KsO7oy6q delta 10681 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\n" "Language: en\n" @@ -23,8 +23,8 @@ msgstr "" msgid "Please log in to access this page." msgstr "Please log in to access this page." -#: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374 -#: app/routes/users/contracts.py:96 +#: app/forms.py:37 app/routes/auth.py:228 app/routes/auth.py:387 +#: app/routes/users/contracts.py:98 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s: %(msg)s" @@ -61,7 +61,7 @@ msgstr "Username is required." msgid "Password is required." msgstr "Password is required." -#: app/validators.py:227 app/validators.py:299 +#: app/validators.py:227 app/validators.py:294 msgid "Username must be 3-80 characters." msgstr "Username must be 3-80 characters." @@ -69,169 +69,169 @@ msgstr "Username must be 3-80 characters." msgid "Email must be 120 characters or less." msgstr "Email must be 120 characters or less." -#: app/validators.py:246 app/validators.py:314 app/validators.py:345 -#: app/validators.py:409 +#: app/validators.py:246 app/validators.py:309 app/validators.py:340 +#: app/validators.py:403 msgid "Full name is required." msgstr "Full name is required." -#: app/validators.py:281 +#: app/validators.py:276 msgid "Passwords do not match." msgstr "Passwords do not match." -#: app/validators.py:318 app/validators.py:353 +#: app/validators.py:313 app/validators.py:348 msgid "Invalid role selected." msgstr "Invalid role selected." -#: app/validators.py:454 +#: app/validators.py:443 msgid "Player must be selected." msgstr "Player must be selected." -#: app/validators.py:457 +#: app/validators.py:446 msgid "Notes must be 2000 characters or less." msgstr "Notes must be 2000 characters or less." -#: app/validators.py:494 app/validators.py:853 +#: app/validators.py:483 app/validators.py:854 msgid "Invalid coach selection." msgstr "Invalid coach selection." -#: app/validators.py:500 app/validators.py:859 +#: app/validators.py:489 app/validators.py:860 msgid "Invalid manager selection." msgstr "Invalid manager selection." -#: app/validators.py:511 +#: app/validators.py:507 msgid "Invalid player selection." msgstr "Invalid player selection." -#: app/validators.py:515 +#: app/validators.py:516 msgid "Unknown roster status." msgstr "Unknown roster status." -#: app/validators.py:538 +#: app/validators.py:539 msgid "Date must be in YYYY-MM-DD format." msgstr "Date must be in YYYY-MM-DD format." -#: app/validators.py:539 +#: app/validators.py:540 msgid "A date is required." msgstr "A date is required." -#: app/validators.py:545 app/validators.py:610 +#: app/validators.py:546 app/validators.py:611 msgid "Start time must be in HH:MM format." msgstr "Start time must be in HH:MM format." -#: app/validators.py:546 app/validators.py:611 +#: app/validators.py:547 app/validators.py:612 msgid "A start time is required." msgstr "A start time is required." -#: app/validators.py:552 +#: app/validators.py:553 msgid "End time must be in HH:MM format." msgstr "End time must be in HH:MM format." -#: app/validators.py:553 +#: app/validators.py:554 msgid "An end time is required." msgstr "An end time is required." -#: app/validators.py:557 +#: app/validators.py:558 msgid "Points must be 2000 characters or less." msgstr "Points must be 2000 characters or less." -#: app/validators.py:572 +#: app/validators.py:573 msgid "End time must be after start time." msgstr "End time must be after start time." -#: app/validators.py:601 app/validators.py:603 +#: app/validators.py:602 app/validators.py:604 msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Day must be 0 (Monday) to 6 (Sunday)." -#: app/validators.py:604 +#: app/validators.py:605 msgid "A day is required." msgstr "A day is required." -#: app/validators.py:639 +#: app/validators.py:640 msgid "Player selection is malformed." msgstr "Player selection is malformed." -#: app/validators.py:665 app/validators.py:775 +#: app/validators.py:666 app/validators.py:776 msgid "A title is required." msgstr "A title is required." -#: app/validators.py:674 +#: app/validators.py:675 msgid "Invalid date format." msgstr "Invalid date format." -#: app/validators.py:679 app/validators.py:686 +#: app/validators.py:680 app/validators.py:687 msgid "Invalid time format." msgstr "Invalid time format." -#: app/validators.py:680 +#: app/validators.py:681 msgid "Start time is required. Please select a time slot." msgstr "Start time is required. Please select a time slot." -#: app/validators.py:694 +#: app/validators.py:695 msgid "Unknown match status." msgstr "Unknown match status." -#: app/validators.py:710 +#: app/validators.py:711 msgid "The end time must come after the start time." msgstr "The end time must come after the start time." -#: app/validators.py:724 +#: app/validators.py:725 msgid "Unknown match type." msgstr "Unknown match type." -#: app/validators.py:736 +#: app/validators.py:737 msgid "A team cannot play against itself." msgstr "A team cannot play against itself." -#: app/validators.py:784 +#: app/validators.py:785 msgid "Unknown game." msgstr "Unknown game." -#: app/validators.py:789 +#: app/validators.py:790 msgid "Invalid start date format." msgstr "Invalid start date format." -#: app/validators.py:790 +#: app/validators.py:791 msgid "A start date is required." msgstr "A start date is required." -#: app/validators.py:796 +#: app/validators.py:797 msgid "Invalid end date format." msgstr "Invalid end date format." -#: app/validators.py:804 +#: app/validators.py:805 msgid "A tryout must allow at least one player." msgstr "A tryout must allow at least one player." -#: app/validators.py:807 +#: app/validators.py:808 msgid "The player limit must be a whole number." msgstr "The player limit must be a whole number." -#: app/validators.py:819 +#: app/validators.py:820 msgid "End date cannot be before start date." msgstr "End date cannot be before start date." -#: app/validators.py:846 app/validators.py:847 +#: app/validators.py:847 app/validators.py:848 msgid "Team name is required." msgstr "Team name is required." -#: app/validators.py:871 +#: app/validators.py:872 msgid "Scores run from 1 to 10." msgstr "Scores run from 1 to 10." -#: app/validators.py:872 +#: app/validators.py:873 msgid "A score must be a whole number from 1 to 10." msgstr "A score must be a whole number from 1 to 10." -#: app/routes/auth.py:241 +#: app/routes/auth.py:245 msgid "This account has been deactivated." msgstr "This account has been deactivated." -#: app/routes/auth.py:276 +#: app/routes/auth.py:280 #, python-format msgid "Welcome back, %(username)s!" msgstr "Welcome back, %(username)s!" -#: app/routes/auth.py:306 +#: app/routes/auth.py:310 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." @@ -239,27 +239,32 @@ msgstr "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." -#: app/routes/auth.py:363 +#: app/routes/auth.py:376 msgid "Your registration could not be processed. Please try again." msgstr "Your registration could not be processed. Please try again." -#: app/routes/auth.py:388 app/routes/users/accounts.py:329 +#: app/routes/auth.py:411 app/routes/users/accounts.py:339 msgid "Username already exists." msgstr "Username already exists." -#: app/routes/auth.py:392 app/routes/users/accounts.py:333 +#: app/routes/auth.py:415 app/routes/users/accounts.py:343 msgid "Email already registered." msgstr "Email already registered." -#: app/routes/auth.py:436 +#: app/routes/auth.py:422 app/routes/auth.py:617 +#: app/routes/users/accounts.py:121 +msgid "This Discord account is already linked to another account." +msgstr "This Discord account is already linked to another account." + +#: app/routes/auth.py:466 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:457 +#: app/routes/auth.py:491 msgid "Discord OAuth2 is not configured." msgstr "Discord OAuth2 is not configured." -#: app/routes/auth.py:498 +#: app/routes/auth.py:540 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -267,27 +272,35 @@ msgstr "" "Discord authorization could not be verified. Please start the connection " "again from this page." -#: app/routes/auth.py:507 +#: app/routes/auth.py:549 msgid "Discord authorization failed. No code received." msgstr "Discord authorization failed. No code received." -#: app/routes/auth.py:531 +#: app/routes/auth.py:573 msgid "Failed to connect to Discord. Please try again." msgstr "Failed to connect to Discord. Please try again." -#: app/routes/auth.py:535 +#: app/routes/auth.py:577 msgid "Failed to obtain Discord access token." msgstr "Failed to obtain Discord access token." -#: app/routes/auth.py:550 +#: app/routes/auth.py:592 app/routes/auth.py:602 msgid "Failed to fetch Discord user profile." msgstr "Failed to fetch Discord user profile." -#: app/routes/auth.py:597 +#: app/routes/auth.py:609 +msgid "Please log in to connect your Discord account." +msgstr "Please log in to connect your Discord account." + +#: app/routes/auth.py:628 +msgid "Discord account connected!" +msgstr "Discord account connected!" + +#: app/routes/auth.py:675 msgid "Discord account connected! Your profile has been pre-filled." msgstr "Discord account connected! Your profile has been pre-filled." -#: app/routes/auth.py:625 +#: app/routes/auth.py:703 msgid "You have been logged out." msgstr "You have been logged out." @@ -323,8 +336,8 @@ msgstr "Evaluation updated!" #: app/routes/teams.py:384 app/routes/teams.py:427 app/routes/teams.py:455 #: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:444 #: app/routes/tryouts.py:460 app/routes/tryouts.py:480 -#: app/routes/tryouts.py:519 app/routes/tryouts.py:555 -#: app/routes/tryouts.py:574 +#: app/routes/tryouts.py:527 app/routes/tryouts.py:563 +#: app/routes/tryouts.py:582 msgid "Permission denied." msgstr "Permission denied." @@ -463,7 +476,7 @@ msgstr "Coach removed from %(name)s." msgid "Manager removed from %(name)s." msgstr "Manager removed from %(name)s." -#: app/routes/teams.py:490 app/routes/tryouts.py:484 app/routes/tryouts.py:585 +#: app/routes/teams.py:490 app/routes/tryouts.py:489 app/routes/tryouts.py:593 msgid "Please select a player." msgstr "Please select a player." @@ -545,7 +558,7 @@ msgstr "This tryout is not accepting registrations." msgid "You are already registered for this tryout." msgstr "You are already registered for this tryout." -#: app/routes/tryouts.py:428 app/routes/tryouts.py:503 +#: app/routes/tryouts.py:428 app/routes/tryouts.py:511 msgid "This tryout is full." msgstr "This tryout is full." @@ -562,47 +575,47 @@ msgstr "Tryout status updated to %(new_status)s." msgid "Registration status updated." msgstr "Registration status updated." -#: app/routes/tryouts.py:489 +#: app/routes/tryouts.py:497 msgid "Can only register players." msgstr "Can only register players." -#: app/routes/tryouts.py:495 +#: app/routes/tryouts.py:503 #, python-format msgid "%(username)s is already registered for this tryout." msgstr "%(username)s is already registered for this tryout." -#: app/routes/tryouts.py:509 +#: app/routes/tryouts.py:517 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s registered for tryout!" -#: app/routes/tryouts.py:545 +#: app/routes/tryouts.py:553 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s removed from tryout." -#: app/routes/tryouts.py:563 +#: app/routes/tryouts.py:571 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Team \"%(team_name)s\" created!" -#: app/routes/tryouts.py:594 +#: app/routes/tryouts.py:602 msgid "That player is not registered for this tryout." msgstr "That player is not registered for this tryout." -#: app/routes/tryouts.py:600 +#: app/routes/tryouts.py:608 msgid "Player is already on this team." msgstr "Player is already on this team." -#: app/routes/tryouts.py:605 +#: app/routes/tryouts.py:613 msgid "Player added to team!" msgstr "Player added to team!" -#: app/routes/tryouts.py:615 +#: app/routes/tryouts.py:623 msgid "You do not have permission to delete this tryout." msgstr "You do not have permission to delete this tryout." -#: app/routes/tryouts.py:651 +#: app/routes/tryouts.py:659 msgid "Tryout deleted successfully." msgstr "Tryout deleted successfully." @@ -630,11 +643,11 @@ msgstr "Only the president can edit users." msgid "Email already in use by another account." msgstr "Email already in use by another account." -#: app/routes/users/accounts.py:122 +#: app/routes/users/accounts.py:132 msgid "You cannot change your own role. Ask another president to do it." msgstr "You cannot change your own role. Ask another president to do it." -#: app/routes/users/accounts.py:135 +#: app/routes/users/accounts.py:145 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -642,29 +655,29 @@ msgstr "" "This is the last active president. Promote another account before " "changing this one." -#: app/routes/users/accounts.py:214 +#: app/routes/users/accounts.py:224 #, python-format msgid "User %(username)s updated successfully!" msgstr "User %(username)s updated successfully!" -#: app/routes/users/accounts.py:235 +#: app/routes/users/accounts.py:245 msgid "Only the president can delete users." msgstr "Only the president can delete users." -#: app/routes/users/accounts.py:239 +#: app/routes/users/accounts.py:249 msgid "You cannot delete your own account." msgstr "You cannot delete your own account." -#: app/routes/users/accounts.py:298 +#: app/routes/users/accounts.py:308 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "User %(deleted_username)s has been removed." -#: app/routes/users/accounts.py:309 +#: app/routes/users/accounts.py:319 msgid "Only the president can create users." msgstr "Only the president can create users." -#: app/routes/users/accounts.py:357 +#: app/routes/users/accounts.py:367 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "User %(full_name)s created as %(role)s!" @@ -673,32 +686,32 @@ msgstr "User %(full_name)s created as %(role)s!" msgid "Only coaches can manage availability." msgstr "Only coaches can manage availability." -#: app/routes/users/contracts.py:84 +#: app/routes/users/contracts.py:86 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Only presidents, managers, and coaches can upload contracts." -#: app/routes/users/contracts.py:103 +#: app/routes/users/contracts.py:105 msgid "You do not have permission to upload a contract for this player." msgstr "You do not have permission to upload a contract for this player." -#: app/routes/users/contracts.py:143 +#: app/routes/users/contracts.py:145 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contract uploaded successfully for %(username)s!" -#: app/routes/users/contracts.py:157 +#: app/routes/users/contracts.py:159 msgid "Only the player can upload their signed contract." msgstr "Only the player can upload their signed contract." -#: app/routes/users/contracts.py:175 +#: app/routes/users/contracts.py:177 msgid "Signed contract uploaded successfully!" msgstr "Signed contract uploaded successfully!" -#: app/routes/users/contracts.py:185 app/routes/users/contracts.py:200 +#: app/routes/users/contracts.py:187 app/routes/users/contracts.py:202 msgid "You do not have permission to download this contract." msgstr "You do not have permission to download this contract." -#: app/routes/users/contracts.py:203 +#: app/routes/users/contracts.py:205 msgid "No signed contract available." msgstr "No signed contract available." @@ -796,15 +809,15 @@ msgstr "Only coaches can reject One on One requests." msgid "One on One request from %(player)s has been rejected." msgstr "One on One request from %(player)s has been rejected." -#: app/routes/users/profile.py:83 +#: app/routes/users/profile.py:82 msgid "Username already taken." msgstr "Username already taken." -#: app/routes/users/profile.py:93 +#: app/routes/users/profile.py:92 msgid "Email already in use." msgstr "Email already in use." -#: app/routes/users/profile.py:123 +#: app/routes/users/profile.py:121 msgid "Profile updated successfully!" msgstr "Profile updated successfully!" @@ -953,7 +966,7 @@ msgstr "%(total)s in total" msgid "Next" msgstr "Next" -#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:154 +#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:148 #: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3 msgid "Dashboard" msgstr "Dashboard" @@ -995,38 +1008,34 @@ msgid "My Notes" msgstr "My Notes" #: app/templates/layouts/base.html:106 -msgid "Availability" -msgstr "Availability" - -#: app/templates/layouts/base.html:112 msgid "Notes & One on One" msgstr "Notes & One on One" -#: app/templates/layouts/base.html:119 app/templates/pages/contracts.html:2 +#: app/templates/layouts/base.html:113 app/templates/pages/contracts.html:2 #: app/templates/pages/contracts.html:3 app/templates/pages/profile.html:9 msgid "Contracts" msgstr "Contracts" -#: app/templates/layouts/base.html:126 app/templates/pages/profile.html:2 +#: app/templates/layouts/base.html:120 app/templates/pages/profile.html:2 #: app/templates/pages/profile.html:3 msgid "My Profile" msgstr "My Profile" -#: app/templates/layouts/base.html:137 +#: app/templates/layouts/base.html:131 msgid "Logout" msgstr "Logout" -#: app/templates/layouts/base.html:158 +#: app/templates/layouts/base.html:152 msgid "Toggle dark mode" msgstr "Toggle dark mode" -#: app/templates/layouts/base.html:170 app/templates/layouts/base.html:189 +#: app/templates/layouts/base.html:164 app/templates/layouts/base.html:183 msgid "Dismiss" msgstr "Dismiss" -#: app/templates/layouts/base.html:199 -msgid "Team Tryout Management System" -msgstr "Team Tryout Management System" +#: app/templates/layouts/base.html:193 +msgid "UdeS team manager" +msgstr "UdeS team manager" #: app/templates/layouts/macros.html:116 msgid "Close" @@ -1209,6 +1218,10 @@ msgid "Loading availability grid..." msgstr "Loading availability grid..." #: app/templates/pages/coach_availability.html:19 +msgid "Save Availability" +msgstr "Save Availability" + +#: app/templates/pages/coach_availability.html:22 #: app/templates/pages/profile.html:200 app/templates/pages/profile.html:220 msgid "Clear All" msgstr "Clear All" @@ -1372,7 +1385,7 @@ msgid "Role" msgstr "Role" #: app/templates/pages/create_user.html:41 app/templates/pages/login.html:11 -#: app/templates/pages/register.html:139 +#: app/templates/pages/register.html:137 msgid "Password" msgstr "Password" @@ -1535,7 +1548,7 @@ msgstr "Edit Profile" #: app/templates/pages/edit_profile.html:33 #: app/templates/pages/edit_user.html:43 app/templates/pages/profile.html:63 -#: app/templates/pages/register.html:83 +#: app/templates/pages/register.html:81 msgid "E-Sports Profile" msgstr "E-Sports Profile" @@ -1544,12 +1557,12 @@ msgid "Update your competitive gaming profile for tryouts." msgstr "Update your competitive gaming profile for tryouts." #: app/templates/pages/edit_profile.html:37 -#: app/templates/pages/register.html:87 +#: app/templates/pages/register.html:85 msgid "Games You Play" msgstr "Games You Play" #: app/templates/pages/edit_profile.html:47 -#: app/templates/pages/register.html:101 +#: app/templates/pages/register.html:99 msgid "Select all games you're signing in for." msgstr "Select all games you're signing in for." @@ -1593,48 +1606,43 @@ msgid "e.g. Name#1234" msgstr "e.g. Name#1234" #: app/templates/pages/edit_profile.html:87 -#: app/templates/pages/edit_user.html:95 -msgid "(for DMs)" -msgstr "(for DMs)" +#: app/templates/pages/register.html:57 +msgid "Reconnect" +msgstr "Reconnect" -#: app/templates/pages/edit_profile.html:88 -#: app/templates/pages/edit_user.html:96 -msgid "Numeric ID (e.g. 123456789012345678)" -msgstr "Numeric ID (e.g. 123456789012345678)" +#: app/templates/pages/edit_profile.html:87 +#: app/templates/pages/register.html:67 +msgid "Connect Discord Account" +msgstr "Connect Discord Account" -#: app/templates/pages/edit_profile.html:89 -#: app/templates/pages/edit_user.html:97 -msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID" -msgstr "Enable Developer Mode in Discord → Right-click profile → Copy ID" - -#: app/templates/pages/edit_profile.html:94 +#: app/templates/pages/edit_profile.html:93 #: app/templates/pages/edit_user.html:100 msgid "League OS Connection" msgstr "League OS Connection" -#: app/templates/pages/edit_profile.html:95 -#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:131 +#: app/templates/pages/edit_profile.html:94 +#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:129 msgid "League OS profile link or ID" msgstr "League OS profile link or ID" -#: app/templates/pages/edit_profile.html:100 +#: app/templates/pages/edit_profile.html:99 msgid "Change Password" msgstr "Change Password" -#: app/templates/pages/edit_profile.html:101 +#: app/templates/pages/edit_profile.html:100 msgid "Leave blank to keep your current password." msgstr "Leave blank to keep your current password." -#: app/templates/pages/edit_profile.html:103 +#: app/templates/pages/edit_profile.html:102 msgid "New Password" msgstr "New Password" -#: app/templates/pages/edit_profile.html:104 +#: app/templates/pages/edit_profile.html:103 #: app/templates/pages/edit_user.html:107 msgid "Enter new password" msgstr "Enter new password" -#: app/templates/pages/edit_profile.html:109 app/templates/pages/teams.html:296 +#: app/templates/pages/edit_profile.html:108 app/templates/pages/teams.html:296 msgid "Save Changes" msgstr "Save Changes" @@ -1650,6 +1658,18 @@ msgstr "Games" msgid "Enter gamertag for each selected game to link to Tracker Network." msgstr "Enter gamertag for each selected game to link to Tracker Network." +#: app/templates/pages/edit_user.html:95 +msgid "(for DMs)" +msgstr "(for DMs)" + +#: app/templates/pages/edit_user.html:96 +msgid "Numeric ID (e.g. 123456789012345678)" +msgstr "Numeric ID (e.g. 123456789012345678)" + +#: app/templates/pages/edit_user.html:97 +msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID" +msgstr "Enable Developer Mode in Discord → Right-click profile → Copy ID" + #: app/templates/pages/edit_user.html:106 msgid "(leave blank to keep current)" msgstr "(leave blank to keep current)" @@ -2365,7 +2385,7 @@ msgstr "" "Select time slots when you're available for One on One sessions (8am to " "10pm)." -#: app/templates/pages/profile.html:454 +#: app/templates/pages/profile.html:460 msgid "Click or click-and-drag to select your available hours" msgstr "Click or click-and-drag to select your available hours" @@ -2413,11 +2433,7 @@ msgstr "" msgid "Connected" msgstr "Connected" -#: app/templates/pages/register.html:57 -msgid "Reconnect" -msgstr "Reconnect" - -#: app/templates/pages/register.html:63 +#: app/templates/pages/register.html:61 msgid "" "Discord connected. Game connections have been used to pre-fill your " "profile below." @@ -2426,50 +2442,46 @@ msgstr "" "profile below." #: app/templates/pages/register.html:69 -msgid "Connect Discord Account" -msgstr "Connect Discord Account" - -#: app/templates/pages/register.html:71 msgid "Connect to pre-fill your gamertags from Steam, Battle.net, Xbox, etc." msgstr "Connect to pre-fill your gamertags from Steam, Battle.net, Xbox, etc." -#: app/templates/pages/register.html:74 +#: app/templates/pages/register.html:72 msgid "Discord Username (Manual)" msgstr "Discord Username (Manual)" -#: app/templates/pages/register.html:75 +#: app/templates/pages/register.html:73 msgid "e.g. YourName" msgstr "e.g. YourName" -#: app/templates/pages/register.html:84 +#: app/templates/pages/register.html:82 msgid "Set up your competitive gaming profile for tryouts." msgstr "Set up your competitive gaming profile for tryouts." -#: app/templates/pages/register.html:129 +#: app/templates/pages/register.html:127 msgid "League OS Connection (Optional)" msgstr "League OS Connection (Optional)" -#: app/templates/pages/register.html:133 +#: app/templates/pages/register.html:131 msgid "Connect your League OS profile for organized play." msgstr "Connect your League OS profile for organized play." -#: app/templates/pages/register.html:137 +#: app/templates/pages/register.html:135 msgid "Security" msgstr "Security" -#: app/templates/pages/register.html:140 +#: app/templates/pages/register.html:138 msgid "Create a password" msgstr "Create a password" -#: app/templates/pages/register.html:144 +#: app/templates/pages/register.html:142 msgid "Confirm Password" msgstr "Confirm Password" -#: app/templates/pages/register.html:145 +#: app/templates/pages/register.html:143 msgid "Confirm your password" msgstr "Confirm your password" -#: app/templates/pages/register.html:159 +#: app/templates/pages/register.html:157 msgid "Create Account" msgstr "Create Account" @@ -2984,3 +2996,8 @@ msgstr "View Profile" #~ msgid "Invalid date or time format." #~ msgstr "Invalid date or time format." +#~ msgid "Availability" +#~ msgstr "Availability" + +#~ msgid "Team Tryout Management System" +#~ msgstr "Team Tryout Management System" diff --git a/app/translations/fr/LC_MESSAGES/messages.mo b/app/translations/fr/LC_MESSAGES/messages.mo index b9f3180a3f7382439cb68f64561b4ec9a91a504d..bc11174bc224b8470f7d42f75cd45fff6a099738 100644 GIT binary patch delta 10871 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 delta 10703 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 RU|(<8n_v2R@9*Cc@_*!j(NzEd diff --git a/app/translations/fr/LC_MESSAGES/messages.po b/app/translations/fr/LC_MESSAGES/messages.po index 690e0cc..ea43343 100644 --- a/app/translations/fr/LC_MESSAGES/messages.po +++ b/app/translations/fr/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-11 20:39-0400\n" +"POT-Creation-Date: 2026-08-16 23:22-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -23,8 +23,8 @@ msgstr "" msgid "Please log in to access this page." msgstr "Veuillez vous connecter pour accéder à cette page." -#: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374 -#: app/routes/users/contracts.py:96 +#: app/forms.py:37 app/routes/auth.py:228 app/routes/auth.py:387 +#: app/routes/users/contracts.py:98 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s : %(msg)s" @@ -63,7 +63,7 @@ msgstr "Le nom d’utilisateur est obligatoire." msgid "Password is required." msgstr "Le mot de passe est obligatoire." -#: app/validators.py:227 app/validators.py:299 +#: app/validators.py:227 app/validators.py:294 msgid "Username must be 3-80 characters." msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères." @@ -71,169 +71,169 @@ msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères." msgid "Email must be 120 characters or less." msgstr "L’adresse courriel ne doit pas dépasser 120 caractères." -#: app/validators.py:246 app/validators.py:314 app/validators.py:345 -#: app/validators.py:409 +#: app/validators.py:246 app/validators.py:309 app/validators.py:340 +#: app/validators.py:403 msgid "Full name is required." msgstr "Le nom complet est obligatoire." -#: app/validators.py:281 +#: app/validators.py:276 msgid "Passwords do not match." msgstr "Les mots de passe ne concordent pas." -#: app/validators.py:318 app/validators.py:353 +#: app/validators.py:313 app/validators.py:348 msgid "Invalid role selected." msgstr "Rôle sélectionné invalide." -#: app/validators.py:454 +#: app/validators.py:443 msgid "Player must be selected." msgstr "Vous devez choisir un joueur." -#: app/validators.py:457 +#: app/validators.py:446 msgid "Notes must be 2000 characters or less." msgstr "Les notes ne doivent pas dépasser 2000 caractères." -#: app/validators.py:494 app/validators.py:853 +#: app/validators.py:483 app/validators.py:854 msgid "Invalid coach selection." msgstr "Sélection de coach invalide." -#: app/validators.py:500 app/validators.py:859 +#: app/validators.py:489 app/validators.py:860 msgid "Invalid manager selection." msgstr "Sélection de gérant invalide." -#: app/validators.py:511 +#: app/validators.py:507 msgid "Invalid player selection." msgstr "Sélection de joueur invalide." -#: app/validators.py:515 +#: app/validators.py:516 msgid "Unknown roster status." msgstr "Statut d'effectif inconnu." -#: app/validators.py:538 +#: app/validators.py:539 msgid "Date must be in YYYY-MM-DD format." msgstr "La date doit être au format AAAA-MM-JJ." -#: app/validators.py:539 +#: app/validators.py:540 msgid "A date is required." msgstr "Une date est requise." -#: app/validators.py:545 app/validators.py:610 +#: app/validators.py:546 app/validators.py:611 msgid "Start time must be in HH:MM format." msgstr "L’heure de début doit être au format HH:MM." -#: app/validators.py:546 app/validators.py:611 +#: app/validators.py:547 app/validators.py:612 msgid "A start time is required." msgstr "Une heure de début est requise." -#: app/validators.py:552 +#: app/validators.py:553 msgid "End time must be in HH:MM format." msgstr "L’heure de fin doit être au format HH:MM." -#: app/validators.py:553 +#: app/validators.py:554 msgid "An end time is required." msgstr "Une heure de fin est requise." -#: app/validators.py:557 +#: app/validators.py:558 msgid "Points must be 2000 characters or less." msgstr "Les points ne doivent pas dépasser 2000 caractères." -#: app/validators.py:572 +#: app/validators.py:573 msgid "End time must be after start time." msgstr "L'heure de fin doit être postérieure à l'heure de début." -#: app/validators.py:601 app/validators.py:603 +#: app/validators.py:602 app/validators.py:604 msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)." -#: app/validators.py:604 +#: app/validators.py:605 msgid "A day is required." msgstr "Un jour est requis." -#: app/validators.py:639 +#: app/validators.py:640 msgid "Player selection is malformed." msgstr "La sélection de joueurs est mal formée." -#: app/validators.py:665 app/validators.py:775 +#: app/validators.py:666 app/validators.py:776 msgid "A title is required." msgstr "Un titre est requis." -#: app/validators.py:674 +#: app/validators.py:675 msgid "Invalid date format." msgstr "Format de date invalide." -#: app/validators.py:679 app/validators.py:686 +#: app/validators.py:680 app/validators.py:687 msgid "Invalid time format." msgstr "Format d’heure invalide." -#: app/validators.py:680 +#: app/validators.py:681 msgid "Start time is required. Please select a time slot." msgstr "L’heure de début est obligatoire. Choisissez une plage horaire." -#: app/validators.py:694 +#: app/validators.py:695 msgid "Unknown match status." msgstr "Statut de match inconnu." -#: app/validators.py:710 +#: app/validators.py:711 msgid "The end time must come after the start time." msgstr "L'heure de fin doit être postérieure à l'heure de début." -#: app/validators.py:724 +#: app/validators.py:725 msgid "Unknown match type." msgstr "Type de match inconnu." -#: app/validators.py:736 +#: app/validators.py:737 msgid "A team cannot play against itself." msgstr "Une équipe ne peut pas jouer contre elle-même." -#: app/validators.py:784 +#: app/validators.py:785 msgid "Unknown game." msgstr "Jeu inconnu." -#: app/validators.py:789 +#: app/validators.py:790 msgid "Invalid start date format." msgstr "Format de date de début invalide." -#: app/validators.py:790 +#: app/validators.py:791 msgid "A start date is required." msgstr "Une date de début est requise." -#: app/validators.py:796 +#: app/validators.py:797 msgid "Invalid end date format." msgstr "Format de date de fin invalide." -#: app/validators.py:804 +#: app/validators.py:805 msgid "A tryout must allow at least one player." msgstr "Une sélection doit accepter au moins un joueur." -#: app/validators.py:807 +#: app/validators.py:808 msgid "The player limit must be a whole number." msgstr "La limite de joueurs doit être un nombre entier." -#: app/validators.py:819 +#: app/validators.py:820 msgid "End date cannot be before start date." msgstr "La date de fin ne peut pas précéder la date de début." -#: app/validators.py:846 app/validators.py:847 +#: app/validators.py:847 app/validators.py:848 msgid "Team name is required." msgstr "Le nom de l’équipe est obligatoire." -#: app/validators.py:871 +#: app/validators.py:872 msgid "Scores run from 1 to 10." msgstr "Les notes vont de 1 à 10." -#: app/validators.py:872 +#: app/validators.py:873 msgid "A score must be a whole number from 1 to 10." msgstr "Une note doit être un nombre entier de 1 à 10." -#: app/routes/auth.py:241 +#: app/routes/auth.py:245 msgid "This account has been deactivated." msgstr "Ce compte a été désactivé." -#: app/routes/auth.py:276 +#: app/routes/auth.py:280 #, python-format msgid "Welcome back, %(username)s!" msgstr "Bon retour, %(username)s !" -#: app/routes/auth.py:306 +#: app/routes/auth.py:310 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." @@ -241,27 +241,32 @@ msgstr "" "Échec de la connexion. Vérifiez le nom d’utilisateur et le mot de passe, " "ou demandez de l’aide à un président." -#: app/routes/auth.py:363 +#: app/routes/auth.py:376 msgid "Your registration could not be processed. Please try again." msgstr "Votre inscription n'a pas pu être traitée. Veuillez réessayer." -#: app/routes/auth.py:388 app/routes/users/accounts.py:329 +#: app/routes/auth.py:411 app/routes/users/accounts.py:339 msgid "Username already exists." msgstr "Ce nom d’utilisateur est déjà pris." -#: app/routes/auth.py:392 app/routes/users/accounts.py:333 +#: app/routes/auth.py:415 app/routes/users/accounts.py:343 msgid "Email already registered." msgstr "Cette adresse courriel est déjà enregistrée." -#: app/routes/auth.py:436 +#: app/routes/auth.py:422 app/routes/auth.py:617 +#: app/routes/users/accounts.py:121 +msgid "This Discord account is already linked to another account." +msgstr "Ce compte Discord est déjà lié à un autre compte." + +#: app/routes/auth.py:466 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:457 +#: app/routes/auth.py:491 msgid "Discord OAuth2 is not configured." msgstr "La connexion Discord n’est pas configurée." -#: app/routes/auth.py:498 +#: app/routes/auth.py:540 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -269,27 +274,35 @@ msgstr "" "L’autorisation Discord n’a pas pu être vérifiée. Relancez la connexion " "depuis cette page." -#: app/routes/auth.py:507 +#: app/routes/auth.py:549 msgid "Discord authorization failed. No code received." msgstr "L’autorisation Discord a échoué : aucun code reçu." -#: app/routes/auth.py:531 +#: app/routes/auth.py:573 msgid "Failed to connect to Discord. Please try again." msgstr "Impossible de joindre Discord. Veuillez réessayer." -#: app/routes/auth.py:535 +#: app/routes/auth.py:577 msgid "Failed to obtain Discord access token." msgstr "Impossible d’obtenir le jeton d’accès Discord." -#: app/routes/auth.py:550 +#: app/routes/auth.py:592 app/routes/auth.py:602 msgid "Failed to fetch Discord user profile." msgstr "Impossible de récupérer le profil Discord." -#: app/routes/auth.py:597 +#: app/routes/auth.py:609 +msgid "Please log in to connect your Discord account." +msgstr "Veuillez vous connecter pour lier votre compte Discord." + +#: app/routes/auth.py:628 +msgid "Discord account connected!" +msgstr "Compte Discord connecté !" + +#: app/routes/auth.py:675 msgid "Discord account connected! Your profile has been pre-filled." msgstr "Compte Discord connecté. Votre profil a été pré-rempli." -#: app/routes/auth.py:625 +#: app/routes/auth.py:703 msgid "You have been logged out." msgstr "Vous avez été déconnecté." @@ -325,8 +338,8 @@ msgstr "Évaluation mise à jour." #: app/routes/teams.py:384 app/routes/teams.py:427 app/routes/teams.py:455 #: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:444 #: app/routes/tryouts.py:460 app/routes/tryouts.py:480 -#: app/routes/tryouts.py:519 app/routes/tryouts.py:555 -#: app/routes/tryouts.py:574 +#: app/routes/tryouts.py:527 app/routes/tryouts.py:563 +#: app/routes/tryouts.py:582 msgid "Permission denied." msgstr "Accès refusé." @@ -467,7 +480,7 @@ msgstr "Coach retiré de %(name)s." msgid "Manager removed from %(name)s." msgstr "Gérant retiré de %(name)s." -#: app/routes/teams.py:490 app/routes/tryouts.py:484 app/routes/tryouts.py:585 +#: app/routes/teams.py:490 app/routes/tryouts.py:489 app/routes/tryouts.py:593 msgid "Please select a player." msgstr "Veuillez choisir un joueur." @@ -549,7 +562,7 @@ msgstr "Cette sélection n’accepte pas d’inscriptions." msgid "You are already registered for this tryout." msgstr "Vous êtes déjà inscrit à cette sélection." -#: app/routes/tryouts.py:428 app/routes/tryouts.py:503 +#: app/routes/tryouts.py:428 app/routes/tryouts.py:511 msgid "This tryout is full." msgstr "Cette sélection est complète." @@ -566,47 +579,47 @@ msgstr "Statut de la sélection mis à jour : %(new_status)s." msgid "Registration status updated." msgstr "Statut d’inscription mis à jour." -#: app/routes/tryouts.py:489 +#: app/routes/tryouts.py:497 msgid "Can only register players." msgstr "Seuls des joueurs peuvent être inscrits." -#: app/routes/tryouts.py:495 +#: app/routes/tryouts.py:503 #, python-format msgid "%(username)s is already registered for this tryout." msgstr "%(username)s est déjà inscrit à cette sélection." -#: app/routes/tryouts.py:509 +#: app/routes/tryouts.py:517 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s est inscrit à la sélection." -#: app/routes/tryouts.py:545 +#: app/routes/tryouts.py:553 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s a été retiré de la sélection." -#: app/routes/tryouts.py:563 +#: app/routes/tryouts.py:571 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Équipe « %(team_name)s » créée." -#: app/routes/tryouts.py:594 +#: app/routes/tryouts.py:602 msgid "That player is not registered for this tryout." msgstr "Ce joueur n’est pas inscrit à cette sélection." -#: app/routes/tryouts.py:600 +#: app/routes/tryouts.py:608 msgid "Player is already on this team." msgstr "Ce joueur est déjà dans cette équipe." -#: app/routes/tryouts.py:605 +#: app/routes/tryouts.py:613 msgid "Player added to team!" msgstr "Joueur ajouté à l’équipe." -#: app/routes/tryouts.py:615 +#: app/routes/tryouts.py:623 msgid "You do not have permission to delete this tryout." msgstr "Vous n’avez pas les droits pour supprimer cette sélection." -#: app/routes/tryouts.py:651 +#: app/routes/tryouts.py:659 msgid "Tryout deleted successfully." msgstr "Sélection supprimée." @@ -634,13 +647,13 @@ msgstr "Seul le président peut modifier des utilisateurs." msgid "Email already in use by another account." msgstr "Cette adresse courriel est déjà utilisée par un autre compte." -#: app/routes/users/accounts.py:122 +#: app/routes/users/accounts.py:132 msgid "You cannot change your own role. Ask another president to do it." msgstr "" "Vous ne pouvez pas modifier votre propre rôle. Demandez à un autre " "président de le faire." -#: app/routes/users/accounts.py:135 +#: app/routes/users/accounts.py:145 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -648,29 +661,29 @@ msgstr "" "C’est le dernier président actif. Promouvez un autre compte avant de " "modifier celui-ci." -#: app/routes/users/accounts.py:214 +#: app/routes/users/accounts.py:224 #, python-format msgid "User %(username)s updated successfully!" msgstr "Utilisateur %(username)s mis à jour." -#: app/routes/users/accounts.py:235 +#: app/routes/users/accounts.py:245 msgid "Only the president can delete users." msgstr "Seul le président peut supprimer des utilisateurs." -#: app/routes/users/accounts.py:239 +#: app/routes/users/accounts.py:249 msgid "You cannot delete your own account." msgstr "Vous ne pouvez pas supprimer votre propre compte." -#: app/routes/users/accounts.py:298 +#: app/routes/users/accounts.py:308 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "L’utilisateur %(deleted_username)s a été supprimé." -#: app/routes/users/accounts.py:309 +#: app/routes/users/accounts.py:319 msgid "Only the president can create users." msgstr "Seul le président peut créer des utilisateurs." -#: app/routes/users/accounts.py:357 +#: app/routes/users/accounts.py:367 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s." @@ -679,32 +692,32 @@ msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s." msgid "Only coaches can manage availability." msgstr "Seuls les coachs peuvent gérer leurs disponibilités." -#: app/routes/users/contracts.py:84 +#: app/routes/users/contracts.py:86 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Seuls les présidents, gérants et coachs peuvent téléverser un contrat." -#: app/routes/users/contracts.py:103 +#: app/routes/users/contracts.py:105 msgid "You do not have permission to upload a contract for this player." msgstr "Vous n’avez pas les droits pour téléverser un contrat pour ce joueur." -#: app/routes/users/contracts.py:143 +#: app/routes/users/contracts.py:145 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contrat téléversé pour %(username)s." -#: app/routes/users/contracts.py:157 +#: app/routes/users/contracts.py:159 msgid "Only the player can upload their signed contract." msgstr "Seul le joueur peut téléverser son contrat signé." -#: app/routes/users/contracts.py:175 +#: app/routes/users/contracts.py:177 msgid "Signed contract uploaded successfully!" msgstr "Contrat signé téléversé." -#: app/routes/users/contracts.py:185 app/routes/users/contracts.py:200 +#: app/routes/users/contracts.py:187 app/routes/users/contracts.py:202 msgid "You do not have permission to download this contract." msgstr "Vous n’avez pas les droits pour télécharger ce contrat." -#: app/routes/users/contracts.py:203 +#: app/routes/users/contracts.py:205 msgid "No signed contract available." msgstr "Aucun contrat signé disponible." @@ -804,15 +817,15 @@ msgstr "Seuls les coachs peuvent refuser une demande de rencontre." msgid "One on One request from %(player)s has been rejected." msgstr "La demande de rencontre de %(player)s a été refusée." -#: app/routes/users/profile.py:83 +#: app/routes/users/profile.py:82 msgid "Username already taken." msgstr "Ce nom d’utilisateur est déjà pris." -#: app/routes/users/profile.py:93 +#: app/routes/users/profile.py:92 msgid "Email already in use." msgstr "Cette adresse courriel est déjà utilisée." -#: app/routes/users/profile.py:123 +#: app/routes/users/profile.py:121 msgid "Profile updated successfully!" msgstr "Profil mis à jour." @@ -959,7 +972,7 @@ msgstr "%(total)s au total" msgid "Next" msgstr "Suivant" -#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:154 +#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:148 #: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3 msgid "Dashboard" msgstr "Tableau de bord" @@ -1001,38 +1014,34 @@ msgid "My Notes" msgstr "Mes notes" #: app/templates/layouts/base.html:106 -msgid "Availability" -msgstr "Disponibilités" - -#: app/templates/layouts/base.html:112 msgid "Notes & One on One" msgstr "Notes et rencontres individuelles" -#: app/templates/layouts/base.html:119 app/templates/pages/contracts.html:2 +#: app/templates/layouts/base.html:113 app/templates/pages/contracts.html:2 #: app/templates/pages/contracts.html:3 app/templates/pages/profile.html:9 msgid "Contracts" msgstr "Contrats" -#: app/templates/layouts/base.html:126 app/templates/pages/profile.html:2 +#: app/templates/layouts/base.html:120 app/templates/pages/profile.html:2 #: app/templates/pages/profile.html:3 msgid "My Profile" msgstr "Mon profil" -#: app/templates/layouts/base.html:137 +#: app/templates/layouts/base.html:131 msgid "Logout" msgstr "Déconnexion" -#: app/templates/layouts/base.html:158 +#: app/templates/layouts/base.html:152 msgid "Toggle dark mode" msgstr "Basculer le mode sombre" -#: app/templates/layouts/base.html:170 app/templates/layouts/base.html:189 +#: app/templates/layouts/base.html:164 app/templates/layouts/base.html:183 msgid "Dismiss" msgstr "Fermer" -#: app/templates/layouts/base.html:199 -msgid "Team Tryout Management System" -msgstr "Système de gestion des sélections d’équipe" +#: app/templates/layouts/base.html:193 +msgid "UdeS team manager" +msgstr "UdeS team manager" #: app/templates/layouts/macros.html:116 msgid "Close" @@ -1217,6 +1226,10 @@ msgid "Loading availability grid..." msgstr "Chargement de la grille de disponibilités..." #: app/templates/pages/coach_availability.html:19 +msgid "Save Availability" +msgstr "Enregistrer les disponibilités" + +#: app/templates/pages/coach_availability.html:22 #: app/templates/pages/profile.html:200 app/templates/pages/profile.html:220 msgid "Clear All" msgstr "Tout effacer" @@ -1378,7 +1391,7 @@ msgid "Role" msgstr "Rôle" #: app/templates/pages/create_user.html:41 app/templates/pages/login.html:11 -#: app/templates/pages/register.html:139 +#: app/templates/pages/register.html:137 msgid "Password" msgstr "Mot de passe" @@ -1541,7 +1554,7 @@ msgstr "Modifier le profil" #: app/templates/pages/edit_profile.html:33 #: app/templates/pages/edit_user.html:43 app/templates/pages/profile.html:63 -#: app/templates/pages/register.html:83 +#: app/templates/pages/register.html:81 msgid "E-Sports Profile" msgstr "Profil e-sport" @@ -1550,12 +1563,12 @@ msgid "Update your competitive gaming profile for tryouts." msgstr "Mettez à jour votre profil de joueur compétitif pour les sélections." #: app/templates/pages/edit_profile.html:37 -#: app/templates/pages/register.html:87 +#: app/templates/pages/register.html:85 msgid "Games You Play" msgstr "Jeux auxquels vous jouez" #: app/templates/pages/edit_profile.html:47 -#: app/templates/pages/register.html:101 +#: app/templates/pages/register.html:99 msgid "Select all games you're signing in for." msgstr "Sélectionnez tous les jeux pour lesquels vous vous inscrivez." @@ -1599,50 +1612,43 @@ msgid "e.g. Name#1234" msgstr "ex. : Nom#1234" #: app/templates/pages/edit_profile.html:87 -#: app/templates/pages/edit_user.html:95 -msgid "(for DMs)" -msgstr "(pour les messages privés)" +#: app/templates/pages/register.html:57 +msgid "Reconnect" +msgstr "Reconnecter" -#: app/templates/pages/edit_profile.html:88 -#: app/templates/pages/edit_user.html:96 -msgid "Numeric ID (e.g. 123456789012345678)" -msgstr "Identifiant numérique (ex. : 123456789012345678)" +#: app/templates/pages/edit_profile.html:87 +#: app/templates/pages/register.html:67 +msgid "Connect Discord Account" +msgstr "Connecter un compte Discord" -#: app/templates/pages/edit_profile.html:89 -#: app/templates/pages/edit_user.html:97 -msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID" -msgstr "" -"Activez le mode développeur dans Discord → clic droit sur le profil → " -"Copier l’identifiant" - -#: app/templates/pages/edit_profile.html:94 +#: app/templates/pages/edit_profile.html:93 #: app/templates/pages/edit_user.html:100 msgid "League OS Connection" msgstr "Connexion League OS" -#: app/templates/pages/edit_profile.html:95 -#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:131 +#: app/templates/pages/edit_profile.html:94 +#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:129 msgid "League OS profile link or ID" msgstr "Lien ou identifiant du profil League OS" -#: app/templates/pages/edit_profile.html:100 +#: app/templates/pages/edit_profile.html:99 msgid "Change Password" msgstr "Changer le mot de passe" -#: app/templates/pages/edit_profile.html:101 +#: app/templates/pages/edit_profile.html:100 msgid "Leave blank to keep your current password." msgstr "Laissez vide pour conserver votre mot de passe actuel." -#: app/templates/pages/edit_profile.html:103 +#: app/templates/pages/edit_profile.html:102 msgid "New Password" msgstr "Nouveau mot de passe" -#: app/templates/pages/edit_profile.html:104 +#: app/templates/pages/edit_profile.html:103 #: app/templates/pages/edit_user.html:107 msgid "Enter new password" msgstr "Saisir le nouveau mot de passe" -#: app/templates/pages/edit_profile.html:109 app/templates/pages/teams.html:296 +#: app/templates/pages/edit_profile.html:108 app/templates/pages/teams.html:296 msgid "Save Changes" msgstr "Enregistrer les modifications" @@ -1658,6 +1664,20 @@ msgstr "Jeux" msgid "Enter gamertag for each selected game to link to Tracker Network." msgstr "Saisissez un pseudo par jeu sélectionné pour le lier à Tracker Network." +#: app/templates/pages/edit_user.html:95 +msgid "(for DMs)" +msgstr "(pour les messages privés)" + +#: app/templates/pages/edit_user.html:96 +msgid "Numeric ID (e.g. 123456789012345678)" +msgstr "Identifiant numérique (ex. : 123456789012345678)" + +#: app/templates/pages/edit_user.html:97 +msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID" +msgstr "" +"Activez le mode développeur dans Discord → clic droit sur le profil → " +"Copier l’identifiant" + #: app/templates/pages/edit_user.html:106 msgid "(leave blank to keep current)" msgstr "(laisser vide pour conserver l’actuel)" @@ -2378,7 +2398,7 @@ msgstr "" "Choisissez les plages où vous êtes disponible pour des rencontres " "individuelles (8 h à 22 h)." -#: app/templates/pages/profile.html:454 +#: app/templates/pages/profile.html:460 msgid "Click or click-and-drag to select your available hours" msgstr "Cliquez ou faites glisser pour choisir vos heures de disponibilité" @@ -2426,11 +2446,7 @@ msgstr "" msgid "Connected" msgstr "Connecté" -#: app/templates/pages/register.html:57 -msgid "Reconnect" -msgstr "Reconnecter" - -#: app/templates/pages/register.html:63 +#: app/templates/pages/register.html:61 msgid "" "Discord connected. Game connections have been used to pre-fill your " "profile below." @@ -2439,52 +2455,48 @@ msgstr "" "ci-dessous." #: app/templates/pages/register.html:69 -msgid "Connect Discord Account" -msgstr "Connecter un compte Discord" - -#: app/templates/pages/register.html:71 msgid "Connect to pre-fill your gamertags from Steam, Battle.net, Xbox, etc." msgstr "" "Connectez-vous pour pré-remplir vos pseudos depuis Steam, Battle.net, " "Xbox, etc." -#: app/templates/pages/register.html:74 +#: app/templates/pages/register.html:72 msgid "Discord Username (Manual)" msgstr "Nom d’utilisateur Discord (saisie manuelle)" -#: app/templates/pages/register.html:75 +#: app/templates/pages/register.html:73 msgid "e.g. YourName" msgstr "ex. : VotrePseudo" -#: app/templates/pages/register.html:84 +#: app/templates/pages/register.html:82 msgid "Set up your competitive gaming profile for tryouts." msgstr "Configurez votre profil de joueur compétitif pour les sélections." -#: app/templates/pages/register.html:129 +#: app/templates/pages/register.html:127 msgid "League OS Connection (Optional)" msgstr "Connexion League OS (facultative)" -#: app/templates/pages/register.html:133 +#: app/templates/pages/register.html:131 msgid "Connect your League OS profile for organized play." msgstr "Liez votre profil League OS pour le jeu organisé." -#: app/templates/pages/register.html:137 +#: app/templates/pages/register.html:135 msgid "Security" msgstr "Sécurité" -#: app/templates/pages/register.html:140 +#: app/templates/pages/register.html:138 msgid "Create a password" msgstr "Créez un mot de passe" -#: app/templates/pages/register.html:144 +#: app/templates/pages/register.html:142 msgid "Confirm Password" msgstr "Confirmer le mot de passe" -#: app/templates/pages/register.html:145 +#: app/templates/pages/register.html:143 msgid "Confirm your password" msgstr "Confirmez votre mot de passe" -#: app/templates/pages/register.html:159 +#: app/templates/pages/register.html:157 msgid "Create Account" msgstr "Créer le compte" @@ -3008,3 +3020,8 @@ msgstr "Voir le profil" #~ msgid "Invalid date or time format." #~ msgstr "Format de date ou d’heure invalide." +#~ msgid "Availability" +#~ msgstr "Disponibilités" + +#~ msgid "Team Tryout Management System" +#~ msgstr "Système de gestion des sélections d’équipe" diff --git a/app/validators.py b/app/validators.py index 7c25b24..b2502ca 100644 --- a/app/validators.py +++ b/app/validators.py @@ -256,11 +256,6 @@ class RegisterSchema(StripMixin): allow_none=True, load_default=None, ) - discord_user_id = fields.String( - validate=validate_discord_user_id, - allow_none=True, - load_default=None, - ) league_os_profile = fields.String( validate=validate.Length(max=256), allow_none=True, @@ -392,7 +387,6 @@ class EditProfileSchema(StripMixin): phone: Optional. password: Optional (only if changing). discord_username: Optional. - discord_user_id: Optional. league_os_profile: Optional. games: Optional list. """ @@ -428,11 +422,6 @@ class EditProfileSchema(StripMixin): allow_none=True, load_default=None, ) - discord_user_id = fields.String( - validate=validate_discord_user_id, - allow_none=True, - load_default=None, - ) league_os_profile = fields.String( validate=validate.Length(max=256), allow_none=True, diff --git a/docs/database-schema.md b/docs/database-schema.md index b65ff54..197b85d 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -1,8 +1,9 @@ # Le schéma réel, et comment sortir de `create_all()` -> **État au 2026-08-11** : l'outil de relevé existe et est testé. Le relevé -> lui-même n'a pas été exécuté — il demande un accès à la base de production, -> qui ne peut pas venir du dépôt. Tout ce qui suit attend cette exécution. +> **État au 2026-08-16** : l'outil de relevé existe et est testé, y compris +> pour les collisions d'identité Discord. Le relevé lui-même n'a pas été +> exécuté — il demande un accès à la base de production, qui ne peut pas venir +> du dépôt. Tout changement de schéma ci-dessous attend cette exécution. ## Pourquoi c'est le nœud @@ -60,6 +61,14 @@ existe-t-il encore ? (`SEC-003`) : python app/supporting_scripts/schema_report.py --check-seed-accounts ``` +L'identité Discord est désormais conservée côté serveur et toute nouvelle +collision est refusée par l'application. Les doublons historiques restent à +identifier avant d'ajouter la contrainte `UNIQUE` de `SEC-012` : + +```bash +python app/supporting_scripts/schema_report.py --check-discord-identities +``` + ## Étape 3 — Alembic, décrivant le schéma **réel** (`DB-002`) Le piège de cette étape tient en une phrase : **la migration initiale doit @@ -111,7 +120,7 @@ Dans cet ordre, parce qu'ils dépendent tous de `DB-002` : | `DB-008` | Trancher `attendance_confirmed` côté tryout | `discord_bot.py` écrit un attribut fantôme ; aujourd'hui journalisé en avertissement | | `DB-009` | Horodatages avec fuseau | `datetime.utcnow` partout, déprécié en 3.12 | | `ARCH-001` | Fusionner coach/équipe sur la relation m2m | Migration de données ; `app/permissions.py` rend la duplication inoffensive **en lecture** seulement, l'écriture crée toujours les deux | -| `SEC-012` | Identité Discord côté serveur, `unique=True` | La colonne doit être unique, donc dédoublonnée d'abord | +| `SEC-012` | Ajouter `unique=True` sur l'identité Discord | La valeur OAuth reste côté serveur et les nouvelles collisions sont refusées ; les lignes historiques doivent être dédoublonnées d'abord | ## Ce qu'on ne fait pas diff --git a/tests/test_discord_identity.py b/tests/test_discord_identity.py new file mode 100644 index 0000000..eba679f --- /dev/null +++ b/tests/test_discord_identity.py @@ -0,0 +1,222 @@ +"""The Discord snowflake is a verified identity, not profile text. + +SEC-AUTH-005. Discord OAuth used to put its result in two hidden inputs; +registration then trusted those client-controlled values, and edit_profile +let the account owner replace the snowflake later. The bot uses that value to +route private messages and authorize reaction-driven writes. +""" + +import time +from urllib.parse import parse_qs, urlparse + +from app.routes import auth as auth_module +from app.routes.auth import ( + MIN_REGISTRATION_SECONDS, + REGISTRATION_ISSUED_KEY, +) + +FORM = { + 'username': 'brandnew', + 'email': 'brandnew@example.test', + 'password': 'Password123', + 'confirm_password': 'Password123', + 'full_name': 'Brand New', +} + + +def _allow_registration(client): + with client.session_transaction() as session: + session[REGISTRATION_ISSUED_KEY] = time.time() - MIN_REGISTRATION_SECONDS - 1 + + +def _user(app, username='brandnew'): + from app.models import User + + with app.app_context(): + return User.query.filter_by(username=username).first() + + +class _DiscordResponse: + def __init__(self, payload): + self.payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self.payload + + +def _complete_profile_oauth(client, monkeypatch, discord_user_id): + monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', 'client-id') + monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_SECRET', 'client-secret') + monkeypatch.setattr( + auth_module, + 'DISCORD_REDIRECT_URI', + 'https://example.test/auth/discord/callback', + ) + start = client.get('/auth/discord/login') + state = parse_qs(urlparse(start.headers['Location']).query)['state'][0] + + monkeypatch.setattr( + auth_module.requests, + 'post', + lambda *args, **kwargs: _DiscordResponse({'access_token': 'token'}), + ) + monkeypatch.setattr( + auth_module.requests, + 'get', + lambda *args, **kwargs: _DiscordResponse( + {'id': discord_user_id, 'username': 'verified-user'} + ), + ) + return client.get(f'/auth/discord/callback?code=code&state={state}') + + +class TestRegistrationIdentity: + def test_a_posted_snowflake_without_oauth_is_ignored(self, app, client): + _allow_registration(client) + + client.post( + '/auth/register', + data=dict(FORM, discord_user_id='111111111111111111'), + ) + + assert _user(app).discord_user_id is None + + def test_the_server_side_oauth_identity_wins_over_the_form(self, app, client): + verified = '222222222222222222' + _allow_registration(client) + with client.session_transaction() as session: + session['discord_oauth'] = { + 'id': verified, + 'username': 'verified-user', + } + + client.post( + '/auth/register', + data=dict( + FORM, + discord_user_id='111111111111111111', + discord_username='forged-user', + ), + ) + + created = _user(app) + assert created.discord_user_id == verified + assert created.discord_username == 'verified-user' + + def test_an_oauth_identity_already_in_use_is_refused(self, app, client, make_user): + verified = '222222222222222222' + make_user('player', discord_user_id=verified) + _allow_registration(client) + with client.session_transaction() as session: + session['discord_oauth'] = { + 'id': verified, + 'username': 'verified-user', + } + + client.post('/auth/register', data=FORM) + + assert _user(app) is None + + def test_the_registration_page_has_no_client_identity_field(self, client): + with client.session_transaction() as session: + session['discord_oauth'] = { + 'id': '222222222222222222', + 'username': 'verified-user', + } + + page = client.get('/auth/register').get_data(as_text=True) + + assert 'name="discord_user_id"' not in page + + +class TestProfileIdentity: + def test_a_profile_post_cannot_replace_the_verified_snowflake(self, app, client, as_role): + original = '222222222222222222' + user_id = as_role('player', discord_user_id=original) + + client.post( + '/users/profile/edit', + data={ + 'username': 'player1', + 'full_name': 'Player 1', + 'email': 'player1@example.test', + 'discord_user_id': '111111111111111111', + }, + ) + + from app.models import User + + with app.app_context(): + assert ( + app.extensions['sqlalchemy'].session.get(User, user_id).discord_user_id == original + ) + + def test_the_profile_form_does_not_offer_the_snowflake(self, client, as_role): + as_role('player', discord_user_id='222222222222222222') + + page = client.get('/users/profile/edit').get_data(as_text=True) + + assert 'name="discord_user_id"' not in page + assert '/auth/discord/login' in page + + def test_a_signed_in_user_can_relink_only_through_oauth( + self, app, client, as_role, monkeypatch + ): + user_id = as_role('player', discord_user_id='111111111111111111') + + response = _complete_profile_oauth( + client, + monkeypatch, + discord_user_id='222222222222222222', + ) + + from app.models import User + + assert '/users/profile/edit' in response.headers['Location'] + with app.app_context(): + user = app.extensions['sqlalchemy'].session.get(User, user_id) + assert user.discord_user_id == '222222222222222222' + assert user.discord_username == 'verified-user' + + def test_profile_oauth_refuses_an_identity_owned_by_someone_else( + self, app, client, as_role, make_user, monkeypatch + ): + taken = '222222222222222222' + make_user('player', discord_user_id=taken) + user_id = as_role('player', discord_user_id='111111111111111111') + + _complete_profile_oauth(client, monkeypatch, discord_user_id=taken) + + from app.models import User + + with app.app_context(): + assert ( + app.extensions['sqlalchemy'].session.get(User, user_id).discord_user_id + == '111111111111111111' + ) + + +class TestAdministrativeFallback: + def test_an_admin_cannot_assign_a_snowflake_twice(self, app, client, as_role, make_user): + taken = '222222222222222222' + make_user('player', discord_user_id=taken) + target_id = make_user('player') + as_role('admin') + + client.post( + f'/users/{target_id}/edit', + data={ + 'full_name': 'Target Player', + 'email': 'target@example.test', + 'role': 'player', + 'discord_user_id': taken, + }, + ) + + from app.models import User + + with app.app_context(): + assert app.extensions['sqlalchemy'].session.get(User, target_id).discord_user_id is None diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 58c92a0..3d0dbf8 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -202,6 +202,67 @@ class TestCatalogueIntegrity: f'{len(untranslated)} untranslated string(s) in {locale}: {untranslated[:5]}' ) + # Babel keeps its guessed translation when it marks an entry fuzzy, + # but gettext deliberately ignores that guess at runtime. Merely + # checking m.string therefore let five English fallbacks through after + # the branding merge, including a dangerously wrong French label. + fuzzy = [m.id for m in catalog if m.id and 'fuzzy' in m.flags] + assert not fuzzy, f'{len(fuzzy)} fuzzy string(s) in {locale}: {fuzzy[:5]}' + + def test_the_catalogue_contains_every_message_in_the_source(self, tmp_path): + """A translated PO can still be stale. + + The previous guard only inspected entries already in the catalogue. + A new `_()` in Python or Jinja therefore stayed English without any + failure until somebody happened to run extraction by hand. + """ + import os + import subprocess + import sys + + from babel.messages.pofile import read_po + + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + extracted_path = tmp_path / 'messages.pot' + subprocess.run( + [ + sys.executable, + '-m', + 'babel.messages.frontend', + 'extract', + '-F', + 'babel.cfg', + '-k', + '_l', + '-o', + str(extracted_path), + '.', + ], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + with open(extracted_path, encoding='utf-8') as handle: + source_ids = {message.id for message in read_po(handle) if message.id} + + for locale in SUPPORTED_LOCALES: + path = os.path.join( + root, + 'app', + 'translations', + locale, + 'LC_MESSAGES', + 'messages.po', + ) + with open(path, encoding='utf-8') as handle: + catalogue_ids = {message.id for message in read_po(handle) if message.id} + missing = source_ids - catalogue_ids + assert not missing, ( + f'{len(missing)} source string(s) absent from {locale}: ' + f'{sorted(missing, key=str)[:5]}' + ) + class TestSelectorUnit: def test_select_locale_returns_a_supported_code(self, app): diff --git a/tests/test_schema_report.py b/tests/test_schema_report.py index 8b2fd51..ff3b27e 100644 --- a/tests/test_schema_report.py +++ b/tests/test_schema_report.py @@ -257,3 +257,36 @@ class TestSeedAccountCheck: output = capsys.readouterr().out assert 'password has been changed' in output assert 'PASSWORD IS STILL' not in output + + +class TestDiscordIdentityCheck: + def test_it_finds_identity_collisions_before_the_unique_migration(self, live_db, app, capsys): + engine, _tamper = live_db + path = str(engine.url).replace('sqlite:///', '') + connection = sqlite3.connect(path) + for username in ('alice', 'bob'): + connection.execute( + 'INSERT INTO users ' + '(username, password_hash, role, full_name, email, ' + 'is_active_account, discord_user_id) ' + "VALUES (?, 'hash', 'player', ?, ?, 1, '222222222222222222')", + (username, username.title(), f'{username}@example.test'), + ) + connection.commit() + connection.close() + + with app.app_context(): + result = main(['--url', str(engine.url), '--check-discord-identities']) + + output = capsys.readouterr().out + assert result == 1 + assert '222222222222222222: 2 accounts (alice, bob)' in output + + def test_a_clean_identity_set_does_not_change_the_exit_code(self, live_db, app, capsys): + engine, _tamper = live_db + + with app.app_context(): + result = main(['--url', str(engine.url), '--check-discord-identities']) + + assert result == 0 + assert 'No Discord identity is shared' in capsys.readouterr().out From f84cb4e3b6d4db25ec2326ceb7938424f2a0c3d0 Mon Sep 17 00:00:00 2001 From: GGThed Date: Mon, 17 Aug 2026 13:51:37 -0400 Subject: [PATCH 2/5] fix(audit): durcir les validations de securite --- .github/workflows/ci.yml | 1 + app/supporting_scripts/security_scan.py | 50 ++++++++----- app/templates/pages/match_form.html | 7 +- tests/test_csp.py | 17 ++++- tests/test_security_scan.py | 93 +++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 19 deletions(-) create mode 100644 tests/test_security_scan.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95d343e..327af4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,7 @@ jobs: - name: Run security scan env: SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }} + DATABASE_URL: 'sqlite:///:memory:' FLASK_DEBUG: 'false' run: python app/supporting_scripts/security_scan.py --skip-http diff --git a/app/supporting_scripts/security_scan.py b/app/supporting_scripts/security_scan.py index 32632c2..54bb4e0 100644 --- a/app/supporting_scripts/security_scan.py +++ b/app/supporting_scripts/security_scan.py @@ -6,7 +6,7 @@ This script performs pre-deployment security checks to validate: - Debug mode status - HTTPS configuration - Dependency vulnerabilities -- Database connectivity +- Required database configuration Usage: python security_scan.py [--url http://localhost:5000] @@ -19,6 +19,10 @@ import subprocess import sys import urllib.request from datetime import datetime +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +REQUIREMENTS_FILE = PROJECT_ROOT / 'requirements.txt' def check_environment(): @@ -31,8 +35,8 @@ def check_environment(): print('1. ENVIRONMENT VARIABLES CHECK') print('=' * 60) - critical_vars = ['SECRET_KEY'] - recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS'] + critical_vars = ['SECRET_KEY', 'DATABASE_URL'] + recommended_vars = ['CORS_ALLOWED_ORIGINS'] all_ok = True for var in critical_vars: @@ -58,7 +62,8 @@ def check_environment(): # Check FLASK_DEBUG debug = os.getenv('FLASK_DEBUG', 'false').lower() if debug == 'true': - print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.') + print('[FAIL] FLASK_DEBUG is enabled! It must be disabled in production.') + all_ok = False else: print('[OK] FLASK_DEBUG is disabled') @@ -91,10 +96,11 @@ def check_https_headers(url): all_ok = True try: - # Create a context that doesn't verify SSL (for local testing) + # Keep the default certificate and hostname verification. A scanner + # that accepts an invalid certificate can validate headers while the + # transport itself is impersonated. Local runs without TLS should use + # http:// explicitly or opt out with --skip-http. ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE req = urllib.request.Request(url, method='HEAD') @@ -148,9 +154,9 @@ def check_https_headers(url): all_ok = False except urllib.error.URLError as e: - print(f'[SKIP] Cannot connect to {url}: {e.reason}') - print('[SKIP] Run with --url to check headers') - return True # Not a failure, just can't check + print(f'[FAIL] Cannot connect to {url}: {e.reason}') + print('[INFO] Use --skip-http only when the live check is intentionally out of scope.') + return False return all_ok @@ -167,7 +173,15 @@ def check_dependencies(): try: result = subprocess.run( - [sys.executable, '-m', 'pip_audit', '--format', 'json'], + [ + sys.executable, + '-m', + 'pip_audit', + '--requirement', + str(REQUIREMENTS_FILE), + '--format', + 'json', + ], capture_output=True, text=True, timeout=60, @@ -195,14 +209,16 @@ def check_dependencies(): if result.stdout: print(f'[INFO] {result.stdout.strip()}') if result.stderr: - print(f'[WARN] {result.stderr.strip()}') - return True + print(f'[FAIL] {result.stderr.strip()}') + else: + print(f'[FAIL] pip-audit exited with status {result.returncode}.') + return False except FileNotFoundError: - print('[SKIP] pip-audit not installed. Run: pip install pip-audit') - return True + print('[FAIL] pip-audit not installed. Run: pip install pip-audit') + return False except subprocess.TimeoutExpired: - print('[WARN] pip-audit timed out') - return True + print('[FAIL] pip-audit timed out') + return False def check_file_permissions(): diff --git a/app/templates/pages/match_form.html b/app/templates/pages/match_form.html index a589205..d1a0e21 100644 --- a/app/templates/pages/match_form.html +++ b/app/templates/pages/match_form.html @@ -661,7 +661,7 @@ function renderMergedDisponibilityGrid() { } dayRow += '
' + + 'data-action="toggle-time-slot">' + slotData.display + '' + count + '' + '
'; @@ -1120,6 +1120,11 @@ function togglePresence(matchId, participantId, badgeEl) { registerActions({ 'toggle-match-type': toggleMatchType, 'clear-time-selection': clearTimeSelection, + 'toggle-time-slot': function (element) { + toggleTimeSlot(parseInt(element.getAttribute('data-day'), 10), + element.getAttribute('data-time'), + element); + }, 'update-randomize-preview': updateRandomizePreview, 'randomize-teams': randomizeTeams, 'return-to-pool': returnToPool, diff --git a/tests/test_csp.py b/tests/test_csp.py index 3002992..c268ee7 100644 --- a/tests/test_csp.py +++ b/tests/test_csp.py @@ -31,6 +31,14 @@ INLINE_HANDLER = re.compile( re.I, ) +# An event attribute assembled inside a JavaScript string is absent from the +# template DOM, so the expression above cannot see it. Once assigned through +# innerHTML it is still an inline handler and the CSP still refuses to run it. +DYNAMIC_INLINE_HANDLER = re.compile( + r'''["']on(?:click|change|submit|input|load|keyup|keydown|mouseover|focus|blur)\s*=''', + re.I, +) + #: Remaining inline handlers, per template. Lower these as you migrate; #: never raise one. Templates absent from this map must have none. #: No template may carry an inline event handler. The migration is done; @@ -52,7 +60,8 @@ def _templates(): def _count_handlers(path): with open(path, encoding='utf-8') as handle: - return len(INLINE_HANDLER.findall(handle.read())) + content = handle.read() + return len(INLINE_HANDLER.findall(content)) + len(DYNAMIC_INLINE_HANDLER.findall(content)) class TestPolicyHeader: @@ -102,6 +111,12 @@ class TestPolicyHeader: class TestInlineHandlerRatchet: + def test_a_handler_built_inside_a_javascript_string_is_counted(self, tmp_path): + template = tmp_path / 'dynamic-handler.html' + template.write_text("html += ''; + const text = document.createElement('span'); + text.textContent = message; + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'alert-close'; + close.dataset.action = 'remove-element'; + close.textContent = '×'; + alert.append(text, close); flashContainer.appendChild(alert); } diff --git a/app/templates/pages/match_form.html b/app/templates/pages/match_form.html index d1a0e21..9254e3b 100644 --- a/app/templates/pages/match_form.html +++ b/app/templates/pages/match_form.html @@ -450,11 +450,18 @@ var playerDataById = { player_data: { {%- for p in all_players %} - {{ p.id }}: "{{ p.username | escape }}", + {{ p.id }}: {{ p.username | tojson }}, {%- endfor %} } }; +var HTML_ESCAPES = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}; +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, function (character) { + return HTML_ESCAPES[character]; + }); +} + // All registered player IDs var allRegisteredPlayers = [ {%- for p in all_players %} @@ -555,7 +562,7 @@ document.addEventListener('DOMContentLoaded', function() { var playerName = playerDataById.player_data[pid]; if (playerName) { var html = '
'; - html += playerName; + html += escapeHtml(playerName); html += ''; html += '
'; teamDiv.insertAdjacentHTML('beforeend', html); @@ -568,7 +575,7 @@ document.addEventListener('DOMContentLoaded', function() { var playerName = playerDataById.player_data[pid]; if (playerName) { var html = '
'; - html += playerName; + html += escapeHtml(playerName); html += ''; html += '
'; teamDiv.insertAdjacentHTML('beforeend', html); @@ -920,7 +927,7 @@ function updatePlayerPool() { var availabilityClass = isAvailable ? 'available' : 'unavailable'; html += '
'; - html += '' + playerName + ''; + html += '' + escapeHtml(playerName) + ''; html += '
'; html += ''; html += ''; @@ -943,7 +950,7 @@ function assignToTeam(playerId, teamSide) { if (!playerName) return; var html = '
'; - html += playerName; + html += escapeHtml(playerName); html += ''; html += '
'; @@ -1062,7 +1069,7 @@ function randomizeTeams() { var playerName = playerDataById.player_data[pid]; if (playerName) { var html = '
'; - html += playerName; + html += escapeHtml(playerName); html += ''; html += '
'; teamDiv.insertAdjacentHTML('beforeend', html); @@ -1074,7 +1081,7 @@ function randomizeTeams() { var playerName = playerDataById.player_data[pid]; if (playerName) { var html = '
'; - html += playerName; + html += escapeHtml(playerName); html += ''; html += '
'; teamDiv.insertAdjacentHTML('beforeend', html); diff --git a/app/translations/en/LC_MESSAGES/messages.mo b/app/translations/en/LC_MESSAGES/messages.mo index 8fc3943a946fc204fe799869613b37189ae7fc6d..278780bcfcb805fbeb2f7169da4c31e42ed60de9 100644 GIT binary patch delta 12298 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 E0TN`gdjJ3c delta 10829 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 N;`@moMg8|e`9Jx5%ZUI0 diff --git a/app/translations/en/LC_MESSAGES/messages.po b/app/translations/en/LC_MESSAGES/messages.po index 1d92bad..6ce50a8 100644 --- a/app/translations/en/LC_MESSAGES/messages.po +++ b/app/translations/en/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-16 23:22-0400\n" +"POT-Creation-Date: 2026-08-17 14:18-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -23,8 +23,8 @@ msgstr "" msgid "Please log in to access this page." msgstr "Please log in to access this page." -#: app/forms.py:37 app/routes/auth.py:228 app/routes/auth.py:387 -#: app/routes/users/contracts.py:98 +#: app/forms.py:37 app/routes/auth.py:229 app/routes/auth.py:388 +#: app/routes/auth.py:402 app/routes/users/contracts.py:98 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s: %(msg)s" @@ -61,7 +61,7 @@ msgstr "Username is required." msgid "Password is required." msgstr "Password is required." -#: app/validators.py:227 app/validators.py:294 +#: app/validators.py:227 app/validators.py:324 msgid "Username must be 3-80 characters." msgstr "Username must be 3-80 characters." @@ -69,8 +69,8 @@ msgstr "Username must be 3-80 characters." msgid "Email must be 120 characters or less." msgstr "Email must be 120 characters or less." -#: app/validators.py:246 app/validators.py:309 app/validators.py:340 -#: app/validators.py:403 +#: app/validators.py:246 app/validators.py:339 app/validators.py:370 +#: app/validators.py:433 msgid "Full name is required." msgstr "Full name is required." @@ -78,160 +78,200 @@ msgstr "Full name is required." msgid "Passwords do not match." msgstr "Passwords do not match." -#: app/validators.py:313 app/validators.py:348 -msgid "Invalid role selected." -msgstr "Invalid role selected." - -#: app/validators.py:443 -msgid "Player must be selected." -msgstr "Player must be selected." - -#: app/validators.py:446 -msgid "Notes must be 2000 characters or less." -msgstr "Notes must be 2000 characters or less." - -#: app/validators.py:483 app/validators.py:854 -msgid "Invalid coach selection." -msgstr "Invalid coach selection." - -#: app/validators.py:489 app/validators.py:860 -msgid "Invalid manager selection." -msgstr "Invalid manager selection." - -#: app/validators.py:507 -msgid "Invalid player selection." -msgstr "Invalid player selection." - -#: app/validators.py:516 -msgid "Unknown roster status." -msgstr "Unknown roster status." - -#: app/validators.py:539 -msgid "Date must be in YYYY-MM-DD format." -msgstr "Date must be in YYYY-MM-DD format." - -#: app/validators.py:540 -msgid "A date is required." -msgstr "A date is required." - -#: app/validators.py:546 app/validators.py:611 -msgid "Start time must be in HH:MM format." -msgstr "Start time must be in HH:MM format." - -#: app/validators.py:547 app/validators.py:612 -msgid "A start time is required." -msgstr "A start time is required." - -#: app/validators.py:553 -msgid "End time must be in HH:MM format." -msgstr "End time must be in HH:MM format." - -#: app/validators.py:554 -msgid "An end time is required." -msgstr "An end time is required." - -#: app/validators.py:558 -msgid "Points must be 2000 characters or less." -msgstr "Points must be 2000 characters or less." - -#: app/validators.py:573 -msgid "End time must be after start time." -msgstr "End time must be after start time." - -#: app/validators.py:602 app/validators.py:604 -msgid "Day must be 0 (Monday) to 6 (Sunday)." -msgstr "Day must be 0 (Monday) to 6 (Sunday)." - -#: app/validators.py:605 -msgid "A day is required." -msgstr "A day is required." - -#: app/validators.py:640 -msgid "Player selection is malformed." -msgstr "Player selection is malformed." - -#: app/validators.py:666 app/validators.py:776 -msgid "A title is required." -msgstr "A title is required." - -#: app/validators.py:675 -msgid "Invalid date format." -msgstr "Invalid date format." - -#: app/validators.py:680 app/validators.py:687 -msgid "Invalid time format." -msgstr "Invalid time format." - -#: app/validators.py:681 -msgid "Start time is required. Please select a time slot." -msgstr "Start time is required. Please select a time slot." - -#: app/validators.py:695 -msgid "Unknown match status." -msgstr "Unknown match status." - -#: app/validators.py:711 -msgid "The end time must come after the start time." -msgstr "The end time must come after the start time." - -#: app/validators.py:725 -msgid "Unknown match type." -msgstr "Unknown match type." - -#: app/validators.py:737 -msgid "A team cannot play against itself." -msgstr "A team cannot play against itself." - -#: app/validators.py:785 +#: app/validators.py:284 app/validators.py:924 msgid "Unknown game." msgstr "Unknown game." -#: app/validators.py:790 +#: app/validators.py:291 +msgid "Gamertag must be between 1 and 120 characters." +msgstr "Gamertag must be between 1 and 120 characters." + +#: app/validators.py:297 +msgid "Platform must be 30 characters or less." +msgstr "Platform must be 30 characters or less." + +#: app/validators.py:306 +msgid "Unknown platform for this game." +msgstr "Unknown platform for this game." + +#: app/validators.py:343 app/validators.py:378 +msgid "Invalid role selected." +msgstr "Invalid role selected." + +#: app/validators.py:473 app/validators.py:596 app/validators.py:629 +msgid "Player must be selected." +msgstr "Player must be selected." + +#: app/validators.py:476 +msgid "Notes must be 2000 characters or less." +msgstr "Notes must be 2000 characters or less." + +#: app/validators.py:513 app/validators.py:993 +msgid "Invalid coach selection." +msgstr "Invalid coach selection." + +#: app/validators.py:519 app/validators.py:999 +msgid "Invalid manager selection." +msgstr "Invalid manager selection." + +#: app/validators.py:537 app/validators.py:595 app/validators.py:628 +msgid "Invalid player selection." +msgstr "Invalid player selection." + +#: app/validators.py:546 +msgid "Unknown roster status." +msgstr "Unknown roster status." + +#: app/validators.py:559 +msgid "Unknown tryout status." +msgstr "Unknown tryout status." + +#: app/validators.py:570 +msgid "Unknown registration status." +msgstr "Unknown registration status." + +#: app/validators.py:583 +msgid "Team name must be between 1 and 100 characters." +msgstr "Team name must be between 1 and 100 characters." + +#: app/validators.py:603 +msgid "Position must be 50 characters or less." +msgstr "Position must be 50 characters or less." + +#: app/validators.py:616 +msgid "Note content must be between 1 and 5000 characters." +msgstr "Note content must be between 1 and 5000 characters." + +#: app/validators.py:642 +msgid "Select at most one note context." +msgstr "Select at most one note context." + +#: app/validators.py:654 +msgid "Rejection reason must be 2000 characters or less." +msgstr "Rejection reason must be 2000 characters or less." + +#: app/validators.py:678 +msgid "Date must be in YYYY-MM-DD format." +msgstr "Date must be in YYYY-MM-DD format." + +#: app/validators.py:679 +msgid "A date is required." +msgstr "A date is required." + +#: app/validators.py:685 app/validators.py:750 +msgid "Start time must be in HH:MM format." +msgstr "Start time must be in HH:MM format." + +#: app/validators.py:686 app/validators.py:751 +msgid "A start time is required." +msgstr "A start time is required." + +#: app/validators.py:692 +msgid "End time must be in HH:MM format." +msgstr "End time must be in HH:MM format." + +#: app/validators.py:693 +msgid "An end time is required." +msgstr "An end time is required." + +#: app/validators.py:697 +msgid "Points must be 2000 characters or less." +msgstr "Points must be 2000 characters or less." + +#: app/validators.py:712 +msgid "End time must be after start time." +msgstr "End time must be after start time." + +#: app/validators.py:741 app/validators.py:743 +msgid "Day must be 0 (Monday) to 6 (Sunday)." +msgstr "Day must be 0 (Monday) to 6 (Sunday)." + +#: app/validators.py:744 +msgid "A day is required." +msgstr "A day is required." + +#: app/validators.py:779 +msgid "Player selection is malformed." +msgstr "Player selection is malformed." + +#: app/validators.py:805 app/validators.py:915 +msgid "A title is required." +msgstr "A title is required." + +#: app/validators.py:814 +msgid "Invalid date format." +msgstr "Invalid date format." + +#: app/validators.py:819 app/validators.py:826 +msgid "Invalid time format." +msgstr "Invalid time format." + +#: app/validators.py:820 +msgid "Start time is required. Please select a time slot." +msgstr "Start time is required. Please select a time slot." + +#: app/validators.py:834 +msgid "Unknown match status." +msgstr "Unknown match status." + +#: app/validators.py:850 +msgid "The end time must come after the start time." +msgstr "The end time must come after the start time." + +#: app/validators.py:864 +msgid "Unknown match type." +msgstr "Unknown match type." + +#: app/validators.py:876 +msgid "A team cannot play against itself." +msgstr "A team cannot play against itself." + +#: app/validators.py:929 msgid "Invalid start date format." msgstr "Invalid start date format." -#: app/validators.py:791 +#: app/validators.py:930 msgid "A start date is required." msgstr "A start date is required." -#: app/validators.py:797 +#: app/validators.py:936 msgid "Invalid end date format." msgstr "Invalid end date format." -#: app/validators.py:805 +#: app/validators.py:944 msgid "A tryout must allow at least one player." msgstr "A tryout must allow at least one player." -#: app/validators.py:808 +#: app/validators.py:947 msgid "The player limit must be a whole number." msgstr "The player limit must be a whole number." -#: app/validators.py:820 +#: app/validators.py:959 msgid "End date cannot be before start date." msgstr "End date cannot be before start date." -#: app/validators.py:847 app/validators.py:848 +#: app/validators.py:986 app/validators.py:987 msgid "Team name is required." msgstr "Team name is required." -#: app/validators.py:872 +#: app/validators.py:1011 msgid "Scores run from 1 to 10." msgstr "Scores run from 1 to 10." -#: app/validators.py:873 +#: app/validators.py:1012 msgid "A score must be a whole number from 1 to 10." msgstr "A score must be a whole number from 1 to 10." -#: app/routes/auth.py:245 +#: app/routes/auth.py:246 msgid "This account has been deactivated." msgstr "This account has been deactivated." -#: app/routes/auth.py:280 +#: app/routes/auth.py:281 #, python-format msgid "Welcome back, %(username)s!" msgstr "Welcome back, %(username)s!" -#: app/routes/auth.py:310 +#: app/routes/auth.py:311 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." @@ -239,32 +279,32 @@ msgstr "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." -#: app/routes/auth.py:376 +#: app/routes/auth.py:377 msgid "Your registration could not be processed. Please try again." msgstr "Your registration could not be processed. Please try again." -#: app/routes/auth.py:411 app/routes/users/accounts.py:339 +#: app/routes/auth.py:419 app/routes/users/accounts.py:343 msgid "Username already exists." msgstr "Username already exists." -#: app/routes/auth.py:415 app/routes/users/accounts.py:343 +#: app/routes/auth.py:423 app/routes/users/accounts.py:347 msgid "Email already registered." msgstr "Email already registered." -#: app/routes/auth.py:422 app/routes/auth.py:617 +#: app/routes/auth.py:430 app/routes/auth.py:623 #: app/routes/users/accounts.py:121 msgid "This Discord account is already linked to another account." msgstr "This Discord account is already linked to another account." -#: app/routes/auth.py:466 +#: app/routes/auth.py:472 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:491 +#: app/routes/auth.py:497 msgid "Discord OAuth2 is not configured." msgstr "Discord OAuth2 is not configured." -#: app/routes/auth.py:540 +#: app/routes/auth.py:546 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -272,35 +312,35 @@ msgstr "" "Discord authorization could not be verified. Please start the connection " "again from this page." -#: app/routes/auth.py:549 +#: app/routes/auth.py:555 msgid "Discord authorization failed. No code received." msgstr "Discord authorization failed. No code received." -#: app/routes/auth.py:573 +#: app/routes/auth.py:579 msgid "Failed to connect to Discord. Please try again." msgstr "Failed to connect to Discord. Please try again." -#: app/routes/auth.py:577 +#: app/routes/auth.py:583 msgid "Failed to obtain Discord access token." msgstr "Failed to obtain Discord access token." -#: app/routes/auth.py:592 app/routes/auth.py:602 +#: app/routes/auth.py:598 app/routes/auth.py:608 msgid "Failed to fetch Discord user profile." msgstr "Failed to fetch Discord user profile." -#: app/routes/auth.py:609 +#: app/routes/auth.py:615 msgid "Please log in to connect your Discord account." msgstr "Please log in to connect your Discord account." -#: app/routes/auth.py:628 +#: app/routes/auth.py:634 msgid "Discord account connected!" msgstr "Discord account connected!" -#: app/routes/auth.py:675 +#: app/routes/auth.py:681 msgid "Discord account connected! Your profile has been pre-filled." msgstr "Discord account connected! Your profile has been pre-filled." -#: app/routes/auth.py:703 +#: app/routes/auth.py:709 msgid "You have been logged out." msgstr "You have been logged out." @@ -334,10 +374,10 @@ msgstr "Evaluation updated!" #: app/routes/evaluations.py:210 app/routes/teams.py:341 #: app/routes/teams.py:384 app/routes/teams.py:427 app/routes/teams.py:455 -#: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:444 -#: app/routes/tryouts.py:460 app/routes/tryouts.py:480 -#: app/routes/tryouts.py:527 app/routes/tryouts.py:563 -#: app/routes/tryouts.py:582 +#: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:471 +#: app/routes/tryouts.py:491 app/routes/tryouts.py:515 +#: app/routes/tryouts.py:562 app/routes/tryouts.py:598 +#: app/routes/tryouts.py:621 msgid "Permission denied." msgstr "Permission denied." @@ -345,35 +385,35 @@ msgstr "Permission denied." msgid "That language is not available." msgstr "That language is not available." -#: app/routes/matches.py:364 +#: app/routes/matches.py:383 msgid "You do not have permission to schedule matches for this tryout." msgstr "You do not have permission to schedule matches for this tryout." -#: app/routes/matches.py:368 app/routes/matches.py:448 +#: app/routes/matches.py:387 app/routes/matches.py:463 msgid "This tryout has ended. Matches can no longer be created or modified." msgstr "This tryout has ended. Matches can no longer be created or modified." -#: app/routes/matches.py:430 +#: app/routes/matches.py:445 msgid "Match scheduled successfully!" msgstr "Match scheduled successfully!" -#: app/routes/matches.py:444 app/routes/team_matches.py:220 +#: app/routes/matches.py:459 app/routes/team_matches.py:220 msgid "You do not have permission to edit this match." msgstr "You do not have permission to edit this match." -#: app/routes/matches.py:539 app/routes/team_matches.py:250 +#: app/routes/matches.py:552 app/routes/team_matches.py:250 msgid "Match updated successfully!" msgstr "Match updated successfully!" -#: app/routes/matches.py:575 app/routes/team_matches.py:265 +#: app/routes/matches.py:588 app/routes/team_matches.py:265 msgid "You do not have permission to delete this match." msgstr "You do not have permission to delete this match." -#: app/routes/matches.py:578 +#: app/routes/matches.py:591 msgid "This tryout has ended. Matches can no longer be deleted." msgstr "This tryout has ended. Matches can no longer be deleted." -#: app/routes/matches.py:591 app/routes/team_matches.py:269 +#: app/routes/matches.py:604 app/routes/team_matches.py:269 msgid "Match deleted successfully." msgstr "Match deleted successfully." @@ -476,7 +516,7 @@ msgstr "Coach removed from %(name)s." msgid "Manager removed from %(name)s." msgstr "Manager removed from %(name)s." -#: app/routes/teams.py:490 app/routes/tryouts.py:489 app/routes/tryouts.py:593 +#: app/routes/teams.py:490 app/routes/tryouts.py:524 msgid "Please select a player." msgstr "Please select a player." @@ -494,7 +534,7 @@ msgstr "%(username)s is already on %(name)s." msgid "%(username)s added to %(name)s!" msgstr "%(username)s added to %(name)s!" -#: app/routes/teams.py:527 app/routes/teams.py:601 +#: app/routes/teams.py:527 app/routes/teams.py:605 #, python-format msgid "%(username)s is not on %(name)s." msgstr "%(username)s is not on %(name)s." @@ -504,130 +544,130 @@ msgstr "%(username)s is not on %(name)s." msgid "%(username)s removed from %(name)s." msgstr "%(username)s removed from %(name)s." -#: app/routes/teams.py:572 app/routes/teams.py:590 +#: app/routes/teams.py:572 app/routes/teams.py:594 msgid "You do not have permission to add notes to this team." msgstr "You do not have permission to add notes to this team." -#: app/routes/teams.py:580 +#: app/routes/teams.py:584 msgid "Team notes added successfully!" msgstr "Team notes added successfully!" -#: app/routes/teams.py:595 app/routes/users/notes.py:207 -#: app/routes/users/notes.py:250 +#: app/routes/teams.py:599 app/routes/users/notes.py:222 +#: app/routes/users/notes.py:262 msgid "Can only add notes for players." msgstr "Can only add notes for players." -#: app/routes/teams.py:611 +#: app/routes/teams.py:619 #, python-format msgid "Note added for %(username)s!" msgstr "Note added for %(username)s!" -#: app/routes/tryouts.py:98 +#: app/routes/tryouts.py:125 msgid "You do not have permission to create tryouts." msgstr "You do not have permission to create tryouts." -#: app/routes/tryouts.py:145 +#: app/routes/tryouts.py:172 msgid "Tryout created successfully!" msgstr "Tryout created successfully!" -#: app/routes/tryouts.py:158 +#: app/routes/tryouts.py:185 msgid "You do not have permission to edit this tryout." msgstr "You do not have permission to edit this tryout." -#: app/routes/tryouts.py:162 +#: app/routes/tryouts.py:189 msgid "This tryout has ended and can no longer be modified." msgstr "This tryout has ended and can no longer be modified." -#: app/routes/tryouts.py:202 +#: app/routes/tryouts.py:229 msgid "Tryout updated successfully!" msgstr "Tryout updated successfully!" -#: app/routes/tryouts.py:242 +#: app/routes/tryouts.py:269 msgid "You do not have permission to view this tryout." msgstr "You do not have permission to view this tryout." -#: app/routes/tryouts.py:411 +#: app/routes/tryouts.py:437 msgid "Only players can register for tryouts." msgstr "Only players can register for tryouts." -#: app/routes/tryouts.py:415 +#: app/routes/tryouts.py:442 msgid "This tryout is not accepting registrations." msgstr "This tryout is not accepting registrations." -#: app/routes/tryouts.py:422 +#: app/routes/tryouts.py:449 msgid "You are already registered for this tryout." msgstr "You are already registered for this tryout." -#: app/routes/tryouts.py:428 app/routes/tryouts.py:511 +#: app/routes/tryouts.py:455 app/routes/tryouts.py:546 msgid "This tryout is full." msgstr "This tryout is full." -#: app/routes/tryouts.py:434 +#: app/routes/tryouts.py:461 msgid "Successfully registered for tryout!" msgstr "Successfully registered for tryout!" -#: app/routes/tryouts.py:450 +#: app/routes/tryouts.py:481 #, python-format msgid "Tryout status updated to %(new_status)s." msgstr "Tryout status updated to %(new_status)s." -#: app/routes/tryouts.py:470 +#: app/routes/tryouts.py:505 msgid "Registration status updated." msgstr "Registration status updated." -#: app/routes/tryouts.py:497 +#: app/routes/tryouts.py:532 msgid "Can only register players." msgstr "Can only register players." -#: app/routes/tryouts.py:503 +#: app/routes/tryouts.py:538 #, python-format msgid "%(username)s is already registered for this tryout." msgstr "%(username)s is already registered for this tryout." -#: app/routes/tryouts.py:517 +#: app/routes/tryouts.py:552 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s registered for tryout!" -#: app/routes/tryouts.py:553 +#: app/routes/tryouts.py:588 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s removed from tryout." -#: app/routes/tryouts.py:571 +#: app/routes/tryouts.py:610 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Team \"%(team_name)s\" created!" -#: app/routes/tryouts.py:602 +#: app/routes/tryouts.py:643 app/routes/users/notes.py:350 msgid "That player is not registered for this tryout." msgstr "That player is not registered for this tryout." -#: app/routes/tryouts.py:608 +#: app/routes/tryouts.py:648 msgid "Player is already on this team." msgstr "Player is already on this team." -#: app/routes/tryouts.py:613 +#: app/routes/tryouts.py:653 msgid "Player added to team!" msgstr "Player added to team!" -#: app/routes/tryouts.py:623 +#: app/routes/tryouts.py:663 msgid "You do not have permission to delete this tryout." msgstr "You do not have permission to delete this tryout." -#: app/routes/tryouts.py:659 +#: app/routes/tryouts.py:699 msgid "Tryout deleted successfully." msgstr "Tryout deleted successfully." -#: app/routes/users/_shared.py:51 +#: app/routes/users/_shared.py:50 msgid "No file selected." msgstr "No file selected." -#: app/routes/users/_shared.py:55 +#: app/routes/users/_shared.py:54 msgid "Only PDF files are allowed for contracts." msgstr "Only PDF files are allowed for contracts." -#: app/routes/users/_shared.py:60 +#: app/routes/users/_shared.py:59 msgid "That file is not a PDF, whatever its name says." msgstr "That file is not a PDF, whatever its name says." @@ -643,11 +683,11 @@ msgstr "Only the president can edit users." msgid "Email already in use by another account." msgstr "Email already in use by another account." -#: app/routes/users/accounts.py:132 +#: app/routes/users/accounts.py:138 msgid "You cannot change your own role. Ask another president to do it." msgstr "You cannot change your own role. Ask another president to do it." -#: app/routes/users/accounts.py:145 +#: app/routes/users/accounts.py:151 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -655,29 +695,29 @@ msgstr "" "This is the last active president. Promote another account before " "changing this one." -#: app/routes/users/accounts.py:224 +#: app/routes/users/accounts.py:228 #, python-format msgid "User %(username)s updated successfully!" msgstr "User %(username)s updated successfully!" -#: app/routes/users/accounts.py:245 +#: app/routes/users/accounts.py:249 msgid "Only the president can delete users." msgstr "Only the president can delete users." -#: app/routes/users/accounts.py:249 +#: app/routes/users/accounts.py:253 msgid "You cannot delete your own account." msgstr "You cannot delete your own account." -#: app/routes/users/accounts.py:308 +#: app/routes/users/accounts.py:312 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "User %(deleted_username)s has been removed." -#: app/routes/users/accounts.py:319 +#: app/routes/users/accounts.py:323 msgid "Only the president can create users." msgstr "Only the president can create users." -#: app/routes/users/accounts.py:367 +#: app/routes/users/accounts.py:371 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "User %(full_name)s created as %(role)s!" @@ -715,54 +755,93 @@ msgstr "You do not have permission to download this contract." msgid "No signed contract available." msgstr "No signed contract available." -#: app/routes/users/notes.py:34 +#: app/routes/users/notes.py:43 msgid "This page is for players only." msgstr "This page is for players only." -#: app/routes/users/notes.py:71 +#: app/routes/users/notes.py:80 msgid "Only coaches can access the notes dashboard." msgstr "Only coaches can access the notes dashboard." -#: app/routes/users/notes.py:161 +#: app/routes/users/notes.py:172 msgid "Only coaches can manage team notes." msgstr "Only coaches can manage team notes." -#: app/routes/users/notes.py:167 +#: app/routes/users/notes.py:178 msgid "You are not assigned to a team." msgstr "You are not assigned to a team." -#: app/routes/users/notes.py:180 +#: app/routes/users/notes.py:195 msgid "Team notes saved successfully!" msgstr "Team notes saved successfully!" -#: app/routes/users/notes.py:195 +#: app/routes/users/notes.py:210 msgid "Only coaches can manage personal notes." msgstr "Only coaches can manage personal notes." -#: app/routes/users/notes.py:202 app/routes/users/notes.py:245 -#: app/routes/users/notes.py:296 app/routes/users/notes.py:350 -msgid "Player and content are required." -msgstr "Player and content are required." - -#: app/routes/users/notes.py:211 app/routes/users/notes.py:254 -#: app/routes/users/notes.py:300 app/routes/users/notes.py:354 +#: app/routes/users/notes.py:226 app/routes/users/notes.py:266 +#: app/routes/users/notes.py:346 app/routes/users/notes.py:411 msgid "You can only write notes about players you work with." msgstr "You can only write notes about players you work with." -#: app/routes/users/notes.py:221 app/routes/users/notes.py:267 +#: app/routes/users/notes.py:236 app/routes/users/notes.py:306 #, python-format msgid "Note added for %(username)s." msgstr "Note added for %(username)s." -#: app/routes/users/notes.py:235 app/routes/users/notes.py:281 -#: app/routes/users/notes.py:334 +#: app/routes/users/notes.py:250 app/routes/users/notes.py:320 +#: app/routes/users/notes.py:384 msgid "Only coaches can add personal notes." msgstr "Only coaches can add personal notes." -#: app/routes/users/notes.py:311 app/routes/users/notes.py:365 +#: app/routes/users/notes.py:272 +msgid "You cannot use that match as note context." +msgstr "You cannot use that match as note context." + +#: app/routes/users/notes.py:275 +msgid "That player did not participate in the selected match." +msgstr "That player did not participate in the selected match." + +#: app/routes/users/notes.py:281 +msgid "You cannot use that tryout as note context." +msgstr "You cannot use that tryout as note context." + +#: app/routes/users/notes.py:284 +msgid "That player is not registered for the selected tryout." +msgstr "That player is not registered for the selected tryout." + +#: app/routes/users/notes.py:290 +msgid "You cannot use that team as note context." +msgstr "You cannot use that team as note context." + +#: app/routes/users/notes.py:293 +msgid "That player is not on the selected team." +msgstr "That player is not on the selected team." + +#: app/routes/users/notes.py:325 +msgid "You do not have permission to add notes for this tryout." +msgstr "You do not have permission to add notes for this tryout." + +#: app/routes/users/notes.py:342 +msgid "Invalid tryout context." +msgstr "Invalid tryout context." + +#: app/routes/users/notes.py:361 app/routes/users/notes.py:426 msgid "Note added successfully." msgstr "Note added successfully." +#: app/routes/users/notes.py:389 +msgid "You do not have permission to add notes for this match." +msgstr "You do not have permission to add notes for this match." + +#: app/routes/users/notes.py:407 +msgid "Invalid match context." +msgstr "Invalid match context." + +#: app/routes/users/notes.py:415 +msgid "That player did not participate in this match." +msgstr "That player did not participate in this match." + #: app/routes/users/one_on_one.py:23 msgid "Only players can request One on One sessions." msgstr "Only players can request One on One sessions." @@ -804,7 +883,7 @@ msgstr "One on One request from %(player)s has been approved!" msgid "Only coaches can reject One on One requests." msgstr "Only coaches can reject One on One requests." -#: app/routes/users/one_on_one.py:258 +#: app/routes/users/one_on_one.py:263 #, python-format msgid "One on One request from %(player)s has been rejected." msgstr "One on One request from %(player)s has been rejected." @@ -817,7 +896,7 @@ msgstr "Username already taken." msgid "Email already in use." msgstr "Email already in use." -#: app/routes/users/profile.py:121 +#: app/routes/users/profile.py:131 msgid "Profile updated successfully!" msgstr "Profile updated successfully!" @@ -1213,7 +1292,7 @@ msgid "Select time slots when you're available for One on One sessions" msgstr "Select time slots when you're available for One on One sessions" #: app/templates/pages/coach_availability.html:14 -#: app/templates/pages/profile.html:216 +#: app/templates/pages/profile.html:213 msgid "Loading availability grid..." msgstr "Loading availability grid..." @@ -1222,7 +1301,7 @@ msgid "Save Availability" msgstr "Save Availability" #: app/templates/pages/coach_availability.html:22 -#: app/templates/pages/profile.html:200 app/templates/pages/profile.html:220 +#: app/templates/pages/profile.html:197 app/templates/pages/profile.html:217 msgid "Clear All" msgstr "Clear All" @@ -1998,23 +2077,23 @@ msgstr "" msgid "Green indicators show player availability for the match date/time" msgstr "Green indicators show player availability for the match date/time" -#: app/templates/pages/match_form.html:625 +#: app/templates/pages/match_form.html:632 msgid "Click time slots consecutively to set match duration" msgstr "Click time slots consecutively to set match duration" -#: app/templates/pages/match_form.html:892 +#: app/templates/pages/match_form.html:899 msgid "No players registered" msgstr "No players registered" -#: app/templates/pages/match_form.html:925 +#: app/templates/pages/match_form.html:932 msgid "T1" msgstr "T1" -#: app/templates/pages/match_form.html:926 +#: app/templates/pages/match_form.html:933 msgid "T2" msgstr "T2" -#: app/templates/pages/match_form.html:931 +#: app/templates/pages/match_form.html:938 msgid "No players available" msgstr "No players available" @@ -2369,15 +2448,11 @@ msgstr "" "Select your available time blocks for matches (5pm to 12am). Green = " "selected, Gray = available to select." -#: app/templates/pages/profile.html:197 -msgid "Save Disponibilities" -msgstr "Save Disponibilities" - -#: app/templates/pages/profile.html:211 +#: app/templates/pages/profile.html:208 msgid "My Coaching Availability" msgstr "My Coaching Availability" -#: app/templates/pages/profile.html:212 +#: app/templates/pages/profile.html:209 msgid "" "Select time slots when you're available for One on One sessions (8am to " "10pm)." @@ -2385,7 +2460,7 @@ msgstr "" "Select time slots when you're available for One on One sessions (8am to " "10pm)." -#: app/templates/pages/profile.html:460 +#: app/templates/pages/profile.html:457 msgid "Click or click-and-drag to select your available hours" msgstr "Click or click-and-drag to select your available hours" @@ -3001,3 +3076,9 @@ msgstr "View Profile" #~ msgid "Team Tryout Management System" #~ msgstr "Team Tryout Management System" + +#~ msgid "Player and content are required." +#~ msgstr "Player and content are required." + +#~ msgid "Save Disponibilities" +#~ msgstr "Save Disponibilities" diff --git a/app/translations/fr/LC_MESSAGES/messages.mo b/app/translations/fr/LC_MESSAGES/messages.mo index bc11174bc224b8470f7d42f75cd45fff6a099738..156d19dd0079830b93a9dc4ae541d1b358d1052f 100644 GIT binary patch delta 12457 zcma*s33!#oy~pvFKtdA8MnYHu$q8WzgoGr7EfB)0>^rD{KuAs?keonrB1}xkz1rwqmhVRC>R^%!sttKF{^BerD#Kcb5PB=RIgQ zFRJYBb!>eX85@YjX_$7zC1>Z|tCUrpLLPCcqkt?8Idy*Ki=Q_MdSa5c8Y{g{TA zusKGjI8J-)j4mw3BwT9rIWJO3q~Svh!5i2VYcN_QCSU_h$41x>!*Mdk;7km|g&2V= zFcvqX-aCZa(+8-Hd}-_7S3mdBm}(|$gHd$ohtW9J>ajkI8fcZR@3QsRZT*a`Uq&rN zVbC~{7>3PJ<7e7>AJv)P89_k<7oc`H7q#;hs7P!<4OE5N!AHpAoy(~H4cZwquo?A% zsK|IwN3{Tha2IkaP9q2U~Gm8X$tD7dZTtU7&YNU z)O%i3{{_ftI!~Z-;Q%(oKO$>){*mtZoH`U*Wbi-Cz@c~#s^d1)K(Av2zHRI0P!nCm zNW5kZ$aI`g>d~n8;!zW|LM?aPU?2;`vZLs9)EVn-~(EZl%z{1COV0W=0;zK?=-QiQsGvr$K}619V0qXyn;>#v~B z?kL9KNz{ZFQSV(vMdUgvrvf{eBa6en)Z1eL&O!Fz_&%Ya0isy028csVn1uDPJ!&C$ zp#~m`TEHaa7#$Dl{q?9w?Lcj4FRK4h)Ohb<6kb3b!Pm$*KIfKgsN2aLMMG4_&X|h> zP!lY{TDSr=z*w%tQ9I!T~PgdqH<>xrs4yroo~iQxDS<6r?4AdM$OkM+i}`4zq63S z2z&)KK}c776sQFxp^hLM70TYI2}YtKQh>_(BGeAOsE9peeH;~`m6(j{us*(tKCSQ% z6cn=S*asW4Oieh^Iu-SNCMx8sQ9FLswttL@SU`?hU^w=q9*+dknS_eeT4V}m7i!)w za)^I-3IY713HC(oYy!q(DaPP39EqDS8gF0&tV4pRo`hO(Csc%bpdvC1HSsvq!ab<| z%TWv7lIt@QzDR>Y{{|{_zei>HC)f_Jq9V|whvT%wURWJ+RDUmOzA zwZOfo@5e#Zdtdk{XyUI?XH<*0>H37CIy6E>Bnh>EEDXT0s3g1}`P*^xj~=+q*1te4 zs7)_()*aDBeGoRp5>#Y;3vI(XRF=MsTKOr|k$i?atD3z{ds9@uEYtu)QAbgLx^54m zcDewSj8C98vKG~U6DHsRWPG1G3^J;w9k6l#FKVj$i?h4Kf~1R;0vmBbj- zQKe&3%(db-|h<1Ixc=PC@str*7q&I=SY(IM1MkD)^M2UPZcj+*EuYJiZt z&47(iN7W3qur8=v>4Qq%(Wubp+vg?L*{H}ZK%XXBOhGGLX*)b?JM2Jx(RO2P{1~-> zOQ`M{|i$vsz349S?2WTHy`gob=-rBL=`G& zKDPC1$hX4@9bmG$AGV}E0hN@GqK;yNZGR0%Q~xvSi`SLaX~V;@`a3g_`0LC|Y0!kr zP@&moJMOX`MBRqt)=yFWm7(pS5$XsBqjo$JH9;Y2fwQnVK8vAPh1%%5J_`H-I~T1D z2Rlw{>O-(C&bDqr4R9JY;0_koQ4r)Q?Q9J(>6`32DhK)y>>(?Lk{v^}~Y7S~4%TWtEh!OaP zZ9i?>Z%XEOYK}4;>!K#CkILev7=m3<3+ju}I0juf4eR0Ks2x6w+Tl)A#E#ka_iX)- z7(@G2)VMW96MrQ~9SRCrCThi9P%G|*+Sv#U!|A9qorAg!kDzjABeuumsOx+awSbs0 zX1pZKpxzcW&jg%=?lHvQMd3IN+QH|j7q6qTyCxq6WpQiNz#~yfR)Si{V$_cOSO>SG z7Wy*Q!Z$Dq-$IRl4zo<1HY(}*U_%^$8XzAvVKM6c z*{FyuK`r1J)CAj6zZLsY3-|z)8yC=p!9ISPQYf@Wo$+YY7p@RlW1WPf#DAMw1CUcEfmFj*W0HYT$RU3H}|kFf!l#vA!>A+$E@6Q-wYy-&qPet6BwS zry;1&N1#IQLS=a;YkyRhkFyq`LOlx=+6AawS&F&^D^QW#gj(2M>&XJ*uN7am4bg6s zZ0V@8?2NT=AZh`_P!mta`ZxoX#S1Y3pFvIVDr(^$S^tW`)B_96#==nxPADY)x;7~^ z=>AT`##o9PUK=b3=V3+-0c#M^Lxu z3U+6HC$7XajKv%tEWunnfjuzTW1ja%t$2>DZ^FUUKR{)FVky6j*cDUpX&j2LV+uyj zFdw#V*p+%IhU@c#VzhJl=q&OQs1aTvzn98~fy zL*0TM7=_1C{m-KMT}LHn$OFV**_uc}$&rn^b`vlbXId9yed_B`3)qJW`6blAx2%n4 znTb21CK!pDaJsF}N4>WOwZNBV5&s4hPSK!&K1VH}=4`WoSkwTisD<`IEo2;OpqaM* zIO)YrP0{L@QAneIC`n5*z9MAED5MhL2F8 zy^g~%@*x(1lQ9$DN4;39zp#^oJ2+7G$!K()Wji=nCFq` zOQ)ee1?}uE%)())g*=KnyES+Z?!_4x{HR&b9IQiq88*UaP`UCl>Zp#QBJ#eipTkt@ zSFjg0T1fmA^6?AJgW0IF-HKiD7*4_;uoF&LWd41A6>5U37=*X5DF!Y!J5E9u^$gT) z7=giK;wpjZy^COgnT37+*;RaL$0+!gEz|Pd; zP}x5U_5N~n;cKXg|Aad7uYD8-PzZk9yf6}Vwojq1*&bAKeu)}5=n1pXTMV~~Kf=1yuc0Dz3$^kF%glSJ zs3Yrw?Qs;g#l^P150#W(q9*(n74f=HR=@9a5-B9mkcu^NFoxl9%)p5lj?1xnfmn^O4w&dI>72evO*w z5Ne^nw_ZnuJmzWhB}_*h-8j^HbFmq&K}F~gy6_B!;tdR9ekZ_h?sW);PM5qG1K@z1F1nYhCK7NCpzYSft@Kt6T&SaJiJG7TMq+PUA7$&4u^#Q^tBAku??M`s zbW2fZxy^Q{L_I%_8t^1)!RJv&aRIeZXSIn~7;3^q)Pgfn8@n4d{%}<2r=$8ku$uU* zu*g1GgVEGCVFd0+CFNVF0e_3?e*x9+8rH_ZHRk$;p%y#<8{lwMM2b)wn2nurAx^@h zJ_>^MjpuxW~D|cfQbuZS(g{T33g*u8V)LEWDO>`bz_?0#4*Jhkd zOryOgDj8=W^ZJ|zDCosCn1wH3242KijCs~vqZI5yeF%2K#i&rej$QFv)O#J)oAxQ# zf%*pQh-Xmmg>5kPzL>51{~!eoyx)2ayHiiuXg3A92W88D*&jmwK?GK}p zs}dE#*DwLkU?2PkDmS`rGT)6}SpEAyfr4HvMP>6`>mqbfUx9Aihsuq(&8B}3RFV$F zMmP?26cwmjv=|%W3REOtK+W?eY6GXxrxn%QV$L=cmDMhc$1F_7k(h}OVpH6Mt??x4 z=x(5HLEWw9{TNill2IYQ9~JcCVd`yB{ci!vx{P}7J50v#o#tn^6E>q>fSPy_DhGC=CVUgM(Mzatd^ag5^mTTb4pFF( zWnc&nMNM=c>I{ofxv>acxE0m!Ev$v_qxzk}P`rkk=zH59_JTQzWF*2qCzpcGa5y$c zH!7)?pswM1)X(uJsQVti+dRJ;$59`T!|?!4!q7eDzjRJVE&MFD!7Hdp#O^i!zR?b= z|Np;o3QDH=sH9kgb8#~&2NGU1FJ@pk^#K@x_hBzA!gja|bz~o-#=C@d@CvrX8(0(L zUotksaPrS-NkK1mz}A?9I;$zDvs{FV%=6Y$sE}W`-a=)4aHW~38P=oT-#Q+nsF$L~ zeF$~L8`0;YaFl}1>H;chu3%e?c-aip4Yh-zs88(!s7SqpE2c{Qxqh5g{@g>Z_p#A1gIGwRC^`}rDkTci=zs4MF zf51#U6P2Xfa4a52Z6NWWv7fc{Ao17rSw%xG9>p>EFC2tpUNs@xgqhT3` zO-2puL0!K`P!m3eb#WbP-0i3Z9YYtMMdgC;TMCUSG&*9EA`5kfBT!jB4i(DjcsDLV z?f5h*q?hgUsMk#YbW|jJU?-f9o$&y+z-y?DH8@)R2N$1{VhYYk>o0IR9X4TCjCkGb zbRa6L??LTk8ft+Lp|0g}jKdA6euq)F>J(~&A7L7PjrtxW9@CfB#~D!=Lc>BFj~`+N zX1-yPu>gBg--vziQ`A7oRs6)_J=g;;U_VTJ(F+QLgO8ivfE?^beHISJmr)@Nc+333BpcQKDE7w>FbA8SFz=5;)mP%ZdX7!8?c3&7 z3_xFR8n#f#!!J?ye&9Ri6FD2T^TVhR-$Er<*l$fvB%s>6VGoq@vvGn(XFp?<}|5Jzn>0uiw*ie*ONQ2l9%G3S8c@Ii(d|`>66t&&|PU zrsDDbmkBzgr{6h4G#D92v%cwhMGe=W- zc>J-zvCL*dOq_X@VWzOVG-tGDx~FuO$5rN@QdI6O%c~Amxi`;SQGUlmLc~)4v)PWn zVDj@deffDFt*fHk&FG41b;9ITul9CS)13QCD_sBC)$7hHasB^#nq2#tzW$WL-_~Sn z{@qhj{8tM@{Tc51{#H{PS6(e#5>&gBDT{$lFWzxaYd^Dp*BDxvRqSW4mLU{p{8cie^M`JAT?^)eir&dz#0vpPw4}^pzHs7gavHXKhfYVYg3# zo6N`0Q(;5E+|%3@Y0gMp6tAm*35q=B`DH~jbnyPvS6amU_~_0VEBn3DB+#FED7saJ zx2U+NoTU447s^7uHuei!y=?B2zulqOws-XP@=Y;2QP=Pm1eo6 zXc{j07`e~5OpcZ7o0nOQWsaq$=8}1(O}*bA=UmtOb~Sy@`7h_(=RWuOV>EY{`)ru+ zum&E(C_IC~_zOm$)7o(wLIVuK_NZ8UV;#)Ix|oaMSd4ma z5!T1`sDsqHVZ{f)N27Zu29d;NP< zfWM-~_ikhQ^)Q6_ooE^wI0?1FG}Ov-QGu190xCzXU=uQyvlsRJPgXDT9!9#))wRekA zDPM(}a3|`${TPf#Q8jfAwPinJU-TiGTpWnR;<&fb&;XZE1N@Ad@E(SuPe&6-IBMXg zr~nd?tU5hV?@vKxY9?wyWvJ&DqsCi{%G3_jmLEpOaXTmNg>O+?aTWDoAo=Twk*Eot zL0`;84Umtj_7YSoSD=b@HL8~0M+NjRY=Nh+HaJw~Lj@ebUSI&- ztx)5pp~fALt#A&u)A_HYF_;S%Q4zI%-0W>C>V;9L6i!DCG#i!r6{rB#qcT{DPvcHh zfVHV)?R6L`z!ugdY(l>`2I~Bep`iitQK_4PnqW3I!g;8zc^{+jBYXW6Rv%N;d-qTQ z{e>#Rz`iDs2B=zUikhblYM~vlHuF1!X{bt{Mom^Vk6LuT*p#V2Wl4s{b5vq@7Y!74OAZJdG;W z2dJ$G9cZp6;&A#SP|t5gt@sEkQ>RgTehD?clV&p0Fpd1{!6sagtx-kR#X21ILJ|7m zB2*@pq6Xf83hZNShTmdM^ddc4VQuV)E^8*n(SHq<$tt&PTt*ELJlG7_5u4K=g)ML{ z#^4r=$CIdm9-#tg{)E}W!I(mS7IM6tPf+7tvj(wW(eyi@uDdg71k#v|iuh&Jxm|&p zXg3Dn6;$ePVh{!nH7kw4+VrDQ<0PQApbPT1GlYNWQ@t8BP8BMH2a)fI+c`r+1OA9g z@qJWkAE5@W{iGSVA^OvgMio~a>V7xWgagopqfrZRqcXGxwS~t~HShxlqgT2DV*f*F zXyuWp)U?I6n2tJjWvBs`qgJv5wI%;Tt@t+9#oukeMuxc_YHfsCc{5be#$ydk!(irj zo}!@u^3a7dF%(y#R=5?l!o8?WownD%v;7;WPxB+xz`?^zO+=zH)*BV*092sqs0HPq zyAF+6G*q<#i_4k!OAsx&SrfIO^+Q?VAlf(mdc`r;Z4!40Shs!-1#K?U>`YQjsX=Wb&ddS{vp zhNChRkE*p!ndD!oNa2D87>=5FG-`l}sFanW0(lKJ!AjK6hRvuz{)wuIv)CFRqKYh* z&z(MGov|mD;1K*6C!()=q#0leD)ptd{|+kk8&SpcH7eCVU@Tt40u1Iyo&tCQwbEBn znOtq%jP2>~LVZ7eLXB7J88eXy*Fe)QMQCpCOO>qorPs^+utzV$3 z{4Ofs;4EVVDkDuW921bh-A+Fm+LK|ZK(g%v6EKv%+qw)DNCh^+kFYJCK)wG5YGD6S z=A1`aQ&1VoLhbnkOu#~Hs`I~^h6Xx_@pu!5Vq~`YVsau^FmpJ6Tgv8O%UsY&5#HM-ynMs;8h*^&&RLMb-~c5&zTnZ((&2 zjWt^oh6*ee6+mm$#NDwz4nh@Y4l2+R)M;j672R^{7F0$K zSU*Fp@I31IYv_yjQAPO>6;ODdIVF8i{jt~)-@smY6l2iOJJoZ zFo@5S`d#n|oPes{D$K;w*allnGN)xYD!>ZV2j>iSL*D{ZTfI;({8g{|%RVG00@|{F*|uRUT9ZKE=-X2WkOHQ_R0;jK+BS3$0bC({{PK z&;B=$#|L zN--2ypj)Z^kcI|0jEeL;Dv;Z#f&7b2--S9Yoos(3hS4v?M4XFF(St7hAA9{*tVh3j ziFvLo2GXBY!v1S-in$Pk>reshzyLgkes~Hsz&U&UItJ6fiz+6c>1GR}Q7cYDeIL>> z3X3rum!Uq;#+x`$# zVA<9}tU=$6TJb_m!DUz*Pw9qI_$_Ln+t?EyVKR1`X^OK1Rh(;4nfc6m7B%447=aP9 z%oZh}GLw#)Xew%fFJNyxgv-$FGux!MU?iTk*KeX$@+)eEf1(DiTWZdA zW7HP4LanqX>iGfK5Hqkb7GNV>i0L~26*LOD@BrK6xEIWe@1Q1FkE+%kSRap|GH?Nv zxl8u`eQZHL@I@0qB5Fa&sDOK+-cLu3H@>>h{?D=(mY`O!9#sRou?e2C{aZMgz8~+@ z#SGN9e=O?DHXikzC_zoU+TO3gMEYA$8Tkg2@Ct_VKPR%x>|GliLBB6f#Z{<)g65bX zHZiDFCZK9%AZn|gLS>RENB57SaqG&`(A^m+>Q`Ih1Ak9$&^gs=qhRrY={ctY&;{wdW#rQ1#jM}(fA!IBi?VA(-4IUq$TS3CSVQhhx$%D ziH~6}w!nGVT=Q?E(TNLRqKdB8Vsi}pqb6K~E*wmcd-HUI{`~g>RqU!>5iJ99|qxI)C5@=jQO@d%k~$bw&opF1}agh z_FyeMVeenC_iv!ayNA`k|MOaAQtyw7G!~Vz4yXzHqaq%OTG?dO1T(NEE=4`}o^`9e zeh@Xzajc8~Mit*p)OdH6k$=7DUv3_ZzySKKQRlP+YDH5~Kiy`aGO`%8g4L+wT8U%u z3J%78Z<>shql$GSs<=PLu6PwyQ!#Im|7;q4-ZIB+Evh)GP!nE67ygDrFyd_!`8bTD zKO1AQ0$un8s>p8Qb69t|8D|Ho9hM{;1>!H^QGjO=IGisob zs2UlMns74e`Nf!o8?YT-z;LYfjyW~am`uMfc1E|GMi&~NpsN2i>c#k#=K5&tNPjtY z!V{<$ecv_x6zocW8Ybg5>s9PQKl(lM>CHg}R*8vt2|MWghpsX|_xqu4%tRH{7L34; zQQw6Vn2I-1HPL>x`4lIkYG^p>y>X}_e%|^by67*$Jgh|3MCckl&;EC&q3Y~|4KV|? z1qG<%&QqufzQT$4J-TpEg{hGO z)blT3OI)Hqo5j=E!-Z)45jA0rjb_i=peE{vTIo1cv6Z49zHaZAqcY{O{WGYP-$70I z2(@)Vn@nxQp^N?iHw`_QkG?n!_2A!7MOKcQaJ9X@9jmtjmEudNt-FWKFyI4IOYzu) zes9$AF05z8n2zpNo6V=Q6brcUZ&bu-TTJz4qpE)aK91{9dv^u>@h+-Xe#coD z{-FtMDeArTsG8W1s-4fV4}OCQI{!^7&7KWG4Va0wFdJhrAAN9%^-a`(D=+{zVH|Ep zZPf+TR{oC4NaL->-dKbFIO|kwsPjLIh9-Iwweo$|lUSW%)WG*pdmOgS>|HyoNk0Qs zGuarAb5P@KLyda~m7%NXLci^13u7^m`JL`GbbkAxA}+=DxDHeBOB{wFJItp!4@b~n zj(zbL)OVocN9I>K>DZnAGSs-IQ6D7#o&3KjusJHQGIXo4$zC{#E$QFKp4h}=e%CV^ z2h;x;l?m@%CctRaDHv|eLVdDxP&;LOu5Zs%8#heLR6WO_xyP3+~3|cn7tBh~4JT7p<+s+_o_rC-J~~ z?1pvsm=z7eQ2HY<94DdzoP$BQ6t(wjQP1s1or+Vag`LN?co$pYV|&ey>A^UJzIz^x zQ8dnBJ8ZwtypW3v=$E64tnq%6^0C;3{tD|Ed%flXQ*^zs8~0ztB;1S1_zR|B+(GmE z#BoTb+|DiWg5B{HYJfWbF#Y~GlKv|gg%|M|`~&rY8ga<{i^sdD zS~G(*FyUvW7>@Z#Gl0G5x8it$6*2C$iP1 zaL?^tKY5kk>NDL_`uMs)Px|1u{mScR^lF$>nCp6ON?~zcVX-TxD9=@t_k2lyQC@Ca zPydY9ygZMFb@29#$(-*Mk~2Nem6|_o>XgF#vH1o0#rdAgBjUY0iBCuSmCqf$$@9yz z9zXx0DbtGciadYhZSg5@G4Z%3XHuDeKqpuG4qe-~_gsB`MwDmi;+MS3_bshi-gVh~ zo(Id)YI>%wdN!ckr{c-D!FjHUQ%dqmid=ccu7W%pR*7pG;kl-aEyy3AQ#^&(rg_pU zE_->#ZL0G2WN!K0t4ZI&qP+3>(~67oid>WPrnz$eUzkTWd!DFV>g5UAHo?Dq=dNR( cC#x3tmp44n$dhp()UUkdkvpFCM~4RdFK)&88~^|S diff --git a/app/translations/fr/LC_MESSAGES/messages.po b/app/translations/fr/LC_MESSAGES/messages.po index ea43343..6bbdabb 100644 --- a/app/translations/fr/LC_MESSAGES/messages.po +++ b/app/translations/fr/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-16 23:22-0400\n" +"POT-Creation-Date: 2026-08-17 14:18-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -23,8 +23,8 @@ msgstr "" msgid "Please log in to access this page." msgstr "Veuillez vous connecter pour accéder à cette page." -#: app/forms.py:37 app/routes/auth.py:228 app/routes/auth.py:387 -#: app/routes/users/contracts.py:98 +#: app/forms.py:37 app/routes/auth.py:229 app/routes/auth.py:388 +#: app/routes/auth.py:402 app/routes/users/contracts.py:98 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s : %(msg)s" @@ -63,7 +63,7 @@ msgstr "Le nom d’utilisateur est obligatoire." msgid "Password is required." msgstr "Le mot de passe est obligatoire." -#: app/validators.py:227 app/validators.py:294 +#: app/validators.py:227 app/validators.py:324 msgid "Username must be 3-80 characters." msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères." @@ -71,8 +71,8 @@ msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères." msgid "Email must be 120 characters or less." msgstr "L’adresse courriel ne doit pas dépasser 120 caractères." -#: app/validators.py:246 app/validators.py:309 app/validators.py:340 -#: app/validators.py:403 +#: app/validators.py:246 app/validators.py:339 app/validators.py:370 +#: app/validators.py:433 msgid "Full name is required." msgstr "Le nom complet est obligatoire." @@ -80,160 +80,200 @@ msgstr "Le nom complet est obligatoire." msgid "Passwords do not match." msgstr "Les mots de passe ne concordent pas." -#: app/validators.py:313 app/validators.py:348 -msgid "Invalid role selected." -msgstr "Rôle sélectionné invalide." - -#: app/validators.py:443 -msgid "Player must be selected." -msgstr "Vous devez choisir un joueur." - -#: app/validators.py:446 -msgid "Notes must be 2000 characters or less." -msgstr "Les notes ne doivent pas dépasser 2000 caractères." - -#: app/validators.py:483 app/validators.py:854 -msgid "Invalid coach selection." -msgstr "Sélection de coach invalide." - -#: app/validators.py:489 app/validators.py:860 -msgid "Invalid manager selection." -msgstr "Sélection de gérant invalide." - -#: app/validators.py:507 -msgid "Invalid player selection." -msgstr "Sélection de joueur invalide." - -#: app/validators.py:516 -msgid "Unknown roster status." -msgstr "Statut d'effectif inconnu." - -#: app/validators.py:539 -msgid "Date must be in YYYY-MM-DD format." -msgstr "La date doit être au format AAAA-MM-JJ." - -#: app/validators.py:540 -msgid "A date is required." -msgstr "Une date est requise." - -#: app/validators.py:546 app/validators.py:611 -msgid "Start time must be in HH:MM format." -msgstr "L’heure de début doit être au format HH:MM." - -#: app/validators.py:547 app/validators.py:612 -msgid "A start time is required." -msgstr "Une heure de début est requise." - -#: app/validators.py:553 -msgid "End time must be in HH:MM format." -msgstr "L’heure de fin doit être au format HH:MM." - -#: app/validators.py:554 -msgid "An end time is required." -msgstr "Une heure de fin est requise." - -#: app/validators.py:558 -msgid "Points must be 2000 characters or less." -msgstr "Les points ne doivent pas dépasser 2000 caractères." - -#: app/validators.py:573 -msgid "End time must be after start time." -msgstr "L'heure de fin doit être postérieure à l'heure de début." - -#: app/validators.py:602 app/validators.py:604 -msgid "Day must be 0 (Monday) to 6 (Sunday)." -msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)." - -#: app/validators.py:605 -msgid "A day is required." -msgstr "Un jour est requis." - -#: app/validators.py:640 -msgid "Player selection is malformed." -msgstr "La sélection de joueurs est mal formée." - -#: app/validators.py:666 app/validators.py:776 -msgid "A title is required." -msgstr "Un titre est requis." - -#: app/validators.py:675 -msgid "Invalid date format." -msgstr "Format de date invalide." - -#: app/validators.py:680 app/validators.py:687 -msgid "Invalid time format." -msgstr "Format d’heure invalide." - -#: app/validators.py:681 -msgid "Start time is required. Please select a time slot." -msgstr "L’heure de début est obligatoire. Choisissez une plage horaire." - -#: app/validators.py:695 -msgid "Unknown match status." -msgstr "Statut de match inconnu." - -#: app/validators.py:711 -msgid "The end time must come after the start time." -msgstr "L'heure de fin doit être postérieure à l'heure de début." - -#: app/validators.py:725 -msgid "Unknown match type." -msgstr "Type de match inconnu." - -#: app/validators.py:737 -msgid "A team cannot play against itself." -msgstr "Une équipe ne peut pas jouer contre elle-même." - -#: app/validators.py:785 +#: app/validators.py:284 app/validators.py:924 msgid "Unknown game." msgstr "Jeu inconnu." -#: app/validators.py:790 +#: app/validators.py:291 +msgid "Gamertag must be between 1 and 120 characters." +msgstr "Le gamertag doit compter de 1 à 120 caractères." + +#: app/validators.py:297 +msgid "Platform must be 30 characters or less." +msgstr "La plateforme ne doit pas dépasser 30 caractères." + +#: app/validators.py:306 +msgid "Unknown platform for this game." +msgstr "Plateforme inconnue pour ce jeu." + +#: app/validators.py:343 app/validators.py:378 +msgid "Invalid role selected." +msgstr "Rôle sélectionné invalide." + +#: app/validators.py:473 app/validators.py:596 app/validators.py:629 +msgid "Player must be selected." +msgstr "Vous devez choisir un joueur." + +#: app/validators.py:476 +msgid "Notes must be 2000 characters or less." +msgstr "Les notes ne doivent pas dépasser 2000 caractères." + +#: app/validators.py:513 app/validators.py:993 +msgid "Invalid coach selection." +msgstr "Sélection de coach invalide." + +#: app/validators.py:519 app/validators.py:999 +msgid "Invalid manager selection." +msgstr "Sélection de gérant invalide." + +#: app/validators.py:537 app/validators.py:595 app/validators.py:628 +msgid "Invalid player selection." +msgstr "Sélection de joueur invalide." + +#: app/validators.py:546 +msgid "Unknown roster status." +msgstr "Statut d'effectif inconnu." + +#: app/validators.py:559 +msgid "Unknown tryout status." +msgstr "Statut de sélection inconnu." + +#: app/validators.py:570 +msgid "Unknown registration status." +msgstr "Statut d’inscription inconnu." + +#: app/validators.py:583 +msgid "Team name must be between 1 and 100 characters." +msgstr "Le nom de l’équipe doit compter de 1 à 100 caractères." + +#: app/validators.py:603 +msgid "Position must be 50 characters or less." +msgstr "La position ne doit pas dépasser 50 caractères." + +#: app/validators.py:616 +msgid "Note content must be between 1 and 5000 characters." +msgstr "La note doit compter de 1 à 5000 caractères." + +#: app/validators.py:642 +msgid "Select at most one note context." +msgstr "Sélectionnez au plus un contexte pour la note." + +#: app/validators.py:654 +msgid "Rejection reason must be 2000 characters or less." +msgstr "Le motif de refus ne doit pas dépasser 2000 caractères." + +#: app/validators.py:678 +msgid "Date must be in YYYY-MM-DD format." +msgstr "La date doit être au format AAAA-MM-JJ." + +#: app/validators.py:679 +msgid "A date is required." +msgstr "Une date est requise." + +#: app/validators.py:685 app/validators.py:750 +msgid "Start time must be in HH:MM format." +msgstr "L’heure de début doit être au format HH:MM." + +#: app/validators.py:686 app/validators.py:751 +msgid "A start time is required." +msgstr "Une heure de début est requise." + +#: app/validators.py:692 +msgid "End time must be in HH:MM format." +msgstr "L’heure de fin doit être au format HH:MM." + +#: app/validators.py:693 +msgid "An end time is required." +msgstr "Une heure de fin est requise." + +#: app/validators.py:697 +msgid "Points must be 2000 characters or less." +msgstr "Les points ne doivent pas dépasser 2000 caractères." + +#: app/validators.py:712 +msgid "End time must be after start time." +msgstr "L'heure de fin doit être postérieure à l'heure de début." + +#: app/validators.py:741 app/validators.py:743 +msgid "Day must be 0 (Monday) to 6 (Sunday)." +msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)." + +#: app/validators.py:744 +msgid "A day is required." +msgstr "Un jour est requis." + +#: app/validators.py:779 +msgid "Player selection is malformed." +msgstr "La sélection de joueurs est mal formée." + +#: app/validators.py:805 app/validators.py:915 +msgid "A title is required." +msgstr "Un titre est requis." + +#: app/validators.py:814 +msgid "Invalid date format." +msgstr "Format de date invalide." + +#: app/validators.py:819 app/validators.py:826 +msgid "Invalid time format." +msgstr "Format d’heure invalide." + +#: app/validators.py:820 +msgid "Start time is required. Please select a time slot." +msgstr "L’heure de début est obligatoire. Choisissez une plage horaire." + +#: app/validators.py:834 +msgid "Unknown match status." +msgstr "Statut de match inconnu." + +#: app/validators.py:850 +msgid "The end time must come after the start time." +msgstr "L'heure de fin doit être postérieure à l'heure de début." + +#: app/validators.py:864 +msgid "Unknown match type." +msgstr "Type de match inconnu." + +#: app/validators.py:876 +msgid "A team cannot play against itself." +msgstr "Une équipe ne peut pas jouer contre elle-même." + +#: app/validators.py:929 msgid "Invalid start date format." msgstr "Format de date de début invalide." -#: app/validators.py:791 +#: app/validators.py:930 msgid "A start date is required." msgstr "Une date de début est requise." -#: app/validators.py:797 +#: app/validators.py:936 msgid "Invalid end date format." msgstr "Format de date de fin invalide." -#: app/validators.py:805 +#: app/validators.py:944 msgid "A tryout must allow at least one player." msgstr "Une sélection doit accepter au moins un joueur." -#: app/validators.py:808 +#: app/validators.py:947 msgid "The player limit must be a whole number." msgstr "La limite de joueurs doit être un nombre entier." -#: app/validators.py:820 +#: app/validators.py:959 msgid "End date cannot be before start date." msgstr "La date de fin ne peut pas précéder la date de début." -#: app/validators.py:847 app/validators.py:848 +#: app/validators.py:986 app/validators.py:987 msgid "Team name is required." msgstr "Le nom de l’équipe est obligatoire." -#: app/validators.py:872 +#: app/validators.py:1011 msgid "Scores run from 1 to 10." msgstr "Les notes vont de 1 à 10." -#: app/validators.py:873 +#: app/validators.py:1012 msgid "A score must be a whole number from 1 to 10." msgstr "Une note doit être un nombre entier de 1 à 10." -#: app/routes/auth.py:245 +#: app/routes/auth.py:246 msgid "This account has been deactivated." msgstr "Ce compte a été désactivé." -#: app/routes/auth.py:280 +#: app/routes/auth.py:281 #, python-format msgid "Welcome back, %(username)s!" msgstr "Bon retour, %(username)s !" -#: app/routes/auth.py:310 +#: app/routes/auth.py:311 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." @@ -241,32 +281,32 @@ msgstr "" "Échec de la connexion. Vérifiez le nom d’utilisateur et le mot de passe, " "ou demandez de l’aide à un président." -#: app/routes/auth.py:376 +#: app/routes/auth.py:377 msgid "Your registration could not be processed. Please try again." msgstr "Votre inscription n'a pas pu être traitée. Veuillez réessayer." -#: app/routes/auth.py:411 app/routes/users/accounts.py:339 +#: app/routes/auth.py:419 app/routes/users/accounts.py:343 msgid "Username already exists." msgstr "Ce nom d’utilisateur est déjà pris." -#: app/routes/auth.py:415 app/routes/users/accounts.py:343 +#: app/routes/auth.py:423 app/routes/users/accounts.py:347 msgid "Email already registered." msgstr "Cette adresse courriel est déjà enregistrée." -#: app/routes/auth.py:422 app/routes/auth.py:617 +#: app/routes/auth.py:430 app/routes/auth.py:623 #: app/routes/users/accounts.py:121 msgid "This Discord account is already linked to another account." msgstr "Ce compte Discord est déjà lié à un autre compte." -#: app/routes/auth.py:466 +#: app/routes/auth.py:472 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:491 +#: app/routes/auth.py:497 msgid "Discord OAuth2 is not configured." msgstr "La connexion Discord n’est pas configurée." -#: app/routes/auth.py:540 +#: app/routes/auth.py:546 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -274,35 +314,35 @@ msgstr "" "L’autorisation Discord n’a pas pu être vérifiée. Relancez la connexion " "depuis cette page." -#: app/routes/auth.py:549 +#: app/routes/auth.py:555 msgid "Discord authorization failed. No code received." msgstr "L’autorisation Discord a échoué : aucun code reçu." -#: app/routes/auth.py:573 +#: app/routes/auth.py:579 msgid "Failed to connect to Discord. Please try again." msgstr "Impossible de joindre Discord. Veuillez réessayer." -#: app/routes/auth.py:577 +#: app/routes/auth.py:583 msgid "Failed to obtain Discord access token." msgstr "Impossible d’obtenir le jeton d’accès Discord." -#: app/routes/auth.py:592 app/routes/auth.py:602 +#: app/routes/auth.py:598 app/routes/auth.py:608 msgid "Failed to fetch Discord user profile." msgstr "Impossible de récupérer le profil Discord." -#: app/routes/auth.py:609 +#: app/routes/auth.py:615 msgid "Please log in to connect your Discord account." msgstr "Veuillez vous connecter pour lier votre compte Discord." -#: app/routes/auth.py:628 +#: app/routes/auth.py:634 msgid "Discord account connected!" msgstr "Compte Discord connecté !" -#: app/routes/auth.py:675 +#: app/routes/auth.py:681 msgid "Discord account connected! Your profile has been pre-filled." msgstr "Compte Discord connecté. Votre profil a été pré-rempli." -#: app/routes/auth.py:703 +#: app/routes/auth.py:709 msgid "You have been logged out." msgstr "Vous avez été déconnecté." @@ -336,10 +376,10 @@ msgstr "Évaluation mise à jour." #: app/routes/evaluations.py:210 app/routes/teams.py:341 #: app/routes/teams.py:384 app/routes/teams.py:427 app/routes/teams.py:455 -#: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:444 -#: app/routes/tryouts.py:460 app/routes/tryouts.py:480 -#: app/routes/tryouts.py:527 app/routes/tryouts.py:563 -#: app/routes/tryouts.py:582 +#: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:471 +#: app/routes/tryouts.py:491 app/routes/tryouts.py:515 +#: app/routes/tryouts.py:562 app/routes/tryouts.py:598 +#: app/routes/tryouts.py:621 msgid "Permission denied." msgstr "Accès refusé." @@ -347,37 +387,37 @@ msgstr "Accès refusé." msgid "That language is not available." msgstr "Cette langue n’est pas disponible." -#: app/routes/matches.py:364 +#: app/routes/matches.py:383 msgid "You do not have permission to schedule matches for this tryout." msgstr "Vous n’avez pas les droits pour planifier des matchs pour cette sélection." -#: app/routes/matches.py:368 app/routes/matches.py:448 +#: app/routes/matches.py:387 app/routes/matches.py:463 msgid "This tryout has ended. Matches can no longer be created or modified." msgstr "" "Cette sélection est terminée. Les matchs ne peuvent plus être créés ni " "modifiés." -#: app/routes/matches.py:430 +#: app/routes/matches.py:445 msgid "Match scheduled successfully!" msgstr "Match planifié." -#: app/routes/matches.py:444 app/routes/team_matches.py:220 +#: app/routes/matches.py:459 app/routes/team_matches.py:220 msgid "You do not have permission to edit this match." msgstr "Vous n’avez pas les droits pour modifier ce match." -#: app/routes/matches.py:539 app/routes/team_matches.py:250 +#: app/routes/matches.py:552 app/routes/team_matches.py:250 msgid "Match updated successfully!" msgstr "Match mis à jour." -#: app/routes/matches.py:575 app/routes/team_matches.py:265 +#: app/routes/matches.py:588 app/routes/team_matches.py:265 msgid "You do not have permission to delete this match." msgstr "Vous n’avez pas les droits pour supprimer ce match." -#: app/routes/matches.py:578 +#: app/routes/matches.py:591 msgid "This tryout has ended. Matches can no longer be deleted." msgstr "Cette sélection est terminée. Les matchs ne peuvent plus être supprimés." -#: app/routes/matches.py:591 app/routes/team_matches.py:269 +#: app/routes/matches.py:604 app/routes/team_matches.py:269 msgid "Match deleted successfully." msgstr "Match supprimé." @@ -480,7 +520,7 @@ msgstr "Coach retiré de %(name)s." msgid "Manager removed from %(name)s." msgstr "Gérant retiré de %(name)s." -#: app/routes/teams.py:490 app/routes/tryouts.py:489 app/routes/tryouts.py:593 +#: app/routes/teams.py:490 app/routes/tryouts.py:524 msgid "Please select a player." msgstr "Veuillez choisir un joueur." @@ -498,7 +538,7 @@ msgstr "%(username)s fait déjà partie de %(name)s." msgid "%(username)s added to %(name)s!" msgstr "%(username)s a été ajouté à %(name)s." -#: app/routes/teams.py:527 app/routes/teams.py:601 +#: app/routes/teams.py:527 app/routes/teams.py:605 #, python-format msgid "%(username)s is not on %(name)s." msgstr "%(username)s ne fait pas partie de %(name)s." @@ -508,130 +548,130 @@ msgstr "%(username)s ne fait pas partie de %(name)s." msgid "%(username)s removed from %(name)s." msgstr "%(username)s a été retiré de %(name)s." -#: app/routes/teams.py:572 app/routes/teams.py:590 +#: app/routes/teams.py:572 app/routes/teams.py:594 msgid "You do not have permission to add notes to this team." msgstr "Vous n’avez pas les droits pour ajouter des notes à cette équipe." -#: app/routes/teams.py:580 +#: app/routes/teams.py:584 msgid "Team notes added successfully!" msgstr "Notes d’équipe ajoutées." -#: app/routes/teams.py:595 app/routes/users/notes.py:207 -#: app/routes/users/notes.py:250 +#: app/routes/teams.py:599 app/routes/users/notes.py:222 +#: app/routes/users/notes.py:262 msgid "Can only add notes for players." msgstr "Il n’est possible d’ajouter des notes que pour des joueurs." -#: app/routes/teams.py:611 +#: app/routes/teams.py:619 #, python-format msgid "Note added for %(username)s!" msgstr "Note ajoutée pour %(username)s." -#: app/routes/tryouts.py:98 +#: app/routes/tryouts.py:125 msgid "You do not have permission to create tryouts." msgstr "Vous n’avez pas les droits pour créer une sélection." -#: app/routes/tryouts.py:145 +#: app/routes/tryouts.py:172 msgid "Tryout created successfully!" msgstr "Sélection créée." -#: app/routes/tryouts.py:158 +#: app/routes/tryouts.py:185 msgid "You do not have permission to edit this tryout." msgstr "Vous n’avez pas les droits pour modifier cette sélection." -#: app/routes/tryouts.py:162 +#: app/routes/tryouts.py:189 msgid "This tryout has ended and can no longer be modified." msgstr "Cette sélection est terminée et ne peut plus être modifiée." -#: app/routes/tryouts.py:202 +#: app/routes/tryouts.py:229 msgid "Tryout updated successfully!" msgstr "Sélection mise à jour." -#: app/routes/tryouts.py:242 +#: app/routes/tryouts.py:269 msgid "You do not have permission to view this tryout." msgstr "Vous n’avez pas les droits pour consulter cette sélection." -#: app/routes/tryouts.py:411 +#: app/routes/tryouts.py:437 msgid "Only players can register for tryouts." msgstr "Seuls les joueurs peuvent s’inscrire à une sélection." -#: app/routes/tryouts.py:415 +#: app/routes/tryouts.py:442 msgid "This tryout is not accepting registrations." msgstr "Cette sélection n’accepte pas d’inscriptions." -#: app/routes/tryouts.py:422 +#: app/routes/tryouts.py:449 msgid "You are already registered for this tryout." msgstr "Vous êtes déjà inscrit à cette sélection." -#: app/routes/tryouts.py:428 app/routes/tryouts.py:511 +#: app/routes/tryouts.py:455 app/routes/tryouts.py:546 msgid "This tryout is full." msgstr "Cette sélection est complète." -#: app/routes/tryouts.py:434 +#: app/routes/tryouts.py:461 msgid "Successfully registered for tryout!" msgstr "Inscription à la sélection réussie." -#: app/routes/tryouts.py:450 +#: app/routes/tryouts.py:481 #, python-format msgid "Tryout status updated to %(new_status)s." msgstr "Statut de la sélection mis à jour : %(new_status)s." -#: app/routes/tryouts.py:470 +#: app/routes/tryouts.py:505 msgid "Registration status updated." msgstr "Statut d’inscription mis à jour." -#: app/routes/tryouts.py:497 +#: app/routes/tryouts.py:532 msgid "Can only register players." msgstr "Seuls des joueurs peuvent être inscrits." -#: app/routes/tryouts.py:503 +#: app/routes/tryouts.py:538 #, python-format msgid "%(username)s is already registered for this tryout." msgstr "%(username)s est déjà inscrit à cette sélection." -#: app/routes/tryouts.py:517 +#: app/routes/tryouts.py:552 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s est inscrit à la sélection." -#: app/routes/tryouts.py:553 +#: app/routes/tryouts.py:588 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s a été retiré de la sélection." -#: app/routes/tryouts.py:571 +#: app/routes/tryouts.py:610 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Équipe « %(team_name)s » créée." -#: app/routes/tryouts.py:602 +#: app/routes/tryouts.py:643 app/routes/users/notes.py:350 msgid "That player is not registered for this tryout." msgstr "Ce joueur n’est pas inscrit à cette sélection." -#: app/routes/tryouts.py:608 +#: app/routes/tryouts.py:648 msgid "Player is already on this team." msgstr "Ce joueur est déjà dans cette équipe." -#: app/routes/tryouts.py:613 +#: app/routes/tryouts.py:653 msgid "Player added to team!" msgstr "Joueur ajouté à l’équipe." -#: app/routes/tryouts.py:623 +#: app/routes/tryouts.py:663 msgid "You do not have permission to delete this tryout." msgstr "Vous n’avez pas les droits pour supprimer cette sélection." -#: app/routes/tryouts.py:659 +#: app/routes/tryouts.py:699 msgid "Tryout deleted successfully." msgstr "Sélection supprimée." -#: app/routes/users/_shared.py:51 +#: app/routes/users/_shared.py:50 msgid "No file selected." msgstr "Aucun fichier sélectionné." -#: app/routes/users/_shared.py:55 +#: app/routes/users/_shared.py:54 msgid "Only PDF files are allowed for contracts." msgstr "Seuls les fichiers PDF sont acceptés pour les contrats." -#: app/routes/users/_shared.py:60 +#: app/routes/users/_shared.py:59 msgid "That file is not a PDF, whatever its name says." msgstr "Ce fichier n’est pas un PDF, quel que soit son nom." @@ -647,13 +687,13 @@ msgstr "Seul le président peut modifier des utilisateurs." msgid "Email already in use by another account." msgstr "Cette adresse courriel est déjà utilisée par un autre compte." -#: app/routes/users/accounts.py:132 +#: app/routes/users/accounts.py:138 msgid "You cannot change your own role. Ask another president to do it." msgstr "" "Vous ne pouvez pas modifier votre propre rôle. Demandez à un autre " "président de le faire." -#: app/routes/users/accounts.py:145 +#: app/routes/users/accounts.py:151 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -661,29 +701,29 @@ msgstr "" "C’est le dernier président actif. Promouvez un autre compte avant de " "modifier celui-ci." -#: app/routes/users/accounts.py:224 +#: app/routes/users/accounts.py:228 #, python-format msgid "User %(username)s updated successfully!" msgstr "Utilisateur %(username)s mis à jour." -#: app/routes/users/accounts.py:245 +#: app/routes/users/accounts.py:249 msgid "Only the president can delete users." msgstr "Seul le président peut supprimer des utilisateurs." -#: app/routes/users/accounts.py:249 +#: app/routes/users/accounts.py:253 msgid "You cannot delete your own account." msgstr "Vous ne pouvez pas supprimer votre propre compte." -#: app/routes/users/accounts.py:308 +#: app/routes/users/accounts.py:312 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "L’utilisateur %(deleted_username)s a été supprimé." -#: app/routes/users/accounts.py:319 +#: app/routes/users/accounts.py:323 msgid "Only the president can create users." msgstr "Seul le président peut créer des utilisateurs." -#: app/routes/users/accounts.py:367 +#: app/routes/users/accounts.py:371 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s." @@ -721,56 +761,95 @@ msgstr "Vous n’avez pas les droits pour télécharger ce contrat." msgid "No signed contract available." msgstr "Aucun contrat signé disponible." -#: app/routes/users/notes.py:34 +#: app/routes/users/notes.py:43 msgid "This page is for players only." msgstr "Cette page est réservée aux joueurs." -#: app/routes/users/notes.py:71 +#: app/routes/users/notes.py:80 msgid "Only coaches can access the notes dashboard." msgstr "Seuls les coachs ont accès au tableau des notes." -#: app/routes/users/notes.py:161 +#: app/routes/users/notes.py:172 msgid "Only coaches can manage team notes." msgstr "Seuls les coachs peuvent gérer les notes d’équipe." -#: app/routes/users/notes.py:167 +#: app/routes/users/notes.py:178 msgid "You are not assigned to a team." msgstr "Vous n’êtes assigné à aucune équipe." -#: app/routes/users/notes.py:180 +#: app/routes/users/notes.py:195 msgid "Team notes saved successfully!" msgstr "Notes d’équipe enregistrées." -#: app/routes/users/notes.py:195 +#: app/routes/users/notes.py:210 msgid "Only coaches can manage personal notes." msgstr "Seuls les coachs peuvent gérer les notes personnelles." -#: app/routes/users/notes.py:202 app/routes/users/notes.py:245 -#: app/routes/users/notes.py:296 app/routes/users/notes.py:350 -msgid "Player and content are required." -msgstr "Le joueur et le contenu sont obligatoires." - -#: app/routes/users/notes.py:211 app/routes/users/notes.py:254 -#: app/routes/users/notes.py:300 app/routes/users/notes.py:354 +#: app/routes/users/notes.py:226 app/routes/users/notes.py:266 +#: app/routes/users/notes.py:346 app/routes/users/notes.py:411 msgid "You can only write notes about players you work with." msgstr "" "Vous ne pouvez écrire des notes que sur les joueurs avec qui vous " "travaillez." -#: app/routes/users/notes.py:221 app/routes/users/notes.py:267 +#: app/routes/users/notes.py:236 app/routes/users/notes.py:306 #, python-format msgid "Note added for %(username)s." msgstr "Note ajoutée pour %(username)s." -#: app/routes/users/notes.py:235 app/routes/users/notes.py:281 -#: app/routes/users/notes.py:334 +#: app/routes/users/notes.py:250 app/routes/users/notes.py:320 +#: app/routes/users/notes.py:384 msgid "Only coaches can add personal notes." msgstr "Seuls les coachs peuvent ajouter des notes personnelles." -#: app/routes/users/notes.py:311 app/routes/users/notes.py:365 +#: app/routes/users/notes.py:272 +msgid "You cannot use that match as note context." +msgstr "Vous ne pouvez pas utiliser ce match comme contexte de note." + +#: app/routes/users/notes.py:275 +msgid "That player did not participate in the selected match." +msgstr "Ce joueur n’a pas participé au match sélectionné." + +#: app/routes/users/notes.py:281 +msgid "You cannot use that tryout as note context." +msgstr "Vous ne pouvez pas utiliser cette sélection comme contexte de note." + +#: app/routes/users/notes.py:284 +msgid "That player is not registered for the selected tryout." +msgstr "Ce joueur n’est pas inscrit à la sélection choisie." + +#: app/routes/users/notes.py:290 +msgid "You cannot use that team as note context." +msgstr "Vous ne pouvez pas utiliser cette équipe comme contexte de note." + +#: app/routes/users/notes.py:293 +msgid "That player is not on the selected team." +msgstr "Ce joueur ne fait pas partie de l’équipe sélectionnée." + +#: app/routes/users/notes.py:325 +msgid "You do not have permission to add notes for this tryout." +msgstr "Vous n’avez pas les droits pour ajouter des notes à cette sélection." + +#: app/routes/users/notes.py:342 +msgid "Invalid tryout context." +msgstr "Contexte de sélection invalide." + +#: app/routes/users/notes.py:361 app/routes/users/notes.py:426 msgid "Note added successfully." msgstr "Note ajoutée." +#: app/routes/users/notes.py:389 +msgid "You do not have permission to add notes for this match." +msgstr "Vous n’avez pas les droits pour ajouter des notes à ce match." + +#: app/routes/users/notes.py:407 +msgid "Invalid match context." +msgstr "Contexte de match invalide." + +#: app/routes/users/notes.py:415 +msgid "That player did not participate in this match." +msgstr "Ce joueur n’a pas participé à ce match." + #: app/routes/users/one_on_one.py:23 msgid "Only players can request One on One sessions." msgstr "Seuls les joueurs peuvent demander une rencontre individuelle." @@ -812,7 +891,7 @@ msgstr "La demande de rencontre de %(player)s a été approuvée." msgid "Only coaches can reject One on One requests." msgstr "Seuls les coachs peuvent refuser une demande de rencontre." -#: app/routes/users/one_on_one.py:258 +#: app/routes/users/one_on_one.py:263 #, python-format msgid "One on One request from %(player)s has been rejected." msgstr "La demande de rencontre de %(player)s a été refusée." @@ -825,7 +904,7 @@ msgstr "Ce nom d’utilisateur est déjà pris." msgid "Email already in use." msgstr "Cette adresse courriel est déjà utilisée." -#: app/routes/users/profile.py:121 +#: app/routes/users/profile.py:131 msgid "Profile updated successfully!" msgstr "Profil mis à jour." @@ -1221,7 +1300,7 @@ msgstr "" "individuelles" #: app/templates/pages/coach_availability.html:14 -#: app/templates/pages/profile.html:216 +#: app/templates/pages/profile.html:213 msgid "Loading availability grid..." msgstr "Chargement de la grille de disponibilités..." @@ -1230,7 +1309,7 @@ msgid "Save Availability" msgstr "Enregistrer les disponibilités" #: app/templates/pages/coach_availability.html:22 -#: app/templates/pages/profile.html:200 app/templates/pages/profile.html:220 +#: app/templates/pages/profile.html:197 app/templates/pages/profile.html:217 msgid "Clear All" msgstr "Tout effacer" @@ -2011,23 +2090,23 @@ msgstr "" "Les indicateurs verts signalent les joueurs disponibles à la date et à " "l’heure du match" -#: app/templates/pages/match_form.html:625 +#: app/templates/pages/match_form.html:632 msgid "Click time slots consecutively to set match duration" msgstr "Cliquez des plages consécutives pour définir la durée du match" -#: app/templates/pages/match_form.html:892 +#: app/templates/pages/match_form.html:899 msgid "No players registered" msgstr "Aucun joueur inscrit" -#: app/templates/pages/match_form.html:925 +#: app/templates/pages/match_form.html:932 msgid "T1" msgstr "É1" -#: app/templates/pages/match_form.html:926 +#: app/templates/pages/match_form.html:933 msgid "T2" msgstr "É2" -#: app/templates/pages/match_form.html:931 +#: app/templates/pages/match_form.html:938 msgid "No players available" msgstr "Aucun joueur disponible" @@ -2382,15 +2461,11 @@ msgstr "" "Choisissez vos plages disponibles pour les matchs (17 h à minuit). Vert =" " sélectionné, gris = disponible." -#: app/templates/pages/profile.html:197 -msgid "Save Disponibilities" -msgstr "Enregistrer mes disponibilités" - -#: app/templates/pages/profile.html:211 +#: app/templates/pages/profile.html:208 msgid "My Coaching Availability" msgstr "Mes disponibilités de coaching" -#: app/templates/pages/profile.html:212 +#: app/templates/pages/profile.html:209 msgid "" "Select time slots when you're available for One on One sessions (8am to " "10pm)." @@ -2398,7 +2473,7 @@ msgstr "" "Choisissez les plages où vous êtes disponible pour des rencontres " "individuelles (8 h à 22 h)." -#: app/templates/pages/profile.html:460 +#: app/templates/pages/profile.html:457 msgid "Click or click-and-drag to select your available hours" msgstr "Cliquez ou faites glisser pour choisir vos heures de disponibilité" @@ -3025,3 +3100,9 @@ msgstr "Voir le profil" #~ msgid "Team Tryout Management System" #~ msgstr "Système de gestion des sélections d’équipe" + +#~ msgid "Player and content are required." +#~ msgstr "Le joueur et le contenu sont obligatoires." + +#~ msgid "Save Disponibilities" +#~ msgstr "Enregistrer mes disponibilités" diff --git a/app/validators.py b/app/validators.py index b2502ca..4271819 100644 --- a/app/validators.py +++ b/app/validators.py @@ -23,7 +23,7 @@ from marshmallow import ( validates_schema, ) -from app.models import ESPORT_GAMES, USER_TYPES +from app.models import ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES # ============================================================================= # Custom Validators @@ -276,6 +276,36 @@ class RegisterSchema(StripMixin): raise ValidationError(_l('Passwords do not match.'), field_name='confirm_password') +class GamertagSchema(StripMixin): + """One dynamic per-game identity submitted beside an account form.""" + + game = fields.String( + required=True, + validate=validate.OneOf(ESPORT_GAMES, error=_l('Unknown game.')), + ) + gamertag = fields.String( + required=True, + validate=validate.Length( + min=1, + max=120, + error=_l('Gamertag must be between 1 and 120 characters.'), + ), + ) + platform = fields.String( + allow_none=True, + load_default=None, + validate=validate.Length(max=30, error=_l('Platform must be 30 characters or less.')), + ) + + @validates_schema + def validate_platform_for_game(self, data, **kwargs): + """A forged platform must belong to the selected game's list.""" + platform = data.get('platform') + allowed = GAME_PLATFORMS.get(data.get('game'), ()) + if platform and platform not in allowed: + raise ValidationError(_l('Unknown platform for this game.'), field_name='platform') + + class CreateUserSchema(StripMixin): """Validate president-created user form input. @@ -517,6 +547,115 @@ class TeamPlayerSchema(PlayerSelectionSchema): ) +TRYOUT_STATUSES = ('upcoming', 'in_progress', 'completed') +TRYOUT_REGISTRATION_STATUSES = ('registered', 'attended', 'no_show') + + +class TryoutStatusSchema(StripMixin): + """A state transition requested from the tryout detail page.""" + + status = fields.String( + required=True, + validate=validate.OneOf(TRYOUT_STATUSES, error=_l('Unknown tryout status.')), + ) + + +class TryoutRegistrationStatusSchema(StripMixin): + """Attendance state for one tryout registration.""" + + status = fields.String( + required=True, + validate=validate.OneOf( + TRYOUT_REGISTRATION_STATUSES, + error=_l('Unknown registration status.'), + ), + ) + + +class TryoutTeamSchema(StripMixin): + """A tryout-local team created from its compact inline form.""" + + team_name = fields.String( + required=True, + validate=validate.Length( + min=1, + max=100, + error=_l('Team name must be between 1 and 100 characters.'), + ), + ) + + +class TryoutTeamMemberSchema(StripMixin): + """A registered player and their optional position on a tryout team.""" + + player_id = fields.Integer( + required=True, + validate=validate.Range(min=1), + error_messages={ + 'invalid': _l('Invalid player selection.'), + 'required': _l('Player must be selected.'), + }, + ) + position = fields.String( + load_default='', + validate=validate.Length( + max=50, + error=_l('Position must be 50 characters or less.'), + ), + ) + + +class NoteContentSchema(StripMixin): + """Bounded text stored as a team or personal coaching note.""" + + content = fields.String( + required=True, + validate=validate.Length( + min=1, + max=5000, + error=_l('Note content must be between 1 and 5000 characters.'), + ), + ) + + +class PersonalNoteSchema(NoteContentSchema): + """A personal note with at most one optional, typed context.""" + + player_id = fields.Integer( + required=True, + validate=validate.Range(min=1), + error_messages={ + 'invalid': _l('Invalid player selection.'), + 'required': _l('Player must be selected.'), + }, + ) + match_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1)) + tryout_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1)) + team_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1)) + + @validates_schema + def validate_one_context(self, data, **kwargs): + """A note cannot claim several unrelated contexts at once.""" + contexts = [data.get(name) for name in ('match_id', 'tryout_id', 'team_id')] + if sum(value is not None for value in contexts) > 1: + raise ValidationError( + _l('Select at most one note context.'), + field_name='context', + ) + + +class OneOnOneRejectionSchema(StripMixin): + """Optional explanation sent to a player when a request is rejected.""" + + rejection_reason = fields.String( + load_default='', + validate=validate.Length( + max=2000, + error=_l('Rejection reason must be 2000 characters or less.'), + ), + ) + + class OneOnOneRequestSchema(StripMixin): """A player asking their coach for a session (MNT-12). diff --git a/docs/database-schema.md b/docs/database-schema.md index 197b85d..4e747b0 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -115,7 +115,7 @@ Dans cet ordre, parce qu'ils dépendent tous de `DB-002` : |---|---|---| | `DB-004` | Retirer `create_all()` de `create_app()` | Tant qu'il est là, deux mécanismes décrivent le schéma | | `DB-005` | Cascades de suppression au niveau base | Les cascades ORM sont en place ; PostgreSQL ne les connaît pas | -| `DB-006` | Unicité sur `TryoutRegistration(tryout_id, player_id)` | Le plafond d'inscriptions est aujourd'hui un `count()` suivi d'un `add()` : deux requêtes simultanées passent toutes les deux | +| `DB-006` | Unicité sur `TryoutRegistration(tryout_id, player_id)` | Les deux routes verrouillent désormais la ligne `Tryout` avant le contrôle de doublon, le `count()` et l'`add()` : PostgreSQL sérialise donc leurs décisions de capacité. La contrainte reste nécessaire pour les scripts, imports et futurs chemins d'écriture qui ne passent pas par ces routes | | `DB-007` | Index, `CheckConstraint` sur les statuts, `server_default` | — | | `DB-008` | Trancher `attendance_confirmed` côté tryout | `discord_bot.py` écrit un attribut fantôme ; aujourd'hui journalisé en avertissement | | `DB-009` | Horodatages avec fuseau | `datetime.utcnow` partout, déprécié en 3.12 | diff --git a/docs/deployment.md b/docs/deployment.md index 24329a3..a8e37f4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -282,11 +282,11 @@ have sent new contracts to a new tree and made the existing ones unreadable `logs/` and `backups/` were built from `os.getcwd()` too (OBS-006), and the backup script kept its own copy of the document path — so it archived `./documents` no matter what `DOCUMENTS_ROOT` said. Following prerequisite 2 -was therefore enough, on its own, to make every contract backup empty; the -script prints `No documents directory…` and still exits 0, so a scheduled -task watching the exit code would have seen green indefinitely. All three -roots now come from `app/storage.py`, and the backup run prints the document -source it used. +was therefore enough, on its own, to make every contract backup empty. All +three roots now come from `app/storage.py`, and the backup run prints the +document source it used. A missing or unarchivable document store makes the +run exit non-zero even when the database dump itself is valid, so a scheduler +cannot report a database-only recovery point as a complete backup. **After setting `DOCUMENTS_ROOT` on the node, run the backup once by hand** and check the `Document source:` line and the size of the resulting @@ -327,4 +327,4 @@ Monitor these logs regularly for suspicious activity. - Run `python security_scan.py` after any configuration changes - Test backup restoration quarterly - Review and rotate `SECRET_KEY` if compromised -- Keep Python and system packages updated \ No newline at end of file +- Keep Python and system packages updated diff --git a/pyproject.toml b/pyproject.toml index 101fff0..064e394 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,12 @@ filterwarnings = [ "ignore:datetime.datetime.utcnow:DeprecationWarning", ] +[tool.coverage.report] +# The exhaustive audit established a 71% baseline. Keep one point of margin +# for platform-specific branches while making any material regression fail CI. +fail_under = 70 +show_missing = true + [tool.ruff] line-length = 100 target-version = "py312" diff --git a/tests/test_backup.py b/tests/test_backup.py index 65d004b..7441f1b 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -119,3 +119,15 @@ class TestExitCodes: def test_verifying_a_missing_archive_fails(self, tmp_path): assert backup_module.main(['--verify-only', str(tmp_path / 'nope.dump')]) == 1 + + def test_a_missing_document_store_makes_an_otherwise_valid_run_incomplete( + self, monkeypatch, tmp_path + ): + monkeypatch.setenv('DATABASE_URL', URL) + monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'missing-documents')) + monkeypatch.setattr(backup_module, 'BACKUP_DIR', str(tmp_path / 'backups')) + monkeypatch.setattr(backup_module, 'backup_database', lambda conn: 'database.dump') + monkeypatch.setattr(backup_module, 'verify_backup', lambda path: True) + monkeypatch.setattr(backup_module, 'cleanup_old_backups', lambda: None) + + assert backup_module.main([]) == 1 diff --git a/tests/test_csp.py b/tests/test_csp.py index c268ee7..dc55df3 100644 --- a/tests/test_csp.py +++ b/tests/test_csp.py @@ -117,6 +117,23 @@ class TestInlineHandlerRatchet: assert _count_handlers(template) == 1 + def test_dynamic_player_names_are_escaped_before_html_insertion(self): + template = os.path.join(TEMPLATE_ROOT, 'pages', 'match_form.html') + with open(template, encoding='utf-8') as handle: + content = handle.read() + + assert 'html += playerName;' not in content + assert "' + playerName + '" not in content + assert content.count('escapeHtml(playerName)') == 6 + + def test_api_messages_are_written_as_text(self): + template = os.path.join(TEMPLATE_ROOT, 'pages', 'coach_availability.html') + with open(template, encoding='utf-8') as handle: + content = handle.read() + + assert 'text.textContent = message' in content + assert "alert.innerHTML = '' + message" not in content + @pytest.mark.parametrize('relative,full', list(_templates())) def test_a_template_never_gains_an_inline_handler(self, relative, full): allowed = HANDLER_BUDGET.get(relative, 0) diff --git a/tests/test_filesystem_roots.py b/tests/test_filesystem_roots.py index 94c6f86..d2ad88b 100644 --- a/tests/test_filesystem_roots.py +++ b/tests/test_filesystem_roots.py @@ -9,9 +9,8 @@ in the project directory. The document store was fixed in wave G. The other two were not, and the gap that opened between them is the reason this file exists: `backup.py` kept -archiving `./documents` while the application wrote to `DOCUMENTS_ROOT`, and -the script's answer to a missing directory is to print a line and exit 0. -Following the deployment documentation was what broke it. +archiving `./documents` while the application wrote to `DOCUMENTS_ROOT`. +The script now resolves the shared root and fails the run when it is absent. """ import os @@ -106,16 +105,15 @@ class TestTheBackupScriptAgreesWithTheApplication: with zipfile.ZipFile(archive) as zf: assert any(name.endswith('contrat.pdf') for name in zf.namelist()) - def test_a_missing_store_names_the_path_it_looked_in(self, tmp_path, monkeypatch, capsys): - """ "No documents directory found" read as "there are no documents" - rather than "I am looking in the wrong place".""" + def test_a_missing_store_names_the_path_it_looked_in(self, tmp_path, monkeypatch): + """A missing configured store is an actionable failure, not a skip.""" from app.supporting_scripts import backup missing = tmp_path / 'not-here' monkeypatch.setenv('DOCUMENTS_ROOT', str(missing)) - assert backup.backup_documents() is None - assert str(missing) in capsys.readouterr().out + with pytest.raises(backup.BackupError, match=str(missing).replace('\\', '\\\\')): + backup.backup_documents() class TestLogsFollowTheSameRule: diff --git a/tests/test_form_boundaries.py b/tests/test_form_boundaries.py new file mode 100644 index 0000000..d2b606d --- /dev/null +++ b/tests/test_form_boundaries.py @@ -0,0 +1,281 @@ +"""Regression tests for compact POST forms that bypassed the shared schemas.""" + +from datetime import date, time + +from sqlalchemy.dialects import postgresql + +from app.models import ( + Match, + OneOnOneRequest, + OrgTeam, + PersonalNote, + Team, + TeamMember, + TeamPlayer, + Tryout, + TryoutRegistration, + UserGamertag, +) + + +def _tryout(db, owner_id, *, coach_id=None): + row = Tryout( + title='Boundary tryout', + game='Valorant', + date=date(2030, 4, 1), + created_by=owner_id, + coach_id=coach_id, + ) + db.session.add(row) + db.session.commit() + return row.id + + +def _give_coach_a_player(db, coach_id, player_id, owner_id): + org_team = OrgTeam( + name=f'Org {coach_id}-{player_id}', + created_by=owner_id, + coach_id=coach_id, + ) + db.session.add(org_team) + db.session.flush() + db.session.add(TeamPlayer(org_team_id=org_team.id, player_id=player_id)) + db.session.commit() + return org_team.id + + +def test_tryout_team_name_is_bounded(app, client, as_role): + admin_id = as_role('admin') + from app.extensions import db + + with app.app_context(): + tryout_id = _tryout(db, admin_id) + + response = client.post( + f'/tryouts/{tryout_id}/team/create', + data={'team_name': 'x' * 101}, + follow_redirects=True, + ) + + assert response.status_code == 200 + with app.app_context(): + assert Team.query.filter_by(tryout_id=tryout_id).count() == 0 + + +def test_registration_decisions_lock_the_tryout_row(): + from app.routes.tryouts import registration_lock_statement + + sql = str(registration_lock_statement(42).compile(dialect=postgresql.dialect())) + + assert 'FOR UPDATE' in sql + + +def test_tryout_team_position_is_bounded(app, client, as_role, make_user): + admin_id = as_role('admin') + player_id = make_user('player') + from app.extensions import db + + with app.app_context(): + tryout_id = _tryout(db, admin_id) + team = Team(tryout_id=tryout_id, name='Blue', created_by=admin_id) + db.session.add(team) + db.session.flush() + team_id = team.id + db.session.add(TryoutRegistration(tryout_id=tryout_id, player_id=player_id)) + db.session.commit() + + response = client.post( + f'/tryouts/{tryout_id}/team/{team_id}/add', + data={'player_id': player_id, 'position': 'x' * 51}, + follow_redirects=True, + ) + + assert response.status_code == 200 + with app.app_context(): + assert TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first() is None + + +def test_a_coach_cannot_open_an_unrelated_tryout_note_form(app, client, as_role, make_user): + coach_id = as_role('coach') + other_coach_id = make_user('coach') + admin_id = make_user('admin') + player_id = make_user('player') + from app.extensions import db + + with app.app_context(): + tryout_id = _tryout(db, admin_id, coach_id=other_coach_id) + db.session.add(TryoutRegistration(tryout_id=tryout_id, player_id=player_id)) + db.session.commit() + + response = client.get(f'/users/personal-notes/tryout/{tryout_id}') + + assert response.status_code == 302 + assert response.headers['Location'].endswith('/users/notes-dashboard') + assert coach_id != other_coach_id + + +def test_a_note_cannot_claim_a_team_that_does_not_contain_the_player( + app, client, as_role, make_user +): + coach_id = as_role('coach') + admin_id = make_user('admin') + player_id = make_user('player') + from app.extensions import db + + with app.app_context(): + _give_coach_a_player(db, coach_id, player_id, admin_id) + tryout_id = _tryout(db, admin_id, coach_id=coach_id) + team = Team(tryout_id=tryout_id, name='No player here', created_by=admin_id) + db.session.add(team) + db.session.commit() + team_id = team.id + + response = client.post( + '/users/personal-notes/add', + data={'player_id': player_id, 'content': 'Private note', 'team_id': team_id}, + follow_redirects=True, + ) + + assert response.status_code == 200 + with app.app_context(): + assert PersonalNote.query.count() == 0 + + +def test_a_personal_note_is_bounded(app, client, as_role, make_user): + coach_id = as_role('coach') + admin_id = make_user('admin') + player_id = make_user('player') + from app.extensions import db + + with app.app_context(): + _give_coach_a_player(db, coach_id, player_id, admin_id) + + response = client.post( + '/users/personal-notes/manage', + data={'player_id': player_id, 'content': 'x' * 5001}, + follow_redirects=True, + ) + + assert response.status_code == 200 + with app.app_context(): + assert PersonalNote.query.count() == 0 + + +def test_a_rejection_reason_is_bounded(app, client, as_role, make_user): + coach_id = as_role('coach') + player_id = make_user('player') + from app.extensions import db + + with app.app_context(): + request = OneOnOneRequest( + player_id=player_id, + coach_id=coach_id, + date=date(2030, 4, 2), + start_time=time(18, 0), + end_time=time(18, 30), + ) + db.session.add(request) + db.session.commit() + request_id = request.id + + response = client.post( + f'/users/one-on-one/{request_id}/reject', + data={'rejection_reason': 'x' * 2001}, + follow_redirects=True, + ) + + assert response.status_code == 200 + with app.app_context(): + assert db.session.get(OneOnOneRequest, request_id).status == 'pending' + + +def test_a_match_context_must_contain_the_player(app, client, as_role, make_user): + coach_id = as_role('coach') + admin_id = make_user('admin') + player_id = make_user('player') + from app.extensions import db + + with app.app_context(): + _give_coach_a_player(db, coach_id, player_id, admin_id) + tryout_id = _tryout(db, admin_id, coach_id=coach_id) + match = Match( + tryout_id=tryout_id, + title='Scrim', + date=date(2030, 4, 2), + match_type='player_vs_player', + created_by=coach_id, + ) + db.session.add(match) + db.session.commit() + match_id = match.id + + response = client.post( + '/users/personal-notes/add', + data={'player_id': player_id, 'content': 'Private note', 'match_id': match_id}, + follow_redirects=True, + ) + + assert response.status_code == 200 + with app.app_context(): + assert PersonalNote.query.count() == 0 + + +def test_the_note_dashboard_lists_tryout_teams_not_org_teams(app, client, as_role, make_user): + coach_id = as_role('coach') + admin_id = make_user('admin') + player_id = make_user('player') + from app.extensions import db + + with app.app_context(): + _give_coach_a_player(db, coach_id, player_id, admin_id) + tryout_id = _tryout(db, admin_id, coach_id=coach_id) + team = Team(tryout_id=tryout_id, name='Tryout Alpha', created_by=admin_id) + db.session.add(team) + db.session.commit() + team_id = team.id + + body = client.get('/users/notes-dashboard').get_data(as_text=True) + + assert f'' in body + assert f'>Org {coach_id}-{player_id}' not in body + + +def test_an_oversized_dynamic_gamertag_is_rejected(app, client, as_role): + player_id = as_role('player') + + response = client.post( + '/users/profile/edit', + data={ + 'username': 'player1', + 'full_name': 'Player One', + 'email': 'player1@example.test', + 'games': 'Valorant', + 'gamertag_Valorant': 'x' * 121, + }, + follow_redirects=True, + ) + + assert response.status_code == 200 + with app.app_context(): + assert UserGamertag.query.filter_by(user_id=player_id).count() == 0 + + +def test_a_platform_must_belong_to_the_selected_game(app, client, as_role): + player_id = as_role('player') + + response = client.post( + '/users/profile/edit', + data={ + 'username': 'player1', + 'full_name': 'Player One', + 'email': 'player1@example.test', + 'games': 'Apex Legends', + 'gamertag_Apex Legends': 'LegitName', + 'platform_Apex Legends': 'Forged platform', + }, + follow_redirects=True, + ) + + assert response.status_code == 200 + with app.app_context(): + assert UserGamertag.query.filter_by(user_id=player_id).count() == 0 diff --git a/tests/test_query_shape.py b/tests/test_query_shape.py index 3557788..098d478 100644 --- a/tests/test_query_shape.py +++ b/tests/test_query_shape.py @@ -233,6 +233,42 @@ class TestPendingEvaluations: assert self._pending(client) == 1 +class TestRegisteredPlayersForMatchForm: + """The match forms used to issue one or two user lookups per registration.""" + + def test_the_result_is_unique_ordered_and_constant_cost(self, app, make_user, count_queries): + admin_id = make_user('admin') + player_ids = [make_user('player', username=name) for name in ('zulu', 'alpha', 'mike')] + + with app.app_context(): + tryout = Tryout( + title='Match form', + game='Valorant', + date=date(2030, 3, 1), + created_by=admin_id, + ) + db.session.add(tryout) + db.session.flush() + for player_id in player_ids: + db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id)) + # DB-006 is pending, so prove the UI remains unique even when the + # current database already contains a duplicate registration. + db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_ids[0])) + db.session.commit() + tryout_id = tryout.id + + from app.routes.matches import registered_players + + counter = count_queries() + try: + players = registered_players(tryout_id) + finally: + counter.stop() + + assert [player.username for player in players] == ['alpha', 'mike', 'zulu'] + assert counter.total == 1 + + class TestViewTryout: """PERF-001 — the most-visited page in the application ran one query per registration, one per player evaluated, one per team, and one per From d7a8907953689e05dac06520f20a653635cfaff4 Mon Sep 17 00:00:00 2001 From: GGThed Date: Mon, 17 Aug 2026 15:02:29 -0400 Subject: [PATCH 4/5] =?UTF-8?q?fix(audit):=20moderniser=20les=20acc=C3=A8s?= =?UTF-8?q?=20ORM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/discord_bot.py | 15 ++++--- app/models/contract.py | 2 +- app/routes/evaluations.py | 47 ++++++++++++++------- app/routes/main.py | 16 ++++--- app/routes/matches.py | 12 +++--- app/routes/team_matches.py | 10 ++--- app/routes/teams.py | 26 ++++++------ app/routes/tryouts.py | 20 ++++----- app/routes/users/accounts.py | 6 +-- app/routes/users/availability.py | 2 +- app/routes/users/contracts.py | 8 ++-- app/routes/users/notes.py | 14 +++---- app/routes/users/one_on_one.py | 4 +- tests/test_bot_error_families.py | 3 +- tests/test_query_shape.py | 72 ++++++++++++++++++++++++++++++++ tests/test_storage.py | 8 ++-- 16 files changed, 179 insertions(+), 86 deletions(-) diff --git a/app/discord_bot.py b/app/discord_bot.py index f901281..b3102b3 100644 --- a/app/discord_bot.py +++ b/app/discord_bot.py @@ -683,9 +683,10 @@ class TeamTryoutsBot(commands.Bot): reference_id: ID of the MatchParticipant or TryoutRegistration record. """ # Look up the DB user to get their Discord user ID + from app.extensions import db from app.models import User as DBUser - db_user = DBUser.query.get(user_id) + db_user = db.session.get(DBUser, user_id) if not db_user: logger.warning(f"DB user {user_id} not found for schedule notification") return None @@ -754,7 +755,7 @@ class TeamTryoutsBot(commands.Bot): from app.models import OneOnOneRequest try: - request = OneOnOneRequest.query.get(request_id) + request = db.session.get(OneOnOneRequest, request_id) if not request: # The row is gone; no reaction on this message can ever mean # anything again. Keeping the mapping is what PENDING_MAX_AGE_DAYS @@ -825,7 +826,7 @@ class TeamTryoutsBot(commands.Bot): from app.models import OneOnOneRequest try: - request = OneOnOneRequest.query.get(request_id) + request = db.session.get(OneOnOneRequest, request_id) if not request: logger.info( 'One on One request %s no longer exists; its pending message was dropped.', @@ -918,12 +919,13 @@ class TeamTryoutsBot(commands.Bot): Returns: tuple: (row, player_id) — either may be None. """ + from app.extensions import db from app.models import MatchParticipant, TryoutRegistration if event_type == 'match': - row = MatchParticipant.query.get(reference_id) + row = db.session.get(MatchParticipant, reference_id) elif event_type == 'tryout': - row = TryoutRegistration.query.get(reference_id) + row = db.session.get(TryoutRegistration, reference_id) else: row = None return row, getattr(row, 'player_id', None) @@ -937,11 +939,12 @@ class TeamTryoutsBot(commands.Bot): attendance handlers did not (OPS-009) — same message shape, same threat, one of them checked. The asymmetry was the bug. """ + from app.extensions import db from app.models import User if not player_id: return False - owner = User.query.get(player_id) + owner = db.session.get(User, player_id) return bool(owner and owner.discord_user_id == str(reacting_user.id)) async def handle_attendance_confirm(self, player, message_id, reference_id, channel): diff --git a/app/models/contract.py b/app/models/contract.py index 889a9ac..c18ea94 100644 --- a/app/models/contract.py +++ b/app/models/contract.py @@ -57,7 +57,7 @@ class Contract(db.Model): if isinstance(user, Admin): return True if isinstance(user, Manager): - player = User.query.get(self.player_id) + player = db.session.get(User, self.player_id) if player and player.get_org_teams(): return True if isinstance(user, Coach): diff --git a/app/routes/evaluations.py b/app/routes/evaluations.py index a6aa2cc..e9d0c20 100644 --- a/app/routes/evaluations.py +++ b/app/routes/evaluations.py @@ -27,6 +27,14 @@ from app.validators import EvaluationSchema evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations') +def _users_by_id(user_ids): + """Load a set of users once for aggregate/list views.""" + wanted = {user_id for user_id in user_ids if user_id} + if not wanted: + return {} + return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()} + + @evaluations_bp.route('') @login_required def list_evaluations(): @@ -85,9 +93,10 @@ def list_evaluations(): .group_by(Evaluation.player_id) .all() ) + players_by_id = _users_by_id(row.player_id for row in avg_scores) player_scores = {} for row in avg_scores: - p = User.query.get(row.player_id) + p = players_by_id.get(row.player_id) if p: player_scores[p.id] = { 'player': p, @@ -126,7 +135,7 @@ def evaluate_player(tryout_id, player_id): flash(_('You do not have permission to evaluate players.'), 'danger') return redirect(url_for('main.dashboard')) - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to evaluate players in this tryout.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) @@ -142,7 +151,7 @@ def evaluate_player(tryout_id, player_id): flash(_('Player is not registered for this tryout.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - player = User.query.get_or_404(player_id) + player = db.get_or_404(User, player_id) if not isinstance(player, Player): flash(_('Can only evaluate players.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @@ -160,8 +169,10 @@ def evaluate_player(tryout_id, player_id): tryout_id=tryout_id, player_id=player_id, ).all() + evaluators_by_id = _users_by_id(e.evaluator_id for e in all_evaluations) evaluators = [ - {'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations + {'evaluator': evaluators_by_id.get(e.evaluator_id), 'eval': e} + for e in all_evaluations ] return render_template( @@ -210,21 +221,27 @@ def players_to_evaluate(tryout_id): flash(_('Permission denied.'), 'danger') return redirect(url_for('main.dashboard')) - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to evaluate players in this tryout.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all() - players = [] - for reg in registrations: - p = User.query.get(reg.player_id) - if p and isinstance(p, Player): - existing = Evaluation.query.filter_by( - tryout_id=tryout_id, - player_id=p.id, - evaluator_id=current_user.id, - ).first() - players.append({'player': p, 'evaluated': existing is not None, 'registration': reg}) + players_by_id = _users_by_id(reg.player_id for reg in registrations) + evaluated_player_ids = { + player_id + for (player_id,) in db.session.query(Evaluation.player_id) + .filter_by(tryout_id=tryout_id, evaluator_id=current_user.id) + .all() + } + players = [ + { + 'player': player, + 'evaluated': player.id in evaluated_player_ids, + 'registration': registration, + } + for registration in registrations + if (player := players_by_id.get(registration.player_id)) and isinstance(player, Player) + ] return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players) diff --git a/app/routes/main.py b/app/routes/main.py index 2691b3d..5696c29 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -223,20 +223,18 @@ def dashboard(): elif isinstance(user, Scout): stats['total_players'] = User.query.filter_by(role='player').count() stats['total_evaluations'] = Evaluation.query.count() - stats['avg_scores'] = ( + top_rows = ( db.session.query( - Evaluation.player_id, + User, func.avg(Evaluation.overall_score).label('avg_score'), ) - .group_by(Evaluation.player_id) - .order_by(func.avg(Evaluation.overall_score).desc()) + .join(Evaluation, Evaluation.player_id == User.id) + .filter(User.role == 'player') + .group_by(User.id) + .order_by(func.avg(Evaluation.overall_score).desc(), User.id) .limit(5) .all() ) - stats['top_players'] = [] - for row in stats['avg_scores']: - p = User.query.get(row.player_id) - if p: - stats['top_players'].append((p, round(row.avg_score, 1))) + stats['top_players'] = [(player, round(avg_score, 1)) for player, avg_score in top_rows] return render_template('pages/dashboard.html', user=user, stats=stats) diff --git a/app/routes/matches.py b/app/routes/matches.py index d7cfa43..6f6f4ed 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -289,7 +289,7 @@ def api_events(): @login_required def api_events_for_tryout(tryout_id): """API endpoint returning calendar events for a specific tryout.""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) can_view = current_user.can_manage_this_tryout(tryout) is_registered = False @@ -378,7 +378,7 @@ def api_events_for_tryout(tryout_id): @login_required def create_match(tryout_id): """Create a new match / scrimmage within a tryout.""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to schedule matches for this tryout.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @@ -452,7 +452,7 @@ def create_match(tryout_id): @login_required def edit_match(match_id): """Edit an existing match.""" - match = Match.query.get_or_404(match_id) + match = db.get_or_404(Match, match_id) tryout = match.tryout if not current_user.can_manage_this_tryout(tryout): @@ -582,7 +582,7 @@ def api_manageable_tryouts(): @login_required def delete_match(match_id): """Delete a match.""" - match = Match.query.get_or_404(match_id) + match = db.get_or_404(Match, match_id) tryout = match.tryout if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to delete this match.'), 'danger') @@ -669,10 +669,10 @@ def api_available_players(date, time): @login_required def toggle_presence(match_id, participant_id): """Toggle attendance_confirmed for a match participant.""" - match = Match.query.get_or_404(match_id) + match = db.get_or_404(Match, match_id) tryout = match.tryout - participant = MatchParticipant.query.get_or_404(participant_id) + participant = db.get_or_404(MatchParticipant, participant_id) if participant.match_id != match_id: return jsonify({'error': 'Participant does not belong to this match'}), 400 diff --git a/app/routes/team_matches.py b/app/routes/team_matches.py index bf45954..7671c94 100644 --- a/app/routes/team_matches.py +++ b/app/routes/team_matches.py @@ -108,7 +108,7 @@ def list_matches(): @login_required def create_match(team_id): """Create a new regular-season team match.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not can_manage_team_match(team): flash(_('You do not have permission to schedule matches for this team.'), 'danger') return redirect(url_for('team_matches.list_matches')) @@ -213,7 +213,7 @@ def create_match(team_id): @login_required def edit_match(match_id): """Edit an existing team match.""" - team_match = TeamMatch.query.get_or_404(match_id) + team_match = db.get_or_404(TeamMatch, match_id) team = team_match.org_team if not can_manage_team_match(team): @@ -259,7 +259,7 @@ def edit_match(match_id): @login_required def delete_match(match_id): """Delete a team match.""" - team_match = TeamMatch.query.get_or_404(match_id) + team_match = db.get_or_404(TeamMatch, match_id) team = team_match.org_team if not can_manage_team_match(team): flash(_('You do not have permission to delete this match.'), 'danger') @@ -293,10 +293,10 @@ def api_manageable_teams(): @login_required def toggle_presence(match_id, participant_id): """Toggle is_confirmed for a team match participant.""" - team_match = TeamMatch.query.get_or_404(match_id) + team_match = db.get_or_404(TeamMatch, match_id) team = team_match.org_team - participant = TeamMatchParticipant.query.get_or_404(participant_id) + participant = db.get_or_404(TeamMatchParticipant, participant_id) if participant.team_match_id != match_id: return jsonify({'error': 'Participant does not belong to this match'}), 400 diff --git a/app/routes/teams.py b/app/routes/teams.py index 898a22e..9698d2a 100644 --- a/app/routes/teams.py +++ b/app/routes/teams.py @@ -223,7 +223,7 @@ def create_team(): @login_required def edit_team(team_id): """Edit an existing organization team.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('You do not have permission to edit this team.'), 'danger') return redirect(url_for('teams.list_teams')) @@ -294,7 +294,7 @@ def delete_team(team_id): day `Manager.can_manage_this_org_team` is narrowed — which it should be — deletion narrows with it instead of staying the one way in. """ - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)): flash(_('You do not have permission to delete teams.'), 'danger') @@ -336,7 +336,7 @@ def delete_team(team_id): @login_required def add_coach(team_id): """Add a coach to an organization team.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) @@ -379,7 +379,7 @@ def add_coach(team_id): @login_required def add_manager(team_id): """Add a manager to an organization team.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) @@ -422,7 +422,7 @@ def add_manager(team_id): @login_required def remove_coach(team_id): """Remove a coach from an organization team.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) @@ -450,7 +450,7 @@ def remove_coach(team_id): @login_required def remove_manager(team_id): """Remove a manager from an organization team.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) @@ -478,7 +478,7 @@ def remove_manager(team_id): @login_required def add_player(team_id): """Add a player to an organization team.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) @@ -515,12 +515,12 @@ def add_player(team_id): @login_required def remove_player(team_id, player_id): """Remove a player from an organization team.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) - player = User.query.get_or_404(player_id) + player = db.get_or_404(User, player_id) tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first() if not tp: flash( @@ -543,7 +543,7 @@ def remove_player(team_id, player_id): @login_required def toggle_player_status(team_id, player_id): """Toggle a player's status between starter and substitute.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): return jsonify({'error': 'Permission denied'}), 403 @@ -567,7 +567,7 @@ def toggle_player_status(team_id, player_id): @login_required def add_team_note(team_id): """Add a team improvement note (coaches only).""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('You do not have permission to add notes to this team.'), 'danger') return redirect(url_for('teams.list_teams')) @@ -589,12 +589,12 @@ def add_team_note(team_id): @login_required def add_player_note(team_id, player_id): """Add a personal note for a player (coaches only).""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('You do not have permission to add notes to this team.'), 'danger') return redirect(url_for('teams.list_teams')) - player = User.query.get_or_404(player_id) + player = db.get_or_404(User, player_id) if not isinstance(player, Player): flash(_('Can only add notes for players.'), 'danger') return redirect(url_for('teams.list_teams')) diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index 7caadeb..af99be7 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -179,7 +179,7 @@ def create_tryout(): @login_required def edit_tryout(tryout_id): """Edit an existing tryout event. Permission based on can_manage_this_tryout.""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to edit this tryout.'), 'danger') @@ -236,7 +236,7 @@ def edit_tryout(tryout_id): @login_required def view_tryout(tryout_id): """View a specific tryout with all details. Permission via polymorphic dispatch.""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) can_view = False if isinstance(current_user, Admin): @@ -466,7 +466,7 @@ def register_for_tryout(tryout_id): @login_required def update_status(tryout_id): """Update the status of a tryout.""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) @@ -486,7 +486,7 @@ def update_status(tryout_id): @login_required def update_registration_status(tryout_id, player_id): """Update a registration's attendance status.""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) @@ -557,12 +557,12 @@ def register_player(tryout_id): @login_required def remove_player(tryout_id, player_id): """Remove a registered player from a tryout (cascades to teams/matches).""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) - player = User.query.get_or_404(player_id) + player = db.get_or_404(User, player_id) registration = TryoutRegistration.query.filter_by( tryout_id=tryout_id, player_id=player_id @@ -593,7 +593,7 @@ def remove_player(tryout_id, player_id): @login_required def create_team(tryout_id): """Create a tryout-specific team.""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @@ -615,8 +615,8 @@ def create_team(tryout_id): @login_required def add_to_team(tryout_id, team_id): """Add a player to a tryout team.""" - team = Team.query.get_or_404(team_id) - tryout = Tryout.query.get_or_404(tryout_id) + team = db.get_or_404(Team, team_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @@ -658,7 +658,7 @@ def add_to_team(tryout_id, team_id): @login_required def delete_tryout(tryout_id): """Delete a tryout and all associated data (matches, teams, registrations, evaluations).""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to delete this tryout.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) diff --git a/app/routes/users/accounts.py b/app/routes/users/accounts.py index c4dc991..1b64b82 100644 --- a/app/routes/users/accounts.py +++ b/app/routes/users/accounts.py @@ -70,7 +70,7 @@ def edit_user(user_id): flash(_('Only the president can edit users.'), 'danger') return redirect(url_for('main.dashboard')) - user = User.query.get_or_404(user_id) + user = db.get_or_404(User, user_id) if request.method == 'POST': actor_name, actor_id = current_user.username, current_user.id @@ -253,7 +253,7 @@ def delete_user(user_id): flash(_('You cannot delete your own account.'), 'danger') return redirect(url_for('users.list_users')) - user = User.query.get_or_404(user_id) + user = db.get_or_404(User, user_id) Evaluation.query.filter( db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id), @@ -379,5 +379,5 @@ def create_user(): @login_required def view_user(user_id): """View a public profile for any user.""" - user = User.query.get_or_404(user_id) + user = db.get_or_404(User, user_id) return render_template('pages/view_user.html', profile_user=user) diff --git a/app/routes/users/availability.py b/app/routes/users/availability.py index 6f30fb2..fcd3400 100644 --- a/app/routes/users/availability.py +++ b/app/routes/users/availability.py @@ -193,7 +193,7 @@ def clear_disponibilities(): @login_required def delete_disponibility(disponibility_id): """Delete a disponibility block.""" - disponibility = PlayerDisponibility.query.get_or_404(disponibility_id) + disponibility = db.get_or_404(PlayerDisponibility, disponibility_id) if disponibility.player_id != current_user.id: return jsonify({'error': 'Unauthorized'}), 403 db.session.delete(disponibility) diff --git a/app/routes/users/contracts.py b/app/routes/users/contracts.py index eda1c8b..ec9df0e 100644 --- a/app/routes/users/contracts.py +++ b/app/routes/users/contracts.py @@ -111,7 +111,7 @@ def upload_contract(): flash(error, 'danger') return redirect(url_for('users.upload_contract')) - player = User.query.get_or_404(player_id) + player = db.get_or_404(User, player_id) player_teams = player.get_org_teams() team = player_teams[0] if player_teams else None @@ -154,7 +154,7 @@ def upload_contract(): @login_required def upload_signed_contract(contract_id): """Upload a signed contract (player only).""" - contract = Contract.query.get_or_404(contract_id) + contract = db.get_or_404(Contract, contract_id) if not contract.can_upload_signed(current_user): flash(_('Only the player can upload their signed contract.'), 'danger') return redirect(url_for('users.list_contracts')) @@ -182,7 +182,7 @@ def upload_signed_contract(contract_id): @login_required def download_contract(contract_id): """Download a contract file.""" - contract = Contract.query.get_or_404(contract_id) + contract = db.get_or_404(Contract, contract_id) if not contract.can_view(current_user): flash(_('You do not have permission to download this contract.'), 'danger') return redirect(url_for('users.list_contracts')) @@ -197,7 +197,7 @@ def download_contract(contract_id): @login_required def download_signed_contract(contract_id): """Download a signed contract file.""" - contract = Contract.query.get_or_404(contract_id) + contract = db.get_or_404(Contract, contract_id) if not contract.can_view(current_user): flash(_('You do not have permission to download this contract.'), 'danger') return redirect(url_for('users.list_contracts')) diff --git a/app/routes/users/notes.py b/app/routes/users/notes.py index c69df65..2433e1e 100644 --- a/app/routes/users/notes.py +++ b/app/routes/users/notes.py @@ -217,7 +217,7 @@ def manage_personal_notes(): return redirect(url_for('users.notes_dashboard')) player_id = data['player_id'] - player = User.query.get_or_404(player_id) + player = db.get_or_404(User, player_id) if not isinstance(player, Player): flash(_('Can only add notes for players.'), 'danger') return redirect(url_for('users.notes_dashboard')) @@ -257,7 +257,7 @@ def add_personal_note(): return redirect(url_for('users.notes_dashboard')) player_id = data['player_id'] - player = User.query.get_or_404(player_id) + player = db.get_or_404(User, player_id) if not isinstance(player, Player): flash(_('Can only add notes for players.'), 'danger') return redirect(url_for('users.notes_dashboard')) @@ -267,7 +267,7 @@ def add_personal_note(): return redirect(url_for('users.notes_dashboard')) if data['match_id']: - match = Match.query.get_or_404(data['match_id']) + match = db.get_or_404(Match, data['match_id']) if not current_user.can_manage_this_tryout(match.tryout): flash(_('You cannot use that match as note context.'), 'danger') return redirect(url_for('users.notes_dashboard')) @@ -276,7 +276,7 @@ def add_personal_note(): return redirect(url_for('users.notes_dashboard')) if data['tryout_id']: - tryout = Tryout.query.get_or_404(data['tryout_id']) + tryout = db.get_or_404(Tryout, data['tryout_id']) if not current_user.can_manage_this_tryout(tryout): flash(_('You cannot use that tryout as note context.'), 'danger') return redirect(url_for('users.notes_dashboard')) @@ -285,7 +285,7 @@ def add_personal_note(): return redirect(url_for('users.notes_dashboard')) if data['team_id']: - team = Team.query.get_or_404(data['team_id']) + team = db.get_or_404(Team, data['team_id']) if not current_user.can_manage_this_tryout(team.tryout): flash(_('You cannot use that team as note context.'), 'danger') return redirect(url_for('users.notes_dashboard')) @@ -320,7 +320,7 @@ def add_note_from_tryout(tryout_id): flash(_('Only coaches can add personal notes.'), 'danger') return redirect(url_for('main.dashboard')) - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to add notes for this tryout.'), 'danger') return redirect(url_for('users.notes_dashboard')) @@ -384,7 +384,7 @@ def add_note_from_match(match_id): flash(_('Only coaches can add personal notes.'), 'danger') return redirect(url_for('main.dashboard')) - match_obj = Match.query.get_or_404(match_id) + match_obj = db.get_or_404(Match, match_id) if not current_user.can_manage_this_tryout(match_obj.tryout): flash(_('You do not have permission to add notes for this match.'), 'danger') return redirect(url_for('users.notes_dashboard')) diff --git a/app/routes/users/one_on_one.py b/app/routes/users/one_on_one.py index bccba76..cd3a612 100644 --- a/app/routes/users/one_on_one.py +++ b/app/routes/users/one_on_one.py @@ -166,7 +166,7 @@ def accept_one_on_one(request_id): 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) + request_obj = db.get_or_404(OneOnOneRequest, request_id) if request_obj.coach_id != current_user.id: flash(_('This request is not for you.'), 'danger') @@ -216,7 +216,7 @@ def reject_one_on_one(request_id): 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) + request_obj = db.get_or_404(OneOnOneRequest, request_id) if request_obj.coach_id != current_user.id: flash(_('This request is not for you.'), 'danger') diff --git a/tests/test_bot_error_families.py b/tests/test_bot_error_families.py index 919626c..8eba102 100644 --- a/tests/test_bot_error_families.py +++ b/tests/test_bot_error_families.py @@ -150,9 +150,10 @@ def _pending(bot, message_id, row_id): def _confirmed(row_id): + from app.extensions import db from app.models import MatchParticipant - return MatchParticipant.query.get(row_id).attendance_confirmed + return db.session.get(MatchParticipant, row_id).attendance_confirmed class TestTheDatabaseRefusedTheWrite: diff --git a/tests/test_query_shape.py b/tests/test_query_shape.py index 098d478..a8e1237 100644 --- a/tests/test_query_shape.py +++ b/tests/test_query_shape.py @@ -269,6 +269,78 @@ class TestRegisteredPlayersForMatchForm: assert counter.total == 1 +class TestEvaluationLists: + """Evaluation pages must not issue one lookup per player or evaluator.""" + + def test_players_to_evaluate_has_a_fixed_query_budget( + self, app, client, as_role, make_user, count_queries + ): + coach_id = as_role('coach') + admin_id = make_user('admin') + player_ids = [make_user('player') for _ in range(12)] + + with app.app_context(): + tryout = Tryout( + title='Evaluation budget', + game='Valorant', + date=date(2030, 3, 1), + created_by=admin_id, + coach_id=coach_id, + ) + db.session.add(tryout) + db.session.flush() + for player_id in player_ids: + db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id)) + db.session.commit() + tryout_id = tryout.id + + counter = count_queries() + try: + response = client.get(f'/evaluations/{tryout_id}/players') + finally: + counter.stop() + + assert response.status_code == 200 + assert 1 <= counter.total <= 10, f'{counter.total} SELECTs for 12 players' + + def test_scout_top_players_are_loaded_with_the_aggregate( + self, app, client, as_role, make_user, count_queries + ): + as_role('scout') + evaluator_id = make_user('coach') + admin_id = make_user('admin') + player_ids = [make_user('player') for _ in range(12)] + + with app.app_context(): + tryout = Tryout( + title='Scout budget', + game='Valorant', + date=date(2030, 3, 1), + created_by=admin_id, + ) + db.session.add(tryout) + db.session.flush() + for score, player_id in enumerate(player_ids, start=1): + db.session.add( + Evaluation( + player_id=player_id, + evaluator_id=evaluator_id, + tryout_id=tryout.id, + overall_score=score, + ) + ) + db.session.commit() + + counter = count_queries() + try: + response = client.get('/dashboard') + finally: + counter.stop() + + assert response.status_code == 200 + assert 1 <= counter.total <= 6, f'{counter.total} SELECTs for the scout dashboard' + + class TestViewTryout: """PERF-001 — the most-visited page in the application ran one query per registration, one per player evaluated, one per team, and one per diff --git a/tests/test_storage.py b/tests/test_storage.py index 434c2be..e96197d 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -135,9 +135,11 @@ class TestThroughTheUploadRoute: contract_id = Contract.query.one().id response = client.get(f'/users/contracts/{contract_id}/download') - - assert response.status_code == 200 - assert response.data.startswith(b'%PDF-') + try: + assert response.status_code == 200 + assert response.data.startswith(b'%PDF-') + finally: + response.close() def _pdf(): From e15b3c12934cd63e6aed37e604100fc3964e8cde Mon Sep 17 00:00:00 2001 From: GGThed Date: Mon, 17 Aug 2026 15:21:08 -0400 Subject: [PATCH 5/5] fix(audit): centraliser l'horloge UTC --- app/discord_bot.py | 6 +++-- app/models/availability/base.py | 7 +++--- app/models/contract.py | 5 ++-- app/models/evaluation.py | 7 +++--- app/models/match_model/base.py | 5 ++-- app/models/one_on_one_request.py | 5 ++-- app/models/org_team/org_team.py | 5 ++-- app/models/org_team/team_player.py | 5 ++-- app/models/participant/base.py | 5 ++-- app/models/personal_note.py | 7 +++--- app/models/team/team.py | 5 ++-- app/models/team/team_member.py | 5 ++-- app/models/team_note.py | 7 +++--- app/models/tryout/tryout.py | 5 ++-- app/models/tryout/tryout_registration.py | 5 ++-- app/models/user_model/user.py | 5 ++-- app/routes/auth.py | 5 ++-- app/routes/team_matches.py | 5 ++-- app/routes/teams.py | 5 ++-- app/routes/tryouts.py | 7 +++--- app/routes/users/contracts.py | 4 ++-- app/routes/users/one_on_one.py | 7 +++--- app/time_utils.py | 19 ++++++++++++++++ pyproject.toml | 3 --- tests/test_time_utils.py | 29 ++++++++++++++++++++++++ 25 files changed, 101 insertions(+), 72 deletions(-) create mode 100644 app/time_utils.py create mode 100644 tests/test_time_utils.py diff --git a/app/discord_bot.py b/app/discord_bot.py index b3102b3..8ba7562 100644 --- a/app/discord_bot.py +++ b/app/discord_bot.py @@ -65,6 +65,8 @@ from discord.ext import commands from dotenv import load_dotenv from sqlalchemy.exc import SQLAlchemyError +from app.time_utils import utc_now_naive + load_dotenv() DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN') @@ -780,7 +782,7 @@ class TeamTryoutsBot(commands.Bot): coach_obj = request.coach request.status = 'approved' - request.responded_at = datetime.utcnow() + request.responded_at = utc_now_naive() except SQLAlchemyError: db.session.rollback() logger.exception('Could not read One on One request %s to approve it', request_id) @@ -875,7 +877,7 @@ class TeamTryoutsBot(commands.Bot): try: request.status = 'rejected' - request.responded_at = datetime.utcnow() + request.responded_at = utc_now_naive() if refusal_note: request.coach_rejection_message = refusal_note except SQLAlchemyError: diff --git a/app/models/availability/base.py b/app/models/availability/base.py index 4aee7ce..dc1cff4 100644 --- a/app/models/availability/base.py +++ b/app/models/availability/base.py @@ -1,8 +1,7 @@ """Abstract base class for availability models (PlayerDisponibility + CoachAvailability).""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class BaseAvailability(db.Model): @@ -13,5 +12,5 @@ class BaseAvailability(db.Model): day_of_week = db.Column(db.Integer, nullable=False) start_time = db.Column(db.Time, nullable=False) end_time = db.Column(db.Time, nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) + updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive) diff --git a/app/models/contract.py b/app/models/contract.py index c18ea94..da90e61 100644 --- a/app/models/contract.py +++ b/app/models/contract.py @@ -1,8 +1,7 @@ """Contract documents for players to sign.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class Contract(db.Model): @@ -23,7 +22,7 @@ class Contract(db.Model): status = db.Column(db.String(20), default='pending') notes = db.Column(db.Text, nullable=True) - uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) + uploaded_at = db.Column(db.DateTime, default=utc_now_naive) signed_at = db.Column(db.DateTime, nullable=True) player = db.relationship('User', foreign_keys=[player_id], backref='contracts') diff --git a/app/models/evaluation.py b/app/models/evaluation.py index 4feb240..9ef8f0e 100644 --- a/app/models/evaluation.py +++ b/app/models/evaluation.py @@ -1,8 +1,7 @@ """Player evaluation record.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class Evaluation(db.Model): @@ -25,8 +24,8 @@ class Evaluation(db.Model): overall_score = db.Column(db.Float, nullable=True) comments = db.Column(db.Text, nullable=True) position_recommendation = db.Column(db.String(50), nullable=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) + updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive) __table_args__ = ( db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'), diff --git a/app/models/match_model/base.py b/app/models/match_model/base.py index f95232c..4716064 100644 --- a/app/models/match_model/base.py +++ b/app/models/match_model/base.py @@ -1,8 +1,7 @@ """Abstract base class for match models (Match + TeamMatch).""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class BaseMatch(db.Model): @@ -18,4 +17,4 @@ class BaseMatch(db.Model): location = db.Column(db.String(200), nullable=True) status = db.Column(db.String(20), default='scheduled') created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) diff --git a/app/models/one_on_one_request.py b/app/models/one_on_one_request.py index 1b1e94f..13f8c00 100644 --- a/app/models/one_on_one_request.py +++ b/app/models/one_on_one_request.py @@ -1,8 +1,7 @@ """Request from player to coach for a One on One session.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class OneOnOneRequest(db.Model): @@ -18,7 +17,7 @@ class OneOnOneRequest(db.Model): end_time = db.Column(db.Time, nullable=False) points = db.Column(db.Text, nullable=True) status = db.Column(db.String(20), default='pending') - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) responded_at = db.Column(db.DateTime, nullable=True) discord_message_id = db.Column(db.BigInteger, nullable=True) coach_rejection_message = db.Column(db.Text, nullable=True) diff --git a/app/models/org_team/org_team.py b/app/models/org_team/org_team.py index ae0a0b1..efb601e 100644 --- a/app/models/org_team/org_team.py +++ b/app/models/org_team/org_team.py @@ -1,9 +1,8 @@ """Persistent organisation team (e.g. Varsity, JV).""" -from datetime import datetime - from app.extensions import db from app.models._associations import org_team_coaches, org_team_managers +from app.time_utils import utc_now_naive class OrgTeam(db.Model): @@ -13,7 +12,7 @@ class OrgTeam(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False, unique=True) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) diff --git a/app/models/org_team/team_player.py b/app/models/org_team/team_player.py index d07865c..04c3242 100644 --- a/app/models/org_team/team_player.py +++ b/app/models/org_team/team_player.py @@ -1,8 +1,7 @@ """Many-to-many junction: player to org-team.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class TeamPlayer(db.Model): @@ -14,7 +13,7 @@ class TeamPlayer(db.Model): org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) status = db.Column(db.String(20), nullable=False, default='starter') position = db.Column(db.String(50), nullable=True) - added_at = db.Column(db.DateTime, default=datetime.utcnow) + added_at = db.Column(db.DateTime, default=utc_now_naive) player = db.relationship('User', foreign_keys=[player_id], backref='team_placements') org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players') diff --git a/app/models/participant/base.py b/app/models/participant/base.py index 7293699..2b4f1f2 100644 --- a/app/models/participant/base.py +++ b/app/models/participant/base.py @@ -1,8 +1,7 @@ """Abstract base class for match participant models.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class BaseParticipant(db.Model): @@ -11,4 +10,4 @@ class BaseParticipant(db.Model): __abstract__ = True player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - added_at = db.Column(db.DateTime, default=datetime.utcnow) + added_at = db.Column(db.DateTime, default=utc_now_naive) diff --git a/app/models/personal_note.py b/app/models/personal_note.py index 4745202..54491ca 100644 --- a/app/models/personal_note.py +++ b/app/models/personal_note.py @@ -1,8 +1,7 @@ """Personal notes from coach to individual player.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class PersonalNote(db.Model): @@ -13,8 +12,8 @@ class PersonalNote(db.Model): player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) content = db.Column(db.Text, nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) + updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive) match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True) team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) diff --git a/app/models/team/team.py b/app/models/team/team.py index 4611fe1..47e1b1d 100644 --- a/app/models/team/team.py +++ b/app/models/team/team.py @@ -1,8 +1,7 @@ """Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class Team(db.Model): @@ -13,7 +12,7 @@ class Team(db.Model): tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) name = db.Column(db.String(100), nullable=False) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) creator = db.relationship('User', backref='created_teams') members = db.relationship('TeamMember', backref='team', lazy='dynamic') diff --git a/app/models/team/team_member.py b/app/models/team/team_member.py index 391048f..0c1a1be 100644 --- a/app/models/team/team_member.py +++ b/app/models/team/team_member.py @@ -1,8 +1,7 @@ """Link between a player and a tryout-specific team.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class TeamMember(db.Model): @@ -13,6 +12,6 @@ class TeamMember(db.Model): team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) position = db.Column(db.String(50), nullable=True) - added_at = db.Column(db.DateTime, default=datetime.utcnow) + added_at = db.Column(db.DateTime, default=utc_now_naive) player = db.relationship('User', overlaps="player_ref,team_assignments") diff --git a/app/models/team_note.py b/app/models/team_note.py index 76c8c21..5f66f31 100644 --- a/app/models/team_note.py +++ b/app/models/team_note.py @@ -1,8 +1,7 @@ """Team improvement notes from coach.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class TeamNote(db.Model): @@ -13,8 +12,8 @@ class TeamNote(db.Model): org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) content = db.Column(db.Text, nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) + updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive) team = db.relationship('OrgTeam', backref='team_notes') coach = db.relationship('User', foreign_keys=[coach_id]) diff --git a/app/models/tryout/tryout.py b/app/models/tryout/tryout.py index e9f4a74..6a6cbf2 100644 --- a/app/models/tryout/tryout.py +++ b/app/models/tryout/tryout.py @@ -1,9 +1,8 @@ """Tryout event for player evaluations and team formation.""" -from datetime import datetime - from app.extensions import db from app.models._associations import tryout_coaches +from app.time_utils import utc_now_naive class Tryout(db.Model): @@ -25,7 +24,7 @@ class Tryout(db.Model): coach_id = db.Column( db.Integer, db.ForeignKey('users.id'), nullable=True ) # deprecated, kept for migration - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts') manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') diff --git a/app/models/tryout/tryout_registration.py b/app/models/tryout/tryout_registration.py index 007e77b..c8b3942 100644 --- a/app/models/tryout/tryout_registration.py +++ b/app/models/tryout/tryout_registration.py @@ -1,8 +1,7 @@ """Registration linking a player to a tryout.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class TryoutRegistration(db.Model): @@ -12,6 +11,6 @@ class TryoutRegistration(db.Model): id = db.Column(db.Integer, primary_key=True) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - registered_at = db.Column(db.DateTime, default=datetime.utcnow) + registered_at = db.Column(db.DateTime, default=utc_now_naive) status = db.Column(db.String(20), default='registered') notes = db.Column(db.Text, nullable=True) diff --git a/app/models/user_model/user.py b/app/models/user_model/user.py index 231a726..bcdff98 100644 --- a/app/models/user_model/user.py +++ b/app/models/user_model/user.py @@ -1,10 +1,9 @@ """Base User model — shared fields and polymorphic configuration.""" -from datetime import datetime - from flask_login import UserMixin from app.extensions import db +from app.time_utils import utc_now_naive class User(UserMixin, db.Model): @@ -25,7 +24,7 @@ class User(UserMixin, db.Model): email = db.Column(db.String(120), unique=True, nullable=False) phone = db.Column(db.String(20), nullable=True) is_active_account = db.Column(db.Boolean, default=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) failed_login_attempts = db.Column(db.Integer, default=0) locked_until = db.Column(db.DateTime, nullable=True) diff --git a/app/routes/auth.py b/app/routes/auth.py index 2925040..b0666af 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -8,7 +8,7 @@ password policy enforcement and sign-up screening. import os import secrets import time -from datetime import datetime, timedelta +from datetime import timedelta from urllib.parse import urlencode, urlparse import requests @@ -22,6 +22,7 @@ from app.forms import form_gamertags from app.i18n import LOCALE_SESSION_KEY from app.logging_config import log_auth_event from app.models import ESPORT_GAMES, Player, User +from app.time_utils import utc_now_naive from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id #: Session key holding the pending OAuth2 anti-forgery token. @@ -294,7 +295,7 @@ def login(): ) if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS: minutes = cooloff_minutes(user.failed_login_attempts) - user.locked_until = datetime.utcnow() + timedelta(minutes=minutes) + user.locked_until = utc_now_naive() + timedelta(minutes=minutes) log_auth_event( 'account.throttled', username=username, diff --git a/app/routes/team_matches.py b/app/routes/team_matches.py index 7671c94..0d87607 100644 --- a/app/routes/team_matches.py +++ b/app/routes/team_matches.py @@ -3,8 +3,6 @@ Uses polymorphic isinstance checks instead of role-string comparisons. """ -from datetime import datetime - from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required @@ -27,6 +25,7 @@ from app.pagination import paginate from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams from app.routes.matches import default_end_time from app.services.scheduling import notify_participants, zip_participants +from app.time_utils import utc_now_naive from app.validators import TeamMatchSchema team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches') @@ -100,7 +99,7 @@ def list_matches(): teams=teams, match_data=match_data, pagination=matches_page, - now=datetime.utcnow(), + now=utc_now_naive(), ) diff --git a/app/routes/teams.py b/app/routes/teams.py index 9698d2a..5c677ad 100644 --- a/app/routes/teams.py +++ b/app/routes/teams.py @@ -3,8 +3,6 @@ Uses polymorphic isinstance checks instead of role-string comparisons. """ -from datetime import datetime - from flask import Blueprint, flash, jsonify, redirect, render_template, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required @@ -29,6 +27,7 @@ from app.models import ( User, ) from app.permissions import visible_org_teams +from app.time_utils import utc_now_naive from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema teams_bp = Blueprint('teams', __name__, url_prefix='/teams') @@ -82,7 +81,7 @@ def my_teams(): from app.models import TeamMatch, TeamMatchParticipant player_teams = current_user.get_org_teams() - now = datetime.utcnow() + now = utc_now_naive() team_data = [] for org_team in player_teams: diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index af99be7..db2468c 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -4,8 +4,6 @@ This module handles CRUD operations for tryouts and player registrations. Uses polymorphic isinstance checks instead of role-string comparisons. """ -from datetime import datetime - from flask import Blueprint, abort, flash, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required @@ -33,6 +31,7 @@ from app.models import ( TryoutRegistration, User, ) +from app.time_utils import utc_now_naive from app.validators import ( PlayerSelectionSchema, TryoutRegistrationStatusSchema, @@ -114,7 +113,7 @@ def list_tryouts(): Delegates to the polymorphic User subclass's get_visible_tryouts() method. """ tryouts = current_user.get_visible_tryouts() - return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow()) + return render_template('pages/tryouts.html', tryouts=tryouts, now=utc_now_naive()) @tryouts_bp.route('/create', methods=['GET', 'POST']) @@ -425,7 +424,7 @@ def view_tryout(tryout_id): matches=matches, match_data=match_data, game_positions=GAME_POSITIONS, - now=datetime.utcnow(), + now=utc_now_naive(), ) diff --git a/app/routes/users/contracts.py b/app/routes/users/contracts.py index ec9df0e..62e227d 100644 --- a/app/routes/users/contracts.py +++ b/app/routes/users/contracts.py @@ -2,7 +2,6 @@ import os import uuid -from datetime import datetime from flask import flash, redirect, render_template, request, send_file, url_for from flask_babel import gettext as _ @@ -20,6 +19,7 @@ from app.routes.users._shared import ( ) from app.routes.users.blueprint import users_bp from app.storage import CONTRACTS_DIR, document_path +from app.time_utils import utc_now_naive from app.validators import UploadContractSchema @@ -172,7 +172,7 @@ def upload_signed_contract(contract_id): contract.signed_filename = signed_filename contract.signed_file_path = signed_path contract.status = 'signed' - contract.signed_at = datetime.utcnow() + contract.signed_at = utc_now_naive() db.session.commit() flash(_('Signed contract uploaded successfully!'), 'success') return redirect(url_for('users.list_contracts')) diff --git a/app/routes/users/one_on_one.py b/app/routes/users/one_on_one.py index cd3a612..6211b19 100644 --- a/app/routes/users/one_on_one.py +++ b/app/routes/users/one_on_one.py @@ -1,7 +1,5 @@ """One-on-one sessions between a player and their coach.""" -from datetime import datetime - from flask import flash, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required @@ -12,6 +10,7 @@ from app.forms import flash_validation_errors, form_payload from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote from app.routes.users.blueprint import users_bp from app.services.notifications import send_discord_notification +from app.time_utils import utc_now_naive from app.validators import OneOnOneRejectionSchema, OneOnOneRequestSchema @@ -178,7 +177,7 @@ def accept_one_on_one(request_id): player = request_obj.player request_obj.status = 'approved' - request_obj.responded_at = datetime.utcnow() + request_obj.responded_at = utc_now_naive() db.session.commit() # Notify player via Discord (same message as if approved through Discord reactions) @@ -235,7 +234,7 @@ def reject_one_on_one(request_id): player = request_obj.player request_obj.status = 'rejected' - request_obj.responded_at = datetime.utcnow() + request_obj.responded_at = utc_now_naive() if rejection_reason: request_obj.coach_rejection_message = rejection_reason db.session.commit() diff --git a/app/time_utils.py b/app/time_utils.py new file mode 100644 index 0000000..54325e5 --- /dev/null +++ b/app/time_utils.py @@ -0,0 +1,19 @@ +"""Time helpers with explicit storage semantics. + +The deployed schema currently stores timestamps in ``DateTime`` columns +without timezone information. Until the real PostgreSQL schema is restored +and migrated, application timestamps must therefore remain naive values. +They are nevertheless generated from an aware UTC clock so the convention is +explicit and does not rely on the deprecated :meth:`datetime.utcnow` API. +""" + +from datetime import UTC, datetime + + +def utc_now_naive() -> datetime: + """Return the current UTC instant without ``tzinfo`` for legacy columns. + + Replace this compatibility boundary with aware UTC values when the + corresponding columns are migrated to timezone-aware types. + """ + return datetime.now(UTC).replace(tzinfo=None) diff --git a/pyproject.toml b/pyproject.toml index 064e394..91a98b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,9 +21,6 @@ filterwarnings = [ "default", # discord.py imports audioop, removed from the stdlib in 3.13. "ignore:'audioop' is deprecated:DeprecationWarning", - # Every model uses datetime.utcnow as a column default. Tracked as - # DB-009; the warning would otherwise drown the run. - "ignore:datetime.datetime.utcnow:DeprecationWarning", ] [tool.coverage.report] diff --git a/tests/test_time_utils.py b/tests/test_time_utils.py new file mode 100644 index 0000000..6619cb6 --- /dev/null +++ b/tests/test_time_utils.py @@ -0,0 +1,29 @@ +"""Regression tests for the application's timestamp convention.""" + +import ast +from datetime import UTC, datetime +from pathlib import Path + +from app.time_utils import utc_now_naive + + +def test_utc_now_naive_is_an_explicit_utc_value(): + before = datetime.now(UTC).replace(tzinfo=None) + actual = utc_now_naive() + after = datetime.now(UTC).replace(tzinfo=None) + + assert actual.tzinfo is None + assert before <= actual <= after + + +def test_application_does_not_call_deprecated_utcnow(): + app_root = Path(__file__).parents[1] / 'app' + offenders = [] + + for path in app_root.rglob('*.py'): + tree = ast.parse(path.read_text(encoding='utf-8'), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Attribute) and node.attr == 'utcnow': + offenders.append(f'{path.relative_to(app_root)}:{node.lineno}') + + assert offenders == []