From 105a72700f2fbcb7c81b2477322c923ad30e330b Mon Sep 17 00:00:00 2001 From: GGThed Date: Mon, 17 Aug 2026 14:34:06 -0400 Subject: [PATCH] fix(audit): fermer les frontieres restantes --- .gitea/workflows/ci.yaml | 45 ++ .github/workflows/ci.yml | 2 +- app/forms.py | 30 ++ app/routes/auth.py | 26 +- app/routes/matches.py | 29 +- app/routes/teams.py | 36 +- app/routes/tryouts.py | 88 +++- app/routes/users/_shared.py | 26 +- app/routes/users/accounts.py | 8 +- app/routes/users/notes.py | 163 ++++-- app/routes/users/one_on_one.py | 9 +- app/routes/users/profile.py | 14 +- app/supporting_scripts/backup.py | 43 +- app/templates/pages/coach_availability.html | 9 +- app/templates/pages/match_form.html | 21 +- app/translations/en/LC_MESSAGES/messages.mo | Bin 45760 -> 47718 bytes app/translations/en/LC_MESSAGES/messages.po | 529 +++++++++++--------- app/translations/fr/LC_MESSAGES/messages.mo | Bin 50084 -> 52210 bytes app/translations/fr/LC_MESSAGES/messages.po | 529 +++++++++++--------- app/validators.py | 141 +++++- docs/database-schema.md | 2 +- docs/deployment.md | 12 +- pyproject.toml | 6 + tests/test_backup.py | 12 + tests/test_csp.py | 17 + tests/test_filesystem_roots.py | 14 +- tests/test_form_boundaries.py | 281 +++++++++++ tests/test_query_shape.py | 36 ++ 28 files changed, 1511 insertions(+), 617 deletions(-) create mode 100644 .gitea/workflows/ci.yaml create mode 100644 tests/test_form_boundaries.py diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..46005ca --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,45 @@ +name: CI - Security, Lint & Tests + +on: + push: + pull_request: + workflow_dispatch: + +# This workflow validates branches only. It has no deployment step and no +# write permission, so an audit-branch push cannot alter main or production. +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + cache: pip + + - name: Install dependencies + run: pip install -r requirements.txt -r requirements-dev.txt + + - name: Audit declared dependencies + run: pip-audit -r requirements.txt + + - name: Lint and check formatting + run: | + ruff check . + ruff format --check . + + - name: Run tests with coverage gate + run: pytest --cov=app --cov-report=term-missing --cov-report=xml + + - name: Run repository security checks + env: + SECRET_KEY: audit-ci-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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 327af4b..a865b6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI - Security & Lint on: push: - branches: [main, master] + branches: [main, master, 'audit/**'] pull_request: branches: [main, master] workflow_dispatch: # Allow manual triggers diff --git a/app/forms.py b/app/forms.py index 4a435e8..16c54a5 100644 --- a/app/forms.py +++ b/app/forms.py @@ -61,3 +61,33 @@ def form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('pass if not payload.get(name): payload.pop(name, None) return payload + + +def form_gamertags(selected_games): + """Validate the dynamic gamertag fields for the selected games. + + These fields cannot be declared statically on the account schemas: their + names contain the game label. They are still untrusted form data, so + every caller uses this shared boundary before adding or changing rows. + """ + from marshmallow import ValidationError + + from app.models import GAME_PLATFORMS + from app.validators import GamertagSchema + + validated = {} + for game in selected_games: + raw_gamertag = request.form.get(f'gamertag_{game}', '') + raw_platform = ( + request.form.get(f'platform_{game}', '') if GAME_PLATFORMS.get(game) else None + ) + if not raw_gamertag.strip(): + continue + try: + validated[game] = GamertagSchema().load( + {'game': game, 'gamertag': raw_gamertag, 'platform': raw_platform} + ) + except ValidationError as err: + messages = [message for values in err.messages.values() for message in values] + raise ValidationError({f'gamertag_{game}': messages}) from err + return validated diff --git a/app/routes/auth.py b/app/routes/auth.py index d74a68d..2925040 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -18,6 +18,7 @@ from flask_login import current_user, login_required, login_user, logout_user from marshmallow import ValidationError from app.extensions import check_password, db, hash_password, limiter +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 @@ -393,6 +394,13 @@ def register(): full_name = validated['full_name'] phone = validated.get('phone') selected_games = validated.get('games', []) + try: + submitted_gamertags = form_gamertags(selected_games) + except ValidationError as err: + for field, messages in err.messages.items(): + for msg in messages: + flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger') + return _rerender_registration(form_data) # 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). @@ -445,16 +453,14 @@ def register(): # Create UserGamertag records for each selected game from app.models import UserGamertag - for game in selected_games: - field_name = f'gamertag_{game}' - gamertag_value = request.form.get(field_name, '').strip() - if gamertag_value: - gamertag = UserGamertag( - user_id=user.id, - game=game, - gamertag=gamertag_value, - ) - db.session.add(gamertag) + for game, gamertag_data in submitted_gamertags.items(): + gamertag = UserGamertag( + user_id=user.id, + game=game, + gamertag=gamertag_data['gamertag'], + platform=gamertag_data['platform'], + ) + db.session.add(gamertag) db.session.commit() # Clear Discord OAuth data from session after successful registration diff --git a/app/routes/matches.py b/app/routes/matches.py index 0e9998d..d7cfa43 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -46,6 +46,25 @@ def match_form_payload(): return form_payload(list_fields=('player_ids',), optional_blank=()) +def registered_players(tryout_id): + """Players registered for one tryout, loaded in a single query. + + The create form previously called ``User.query.get`` twice per + registration (once in the filter and once in the result expression), + and the edit form called it once per row. Besides scaling linearly, both + paths could return duplicates while DB-006 is still pending. The join is + bounded and ``distinct`` preserves the form's intended one-option-per- + player contract until the database constraint lands. + """ + return ( + User.query.join(TryoutRegistration, TryoutRegistration.player_id == User.id) + .filter(TryoutRegistration.tryout_id == tryout_id) + .order_by(User.username) + .distinct() + .all() + ) + + #: How long a match lasts when the form gives a start and no end. DEFAULT_MATCH_MINUTES = 30 @@ -369,11 +388,7 @@ def create_match(tryout_id): return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) teams = Team.query.filter_by(tryout_id=tryout_id).all() - registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all() - all_players = [ - User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id) - ] - all_players = sorted([p for p in all_players if p], key=lambda x: x.username) + all_players = registered_players(tryout_id) prefill_date = request.args.get('date', '') def rerender(): @@ -449,9 +464,7 @@ def edit_match(match_id): return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) teams = Team.query.filter_by(tryout_id=tryout.id).all() - registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all() - all_players = [User.query.get(r.player_id) for r in registrations if r.player_id] - all_players = sorted([p for p in all_players if p], key=lambda x: x.username) + all_players = registered_players(tryout.id) current_player_ids = [p.player_id for p in match.participants.all()] team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()] team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()] diff --git a/app/routes/teams.py b/app/routes/teams.py index 1dabb16..898a22e 100644 --- a/app/routes/teams.py +++ b/app/routes/teams.py @@ -5,7 +5,7 @@ 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 import Blueprint, flash, jsonify, redirect, render_template, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required from marshmallow import ValidationError @@ -29,7 +29,7 @@ from app.models import ( User, ) from app.permissions import visible_org_teams -from app.validators import OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema +from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema teams_bp = Blueprint('teams', __name__, url_prefix='/teams') @@ -572,12 +572,16 @@ def add_team_note(team_id): flash(_('You do not have permission to add notes to this team.'), 'danger') return redirect(url_for('teams.list_teams')) - content = request.form.get('content', '').strip() - if content: - note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content) - db.session.add(note) - db.session.commit() - flash(_('Team notes added successfully!'), 'success') + try: + data = NoteContentSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) + return redirect(url_for('teams.list_teams')) + + note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content']) + db.session.add(note) + db.session.commit() + flash(_('Team notes added successfully!'), 'success') return redirect(url_for('teams.list_teams')) @@ -603,10 +607,14 @@ def add_player_note(team_id, player_id): ) return redirect(url_for('teams.list_teams')) - content = request.form.get('content', '').strip() - if content: - note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content) - db.session.add(note) - db.session.commit() - flash(_('Note added for %(username)s!', username=player.username), 'success') + try: + data = NoteContentSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) + return redirect(url_for('teams.list_teams')) + + note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content']) + db.session.add(note) + db.session.commit() + flash(_('Note added for %(username)s!', username=player.username), 'success') return redirect(url_for('teams.list_teams')) diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index 4c2be4c..7caadeb 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -10,6 +10,7 @@ from flask import Blueprint, abort, flash, redirect, render_template, request, u from flask_babel import gettext as _ from flask_login import current_user, login_required from marshmallow import ValidationError +from sqlalchemy import select from app.extensions import db from app.forms import flash_validation_errors, form_payload @@ -32,7 +33,14 @@ from app.models import ( TryoutRegistration, User, ) -from app.validators import PlayerSelectionSchema, TryoutSchema +from app.validators import ( + PlayerSelectionSchema, + TryoutRegistrationStatusSchema, + TryoutSchema, + TryoutStatusSchema, + TryoutTeamMemberSchema, + TryoutTeamSchema, +) tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts') @@ -79,6 +87,25 @@ def _users_by_id(user_ids): return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()} +def registration_lock_statement(tryout_id): + """The PostgreSQL row lock used by both registration entry points.""" + return select(Tryout).where(Tryout.id == tryout_id).with_for_update() + + +def locked_tryout_or_404(tryout_id): + """Load and row-lock a tryout while a registration slot is decided. + + PostgreSQL serializes concurrent registration attempts on this row. The + duplicate check, capacity count and insert that follow therefore form + one decision instead of three independently racing statements. SQLite + ignores ``FOR UPDATE`` in tests, but production does not. + """ + tryout = db.session.execute(registration_lock_statement(tryout_id)).scalar_one_or_none() + if tryout is None: + abort(404) + return tryout + + @tryouts_bp.route('') @login_required def list_tryouts(): @@ -406,10 +433,10 @@ def view_tryout(tryout_id): @login_required def register_for_tryout(tryout_id): """Register a player for a tryout. Only Players can self-register.""" - tryout = Tryout.query.get_or_404(tryout_id) if not isinstance(current_user, Player): flash(_('Only players can register for tryouts.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + tryout = locked_tryout_or_404(tryout_id) if tryout.status not in ['upcoming', 'in_progress']: flash(_('This tryout is not accepting registrations.'), 'danger') @@ -443,11 +470,15 @@ def update_status(tryout_id): if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) - new_status = request.form.get('status') - if new_status in ['upcoming', 'in_progress', 'completed']: - tryout.status = new_status - db.session.commit() - flash(_('Tryout status updated to %(new_status)s.', new_status=new_status), 'success') + try: + data = TryoutStatusSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) + return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + + tryout.status = data['status'] + db.session.commit() + flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @@ -463,11 +494,15 @@ def update_registration_status(tryout_id, player_id): registration = TryoutRegistration.query.filter_by( tryout_id=tryout_id, player_id=player_id ).first_or_404() - new_status = request.form.get('status') - if new_status in ['registered', 'attended', 'no_show']: - registration.status = new_status - db.session.commit() - flash(_('Registration status updated.'), 'success') + try: + data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) + return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + + registration.status = data['status'] + db.session.commit() + flash(_('Registration status updated.'), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @@ -475,7 +510,7 @@ def update_registration_status(tryout_id, player_id): @login_required def register_player(tryout_id): """Manually register a player for a tryout (by managers/coaches).""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = locked_tryout_or_404(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)) @@ -563,12 +598,16 @@ def create_team(tryout_id): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - team_name = request.form.get('team_name') - if team_name: - team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id) - db.session.add(team) - db.session.commit() - flash(_('Team "%(team_name)s" created!', team_name=team_name), 'success') + try: + data = TryoutTeamSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) + return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + + team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id) + db.session.add(team) + db.session.commit() + flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @@ -588,10 +627,12 @@ def add_to_team(tryout_id, team_id): if team.tryout_id != tryout_id: abort(404) - player_id = request.form.get('player_id', type=int) - if not player_id: - flash(_('Please select a player.'), 'danger') + try: + data = TryoutTeamMemberSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + player_id = data['player_id'] # Only players registered for this tryout may be placed on its teams. is_registered = ( @@ -602,12 +643,11 @@ def add_to_team(tryout_id, team_id): flash(_('That player is not registered for this tryout.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - position = request.form.get('position', '') existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first() if existing: flash(_('Player is already on this team.'), 'info') else: - member = TeamMember(team_id=team_id, player_id=player_id, position=position) + member = TeamMember(team_id=team_id, player_id=player_id, position=data['position']) db.session.add(member) db.session.commit() flash(_('Player added to team!'), 'success') diff --git a/app/routes/users/_shared.py b/app/routes/users/_shared.py index 5ad0267..4acd465 100644 --- a/app/routes/users/_shared.py +++ b/app/routes/users/_shared.py @@ -4,7 +4,6 @@ Nothing here touches the blueprint: these are plain functions, so a test can call them with a request context and nothing else. """ -from flask import request from flask_babel import gettext as _ from app.extensions import db @@ -13,7 +12,7 @@ from app.extensions import db # routes needed them as well (ARCH-005). Importing them from here still # works, so the thirty call sites in this package did not have to move. from app.forms import flash_validation_errors, form_payload # noqa: F401 -from app.models import GAME_PLATFORMS, Admin, Coach, Manager, Player, Scout, UserGamertag +from app.models import Admin, Coach, Manager, Player, Scout, UserGamertag ALLOWED_CONTRACT_EXTENSIONS = {'pdf'} ALLOWED_SIGNED_EXTENSIONS = {'pdf'} @@ -63,20 +62,25 @@ def pdf_upload_error(file, allowed_extensions): def update_user_gamertags(user, selected_games): - """Update gamertags for a user based on form input.""" + """Update gamertags for a user from validated dynamic form fields.""" + from app.forms import form_gamertags + + submitted = form_gamertags(selected_games) existing_gamertags = {gt.game: gt for gt in user.gamertags} for game in selected_games: - gamertag = request.form.get(f'gamertag_{game}', '').strip() - platform = ( - request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None - ) + payload = submitted.get(game) existing = existing_gamertags.get(game) - if gamertag: + if payload: if existing: - existing.gamertag = gamertag - existing.platform = platform + existing.gamertag = payload['gamertag'] + existing.platform = payload['platform'] else: - gt = UserGamertag(user_id=user.id, game=game, gamertag=gamertag, platform=platform) + gt = UserGamertag( + user_id=user.id, + game=game, + gamertag=payload['gamertag'], + platform=payload['platform'], + ) db.session.add(gt) elif existing: db.session.delete(existing) diff --git a/app/routes/users/accounts.py b/app/routes/users/accounts.py index ef39cd8..c4dc991 100644 --- a/app/routes/users/accounts.py +++ b/app/routes/users/accounts.py @@ -121,6 +121,12 @@ def edit_user(user_id): flash(_('This Discord account is already linked to another account.'), 'danger') return _rerender() + try: + update_user_gamertags(user, selected_games) + except ValidationError as err: + flash_validation_errors(err) + return _rerender() + role_changed = user.role != role previous_role = user.role @@ -183,8 +189,6 @@ def edit_user(user_id): user.discord_user_id = discord_user_id or None user.league_os_profile = league_os_profile or None - update_user_gamertags(user, selected_games) - # Blank means "keep the current password"; anything else has already # been checked against the policy by the schema. password = validated.get('password') diff --git a/app/routes/users/notes.py b/app/routes/users/notes.py index be4a295..c69df65 100644 --- a/app/routes/users/notes.py +++ b/app/routes/users/notes.py @@ -7,23 +7,32 @@ it reads exactly what the coach routes write. from flask import flash, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required +from marshmallow import ValidationError from app.extensions import db +from app.forms import flash_validation_errors, form_payload from app.models import ( Coach, Match, MatchParticipant, OneOnOneRequest, - OrgTeam, PersonalNote, Player, + Team, + TeamMember, TeamNote, Tryout, TryoutRegistration, User, ) -from app.permissions import coach_can_access_player, coach_org_teams, coach_player_ids +from app.permissions import ( + coach_can_access_player, + coach_org_teams, + coach_player_ids, + coach_tryouts, +) from app.routes.users.blueprint import users_bp +from app.validators import NoteContentSchema, PersonalNoteSchema @users_bp.route('/my-notes') @@ -116,23 +125,25 @@ def notes_dashboard(): ) # For context selectors in the form + # PersonalNote.team_id references a tryout-local Team, not OrgTeam. The + # previous selector mixed the two namespaces and could either attach the + # note to an unrelated team with the same integer id or fail its FK. + # Every context list now comes from the tryouts this coach may manage. + tryouts = list(reversed(coach_tryouts(current_user)))[:20] + tryout_ids = [tryout.id for tryout in tryouts] matches = ( - Match.query.filter( - db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'), - ) + Match.query.filter(Match.tryout_id.in_(tryout_ids)) .order_by(Match.date.desc()) .limit(20) .all() + if tryout_ids + else [] ) - tryouts = ( - Tryout.query.filter_by( - created_by=current_user.id, - ) - .order_by(Tryout.date.desc()) - .limit(20) - .all() + teams = ( + Team.query.filter(Team.tryout_id.in_(tryout_ids)).order_by(Team.name).all() + if tryout_ids + else [] ) - teams = OrgTeam.query.order_by(OrgTeam.name).all() return render_template( 'pages/notes.html', @@ -168,16 +179,20 @@ def manage_team_notes(): return redirect(url_for('users.notes_dashboard')) org_team = org_teams[0] - content = request.form.get('content', '').strip() - if content: - note = TeamNote( - org_team_id=org_team.id, - coach_id=current_user.id, - content=content, - ) - db.session.add(note) - db.session.commit() - flash(_('Team notes saved successfully!'), 'success') + try: + data = NoteContentSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) + return redirect(url_for('users.notes_dashboard')) + + note = TeamNote( + org_team_id=org_team.id, + coach_id=current_user.id, + content=data['content'], + ) + db.session.add(note) + db.session.commit() + flash(_('Team notes saved successfully!'), 'success') return redirect(url_for('users.notes_dashboard')) @@ -195,12 +210,12 @@ def manage_personal_notes(): flash(_('Only coaches can manage personal notes.'), 'danger') return redirect(url_for('main.dashboard')) - player_id = request.form.get('player_id', type=int) - content = request.form.get('content', '').strip() - - if not player_id or not content: - flash(_('Player and content are required.'), 'danger') + try: + data = PersonalNoteSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) return redirect(url_for('users.notes_dashboard')) + player_id = data['player_id'] player = User.query.get_or_404(player_id) if not isinstance(player, Player): @@ -214,7 +229,7 @@ def manage_personal_notes(): note = PersonalNote( player_id=player_id, coach_id=current_user.id, - content=content, + content=data['content'], ) db.session.add(note) db.session.commit() @@ -235,15 +250,12 @@ def add_personal_note(): flash(_('Only coaches can add personal notes.'), 'danger') return redirect(url_for('main.dashboard')) - player_id = request.form.get('player_id', type=int) - content = request.form.get('content', '').strip() - match_id = request.form.get('match_id', type=int) - tryout_id = request.form.get('tryout_id', type=int) - team_id_str = request.form.get('team_id') - - if not player_id or not content: - flash(_('Player and content are required.'), 'danger') + try: + data = PersonalNoteSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) return redirect(url_for('users.notes_dashboard')) + player_id = data['player_id'] player = User.query.get_or_404(player_id) if not isinstance(player, Player): @@ -254,13 +266,40 @@ def add_personal_note(): flash(_('You can only write notes about players you work with.'), 'danger') return redirect(url_for('users.notes_dashboard')) + if data['match_id']: + match = Match.query.get_or_404(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')) + if not MatchParticipant.query.filter_by(match_id=match.id, player_id=player_id).first(): + flash(_('That player did not participate in the selected match.'), 'danger') + return redirect(url_for('users.notes_dashboard')) + + if data['tryout_id']: + tryout = Tryout.query.get_or_404(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')) + if not TryoutRegistration.query.filter_by(tryout_id=tryout.id, player_id=player_id).first(): + flash(_('That player is not registered for the selected tryout.'), 'danger') + return redirect(url_for('users.notes_dashboard')) + + if data['team_id']: + team = Team.query.get_or_404(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')) + if not TeamMember.query.filter_by(team_id=team.id, player_id=player_id).first(): + flash(_('That player is not on the selected team.'), 'danger') + return redirect(url_for('users.notes_dashboard')) + note = PersonalNote( player_id=player_id, coach_id=current_user.id, - content=content, - match_id=match_id if match_id else None, - tryout_id=tryout_id if tryout_id else None, - team_id=int(team_id_str) if team_id_str and team_id_str.isdigit() else None, + content=data['content'], + match_id=data['match_id'], + tryout_id=data['tryout_id'], + team_id=data['team_id'], ) db.session.add(note) db.session.commit() @@ -282,6 +321,9 @@ def add_note_from_tryout(tryout_id): return redirect(url_for('main.dashboard')) tryout = Tryout.query.get_or_404(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')) preselected_player_id = request.args.get('player_id', type=int) # Get registrations as players for the select list @@ -289,21 +331,29 @@ def add_note_from_tryout(tryout_id): players = [r.player for r in registrations if r.player] if request.method == 'POST': - player_id = request.form.get('player_id', type=int) - content = request.form.get('content', '').strip() + try: + data = PersonalNoteSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) + return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id)) + player_id = data['player_id'] - if not player_id or not content: - flash(_('Player and content are required.'), 'danger') + if data['tryout_id'] not in (None, tryout_id): + flash(_('Invalid tryout context.'), 'danger') return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id)) if not coach_can_access_player(current_user, player_id): flash(_('You can only write notes about players you work with.'), 'danger') return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id)) + if not TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first(): + flash(_('That player is not registered for this tryout.'), 'danger') + return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id)) + note = PersonalNote( player_id=player_id, coach_id=current_user.id, - content=content, + content=data['content'], tryout_id=tryout_id, ) db.session.add(note) @@ -335,6 +385,9 @@ def add_note_from_match(match_id): return redirect(url_for('main.dashboard')) match_obj = Match.query.get_or_404(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')) # Get participants as players for the select list participants = MatchParticipant.query.filter_by(match_id=match_id).all() @@ -343,21 +396,29 @@ def add_note_from_match(match_id): preselected_player_id = request.args.get('player_id', type=int) if request.method == 'POST': - player_id = request.form.get('player_id', type=int) - content = request.form.get('content', '').strip() + try: + data = PersonalNoteSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) + return redirect(url_for('users.add_note_from_match', match_id=match_id)) + player_id = data['player_id'] - if not player_id or not content: - flash(_('Player and content are required.'), 'danger') + if data['match_id'] not in (None, match_id): + flash(_('Invalid match context.'), 'danger') return redirect(url_for('users.add_note_from_match', match_id=match_id)) if not coach_can_access_player(current_user, player_id): flash(_('You can only write notes about players you work with.'), 'danger') return redirect(url_for('users.add_note_from_match', match_id=match_id)) + if not MatchParticipant.query.filter_by(match_id=match_id, player_id=player_id).first(): + flash(_('That player did not participate in this match.'), 'danger') + return redirect(url_for('users.add_note_from_match', match_id=match_id)) + note = PersonalNote( player_id=player_id, coach_id=current_user.id, - content=content, + content=data['content'], match_id=match_id, ) db.session.add(note) diff --git a/app/routes/users/one_on_one.py b/app/routes/users/one_on_one.py index 6fe50b7..bccba76 100644 --- a/app/routes/users/one_on_one.py +++ b/app/routes/users/one_on_one.py @@ -12,7 +12,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.validators import OneOnOneRequestSchema +from app.validators import OneOnOneRejectionSchema, OneOnOneRequestSchema @users_bp.route('/one-on-one', methods=['GET', 'POST']) @@ -226,7 +226,12 @@ def reject_one_on_one(request_id): flash(_('This request has already been processed.'), 'info') return redirect(url_for('users.notes_dashboard')) - rejection_reason = request.form.get('rejection_reason', '').strip() + try: + data = OneOnOneRejectionSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) + return redirect(url_for('users.notes_dashboard')) + rejection_reason = data['rejection_reason'] player = request_obj.player request_obj.status = 'rejected' diff --git a/app/routes/users/profile.py b/app/routes/users/profile.py index 39cfeda..ab56e07 100644 --- a/app/routes/users/profile.py +++ b/app/routes/users/profile.py @@ -98,6 +98,18 @@ def edit_profile(): user_gamertags=current_user.get_gamertags(), ) + try: + update_user_gamertags(current_user, selected_games) + except ValidationError as err: + flash_validation_errors(err) + return render_template( + 'pages/edit_profile.html', + user=current_user, + esport_games=ESPORT_GAMES, + game_platforms=GAME_PLATFORMS, + user_gamertags=current_user.get_gamertags(), + ) + current_user.username = username current_user.full_name = full_name current_user.email = email @@ -106,8 +118,6 @@ def edit_profile(): current_user.discord_username = discord_username or None current_user.league_os_profile = league_os_profile or None - update_user_gamertags(current_user, selected_games) - # Blank means "keep the current password"; anything else has already # been checked against the policy by the schema. password = validated.get('password') diff --git a/app/supporting_scripts/backup.py b/app/supporting_scripts/backup.py index 0b6aa75..2432031 100644 --- a/app/supporting_scripts/backup.py +++ b/app/supporting_scripts/backup.py @@ -55,8 +55,8 @@ PG_RESTORE = os.getenv('PG_RESTORE', 'pg_restore') # wave G introduced DOCUMENTS_ROOT so a release-directory deployment could # keep uploads outside the releases, and docs/deployment.md now tells the # operator to set it — at which point this script archived a directory the -# application had never written to. It does not fail on a missing directory -# either; it prints "No documents directory found", skips, and exits 0. +# application had never written to. A missing or unreadable document store +# is now a failed full-backup run rather than a database-only green result. # # So the more correctly an operator followed the deployment documentation, # the more certainly their contract backups were empty (OBS-006). @@ -262,33 +262,31 @@ def backup_documents(): module happened to be imported with. Returns: - str: Path to the created archive, or None if there is nothing to - archive. Signed contracts live only on disk, so losing this - directory loses the documents themselves. + str: Path to the created archive. + + Raises: + BackupError: If the configured store is absent or cannot be archived. + Signed contracts live only on disk, so a database-only run must + never be reported as a complete backup. """ documents_dir = documents_root() if not os.path.exists(documents_dir): - # Says where it looked. The previous message named no path, so an - # operator who had moved the documents read it as "there are no - # documents" rather than "I am looking in the wrong place". - print(f'[INFO] No documents directory at {documents_dir}. Skipping document backup.') - return None + raise BackupError(f'Documents directory does not exist: {documents_dir}') + if not os.path.isdir(documents_dir): + raise BackupError(f'Documents path is not a directory: {documents_dir}') timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}') try: shutil.make_archive(archive_basename, 'zip', documents_dir) - except Exception as exc: # noqa: BLE001 — a failed document archive must not lose the dump - # This runs after the database dump has already succeeded. Letting - # anything through here would abort the script with a traceback and - # take the one part that worked down with it. Reported to stdout, in - # the format the rest of this script uses; it has no logger. - print(f'[ERROR] Document backup failed: {exc}') - return None + except Exception as exc: # noqa: BLE001 — normalize the shutil boundary + raise BackupError(f'Document backup failed: {exc}') from exc zip_path = f'{archive_basename}.zip' + if not os.path.exists(zip_path) or os.path.getsize(zip_path) == 0: + raise BackupError('Document archiver reported success but produced an empty file.') size_mb = os.path.getsize(zip_path) / (1024 * 1024) print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)') return zip_path @@ -361,15 +359,20 @@ def main(argv=None): return 1 verified = verify_backup(backup_path) - backup_documents() + documents_ok = True + try: + backup_documents() + except BackupError as exc: + documents_ok = False + print(f'[ERROR] {exc}') cleanup_old_backups() print() - if verified: + if verified and documents_ok: print('=== Backup completed successfully ===') return 0 - print('=== Backup FAILED verification — do not rely on this archive ===') + print('=== Backup INCOMPLETE — do not treat this run as a full recovery point ===') return 1 diff --git a/app/templates/pages/coach_availability.html b/app/templates/pages/coach_availability.html index 79d284e..8971f3f 100644 --- a/app/templates/pages/coach_availability.html +++ b/app/templates/pages/coach_availability.html @@ -216,7 +216,14 @@ function flash(message, type) { const flashContainer = document.querySelector('.flash-messages'); const alert = document.createElement('div'); alert.className = 'alert alert-' + type + ' alert-dismissible'; - alert.innerHTML = '' + message + ''; + 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 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 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