fix(audit): fermer les frontieres restantes

This commit is contained in:
GGThed
2026-08-17 14:34:06 -04:00
parent f84cb4e3b6
commit 105a72700f
28 changed files with 1511 additions and 617 deletions
+45
View File
@@ -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
+1 -1
View File
@@ -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
+30
View File
@@ -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
+16 -10
View File
@@ -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
+21 -8
View File
@@ -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()]
+22 -14
View File
@@ -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'))
+64 -24
View File
@@ -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')
+15 -11
View File
@@ -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)
+6 -2
View File
@@ -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')
+112 -51
View File
@@ -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)
+7 -2
View File
@@ -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'
+12 -2
View File
@@ -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')
+23 -20
View File
@@ -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
+8 -1
View File
@@ -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 = '<span>' + message + '</span><button type="button" class="alert-close" data-action="remove-element">&times;</button>';
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);
}
+14 -7
View File
@@ -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 = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'};
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 = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn">↺</span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -568,7 +575,7 @@ document.addEventListener('DOMContentLoaded', function() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn">↺</span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -920,7 +927,7 @@ function updatePlayerPool() {
var availabilityClass = isAvailable ? 'available' : 'unavailable';
html += '<div class="player-item ' + availabilityClass + '" data-player-id="' + pid + '">';
html += '<span class="player-name">' + playerName + '</span>';
html += '<span class="player-name">' + escapeHtml(playerName) + '</span>';
html += '<div class="player-actions">';
html += '<button type="button" class="btn btn-sm btn-primary" data-action="assign-team" data-team-side="1">{{ _('T1') }}</button>';
html += '<button type="button" class="btn btn-sm btn-secondary" data-action="assign-team" data-team-side="2">{{ _('T2') }}</button>';
@@ -943,7 +950,7 @@ function assignToTeam(playerId, teamSide) {
if (!playerName) return;
var html = '<div class="player-item" data-player-id="' + playerId + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn">↺</span>';
html += '</div>';
@@ -1062,7 +1069,7 @@ function randomizeTeams() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn">↺</span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -1074,7 +1081,7 @@ function randomizeTeams() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn">↺</span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
Binary file not shown.
+305 -224
View File
@@ -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 <EMAIL@ADDRESS>\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"
Binary file not shown.
+305 -224
View File
@@ -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 <EMAIL@ADDRESS>\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 dutilisateur 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 dutilisateur doit compter de 3 à 80 caractères."
@@ -71,8 +71,8 @@ msgstr "Le nom dutilisateur doit compter de 3 à 80 caractères."
msgid "Email must be 120 characters or less."
msgstr "Ladresse 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 "Lheure 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 "Lheure 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 dheure invalide."
#: app/validators.py:681
msgid "Start time is required. Please select a time slot."
msgstr "Lheure 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 dinscription 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 "Lheure 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 "Lheure 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 dheure invalide."
#: app/validators.py:820
msgid "Start time is required. Please select a time slot."
msgstr "Lheure 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 dutilisateur et le mot de passe, "
"ou demandez de laide à 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 dutilisateur 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 nest 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 ""
"Lautorisation Discord na 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 "Lautorisation 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 dobtenir le jeton daccè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 nest 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 navez 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 navez 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 navez 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 navez 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 nest possible dajouter 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 navez 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 navez 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 navez 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 sinscrire à une sélection."
#: app/routes/tryouts.py:415
#: app/routes/tryouts.py:442
msgid "This tryout is not accepting registrations."
msgstr "Cette sélection naccepte pas dinscriptions."
#: 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 dinscription 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 nest 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 navez 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 nest 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 ""
"Cest 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 "Lutilisateur %(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 navez 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 na 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 nest 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 navez 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 navez 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 na 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 dutilisateur 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 à "
"lheure 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"
+140 -1
View File
@@ -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).
+1 -1
View File
@@ -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 |
+6 -6
View File
@@ -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
- Keep Python and system packages updated
+6
View File
@@ -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"
+12
View File
@@ -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
+17
View File
@@ -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 = '<span>' + 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)
+6 -8
View File
@@ -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:
+281
View File
@@ -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'<option value="{team_id}">Tryout Alpha</option>' in body
assert f'>Org {coach_id}-{player_id}</option>' 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': '[email protected]',
'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': '[email protected]',
'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
+36
View File
@@ -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