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