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 8fc3943..278780b 100644 Binary files a/app/translations/en/LC_MESSAGES/messages.mo and b/app/translations/en/LC_MESSAGES/messages.mo differ diff --git a/app/translations/en/LC_MESSAGES/messages.po b/app/translations/en/LC_MESSAGES/messages.po 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 bc11174..156d19d 100644 Binary files a/app/translations/fr/LC_MESSAGES/messages.mo and b/app/translations/fr/LC_MESSAGES/messages.mo differ diff --git a/app/translations/fr/LC_MESSAGES/messages.po b/app/translations/fr/LC_MESSAGES/messages.po 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