diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..46005ca --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,45 @@ +name: CI - Security, Lint & Tests + +on: + push: + pull_request: + workflow_dispatch: + +# This workflow validates branches only. It has no deployment step and no +# write permission, so an audit-branch push cannot alter main or production. +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + cache: pip + + - name: Install dependencies + run: pip install -r requirements.txt -r requirements-dev.txt + + - name: Audit declared dependencies + run: pip-audit -r requirements.txt + + - name: Lint and check formatting + run: | + ruff check . + ruff format --check . + + - name: Run tests with coverage gate + run: pytest --cov=app --cov-report=term-missing --cov-report=xml + + - name: Run repository security checks + env: + SECRET_KEY: audit-ci-key-not-for-production-1234567890 + DATABASE_URL: 'sqlite:///:memory:' + FLASK_DEBUG: 'false' + run: python app/supporting_scripts/security_scan.py --skip-http diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95d343e..a865b6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI - Security & Lint on: push: - branches: [main, master] + branches: [main, master, 'audit/**'] pull_request: branches: [main, master] workflow_dispatch: # Allow manual triggers @@ -94,6 +94,7 @@ jobs: - name: Run security scan env: SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }} + DATABASE_URL: 'sqlite:///:memory:' FLASK_DEBUG: 'false' run: python app/supporting_scripts/security_scan.py --skip-http diff --git a/README.md b/README.md index 6fff8c0..01036bd 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,11 @@ Ce qui **n'est pas** fait, pour que personne ne s'y fie : - **Les comptes créés par le formulaire d'inscription sont actifs immédiatement** : il n'y a pas d'étape de validation par le staff. Décision de produit en attente, voir `docs/roles-and-permissions.md`. -- **L'identité Discord** transite encore par un champ caché du formulaire - d'inscription : elle n'est pas prouvée par le passage OAuth2. +- **L'unicité Discord n'est pas encore garantie par PostgreSQL.** L'identité + OAuth reste désormais côté serveur, le profil ne peut plus réécrire le + snowflake et l'application refuse les nouvelles collisions. Les doublons + historiques doivent être relevés puis corrigés avant la contrainte + `UNIQUE` (`schema_report.py --check-discord-identities`). `docs/security-checklist.md` détaille la liste avant mise en production. diff --git a/app/discord_bot.py b/app/discord_bot.py index f901281..8ba7562 100644 --- a/app/discord_bot.py +++ b/app/discord_bot.py @@ -65,6 +65,8 @@ from discord.ext import commands from dotenv import load_dotenv from sqlalchemy.exc import SQLAlchemyError +from app.time_utils import utc_now_naive + load_dotenv() DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN') @@ -683,9 +685,10 @@ class TeamTryoutsBot(commands.Bot): reference_id: ID of the MatchParticipant or TryoutRegistration record. """ # Look up the DB user to get their Discord user ID + from app.extensions import db from app.models import User as DBUser - db_user = DBUser.query.get(user_id) + db_user = db.session.get(DBUser, user_id) if not db_user: logger.warning(f"DB user {user_id} not found for schedule notification") return None @@ -754,7 +757,7 @@ class TeamTryoutsBot(commands.Bot): from app.models import OneOnOneRequest try: - request = OneOnOneRequest.query.get(request_id) + request = db.session.get(OneOnOneRequest, request_id) if not request: # The row is gone; no reaction on this message can ever mean # anything again. Keeping the mapping is what PENDING_MAX_AGE_DAYS @@ -779,7 +782,7 @@ class TeamTryoutsBot(commands.Bot): coach_obj = request.coach request.status = 'approved' - request.responded_at = datetime.utcnow() + request.responded_at = utc_now_naive() except SQLAlchemyError: db.session.rollback() logger.exception('Could not read One on One request %s to approve it', request_id) @@ -825,7 +828,7 @@ class TeamTryoutsBot(commands.Bot): from app.models import OneOnOneRequest try: - request = OneOnOneRequest.query.get(request_id) + request = db.session.get(OneOnOneRequest, request_id) if not request: logger.info( 'One on One request %s no longer exists; its pending message was dropped.', @@ -874,7 +877,7 @@ class TeamTryoutsBot(commands.Bot): try: request.status = 'rejected' - request.responded_at = datetime.utcnow() + request.responded_at = utc_now_naive() if refusal_note: request.coach_rejection_message = refusal_note except SQLAlchemyError: @@ -918,12 +921,13 @@ class TeamTryoutsBot(commands.Bot): Returns: tuple: (row, player_id) — either may be None. """ + from app.extensions import db from app.models import MatchParticipant, TryoutRegistration if event_type == 'match': - row = MatchParticipant.query.get(reference_id) + row = db.session.get(MatchParticipant, reference_id) elif event_type == 'tryout': - row = TryoutRegistration.query.get(reference_id) + row = db.session.get(TryoutRegistration, reference_id) else: row = None return row, getattr(row, 'player_id', None) @@ -937,11 +941,12 @@ class TeamTryoutsBot(commands.Bot): attendance handlers did not (OPS-009) — same message shape, same threat, one of them checked. The asymmetry was the bug. """ + from app.extensions import db from app.models import User if not player_id: return False - owner = User.query.get(player_id) + owner = db.session.get(User, player_id) return bool(owner and owner.discord_user_id == str(reacting_user.id)) async def handle_attendance_confirm(self, player, message_id, reference_id, channel): diff --git a/app/forms.py b/app/forms.py index 4a435e8..16c54a5 100644 --- a/app/forms.py +++ b/app/forms.py @@ -61,3 +61,33 @@ def form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('pass if not payload.get(name): payload.pop(name, None) return payload + + +def form_gamertags(selected_games): + """Validate the dynamic gamertag fields for the selected games. + + These fields cannot be declared statically on the account schemas: their + names contain the game label. They are still untrusted form data, so + every caller uses this shared boundary before adding or changing rows. + """ + from marshmallow import ValidationError + + from app.models import GAME_PLATFORMS + from app.validators import GamertagSchema + + validated = {} + for game in selected_games: + raw_gamertag = request.form.get(f'gamertag_{game}', '') + raw_platform = ( + request.form.get(f'platform_{game}', '') if GAME_PLATFORMS.get(game) else None + ) + if not raw_gamertag.strip(): + continue + try: + validated[game] = GamertagSchema().load( + {'game': game, 'gamertag': raw_gamertag, 'platform': raw_platform} + ) + except ValidationError as err: + messages = [message for values in err.messages.values() for message in values] + raise ValidationError({f'gamertag_{game}': messages}) from err + return validated diff --git a/app/models/availability/base.py b/app/models/availability/base.py index 4aee7ce..dc1cff4 100644 --- a/app/models/availability/base.py +++ b/app/models/availability/base.py @@ -1,8 +1,7 @@ """Abstract base class for availability models (PlayerDisponibility + CoachAvailability).""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class BaseAvailability(db.Model): @@ -13,5 +12,5 @@ class BaseAvailability(db.Model): day_of_week = db.Column(db.Integer, nullable=False) start_time = db.Column(db.Time, nullable=False) end_time = db.Column(db.Time, nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) + updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive) diff --git a/app/models/contract.py b/app/models/contract.py index 889a9ac..da90e61 100644 --- a/app/models/contract.py +++ b/app/models/contract.py @@ -1,8 +1,7 @@ """Contract documents for players to sign.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class Contract(db.Model): @@ -23,7 +22,7 @@ class Contract(db.Model): status = db.Column(db.String(20), default='pending') notes = db.Column(db.Text, nullable=True) - uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) + uploaded_at = db.Column(db.DateTime, default=utc_now_naive) signed_at = db.Column(db.DateTime, nullable=True) player = db.relationship('User', foreign_keys=[player_id], backref='contracts') @@ -57,7 +56,7 @@ class Contract(db.Model): if isinstance(user, Admin): return True if isinstance(user, Manager): - player = User.query.get(self.player_id) + player = db.session.get(User, self.player_id) if player and player.get_org_teams(): return True if isinstance(user, Coach): diff --git a/app/models/evaluation.py b/app/models/evaluation.py index 4feb240..9ef8f0e 100644 --- a/app/models/evaluation.py +++ b/app/models/evaluation.py @@ -1,8 +1,7 @@ """Player evaluation record.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class Evaluation(db.Model): @@ -25,8 +24,8 @@ class Evaluation(db.Model): overall_score = db.Column(db.Float, nullable=True) comments = db.Column(db.Text, nullable=True) position_recommendation = db.Column(db.String(50), nullable=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) + updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive) __table_args__ = ( db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'), diff --git a/app/models/match_model/base.py b/app/models/match_model/base.py index f95232c..4716064 100644 --- a/app/models/match_model/base.py +++ b/app/models/match_model/base.py @@ -1,8 +1,7 @@ """Abstract base class for match models (Match + TeamMatch).""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class BaseMatch(db.Model): @@ -18,4 +17,4 @@ class BaseMatch(db.Model): location = db.Column(db.String(200), nullable=True) status = db.Column(db.String(20), default='scheduled') created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) diff --git a/app/models/one_on_one_request.py b/app/models/one_on_one_request.py index 1b1e94f..13f8c00 100644 --- a/app/models/one_on_one_request.py +++ b/app/models/one_on_one_request.py @@ -1,8 +1,7 @@ """Request from player to coach for a One on One session.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class OneOnOneRequest(db.Model): @@ -18,7 +17,7 @@ class OneOnOneRequest(db.Model): end_time = db.Column(db.Time, nullable=False) points = db.Column(db.Text, nullable=True) status = db.Column(db.String(20), default='pending') - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) responded_at = db.Column(db.DateTime, nullable=True) discord_message_id = db.Column(db.BigInteger, nullable=True) coach_rejection_message = db.Column(db.Text, nullable=True) diff --git a/app/models/org_team/org_team.py b/app/models/org_team/org_team.py index ae0a0b1..efb601e 100644 --- a/app/models/org_team/org_team.py +++ b/app/models/org_team/org_team.py @@ -1,9 +1,8 @@ """Persistent organisation team (e.g. Varsity, JV).""" -from datetime import datetime - from app.extensions import db from app.models._associations import org_team_coaches, org_team_managers +from app.time_utils import utc_now_naive class OrgTeam(db.Model): @@ -13,7 +12,7 @@ class OrgTeam(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False, unique=True) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) diff --git a/app/models/org_team/team_player.py b/app/models/org_team/team_player.py index d07865c..04c3242 100644 --- a/app/models/org_team/team_player.py +++ b/app/models/org_team/team_player.py @@ -1,8 +1,7 @@ """Many-to-many junction: player to org-team.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class TeamPlayer(db.Model): @@ -14,7 +13,7 @@ class TeamPlayer(db.Model): org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) status = db.Column(db.String(20), nullable=False, default='starter') position = db.Column(db.String(50), nullable=True) - added_at = db.Column(db.DateTime, default=datetime.utcnow) + added_at = db.Column(db.DateTime, default=utc_now_naive) player = db.relationship('User', foreign_keys=[player_id], backref='team_placements') org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players') diff --git a/app/models/participant/base.py b/app/models/participant/base.py index 7293699..2b4f1f2 100644 --- a/app/models/participant/base.py +++ b/app/models/participant/base.py @@ -1,8 +1,7 @@ """Abstract base class for match participant models.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class BaseParticipant(db.Model): @@ -11,4 +10,4 @@ class BaseParticipant(db.Model): __abstract__ = True player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - added_at = db.Column(db.DateTime, default=datetime.utcnow) + added_at = db.Column(db.DateTime, default=utc_now_naive) diff --git a/app/models/personal_note.py b/app/models/personal_note.py index 4745202..54491ca 100644 --- a/app/models/personal_note.py +++ b/app/models/personal_note.py @@ -1,8 +1,7 @@ """Personal notes from coach to individual player.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class PersonalNote(db.Model): @@ -13,8 +12,8 @@ class PersonalNote(db.Model): player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) content = db.Column(db.Text, nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) + updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive) match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True) team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) diff --git a/app/models/team/team.py b/app/models/team/team.py index 4611fe1..47e1b1d 100644 --- a/app/models/team/team.py +++ b/app/models/team/team.py @@ -1,8 +1,7 @@ """Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class Team(db.Model): @@ -13,7 +12,7 @@ class Team(db.Model): tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) name = db.Column(db.String(100), nullable=False) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) creator = db.relationship('User', backref='created_teams') members = db.relationship('TeamMember', backref='team', lazy='dynamic') diff --git a/app/models/team/team_member.py b/app/models/team/team_member.py index 391048f..0c1a1be 100644 --- a/app/models/team/team_member.py +++ b/app/models/team/team_member.py @@ -1,8 +1,7 @@ """Link between a player and a tryout-specific team.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class TeamMember(db.Model): @@ -13,6 +12,6 @@ class TeamMember(db.Model): team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) position = db.Column(db.String(50), nullable=True) - added_at = db.Column(db.DateTime, default=datetime.utcnow) + added_at = db.Column(db.DateTime, default=utc_now_naive) player = db.relationship('User', overlaps="player_ref,team_assignments") diff --git a/app/models/team_note.py b/app/models/team_note.py index 76c8c21..5f66f31 100644 --- a/app/models/team_note.py +++ b/app/models/team_note.py @@ -1,8 +1,7 @@ """Team improvement notes from coach.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class TeamNote(db.Model): @@ -13,8 +12,8 @@ class TeamNote(db.Model): org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) content = db.Column(db.Text, nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) + updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive) team = db.relationship('OrgTeam', backref='team_notes') coach = db.relationship('User', foreign_keys=[coach_id]) diff --git a/app/models/tryout/tryout.py b/app/models/tryout/tryout.py index e9f4a74..6a6cbf2 100644 --- a/app/models/tryout/tryout.py +++ b/app/models/tryout/tryout.py @@ -1,9 +1,8 @@ """Tryout event for player evaluations and team formation.""" -from datetime import datetime - from app.extensions import db from app.models._associations import tryout_coaches +from app.time_utils import utc_now_naive class Tryout(db.Model): @@ -25,7 +24,7 @@ class Tryout(db.Model): coach_id = db.Column( db.Integer, db.ForeignKey('users.id'), nullable=True ) # deprecated, kept for migration - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts') manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') diff --git a/app/models/tryout/tryout_registration.py b/app/models/tryout/tryout_registration.py index 007e77b..c8b3942 100644 --- a/app/models/tryout/tryout_registration.py +++ b/app/models/tryout/tryout_registration.py @@ -1,8 +1,7 @@ """Registration linking a player to a tryout.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class TryoutRegistration(db.Model): @@ -12,6 +11,6 @@ class TryoutRegistration(db.Model): id = db.Column(db.Integer, primary_key=True) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - registered_at = db.Column(db.DateTime, default=datetime.utcnow) + registered_at = db.Column(db.DateTime, default=utc_now_naive) status = db.Column(db.String(20), default='registered') notes = db.Column(db.Text, nullable=True) diff --git a/app/models/user_model/user.py b/app/models/user_model/user.py index 231a726..bcdff98 100644 --- a/app/models/user_model/user.py +++ b/app/models/user_model/user.py @@ -1,10 +1,9 @@ """Base User model — shared fields and polymorphic configuration.""" -from datetime import datetime - from flask_login import UserMixin from app.extensions import db +from app.time_utils import utc_now_naive class User(UserMixin, db.Model): @@ -25,7 +24,7 @@ class User(UserMixin, db.Model): email = db.Column(db.String(120), unique=True, nullable=False) phone = db.Column(db.String(20), nullable=True) is_active_account = db.Column(db.Boolean, default=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) failed_login_attempts = db.Column(db.Integer, default=0) locked_until = db.Column(db.DateTime, nullable=True) diff --git a/app/routes/auth.py b/app/routes/auth.py index 0bff41d..b0666af 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -8,7 +8,7 @@ password policy enforcement and sign-up screening. import os import secrets import time -from datetime import datetime, timedelta +from datetime import timedelta from urllib.parse import urlencode, urlparse import requests @@ -18,13 +18,19 @@ 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 -from app.validators import LoginSchema, RegisterSchema +from app.time_utils import utc_now_naive +from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id #: Session key holding the pending OAuth2 anti-forgery token. DISCORD_STATE_KEY = 'discord_oauth_state' +#: Whether the OAuth result should create a registration draft or relink the +#: signed-in account. Kept server-side and covered by the same signed session +#: as the anti-forgery state. +DISCORD_PURPOSE_KEY = 'discord_oauth_purpose' # Failed-attempt tracking. The tally is kept for the audit trail and for the # cool-off marker below; it no longer refuses a correct password (SEC-018). @@ -289,7 +295,7 @@ def login(): ) if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS: minutes = cooloff_minutes(user.failed_login_attempts) - user.locked_until = datetime.utcnow() + timedelta(minutes=minutes) + user.locked_until = utc_now_naive() + timedelta(minutes=minutes) log_auth_event( 'account.throttled', username=username, @@ -354,6 +360,15 @@ def register(): form_data = dict(request.form) form_data['games'] = request.form.getlist('games') + # Once Discord has authenticated the identity, neither its display + # name nor its snowflake is input data anymore. Remove any client + # copies before validation as well as before persistence: otherwise a + # forged, malformed hidden value can still make the verified flow fail. + discord_oauth = session.get('discord_oauth') or {} + if discord_oauth.get('id'): + form_data.pop('discord_username', None) + form_data.pop('discord_user_id', None) + refusal = check_registration_challenge(request.form) if refusal is not None: # Logged, because this is the only place abuse of the sign-up @@ -380,8 +395,25 @@ def register(): full_name = validated['full_name'] phone = validated.get('phone') selected_games = validated.get('games', []) - discord_username = validated.get('discord_username') - discord_user_id = validated.get('discord_user_id') + 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). + # A manual registration may still provide a display name, but never a + # Discord snowflake: that identifier is an authentication factor for + # bot reactions and must come from Discord itself. + discord_user_id = discord_oauth.get('id') + if discord_user_id: + discord_user_id = str(discord_user_id) + discord_username = ( + discord_oauth.get('username') if discord_user_id else validated.get('discord_username') + ) league_os_profile = validated.get('league_os_profile') if User.query.filter_by(username=username).first(): @@ -392,6 +424,13 @@ def register(): flash(_('Email already registered.'), 'danger') return _rerender_registration(form_data) + # The database constraint belongs to DB-002, after production has + # been backed up and deduplicated. Refuse new duplicates now instead + # of leaving the critical impersonation path open until then. + if discord_user_id and User.query.filter_by(discord_user_id=discord_user_id).first(): + flash(_('This Discord account is already linked to another account.'), 'danger') + return _rerender_registration(form_data) + hashed_password = hash_password(password) user = Player( username=username, @@ -415,16 +454,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 @@ -451,11 +488,15 @@ def discord_login(): Returns: Response: Redirect to Discord authorization URL. """ + purpose = 'profile' if current_user.is_authenticated else 'registration' + session[DISCORD_PURPOSE_KEY] = purpose + return_endpoint = 'users.edit_profile' if purpose == 'profile' else 'auth.register' + # DISCORD_REDIRECT_URI is checked too: quoting it when unset used to # raise inside the query builder rather than report a configuration error. if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI: flash(_('Discord OAuth2 is not configured.'), 'danger') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) # Anti-forgery token, required by RFC 6749 §10.12. Without it, an # attacker could have the victim's browser consume an authorization code @@ -479,16 +520,24 @@ def discord_login(): def discord_callback(): """Handle the OAuth2 callback from Discord. - Exchanges the authorization code for an access token, then fetches - the user's profile (/users/@me) and connections (/users/@me/connections). - Results are stored in the session and the user is redirected back to - the registration form where fields will be pre-filled. + Exchanges the authorization code for an access token, then fetches the + user's profile. During registration, connected game accounts are also + loaded into server-side draft state. For a signed-in profile relink, the + verified identity is written directly without passing through a form. Returns: - Response: Redirect to registration page. + Response: Redirect to the registration form or profile editor. """ # The state is consumed whatever happens next: a token is single-use, and # leaving it in the session would allow a replay. + purpose = session.pop(DISCORD_PURPOSE_KEY, 'registration') + if purpose == 'profile' and current_user.is_authenticated: + return_endpoint = 'users.edit_profile' + elif purpose == 'profile': + return_endpoint = 'auth.login' + else: + return_endpoint = 'auth.register' + expected_state = session.pop(DISCORD_STATE_KEY, None) received_state = request.args.get('state', '') @@ -500,12 +549,12 @@ def discord_callback(): ), 'danger', ) - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) code = request.args.get('code') if not code: flash(_('Discord authorization failed. No code received.'), 'danger') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) # Exchange the authorization code for an access token token_data = { @@ -529,11 +578,11 @@ def discord_callback(): access_token = token_json.get('access_token') except requests.RequestException: flash(_('Failed to connect to Discord. Please try again.'), 'danger') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) if not access_token: flash(_('Failed to obtain Discord access token.'), 'danger') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) auth_headers = {'Authorization': f'Bearer {access_token}'} @@ -548,7 +597,43 @@ def discord_callback(): user_data = user_response.json() except requests.RequestException: flash(_('Failed to fetch Discord user profile.'), 'danger') - return redirect(url_for('auth.register')) + return redirect(url_for(return_endpoint)) + + discord_user_id = user_data.get('id') + try: + if not discord_user_id: + raise ValidationError('missing Discord user id') + discord_user_id = str(discord_user_id) + validate_discord_user_id(discord_user_id) + except ValidationError: + flash(_('Failed to fetch Discord user profile.'), 'danger') + return redirect(url_for(return_endpoint)) + + if purpose == 'profile': + # If the session expired while Discord was open, do not turn a profile + # relink into registration state for an anonymous browser. + if not current_user.is_authenticated: + flash(_('Please log in to connect your Discord account.'), 'danger') + return redirect(url_for('auth.login')) + + clash = User.query.filter( + User.discord_user_id == discord_user_id, + User.id != current_user.id, + ).first() + if clash: + flash(_('This Discord account is already linked to another account.'), 'danger') + return redirect(url_for('users.edit_profile')) + + current_user.discord_user_id = discord_user_id + current_user.discord_username = user_data.get('username') or None + db.session.commit() + log_auth_event( + 'account.discord_linked', + username=current_user.username, + user_id=current_user.id, + ) + flash(_('Discord account connected!'), 'success') + return redirect(url_for('users.edit_profile')) # Fetch the user's connected gaming accounts connections = [] @@ -587,7 +672,7 @@ def discord_callback(): # Store in session for the registration form to use session['discord_oauth'] = { - 'id': user_data.get('id'), + 'id': discord_user_id, 'username': user_data.get('username'), 'avatar': user_data.get('avatar'), 'gamertag_suggestions': gamertag_suggestions, diff --git a/app/routes/evaluations.py b/app/routes/evaluations.py index 67233c4..6470ed7 100644 --- a/app/routes/evaluations.py +++ b/app/routes/evaluations.py @@ -35,32 +35,12 @@ from app.validators import EvaluationSchema evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations') -def validate_score(score_value): - """Validate that a score is between 1 and 10.""" - if score_value is None: - return None - try: - score = int(score_value) - if 1 <= score <= 10: - return score - return None - except (ValueError, TypeError): - return None - - -def compute_overall(scores): - """Average the non-None scores, or return None if there are none.""" - valid = [s for s in scores if s is not None] - return sum(valid) / len(valid) if valid else None - - -def _apply_evaluation(evaluation, scores, comments, position): - """Write validated scores/comments/position onto an Evaluation instance.""" - for field_name, _ in EVALUATION_CRITERIA: - setattr(evaluation, field_name, scores[field_name]) - evaluation.overall_score = compute_overall(list(scores.values())) - evaluation.comments = comments - evaluation.position_recommendation = position +def _users_by_id(user_ids): + """Load a set of users once for aggregate/list views.""" + wanted = {user_id for user_id in user_ids if user_id} + if not wanted: + return {} + return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()} @evaluations_bp.route('') @@ -121,9 +101,10 @@ def list_evaluations(): .group_by(Evaluation.player_id) .all() ) + players_by_id = _users_by_id(row.player_id for row in avg_scores) player_scores = {} for row in avg_scores: - p = User.query.get(row.player_id) + p = players_by_id.get(row.player_id) if p: player_scores[p.id] = { 'player': p, @@ -162,7 +143,7 @@ def evaluate_player(tryout_id, player_id): flash(_('You do not have permission to evaluate players.'), 'danger') return redirect(url_for('main.dashboard')) - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to evaluate players in this tryout.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) @@ -178,7 +159,7 @@ def evaluate_player(tryout_id, player_id): flash(_('Player is not registered for this tryout.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - player = User.query.get_or_404(player_id) + player = db.get_or_404(User, player_id) if not isinstance(player, Player): flash(_('Can only evaluate players.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @@ -196,8 +177,10 @@ def evaluate_player(tryout_id, player_id): tryout_id=tryout_id, player_id=player_id, ).all() + evaluators_by_id = _users_by_id(e.evaluator_id for e in all_evaluations) evaluators = [ - {'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations + {'evaluator': evaluators_by_id.get(e.evaluator_id), 'eval': e} + for e in all_evaluations ] return render_template( @@ -246,22 +229,28 @@ def players_to_evaluate(tryout_id): flash(_('Permission denied.'), 'danger') return redirect(url_for('main.dashboard')) - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to evaluate players in this tryout.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all() - players = [] - for reg in registrations: - p = User.query.get(reg.player_id) - if p and isinstance(p, Player): - existing = Evaluation.query.filter_by( - tryout_id=tryout_id, - player_id=p.id, - evaluator_id=current_user.id, - ).first() - players.append({'player': p, 'evaluated': existing is not None, 'registration': reg}) + players_by_id = _users_by_id(reg.player_id for reg in registrations) + evaluated_player_ids = { + player_id + for (player_id,) in db.session.query(Evaluation.player_id) + .filter_by(tryout_id=tryout_id, evaluator_id=current_user.id) + .all() + } + players = [ + { + 'player': player, + 'evaluated': player.id in evaluated_player_ids, + 'registration': registration, + } + for registration in registrations + if (player := players_by_id.get(registration.player_id)) and isinstance(player, Player) + ] return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players) diff --git a/app/routes/main.py b/app/routes/main.py index 2691b3d..5696c29 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -223,20 +223,18 @@ def dashboard(): elif isinstance(user, Scout): stats['total_players'] = User.query.filter_by(role='player').count() stats['total_evaluations'] = Evaluation.query.count() - stats['avg_scores'] = ( + top_rows = ( db.session.query( - Evaluation.player_id, + User, func.avg(Evaluation.overall_score).label('avg_score'), ) - .group_by(Evaluation.player_id) - .order_by(func.avg(Evaluation.overall_score).desc()) + .join(Evaluation, Evaluation.player_id == User.id) + .filter(User.role == 'player') + .group_by(User.id) + .order_by(func.avg(Evaluation.overall_score).desc(), User.id) .limit(5) .all() ) - stats['top_players'] = [] - for row in stats['avg_scores']: - p = User.query.get(row.player_id) - if p: - stats['top_players'].append((p, round(row.avg_score, 1))) + stats['top_players'] = [(player, round(avg_score, 1)) for player, avg_score in top_rows] return render_template('pages/dashboard.html', user=user, stats=stats) diff --git a/app/routes/matches.py b/app/routes/matches.py index 0e9998d..6f6f4ed 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -46,6 +46,25 @@ def match_form_payload(): return form_payload(list_fields=('player_ids',), optional_blank=()) +def registered_players(tryout_id): + """Players registered for one tryout, loaded in a single query. + + The create form previously called ``User.query.get`` twice per + registration (once in the filter and once in the result expression), + and the edit form called it once per row. Besides scaling linearly, both + paths could return duplicates while DB-006 is still pending. The join is + bounded and ``distinct`` preserves the form's intended one-option-per- + player contract until the database constraint lands. + """ + return ( + User.query.join(TryoutRegistration, TryoutRegistration.player_id == User.id) + .filter(TryoutRegistration.tryout_id == tryout_id) + .order_by(User.username) + .distinct() + .all() + ) + + #: How long a match lasts when the form gives a start and no end. DEFAULT_MATCH_MINUTES = 30 @@ -270,7 +289,7 @@ def api_events(): @login_required def api_events_for_tryout(tryout_id): """API endpoint returning calendar events for a specific tryout.""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) can_view = current_user.can_manage_this_tryout(tryout) is_registered = False @@ -359,7 +378,7 @@ def api_events_for_tryout(tryout_id): @login_required def create_match(tryout_id): """Create a new match / scrimmage within a tryout.""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to schedule matches for this tryout.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @@ -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(): @@ -437,7 +452,7 @@ def create_match(tryout_id): @login_required def edit_match(match_id): """Edit an existing match.""" - match = Match.query.get_or_404(match_id) + match = db.get_or_404(Match, match_id) tryout = match.tryout if not current_user.can_manage_this_tryout(tryout): @@ -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()] @@ -569,7 +582,7 @@ def api_manageable_tryouts(): @login_required def delete_match(match_id): """Delete a match.""" - match = Match.query.get_or_404(match_id) + match = db.get_or_404(Match, match_id) tryout = match.tryout if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to delete this match.'), 'danger') @@ -656,10 +669,10 @@ def api_available_players(date, time): @login_required def toggle_presence(match_id, participant_id): """Toggle attendance_confirmed for a match participant.""" - match = Match.query.get_or_404(match_id) + match = db.get_or_404(Match, match_id) tryout = match.tryout - participant = MatchParticipant.query.get_or_404(participant_id) + participant = db.get_or_404(MatchParticipant, participant_id) if participant.match_id != match_id: return jsonify({'error': 'Participant does not belong to this match'}), 400 diff --git a/app/routes/team_matches.py b/app/routes/team_matches.py index bf45954..0d87607 100644 --- a/app/routes/team_matches.py +++ b/app/routes/team_matches.py @@ -3,8 +3,6 @@ Uses polymorphic isinstance checks instead of role-string comparisons. """ -from datetime import datetime - from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required @@ -27,6 +25,7 @@ from app.pagination import paginate from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams from app.routes.matches import default_end_time from app.services.scheduling import notify_participants, zip_participants +from app.time_utils import utc_now_naive from app.validators import TeamMatchSchema team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches') @@ -100,7 +99,7 @@ def list_matches(): teams=teams, match_data=match_data, pagination=matches_page, - now=datetime.utcnow(), + now=utc_now_naive(), ) @@ -108,7 +107,7 @@ def list_matches(): @login_required def create_match(team_id): """Create a new regular-season team match.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not can_manage_team_match(team): flash(_('You do not have permission to schedule matches for this team.'), 'danger') return redirect(url_for('team_matches.list_matches')) @@ -213,7 +212,7 @@ def create_match(team_id): @login_required def edit_match(match_id): """Edit an existing team match.""" - team_match = TeamMatch.query.get_or_404(match_id) + team_match = db.get_or_404(TeamMatch, match_id) team = team_match.org_team if not can_manage_team_match(team): @@ -259,7 +258,7 @@ def edit_match(match_id): @login_required def delete_match(match_id): """Delete a team match.""" - team_match = TeamMatch.query.get_or_404(match_id) + team_match = db.get_or_404(TeamMatch, match_id) team = team_match.org_team if not can_manage_team_match(team): flash(_('You do not have permission to delete this match.'), 'danger') @@ -293,10 +292,10 @@ def api_manageable_teams(): @login_required def toggle_presence(match_id, participant_id): """Toggle is_confirmed for a team match participant.""" - team_match = TeamMatch.query.get_or_404(match_id) + team_match = db.get_or_404(TeamMatch, match_id) team = team_match.org_team - participant = TeamMatchParticipant.query.get_or_404(participant_id) + participant = db.get_or_404(TeamMatchParticipant, participant_id) if participant.team_match_id != match_id: return jsonify({'error': 'Participant does not belong to this match'}), 400 diff --git a/app/routes/teams.py b/app/routes/teams.py index 1dabb16..5c677ad 100644 --- a/app/routes/teams.py +++ b/app/routes/teams.py @@ -3,9 +3,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 +27,8 @@ from app.models import ( User, ) from app.permissions import visible_org_teams -from app.validators import OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema +from app.time_utils import utc_now_naive +from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema teams_bp = Blueprint('teams', __name__, url_prefix='/teams') @@ -82,7 +81,7 @@ def my_teams(): from app.models import TeamMatch, TeamMatchParticipant player_teams = current_user.get_org_teams() - now = datetime.utcnow() + now = utc_now_naive() team_data = [] for org_team in player_teams: @@ -223,7 +222,7 @@ def create_team(): @login_required def edit_team(team_id): """Edit an existing organization team.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('You do not have permission to edit this team.'), 'danger') return redirect(url_for('teams.list_teams')) @@ -294,7 +293,7 @@ def delete_team(team_id): day `Manager.can_manage_this_org_team` is narrowed — which it should be — deletion narrows with it instead of staying the one way in. """ - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)): flash(_('You do not have permission to delete teams.'), 'danger') @@ -336,7 +335,7 @@ def delete_team(team_id): @login_required def add_coach(team_id): """Add a coach to an organization team.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) @@ -379,7 +378,7 @@ def add_coach(team_id): @login_required def add_manager(team_id): """Add a manager to an organization team.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) @@ -422,7 +421,7 @@ def add_manager(team_id): @login_required def remove_coach(team_id): """Remove a coach from an organization team.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) @@ -450,7 +449,7 @@ def remove_coach(team_id): @login_required def remove_manager(team_id): """Remove a manager from an organization team.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) @@ -478,7 +477,7 @@ def remove_manager(team_id): @login_required def add_player(team_id): """Add a player to an organization team.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) @@ -515,12 +514,12 @@ def add_player(team_id): @login_required def remove_player(team_id, player_id): """Remove a player from an organization team.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('Permission denied.'), 'danger') return redirect(url_for('teams.list_teams')) - player = User.query.get_or_404(player_id) + player = db.get_or_404(User, player_id) tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first() if not tp: flash( @@ -543,7 +542,7 @@ def remove_player(team_id, player_id): @login_required def toggle_player_status(team_id, player_id): """Toggle a player's status between starter and substitute.""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): return jsonify({'error': 'Permission denied'}), 403 @@ -567,17 +566,21 @@ def toggle_player_status(team_id, player_id): @login_required def add_team_note(team_id): """Add a team improvement note (coaches only).""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('You do not have permission to add notes to this team.'), 'danger') return redirect(url_for('teams.list_teams')) - 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')) @@ -585,12 +588,12 @@ def add_team_note(team_id): @login_required def add_player_note(team_id, player_id): """Add a personal note for a player (coaches only).""" - team = OrgTeam.query.get_or_404(team_id) + team = db.get_or_404(OrgTeam, team_id) if not current_user.can_manage_this_org_team(team): flash(_('You do not have permission to add notes to this team.'), 'danger') return redirect(url_for('teams.list_teams')) - player = User.query.get_or_404(player_id) + player = db.get_or_404(User, player_id) if not isinstance(player, Player): flash(_('Can only add notes for players.'), 'danger') return redirect(url_for('teams.list_teams')) @@ -603,10 +606,14 @@ def add_player_note(team_id, player_id): ) return redirect(url_for('teams.list_teams')) - content = request.form.get('content', '').strip() - if content: - note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content) - db.session.add(note) - db.session.commit() - flash(_('Note added for %(username)s!', username=player.username), 'success') + try: + data = NoteContentSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) + return redirect(url_for('teams.list_teams')) + + note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content']) + db.session.add(note) + db.session.commit() + flash(_('Note added for %(username)s!', username=player.username), 'success') return redirect(url_for('teams.list_teams')) diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index 4c2be4c..db2468c 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -4,12 +4,11 @@ This module handles CRUD operations for tryouts and player registrations. Uses polymorphic isinstance checks instead of role-string comparisons. """ -from datetime import datetime - from flask import Blueprint, abort, flash, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required from marshmallow import ValidationError +from sqlalchemy import select from app.extensions import db from app.forms import flash_validation_errors, form_payload @@ -32,7 +31,15 @@ from app.models import ( TryoutRegistration, User, ) -from app.validators import PlayerSelectionSchema, TryoutSchema +from app.time_utils import utc_now_naive +from app.validators import ( + PlayerSelectionSchema, + TryoutRegistrationStatusSchema, + TryoutSchema, + TryoutStatusSchema, + TryoutTeamMemberSchema, + TryoutTeamSchema, +) tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts') @@ -79,6 +86,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(): @@ -87,7 +113,7 @@ def list_tryouts(): Delegates to the polymorphic User subclass's get_visible_tryouts() method. """ tryouts = current_user.get_visible_tryouts() - return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow()) + return render_template('pages/tryouts.html', tryouts=tryouts, now=utc_now_naive()) @tryouts_bp.route('/create', methods=['GET', 'POST']) @@ -152,7 +178,7 @@ def create_tryout(): @login_required def edit_tryout(tryout_id): """Edit an existing tryout event. Permission based on can_manage_this_tryout.""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to edit this tryout.'), 'danger') @@ -209,7 +235,7 @@ def edit_tryout(tryout_id): @login_required def view_tryout(tryout_id): """View a specific tryout with all details. Permission via polymorphic dispatch.""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) can_view = False if isinstance(current_user, Admin): @@ -398,7 +424,7 @@ def view_tryout(tryout_id): matches=matches, match_data=match_data, game_positions=GAME_POSITIONS, - now=datetime.utcnow(), + now=utc_now_naive(), ) @@ -406,10 +432,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') @@ -439,15 +465,19 @@ def register_for_tryout(tryout_id): @login_required def update_status(tryout_id): """Update the status of a tryout.""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) - 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)) @@ -455,7 +485,7 @@ def update_status(tryout_id): @login_required def update_registration_status(tryout_id, player_id): """Update a registration's attendance status.""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) @@ -463,11 +493,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 +509,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)) @@ -522,12 +556,12 @@ def register_player(tryout_id): @login_required def remove_player(tryout_id, player_id): """Remove a registered player from a tryout (cascades to teams/matches).""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) - player = User.query.get_or_404(player_id) + player = db.get_or_404(User, player_id) registration = TryoutRegistration.query.filter_by( tryout_id=tryout_id, player_id=player_id @@ -558,17 +592,21 @@ def remove_player(tryout_id, player_id): @login_required def create_team(tryout_id): """Create a tryout-specific team.""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - 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)) @@ -576,8 +614,8 @@ def create_team(tryout_id): @login_required def add_to_team(tryout_id, team_id): """Add a player to a tryout team.""" - team = Team.query.get_or_404(team_id) - tryout = Tryout.query.get_or_404(tryout_id) + team = db.get_or_404(Team, team_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @@ -588,10 +626,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 +642,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') @@ -618,7 +657,7 @@ def add_to_team(tryout_id, team_id): @login_required def delete_tryout(tryout_id): """Delete a tryout and all associated data (matches, teams, registrations, evaluations).""" - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('You do not have permission to delete this tryout.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) diff --git a/app/routes/users/_shared.py b/app/routes/users/_shared.py index 5ad0267..4acd465 100644 --- a/app/routes/users/_shared.py +++ b/app/routes/users/_shared.py @@ -4,7 +4,6 @@ Nothing here touches the blueprint: these are plain functions, so a test can call them with a request context and nothing else. """ -from flask import request from flask_babel import gettext as _ from app.extensions import db @@ -13,7 +12,7 @@ from app.extensions import db # routes needed them as well (ARCH-005). Importing them from here still # works, so the thirty call sites in this package did not have to move. from app.forms import flash_validation_errors, form_payload # noqa: F401 -from app.models import GAME_PLATFORMS, Admin, Coach, Manager, Player, Scout, UserGamertag +from app.models import Admin, Coach, Manager, Player, Scout, UserGamertag ALLOWED_CONTRACT_EXTENSIONS = {'pdf'} ALLOWED_SIGNED_EXTENSIONS = {'pdf'} @@ -63,20 +62,25 @@ def pdf_upload_error(file, allowed_extensions): def update_user_gamertags(user, selected_games): - """Update gamertags for a user based on form input.""" + """Update gamertags for a user from validated dynamic form fields.""" + from app.forms import form_gamertags + + submitted = form_gamertags(selected_games) existing_gamertags = {gt.game: gt for gt in user.gamertags} for game in selected_games: - gamertag = request.form.get(f'gamertag_{game}', '').strip() - platform = ( - request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None - ) + payload = submitted.get(game) existing = existing_gamertags.get(game) - if gamertag: + if payload: if existing: - existing.gamertag = gamertag - existing.platform = platform + existing.gamertag = payload['gamertag'] + existing.platform = payload['platform'] else: - gt = UserGamertag(user_id=user.id, game=game, gamertag=gamertag, platform=platform) + gt = UserGamertag( + user_id=user.id, + game=game, + gamertag=payload['gamertag'], + platform=payload['platform'], + ) db.session.add(gt) elif existing: db.session.delete(existing) diff --git a/app/routes/users/accounts.py b/app/routes/users/accounts.py index 7942525..1b64b82 100644 --- a/app/routes/users/accounts.py +++ b/app/routes/users/accounts.py @@ -70,7 +70,7 @@ def edit_user(user_id): flash(_('Only the president can edit users.'), 'danger') return redirect(url_for('main.dashboard')) - user = User.query.get_or_404(user_id) + user = db.get_or_404(User, user_id) if request.method == 'POST': actor_name, actor_id = current_user.username, current_user.id @@ -111,6 +111,22 @@ def edit_user(user_id): flash(_('Email already in use by another account.'), 'danger') return _rerender() + discord_clash = None + if discord_user_id: + discord_clash = User.query.filter( + User.discord_user_id == discord_user_id, + User.id != user.id, + ).first() + if discord_clash: + flash(_('This Discord account is already linked to another account.'), 'danger') + return _rerender() + + 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 @@ -173,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') @@ -239,7 +253,7 @@ def delete_user(user_id): flash(_('You cannot delete your own account.'), 'danger') return redirect(url_for('users.list_users')) - user = User.query.get_or_404(user_id) + user = db.get_or_404(User, user_id) Evaluation.query.filter( db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id), @@ -365,5 +379,5 @@ def create_user(): @login_required def view_user(user_id): """View a public profile for any user.""" - user = User.query.get_or_404(user_id) + user = db.get_or_404(User, user_id) return render_template('pages/view_user.html', profile_user=user) diff --git a/app/routes/users/availability.py b/app/routes/users/availability.py index ee8fe2f..fcd3400 100644 --- a/app/routes/users/availability.py +++ b/app/routes/users/availability.py @@ -141,41 +141,41 @@ def add_disponibility(): @json_endpoint @login_required def add_disponibilities_bulk(): - """Add multiple disponibility blocks at once.""" + """Replace the current player's disponibility blocks atomically.""" data = request.get_json(silent=True) or {} accepted, rejected = _load_slots(data.get('slots')) + if rejected: + return jsonify( + { + 'error': 'Invalid slots; nothing was changed.', + 'rejected': rejected, + } + ), 400 + + PlayerDisponibility.query.filter_by(player_id=current_user.id).delete() + created = [] for slot in accepted: start_time = slot['start_time'] - existing = PlayerDisponibility.query.filter_by( + disponibility = PlayerDisponibility( player_id=current_user.id, day_of_week=slot['day_of_week'], start_time=start_time, - ).first() - if not existing: - disponibility = PlayerDisponibility( - player_id=current_user.id, - day_of_week=slot['day_of_week'], - start_time=start_time, - end_time=slot_end(start_time), - ) - db.session.add(disponibility) - db.session.flush() - created.append( - { - 'id': disponibility.id, - 'day_of_week': disponibility.day_of_week, - 'day_name': day_name(disponibility.day_of_week), - 'start_time': disponibility.start_time.strftime('%H:%M'), - } - ) + end_time=slot_end(start_time), + ) + db.session.add(disponibility) + db.session.flush() + created.append( + { + 'id': disponibility.id, + 'day_of_week': disponibility.day_of_week, + 'day_name': day_name(disponibility.day_of_week), + 'start_time': disponibility.start_time.strftime('%H:%M'), + } + ) db.session.commit() - # `rejected` is reported rather than swallowed. What was accepted is - # still saved — dropping a whole batch because one cell was malformed - # would be its own kind of surprise — but the client can now tell the - # difference between "nine slots saved" and "ten sent, nine saved". - return jsonify({'success': True, 'created': created, 'rejected': rejected}) + return jsonify({'success': True, 'created': created, 'rejected': []}) @users_bp.route('/disponibilities/clear', methods=['POST']) @@ -193,7 +193,7 @@ def clear_disponibilities(): @login_required def delete_disponibility(disponibility_id): """Delete a disponibility block.""" - disponibility = PlayerDisponibility.query.get_or_404(disponibility_id) + disponibility = db.get_or_404(PlayerDisponibility, disponibility_id) if disponibility.player_id != current_user.id: return jsonify({'error': 'Unauthorized'}), 403 db.session.delete(disponibility) diff --git a/app/routes/users/contracts.py b/app/routes/users/contracts.py index eda1c8b..62e227d 100644 --- a/app/routes/users/contracts.py +++ b/app/routes/users/contracts.py @@ -2,7 +2,6 @@ import os import uuid -from datetime import datetime from flask import flash, redirect, render_template, request, send_file, url_for from flask_babel import gettext as _ @@ -20,6 +19,7 @@ from app.routes.users._shared import ( ) from app.routes.users.blueprint import users_bp from app.storage import CONTRACTS_DIR, document_path +from app.time_utils import utc_now_naive from app.validators import UploadContractSchema @@ -111,7 +111,7 @@ def upload_contract(): flash(error, 'danger') return redirect(url_for('users.upload_contract')) - player = User.query.get_or_404(player_id) + player = db.get_or_404(User, player_id) player_teams = player.get_org_teams() team = player_teams[0] if player_teams else None @@ -154,7 +154,7 @@ def upload_contract(): @login_required def upload_signed_contract(contract_id): """Upload a signed contract (player only).""" - contract = Contract.query.get_or_404(contract_id) + contract = db.get_or_404(Contract, contract_id) if not contract.can_upload_signed(current_user): flash(_('Only the player can upload their signed contract.'), 'danger') return redirect(url_for('users.list_contracts')) @@ -172,7 +172,7 @@ def upload_signed_contract(contract_id): contract.signed_filename = signed_filename contract.signed_file_path = signed_path contract.status = 'signed' - contract.signed_at = datetime.utcnow() + contract.signed_at = utc_now_naive() db.session.commit() flash(_('Signed contract uploaded successfully!'), 'success') return redirect(url_for('users.list_contracts')) @@ -182,7 +182,7 @@ def upload_signed_contract(contract_id): @login_required def download_contract(contract_id): """Download a contract file.""" - contract = Contract.query.get_or_404(contract_id) + contract = db.get_or_404(Contract, contract_id) if not contract.can_view(current_user): flash(_('You do not have permission to download this contract.'), 'danger') return redirect(url_for('users.list_contracts')) @@ -197,7 +197,7 @@ def download_contract(contract_id): @login_required def download_signed_contract(contract_id): """Download a signed contract file.""" - contract = Contract.query.get_or_404(contract_id) + contract = db.get_or_404(Contract, contract_id) if not contract.can_view(current_user): flash(_('You do not have permission to download this contract.'), 'danger') return redirect(url_for('users.list_contracts')) diff --git a/app/routes/users/notes.py b/app/routes/users/notes.py index be4a295..2433e1e 100644 --- a/app/routes/users/notes.py +++ b/app/routes/users/notes.py @@ -7,23 +7,32 @@ it reads exactly what the coach routes write. from flask import flash, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required +from marshmallow import ValidationError from app.extensions import db +from app.forms import flash_validation_errors, form_payload from app.models import ( Coach, Match, MatchParticipant, OneOnOneRequest, - OrgTeam, PersonalNote, Player, + Team, + TeamMember, TeamNote, Tryout, TryoutRegistration, User, ) -from app.permissions import coach_can_access_player, coach_org_teams, coach_player_ids +from app.permissions import ( + coach_can_access_player, + coach_org_teams, + coach_player_ids, + coach_tryouts, +) from app.routes.users.blueprint import users_bp +from app.validators import NoteContentSchema, PersonalNoteSchema @users_bp.route('/my-notes') @@ -116,23 +125,25 @@ def notes_dashboard(): ) # For context selectors in the form + # PersonalNote.team_id references a tryout-local Team, not OrgTeam. The + # previous selector mixed the two namespaces and could either attach the + # note to an unrelated team with the same integer id or fail its FK. + # Every context list now comes from the tryouts this coach may manage. + tryouts = list(reversed(coach_tryouts(current_user)))[:20] + tryout_ids = [tryout.id for tryout in tryouts] matches = ( - Match.query.filter( - db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'), - ) + Match.query.filter(Match.tryout_id.in_(tryout_ids)) .order_by(Match.date.desc()) .limit(20) .all() + if tryout_ids + else [] ) - tryouts = ( - Tryout.query.filter_by( - created_by=current_user.id, - ) - .order_by(Tryout.date.desc()) - .limit(20) - .all() + teams = ( + Team.query.filter(Team.tryout_id.in_(tryout_ids)).order_by(Team.name).all() + if tryout_ids + else [] ) - teams = OrgTeam.query.order_by(OrgTeam.name).all() return render_template( 'pages/notes.html', @@ -168,16 +179,20 @@ def manage_team_notes(): return redirect(url_for('users.notes_dashboard')) org_team = org_teams[0] - content = request.form.get('content', '').strip() - if content: - note = TeamNote( - org_team_id=org_team.id, - coach_id=current_user.id, - content=content, - ) - db.session.add(note) - db.session.commit() - flash(_('Team notes saved successfully!'), 'success') + try: + data = NoteContentSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) + return redirect(url_for('users.notes_dashboard')) + + note = TeamNote( + org_team_id=org_team.id, + coach_id=current_user.id, + content=data['content'], + ) + db.session.add(note) + db.session.commit() + flash(_('Team notes saved successfully!'), 'success') return redirect(url_for('users.notes_dashboard')) @@ -195,14 +210,14 @@ 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) + player = db.get_or_404(User, player_id) if not isinstance(player, Player): flash(_('Can only add notes for players.'), 'danger') return redirect(url_for('users.notes_dashboard')) @@ -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,17 +250,14 @@ 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) + player = db.get_or_404(User, player_id) if not isinstance(player, Player): flash(_('Can only add notes for players.'), 'danger') return redirect(url_for('users.notes_dashboard')) @@ -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 = db.get_or_404(Match, data['match_id']) + if not current_user.can_manage_this_tryout(match.tryout): + flash(_('You cannot use that match as note context.'), 'danger') + return redirect(url_for('users.notes_dashboard')) + 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 = db.get_or_404(Tryout, data['tryout_id']) + if not current_user.can_manage_this_tryout(tryout): + flash(_('You cannot use that tryout as note context.'), 'danger') + return redirect(url_for('users.notes_dashboard')) + 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 = db.get_or_404(Team, data['team_id']) + if not current_user.can_manage_this_tryout(team.tryout): + flash(_('You cannot use that team as note context.'), 'danger') + return redirect(url_for('users.notes_dashboard')) + 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() @@ -281,7 +320,10 @@ def add_note_from_tryout(tryout_id): flash(_('Only coaches can add personal notes.'), 'danger') return redirect(url_for('main.dashboard')) - tryout = Tryout.query.get_or_404(tryout_id) + tryout = db.get_or_404(Tryout, tryout_id) + if not current_user.can_manage_this_tryout(tryout): + flash(_('You do not have permission to add notes for this tryout.'), 'danger') + return redirect(url_for('users.notes_dashboard')) 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) @@ -334,7 +384,10 @@ def add_note_from_match(match_id): flash(_('Only coaches can add personal notes.'), 'danger') return redirect(url_for('main.dashboard')) - match_obj = Match.query.get_or_404(match_id) + match_obj = db.get_or_404(Match, match_id) + if not current_user.can_manage_this_tryout(match_obj.tryout): + flash(_('You do not have permission to add notes for this match.'), 'danger') + return redirect(url_for('users.notes_dashboard')) # Get participants as players for the select list participants = MatchParticipant.query.filter_by(match_id=match_id).all() @@ -343,21 +396,29 @@ def add_note_from_match(match_id): preselected_player_id = request.args.get('player_id', type=int) if request.method == 'POST': - player_id = request.form.get('player_id', type=int) - content = request.form.get('content', '').strip() + try: + data = PersonalNoteSchema().load(form_payload(list_fields=())) + except ValidationError as err: + flash_validation_errors(err) + return redirect(url_for('users.add_note_from_match', match_id=match_id)) + player_id = data['player_id'] - if not player_id or not content: - flash(_('Player and content are required.'), 'danger') + if data['match_id'] not in (None, match_id): + flash(_('Invalid match context.'), 'danger') return redirect(url_for('users.add_note_from_match', match_id=match_id)) if not coach_can_access_player(current_user, player_id): flash(_('You can only write notes about players you work with.'), 'danger') return redirect(url_for('users.add_note_from_match', match_id=match_id)) + if not MatchParticipant.query.filter_by(match_id=match_id, player_id=player_id).first(): + flash(_('That player did not participate in this match.'), 'danger') + return redirect(url_for('users.add_note_from_match', match_id=match_id)) + note = PersonalNote( player_id=player_id, coach_id=current_user.id, - content=content, + content=data['content'], match_id=match_id, ) db.session.add(note) diff --git a/app/routes/users/one_on_one.py b/app/routes/users/one_on_one.py index 6fe50b7..6211b19 100644 --- a/app/routes/users/one_on_one.py +++ b/app/routes/users/one_on_one.py @@ -1,7 +1,5 @@ """One-on-one sessions between a player and their coach.""" -from datetime import datetime - from flask import flash, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required @@ -12,7 +10,8 @@ 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.time_utils import utc_now_naive +from app.validators import OneOnOneRejectionSchema, OneOnOneRequestSchema @users_bp.route('/one-on-one', methods=['GET', 'POST']) @@ -166,7 +165,7 @@ def accept_one_on_one(request_id): flash(_('Only coaches can accept One on One requests.'), 'danger') return redirect(url_for('main.dashboard')) - request_obj = OneOnOneRequest.query.get_or_404(request_id) + request_obj = db.get_or_404(OneOnOneRequest, request_id) if request_obj.coach_id != current_user.id: flash(_('This request is not for you.'), 'danger') @@ -178,7 +177,7 @@ def accept_one_on_one(request_id): player = request_obj.player request_obj.status = 'approved' - request_obj.responded_at = datetime.utcnow() + request_obj.responded_at = utc_now_naive() db.session.commit() # Notify player via Discord (same message as if approved through Discord reactions) @@ -216,7 +215,7 @@ def reject_one_on_one(request_id): flash(_('Only coaches can reject One on One requests.'), 'danger') return redirect(url_for('main.dashboard')) - request_obj = OneOnOneRequest.query.get_or_404(request_id) + request_obj = db.get_or_404(OneOnOneRequest, request_id) if request_obj.coach_id != current_user.id: flash(_('This request is not for you.'), 'danger') @@ -226,11 +225,16 @@ 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' - request_obj.responded_at = datetime.utcnow() + request_obj.responded_at = utc_now_naive() if rejection_reason: request_obj.coach_rejection_message = rejection_reason db.session.commit() diff --git a/app/routes/users/profile.py b/app/routes/users/profile.py index 2898431..ab56e07 100644 --- a/app/routes/users/profile.py +++ b/app/routes/users/profile.py @@ -76,7 +76,6 @@ def edit_profile(): phone = validated.get('phone') selected_games = validated.get('games', []) discord_username = validated.get('discord_username') - discord_user_id = validated.get('discord_user_id') league_os_profile = validated.get('league_os_profile') if username != current_user.username and User.query.filter_by(username=username).first(): @@ -99,17 +98,26 @@ 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 current_user.phone = phone current_user.games = ','.join(selected_games) if selected_games else None current_user.discord_username = discord_username or None - current_user.discord_user_id = discord_user_id or None current_user.league_os_profile = league_os_profile or None - update_user_gamertags(current_user, selected_games) - # Blank means "keep the current password"; anything else has already # been checked against the policy by the schema. password = validated.get('password') diff --git a/app/supporting_scripts/backup.py b/app/supporting_scripts/backup.py index 0b6aa75..2432031 100644 --- a/app/supporting_scripts/backup.py +++ b/app/supporting_scripts/backup.py @@ -55,8 +55,8 @@ PG_RESTORE = os.getenv('PG_RESTORE', 'pg_restore') # wave G introduced DOCUMENTS_ROOT so a release-directory deployment could # keep uploads outside the releases, and docs/deployment.md now tells the # operator to set it — at which point this script archived a directory the -# application had never written to. It does not fail on a missing directory -# either; it prints "No documents directory found", skips, and exits 0. +# application had never written to. A missing or unreadable document store +# is now a failed full-backup run rather than a database-only green result. # # So the more correctly an operator followed the deployment documentation, # the more certainly their contract backups were empty (OBS-006). @@ -262,33 +262,31 @@ def backup_documents(): module happened to be imported with. Returns: - str: Path to the created archive, or None if there is nothing to - archive. Signed contracts live only on disk, so losing this - directory loses the documents themselves. + str: Path to the created archive. + + Raises: + BackupError: If the configured store is absent or cannot be archived. + Signed contracts live only on disk, so a database-only run must + never be reported as a complete backup. """ documents_dir = documents_root() if not os.path.exists(documents_dir): - # Says where it looked. The previous message named no path, so an - # operator who had moved the documents read it as "there are no - # documents" rather than "I am looking in the wrong place". - print(f'[INFO] No documents directory at {documents_dir}. Skipping document backup.') - return None + raise BackupError(f'Documents directory does not exist: {documents_dir}') + if not os.path.isdir(documents_dir): + raise BackupError(f'Documents path is not a directory: {documents_dir}') timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}') try: shutil.make_archive(archive_basename, 'zip', documents_dir) - except Exception as exc: # noqa: BLE001 — a failed document archive must not lose the dump - # This runs after the database dump has already succeeded. Letting - # anything through here would abort the script with a traceback and - # take the one part that worked down with it. Reported to stdout, in - # the format the rest of this script uses; it has no logger. - print(f'[ERROR] Document backup failed: {exc}') - return None + except Exception as exc: # noqa: BLE001 — normalize the shutil boundary + raise BackupError(f'Document backup failed: {exc}') from exc zip_path = f'{archive_basename}.zip' + if not os.path.exists(zip_path) or os.path.getsize(zip_path) == 0: + raise BackupError('Document archiver reported success but produced an empty file.') size_mb = os.path.getsize(zip_path) / (1024 * 1024) print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)') return zip_path @@ -361,15 +359,20 @@ def main(argv=None): return 1 verified = verify_backup(backup_path) - backup_documents() + documents_ok = True + try: + backup_documents() + except BackupError as exc: + documents_ok = False + print(f'[ERROR] {exc}') cleanup_old_backups() print() - if verified: + if verified and documents_ok: print('=== Backup completed successfully ===') return 0 - print('=== Backup FAILED verification — do not rely on this archive ===') + print('=== Backup INCOMPLETE — do not treat this run as a full recovery point ===') return 1 diff --git a/app/supporting_scripts/schema_report.py b/app/supporting_scripts/schema_report.py index 7942786..d9952f2 100644 --- a/app/supporting_scripts/schema_report.py +++ b/app/supporting_scripts/schema_report.py @@ -29,10 +29,13 @@ Usage # Also look for the seeded admin/password account (SEC-003) python app/supporting_scripts/schema_report.py --check-seed-accounts + # Find Discord identities that must be reconciled before UNIQUE (SEC-012) + python app/supporting_scripts/schema_report.py --check-discord-identities + Exit codes ---------- 0 the live schema matches the models - 1 drift found — the report says what + 1 drift or requested data risk found — the report says what 2 could not connect or read the catalogue Reading the output @@ -315,6 +318,42 @@ def find_seed_accounts(engine): return results +def find_duplicate_discord_identities(engine): + """Discord snowflakes claimed by more than one account (SEC-012). + + New links are now refused in application code, but existing production + rows predate that guard. These groups must be reconciled before Alembic + can add the database-level UNIQUE constraint. + + Returns: + list[tuple]: (discord_user_id, comma-separated usernames, count). + """ + from sqlalchemy import text + + with engine.connect() as connection: + rows = connection.execute( + text( + 'SELECT discord_user_id, COUNT(*) AS account_count ' + 'FROM users ' + "WHERE discord_user_id IS NOT NULL AND discord_user_id <> '' " + 'GROUP BY discord_user_id HAVING COUNT(*) > 1 ' + 'ORDER BY discord_user_id' + ) + ).fetchall() + + duplicates = [] + for discord_user_id, account_count in rows: + usernames = connection.execute( + text( + 'SELECT username FROM users ' + 'WHERE discord_user_id = :discord_user_id ORDER BY username' + ), + {'discord_user_id': discord_user_id}, + ).scalars() + duplicates.append((discord_user_id, ', '.join(usernames), account_count)) + return duplicates + + def main(argv=None): parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) parser.add_argument( @@ -327,6 +366,11 @@ def main(argv=None): action='store_true', help='Also look for the admin/password account seeded by clear_db.py (SEC-003).', ) + parser.add_argument( + '--check-discord-identities', + action='store_true', + help='Find duplicate Discord IDs that block the SEC-012 UNIQUE constraint.', + ) args = parser.parse_args(argv) if not args.url: @@ -375,9 +419,24 @@ def main(argv=None): ) print(f' {username} ({role}): {verdict}') + duplicate_discord_identities = [] + if args.check_discord_identities: + print('\n' + '=' * 78) + print('Duplicate Discord identities (SEC-012)') + print('=' * 78) + try: + duplicate_discord_identities = find_duplicate_discord_identities(engine) + except SQLAlchemyError as exc: + print(f'Could not check: {exc}') + else: + if not duplicate_discord_identities: + print('No Discord identity is shared by multiple accounts.') + for discord_user_id, usernames, account_count in duplicate_discord_identities: + print(f' {discord_user_id}: {account_count} accounts ({usernames})') + blocking = sum(1 for f in findings if f.severity == BLOCKING) print(f'\n{len(findings)} finding(s), {blocking} blocking.') - return 1 if findings else 0 + return 1 if findings or duplicate_discord_identities else 0 if __name__ == '__main__': # pragma: no cover diff --git a/app/supporting_scripts/security_scan.py b/app/supporting_scripts/security_scan.py index 32632c2..54bb4e0 100644 --- a/app/supporting_scripts/security_scan.py +++ b/app/supporting_scripts/security_scan.py @@ -6,7 +6,7 @@ This script performs pre-deployment security checks to validate: - Debug mode status - HTTPS configuration - Dependency vulnerabilities -- Database connectivity +- Required database configuration Usage: python security_scan.py [--url http://localhost:5000] @@ -19,6 +19,10 @@ import subprocess import sys import urllib.request from datetime import datetime +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +REQUIREMENTS_FILE = PROJECT_ROOT / 'requirements.txt' def check_environment(): @@ -31,8 +35,8 @@ def check_environment(): print('1. ENVIRONMENT VARIABLES CHECK') print('=' * 60) - critical_vars = ['SECRET_KEY'] - recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS'] + critical_vars = ['SECRET_KEY', 'DATABASE_URL'] + recommended_vars = ['CORS_ALLOWED_ORIGINS'] all_ok = True for var in critical_vars: @@ -58,7 +62,8 @@ def check_environment(): # Check FLASK_DEBUG debug = os.getenv('FLASK_DEBUG', 'false').lower() if debug == 'true': - print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.') + print('[FAIL] FLASK_DEBUG is enabled! It must be disabled in production.') + all_ok = False else: print('[OK] FLASK_DEBUG is disabled') @@ -91,10 +96,11 @@ def check_https_headers(url): all_ok = True try: - # Create a context that doesn't verify SSL (for local testing) + # Keep the default certificate and hostname verification. A scanner + # that accepts an invalid certificate can validate headers while the + # transport itself is impersonated. Local runs without TLS should use + # http:// explicitly or opt out with --skip-http. ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE req = urllib.request.Request(url, method='HEAD') @@ -148,9 +154,9 @@ def check_https_headers(url): all_ok = False except urllib.error.URLError as e: - print(f'[SKIP] Cannot connect to {url}: {e.reason}') - print('[SKIP] Run with --url to check headers') - return True # Not a failure, just can't check + print(f'[FAIL] Cannot connect to {url}: {e.reason}') + print('[INFO] Use --skip-http only when the live check is intentionally out of scope.') + return False return all_ok @@ -167,7 +173,15 @@ def check_dependencies(): try: result = subprocess.run( - [sys.executable, '-m', 'pip_audit', '--format', 'json'], + [ + sys.executable, + '-m', + 'pip_audit', + '--requirement', + str(REQUIREMENTS_FILE), + '--format', + 'json', + ], capture_output=True, text=True, timeout=60, @@ -195,14 +209,16 @@ def check_dependencies(): if result.stdout: print(f'[INFO] {result.stdout.strip()}') if result.stderr: - print(f'[WARN] {result.stderr.strip()}') - return True + print(f'[FAIL] {result.stderr.strip()}') + else: + print(f'[FAIL] pip-audit exited with status {result.returncode}.') + return False except FileNotFoundError: - print('[SKIP] pip-audit not installed. Run: pip install pip-audit') - return True + print('[FAIL] pip-audit not installed. Run: pip install pip-audit') + return False except subprocess.TimeoutExpired: - print('[WARN] pip-audit timed out') - return True + print('[FAIL] pip-audit timed out') + return False def check_file_permissions(): diff --git a/app/templates/pages/coach_availability.html b/app/templates/pages/coach_availability.html index 79d284e..8971f3f 100644 --- a/app/templates/pages/coach_availability.html +++ b/app/templates/pages/coach_availability.html @@ -216,7 +216,14 @@ function flash(message, type) { const flashContainer = document.querySelector('.flash-messages'); const alert = document.createElement('div'); alert.className = 'alert alert-' + type + ' alert-dismissible'; - alert.innerHTML = '' + message + ''; + const text = document.createElement('span'); + text.textContent = message; + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'alert-close'; + close.dataset.action = 'remove-element'; + close.textContent = '×'; + alert.append(text, close); flashContainer.appendChild(alert); } diff --git a/app/templates/pages/edit_profile.html b/app/templates/pages/edit_profile.html index c9977e7..656970c 100644 --- a/app/templates/pages/edit_profile.html +++ b/app/templates/pages/edit_profile.html @@ -79,14 +79,13 @@
-
+
-
-
- - - {{ _('Enable Developer Mode in Discord → Right-click profile → Copy ID') }} + + + {% if user.discord_user_id %}{{ _('Reconnect') }}{% else %}{{ _('Connect Discord Account') }}{% endif %} +
diff --git a/app/templates/pages/match_form.html b/app/templates/pages/match_form.html index a589205..9254e3b 100644 --- a/app/templates/pages/match_form.html +++ b/app/templates/pages/match_form.html @@ -450,11 +450,18 @@ var playerDataById = { player_data: { {%- for p in all_players %} - {{ p.id }}: "{{ p.username | escape }}", + {{ p.id }}: {{ p.username | tojson }}, {%- endfor %} } }; +var HTML_ESCAPES = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}; +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, function (character) { + return HTML_ESCAPES[character]; + }); +} + // All registered player IDs var allRegisteredPlayers = [ {%- for p in all_players %} @@ -555,7 +562,7 @@ document.addEventListener('DOMContentLoaded', function() { var playerName = playerDataById.player_data[pid]; if (playerName) { var html = '
'; - html += playerName; + html += escapeHtml(playerName); html += ''; html += '
'; teamDiv.insertAdjacentHTML('beforeend', html); @@ -568,7 +575,7 @@ document.addEventListener('DOMContentLoaded', function() { var playerName = playerDataById.player_data[pid]; if (playerName) { var html = '
'; - html += playerName; + html += escapeHtml(playerName); html += ''; html += '
'; teamDiv.insertAdjacentHTML('beforeend', html); @@ -661,7 +668,7 @@ function renderMergedDisponibilityGrid() { } dayRow += '
' + + 'data-action="toggle-time-slot">' + slotData.display + '' + count + '' + '
'; @@ -920,7 +927,7 @@ function updatePlayerPool() { var availabilityClass = isAvailable ? 'available' : 'unavailable'; html += '
'; - html += '' + playerName + ''; + html += '' + escapeHtml(playerName) + ''; html += '
'; html += ''; html += ''; @@ -943,7 +950,7 @@ function assignToTeam(playerId, teamSide) { if (!playerName) return; var html = '
'; - html += playerName; + html += escapeHtml(playerName); html += ''; html += '
'; @@ -1062,7 +1069,7 @@ function randomizeTeams() { var playerName = playerDataById.player_data[pid]; if (playerName) { var html = '
'; - html += playerName; + html += escapeHtml(playerName); html += ''; html += '
'; teamDiv.insertAdjacentHTML('beforeend', html); @@ -1074,7 +1081,7 @@ function randomizeTeams() { var playerName = playerDataById.player_data[pid]; if (playerName) { var html = '
'; - html += playerName; + html += escapeHtml(playerName); html += ''; html += '
'; teamDiv.insertAdjacentHTML('beforeend', html); @@ -1120,6 +1127,11 @@ function togglePresence(matchId, participantId, badgeEl) { registerActions({ 'toggle-match-type': toggleMatchType, 'clear-time-selection': clearTimeSelection, + 'toggle-time-slot': function (element) { + toggleTimeSlot(parseInt(element.getAttribute('data-day'), 10), + element.getAttribute('data-time'), + element); + }, 'update-randomize-preview': updateRandomizePreview, 'randomize-teams': randomizeTeams, 'return-to-pool': returnToPool, diff --git a/app/templates/pages/profile.html b/app/templates/pages/profile.html index 89092a2..affd9c1 100644 --- a/app/templates/pages/profile.html +++ b/app/templates/pages/profile.html @@ -193,8 +193,8 @@

{{ _('Loading...') }}

-
@@ -607,7 +607,6 @@ document.addEventListener('DOMContentLoaded', function() { // dispatched by the delegated listener in main.js. This replaces inline // onclick attributes, which no CSP nonce is able to authorise. registerActions({ - 'save-disponibilities': saveDisponibilities, 'clear-disponibilities': clearDisponibilities, 'clear-availability': clearAllAvailability, }); diff --git a/app/templates/pages/register.html b/app/templates/pages/register.html index 6bcc23d..b1a90c1 100644 --- a/app/templates/pages/register.html +++ b/app/templates/pages/register.html @@ -57,8 +57,6 @@ {{ _('Reconnect') }}
- - {{ _('Discord connected. Game connections have been used to pre-fill your profile below.') }} diff --git a/app/time_utils.py b/app/time_utils.py new file mode 100644 index 0000000..54325e5 --- /dev/null +++ b/app/time_utils.py @@ -0,0 +1,19 @@ +"""Time helpers with explicit storage semantics. + +The deployed schema currently stores timestamps in ``DateTime`` columns +without timezone information. Until the real PostgreSQL schema is restored +and migrated, application timestamps must therefore remain naive values. +They are nevertheless generated from an aware UTC clock so the convention is +explicit and does not rely on the deprecated :meth:`datetime.utcnow` API. +""" + +from datetime import UTC, datetime + + +def utc_now_naive() -> datetime: + """Return the current UTC instant without ``tzinfo`` for legacy columns. + + Replace this compatibility boundary with aware UTC values when the + corresponding columns are migrated to timezone-aware types. + """ + return datetime.now(UTC).replace(tzinfo=None) diff --git a/app/translations/en/LC_MESSAGES/messages.mo b/app/translations/en/LC_MESSAGES/messages.mo index 23c3123..278780b 100644 Binary files a/app/translations/en/LC_MESSAGES/messages.mo and b/app/translations/en/LC_MESSAGES/messages.mo differ diff --git a/app/translations/en/LC_MESSAGES/messages.po b/app/translations/en/LC_MESSAGES/messages.po index e4301be..6ce50a8 100644 --- a/app/translations/en/LC_MESSAGES/messages.po +++ b/app/translations/en/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-11 20:39-0400\n" +"POT-Creation-Date: 2026-08-17 14:18-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -23,8 +23,8 @@ msgstr "" msgid "Please log in to access this page." msgstr "Please log in to access this page." -#: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374 -#: app/routes/users/contracts.py:96 +#: 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:299 +#: app/validators.py:227 app/validators.py:324 msgid "Username must be 3-80 characters." msgstr "Username must be 3-80 characters." @@ -69,169 +69,209 @@ msgstr "Username must be 3-80 characters." msgid "Email must be 120 characters or less." msgstr "Email must be 120 characters or less." -#: app/validators.py:246 app/validators.py:314 app/validators.py:345 -#: app/validators.py:409 +#: app/validators.py:246 app/validators.py:339 app/validators.py:370 +#: app/validators.py:433 msgid "Full name is required." msgstr "Full name is required." -#: app/validators.py:281 +#: app/validators.py:276 msgid "Passwords do not match." msgstr "Passwords do not match." -#: app/validators.py:318 app/validators.py:353 -msgid "Invalid role selected." -msgstr "Invalid role selected." - -#: app/validators.py:454 -msgid "Player must be selected." -msgstr "Player must be selected." - -#: app/validators.py:457 -msgid "Notes must be 2000 characters or less." -msgstr "Notes must be 2000 characters or less." - -#: app/validators.py:494 app/validators.py:853 -msgid "Invalid coach selection." -msgstr "Invalid coach selection." - -#: app/validators.py:500 app/validators.py:859 -msgid "Invalid manager selection." -msgstr "Invalid manager selection." - -#: app/validators.py:511 -msgid "Invalid player selection." -msgstr "Invalid player selection." - -#: app/validators.py:515 -msgid "Unknown roster status." -msgstr "Unknown roster status." - -#: app/validators.py:538 -msgid "Date must be in YYYY-MM-DD format." -msgstr "Date must be in YYYY-MM-DD format." - -#: app/validators.py:539 -msgid "A date is required." -msgstr "A date is required." - -#: app/validators.py:545 app/validators.py:610 -msgid "Start time must be in HH:MM format." -msgstr "Start time must be in HH:MM format." - -#: app/validators.py:546 app/validators.py:611 -msgid "A start time is required." -msgstr "A start time is required." - -#: app/validators.py:552 -msgid "End time must be in HH:MM format." -msgstr "End time must be in HH:MM format." - -#: app/validators.py:553 -msgid "An end time is required." -msgstr "An end time is required." - -#: app/validators.py:557 -msgid "Points must be 2000 characters or less." -msgstr "Points must be 2000 characters or less." - -#: app/validators.py:572 -msgid "End time must be after start time." -msgstr "End time must be after start time." - -#: app/validators.py:601 app/validators.py:603 -msgid "Day must be 0 (Monday) to 6 (Sunday)." -msgstr "Day must be 0 (Monday) to 6 (Sunday)." - -#: app/validators.py:604 -msgid "A day is required." -msgstr "A day is required." - -#: app/validators.py:639 -msgid "Player selection is malformed." -msgstr "Player selection is malformed." - -#: app/validators.py:665 app/validators.py:775 -msgid "A title is required." -msgstr "A title is required." - -#: app/validators.py:674 -msgid "Invalid date format." -msgstr "Invalid date format." - -#: app/validators.py:679 app/validators.py:686 -msgid "Invalid time format." -msgstr "Invalid time format." - -#: app/validators.py:680 -msgid "Start time is required. Please select a time slot." -msgstr "Start time is required. Please select a time slot." - -#: app/validators.py:694 -msgid "Unknown match status." -msgstr "Unknown match status." - -#: app/validators.py:710 -msgid "The end time must come after the start time." -msgstr "The end time must come after the start time." - -#: app/validators.py:724 -msgid "Unknown match type." -msgstr "Unknown match type." - -#: app/validators.py:736 -msgid "A team cannot play against itself." -msgstr "A team cannot play against itself." - -#: app/validators.py:784 +#: app/validators.py:284 app/validators.py:924 msgid "Unknown game." msgstr "Unknown game." -#: app/validators.py:789 +#: 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:790 +#: app/validators.py:930 msgid "A start date is required." msgstr "A start date is required." -#: app/validators.py:796 +#: app/validators.py:936 msgid "Invalid end date format." msgstr "Invalid end date format." -#: app/validators.py:804 +#: 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:807 +#: 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:819 +#: app/validators.py:959 msgid "End date cannot be before start date." msgstr "End date cannot be before start date." -#: app/validators.py:846 app/validators.py:847 +#: app/validators.py:986 app/validators.py:987 msgid "Team name is required." msgstr "Team name is required." -#: app/validators.py:871 +#: app/validators.py:1011 msgid "Scores run from 1 to 10." msgstr "Scores run from 1 to 10." -#: app/validators.py:872 +#: 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:241 +#: app/routes/auth.py:246 msgid "This account has been deactivated." msgstr "This account has been deactivated." -#: app/routes/auth.py:276 +#: app/routes/auth.py:281 #, python-format msgid "Welcome back, %(username)s!" msgstr "Welcome back, %(username)s!" -#: app/routes/auth.py:306 +#: app/routes/auth.py:311 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." @@ -239,27 +279,32 @@ msgstr "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." -#: app/routes/auth.py:363 +#: app/routes/auth.py: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:388 app/routes/users/accounts.py:329 +#: app/routes/auth.py:419 app/routes/users/accounts.py:343 msgid "Username already exists." msgstr "Username already exists." -#: app/routes/auth.py:392 app/routes/users/accounts.py:333 +#: app/routes/auth.py:423 app/routes/users/accounts.py:347 msgid "Email already registered." msgstr "Email already registered." -#: app/routes/auth.py:436 +#: 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: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:457 +#: app/routes/auth.py:497 msgid "Discord OAuth2 is not configured." msgstr "Discord OAuth2 is not configured." -#: app/routes/auth.py:498 +#: app/routes/auth.py:546 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -267,27 +312,35 @@ msgstr "" "Discord authorization could not be verified. Please start the connection " "again from this page." -#: app/routes/auth.py:507 +#: app/routes/auth.py:555 msgid "Discord authorization failed. No code received." msgstr "Discord authorization failed. No code received." -#: app/routes/auth.py:531 +#: 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:535 +#: app/routes/auth.py:583 msgid "Failed to obtain Discord access token." msgstr "Failed to obtain Discord access token." -#: app/routes/auth.py:550 +#: 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:597 +#: 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:634 +msgid "Discord account connected!" +msgstr "Discord account connected!" + +#: 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:625 +#: app/routes/auth.py:709 msgid "You have been logged out." msgstr "You have been logged out." @@ -321,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:519 app/routes/tryouts.py:555 -#: app/routes/tryouts.py:574 +#: 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." @@ -332,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." @@ -463,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:484 app/routes/tryouts.py:585 +#: app/routes/teams.py:490 app/routes/tryouts.py:524 msgid "Please select a player." msgstr "Please select a player." @@ -481,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." @@ -491,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:503 +#: 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:489 +#: app/routes/tryouts.py:532 msgid "Can only register players." msgstr "Can only register players." -#: app/routes/tryouts.py:495 +#: 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:509 +#: app/routes/tryouts.py:552 #, python-format msgid "%(username)s registered for tryout!" msgstr "%(username)s registered for tryout!" -#: app/routes/tryouts.py:545 +#: app/routes/tryouts.py:588 #, python-format msgid "%(username)s removed from tryout." msgstr "%(username)s removed from tryout." -#: app/routes/tryouts.py:563 +#: app/routes/tryouts.py:610 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Team \"%(team_name)s\" created!" -#: app/routes/tryouts.py:594 +#: 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:600 +#: app/routes/tryouts.py:648 msgid "Player is already on this team." msgstr "Player is already on this team." -#: app/routes/tryouts.py:605 +#: app/routes/tryouts.py:653 msgid "Player added to team!" msgstr "Player added to team!" -#: app/routes/tryouts.py:615 +#: 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:651 +#: 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." @@ -630,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:122 +#: 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:135 +#: app/routes/users/accounts.py:151 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -642,29 +695,29 @@ msgstr "" "This is the last active president. Promote another account before " "changing this one." -#: app/routes/users/accounts.py:214 +#: app/routes/users/accounts.py:228 #, python-format msgid "User %(username)s updated successfully!" msgstr "User %(username)s updated successfully!" -#: app/routes/users/accounts.py:235 +#: app/routes/users/accounts.py:249 msgid "Only the president can delete users." msgstr "Only the president can delete users." -#: app/routes/users/accounts.py:239 +#: app/routes/users/accounts.py:253 msgid "You cannot delete your own account." msgstr "You cannot delete your own account." -#: app/routes/users/accounts.py:298 +#: app/routes/users/accounts.py:312 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "User %(deleted_username)s has been removed." -#: app/routes/users/accounts.py:309 +#: app/routes/users/accounts.py:323 msgid "Only the president can create users." msgstr "Only the president can create users." -#: app/routes/users/accounts.py:357 +#: app/routes/users/accounts.py:371 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "User %(full_name)s created as %(role)s!" @@ -673,83 +726,122 @@ msgstr "User %(full_name)s created as %(role)s!" msgid "Only coaches can manage availability." msgstr "Only coaches can manage availability." -#: app/routes/users/contracts.py:84 +#: app/routes/users/contracts.py:86 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Only presidents, managers, and coaches can upload contracts." -#: app/routes/users/contracts.py:103 +#: app/routes/users/contracts.py:105 msgid "You do not have permission to upload a contract for this player." msgstr "You do not have permission to upload a contract for this player." -#: app/routes/users/contracts.py:143 +#: app/routes/users/contracts.py:145 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contract uploaded successfully for %(username)s!" -#: app/routes/users/contracts.py:157 +#: app/routes/users/contracts.py:159 msgid "Only the player can upload their signed contract." msgstr "Only the player can upload their signed contract." -#: app/routes/users/contracts.py:175 +#: app/routes/users/contracts.py:177 msgid "Signed contract uploaded successfully!" msgstr "Signed contract uploaded successfully!" -#: app/routes/users/contracts.py:185 app/routes/users/contracts.py:200 +#: app/routes/users/contracts.py:187 app/routes/users/contracts.py:202 msgid "You do not have permission to download this contract." msgstr "You do not have permission to download this contract." -#: app/routes/users/contracts.py:203 +#: app/routes/users/contracts.py:205 msgid "No signed contract available." msgstr "No signed contract available." -#: 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." @@ -791,20 +883,20 @@ 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." -#: app/routes/users/profile.py:83 +#: app/routes/users/profile.py:82 msgid "Username already taken." msgstr "Username already taken." -#: app/routes/users/profile.py:93 +#: app/routes/users/profile.py:92 msgid "Email already in use." msgstr "Email already in use." -#: app/routes/users/profile.py:123 +#: app/routes/users/profile.py:131 msgid "Profile updated successfully!" msgstr "Profile updated successfully!" @@ -953,7 +1045,7 @@ msgstr "%(total)s in total" msgid "Next" msgstr "Next" -#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:154 +#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:148 #: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3 msgid "Dashboard" msgstr "Dashboard" @@ -995,38 +1087,34 @@ msgid "My Notes" msgstr "My Notes" #: app/templates/layouts/base.html:106 -msgid "Availability" -msgstr "Availability" - -#: app/templates/layouts/base.html:112 msgid "Notes & One on One" msgstr "Notes & One on One" -#: app/templates/layouts/base.html:119 app/templates/pages/contracts.html:2 +#: app/templates/layouts/base.html:113 app/templates/pages/contracts.html:2 #: app/templates/pages/contracts.html:3 app/templates/pages/profile.html:9 msgid "Contracts" msgstr "Contracts" -#: app/templates/layouts/base.html:126 app/templates/pages/profile.html:2 +#: app/templates/layouts/base.html:120 app/templates/pages/profile.html:2 #: app/templates/pages/profile.html:3 msgid "My Profile" msgstr "My Profile" -#: app/templates/layouts/base.html:137 +#: app/templates/layouts/base.html:131 msgid "Logout" msgstr "Logout" -#: app/templates/layouts/base.html:158 +#: app/templates/layouts/base.html:152 msgid "Toggle dark mode" msgstr "Toggle dark mode" -#: app/templates/layouts/base.html:170 app/templates/layouts/base.html:189 +#: app/templates/layouts/base.html:164 app/templates/layouts/base.html:183 msgid "Dismiss" msgstr "Dismiss" -#: app/templates/layouts/base.html:199 -msgid "Team Tryout Management System" -msgstr "Team Tryout Management System" +#: app/templates/layouts/base.html:193 +msgid "UdeS team manager" +msgstr "UdeS team manager" #: app/templates/layouts/macros.html:116 msgid "Close" @@ -1204,12 +1292,16 @@ 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..." #: app/templates/pages/coach_availability.html:19 -#: app/templates/pages/profile.html:200 app/templates/pages/profile.html:220 +msgid "Save Availability" +msgstr "Save Availability" + +#: app/templates/pages/coach_availability.html:22 +#: app/templates/pages/profile.html:197 app/templates/pages/profile.html:217 msgid "Clear All" msgstr "Clear All" @@ -1372,7 +1464,7 @@ msgid "Role" msgstr "Role" #: app/templates/pages/create_user.html:41 app/templates/pages/login.html:11 -#: app/templates/pages/register.html:139 +#: app/templates/pages/register.html:137 msgid "Password" msgstr "Password" @@ -1535,7 +1627,7 @@ msgstr "Edit Profile" #: app/templates/pages/edit_profile.html:33 #: app/templates/pages/edit_user.html:43 app/templates/pages/profile.html:63 -#: app/templates/pages/register.html:83 +#: app/templates/pages/register.html:81 msgid "E-Sports Profile" msgstr "E-Sports Profile" @@ -1544,12 +1636,12 @@ msgid "Update your competitive gaming profile for tryouts." msgstr "Update your competitive gaming profile for tryouts." #: app/templates/pages/edit_profile.html:37 -#: app/templates/pages/register.html:87 +#: app/templates/pages/register.html:85 msgid "Games You Play" msgstr "Games You Play" #: app/templates/pages/edit_profile.html:47 -#: app/templates/pages/register.html:101 +#: app/templates/pages/register.html:99 msgid "Select all games you're signing in for." msgstr "Select all games you're signing in for." @@ -1593,48 +1685,43 @@ msgid "e.g. Name#1234" msgstr "e.g. Name#1234" #: app/templates/pages/edit_profile.html:87 -#: app/templates/pages/edit_user.html:95 -msgid "(for DMs)" -msgstr "(for DMs)" +#: app/templates/pages/register.html:57 +msgid "Reconnect" +msgstr "Reconnect" -#: app/templates/pages/edit_profile.html:88 -#: app/templates/pages/edit_user.html:96 -msgid "Numeric ID (e.g. 123456789012345678)" -msgstr "Numeric ID (e.g. 123456789012345678)" +#: app/templates/pages/edit_profile.html:87 +#: app/templates/pages/register.html:67 +msgid "Connect Discord Account" +msgstr "Connect Discord Account" -#: app/templates/pages/edit_profile.html:89 -#: app/templates/pages/edit_user.html:97 -msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID" -msgstr "Enable Developer Mode in Discord → Right-click profile → Copy ID" - -#: app/templates/pages/edit_profile.html:94 +#: app/templates/pages/edit_profile.html:93 #: app/templates/pages/edit_user.html:100 msgid "League OS Connection" msgstr "League OS Connection" -#: app/templates/pages/edit_profile.html:95 -#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:131 +#: app/templates/pages/edit_profile.html:94 +#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:129 msgid "League OS profile link or ID" msgstr "League OS profile link or ID" -#: app/templates/pages/edit_profile.html:100 +#: app/templates/pages/edit_profile.html:99 msgid "Change Password" msgstr "Change Password" -#: app/templates/pages/edit_profile.html:101 +#: app/templates/pages/edit_profile.html:100 msgid "Leave blank to keep your current password." msgstr "Leave blank to keep your current password." -#: app/templates/pages/edit_profile.html:103 +#: app/templates/pages/edit_profile.html:102 msgid "New Password" msgstr "New Password" -#: app/templates/pages/edit_profile.html:104 +#: app/templates/pages/edit_profile.html:103 #: app/templates/pages/edit_user.html:107 msgid "Enter new password" msgstr "Enter new password" -#: app/templates/pages/edit_profile.html:109 app/templates/pages/teams.html:296 +#: app/templates/pages/edit_profile.html:108 app/templates/pages/teams.html:296 msgid "Save Changes" msgstr "Save Changes" @@ -1650,6 +1737,18 @@ msgstr "Games" msgid "Enter gamertag for each selected game to link to Tracker Network." msgstr "Enter gamertag for each selected game to link to Tracker Network." +#: app/templates/pages/edit_user.html:95 +msgid "(for DMs)" +msgstr "(for DMs)" + +#: app/templates/pages/edit_user.html:96 +msgid "Numeric ID (e.g. 123456789012345678)" +msgstr "Numeric ID (e.g. 123456789012345678)" + +#: app/templates/pages/edit_user.html:97 +msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID" +msgstr "Enable Developer Mode in Discord → Right-click profile → Copy ID" + #: app/templates/pages/edit_user.html:106 msgid "(leave blank to keep current)" msgstr "(leave blank to keep current)" @@ -1978,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" @@ -2349,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)." @@ -2365,7 +2460,7 @@ msgstr "" "Select time slots when you're available for One on One sessions (8am to " "10pm)." -#: app/templates/pages/profile.html:454 +#: app/templates/pages/profile.html:457 msgid "Click or click-and-drag to select your available hours" msgstr "Click or click-and-drag to select your available hours" @@ -2413,11 +2508,7 @@ msgstr "" msgid "Connected" msgstr "Connected" -#: app/templates/pages/register.html:57 -msgid "Reconnect" -msgstr "Reconnect" - -#: app/templates/pages/register.html:63 +#: app/templates/pages/register.html:61 msgid "" "Discord connected. Game connections have been used to pre-fill your " "profile below." @@ -2426,50 +2517,46 @@ msgstr "" "profile below." #: app/templates/pages/register.html:69 -msgid "Connect Discord Account" -msgstr "Connect Discord Account" - -#: app/templates/pages/register.html:71 msgid "Connect to pre-fill your gamertags from Steam, Battle.net, Xbox, etc." msgstr "Connect to pre-fill your gamertags from Steam, Battle.net, Xbox, etc." -#: app/templates/pages/register.html:74 +#: app/templates/pages/register.html:72 msgid "Discord Username (Manual)" msgstr "Discord Username (Manual)" -#: app/templates/pages/register.html:75 +#: app/templates/pages/register.html:73 msgid "e.g. YourName" msgstr "e.g. YourName" -#: app/templates/pages/register.html:84 +#: app/templates/pages/register.html:82 msgid "Set up your competitive gaming profile for tryouts." msgstr "Set up your competitive gaming profile for tryouts." -#: app/templates/pages/register.html:129 +#: app/templates/pages/register.html:127 msgid "League OS Connection (Optional)" msgstr "League OS Connection (Optional)" -#: app/templates/pages/register.html:133 +#: app/templates/pages/register.html:131 msgid "Connect your League OS profile for organized play." msgstr "Connect your League OS profile for organized play." -#: app/templates/pages/register.html:137 +#: app/templates/pages/register.html:135 msgid "Security" msgstr "Security" -#: app/templates/pages/register.html:140 +#: app/templates/pages/register.html:138 msgid "Create a password" msgstr "Create a password" -#: app/templates/pages/register.html:144 +#: app/templates/pages/register.html:142 msgid "Confirm Password" msgstr "Confirm Password" -#: app/templates/pages/register.html:145 +#: app/templates/pages/register.html:143 msgid "Confirm your password" msgstr "Confirm your password" -#: app/templates/pages/register.html:159 +#: app/templates/pages/register.html:157 msgid "Create Account" msgstr "Create Account" @@ -2984,3 +3071,14 @@ msgstr "View Profile" #~ msgid "Invalid date or time format." #~ msgstr "Invalid date or time format." +#~ msgid "Availability" +#~ msgstr "Availability" + +#~ msgid "Team Tryout Management System" +#~ msgstr "Team Tryout Management System" + +#~ msgid "Player and content are required." +#~ msgstr "Player and content are required." + +#~ msgid "Save Disponibilities" +#~ msgstr "Save Disponibilities" diff --git a/app/translations/fr/LC_MESSAGES/messages.mo b/app/translations/fr/LC_MESSAGES/messages.mo index b9f3180..156d19d 100644 Binary files a/app/translations/fr/LC_MESSAGES/messages.mo and b/app/translations/fr/LC_MESSAGES/messages.mo differ diff --git a/app/translations/fr/LC_MESSAGES/messages.po b/app/translations/fr/LC_MESSAGES/messages.po index 690e0cc..6bbdabb 100644 --- a/app/translations/fr/LC_MESSAGES/messages.po +++ b/app/translations/fr/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-11 20:39-0400\n" +"POT-Creation-Date: 2026-08-17 14:18-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -23,8 +23,8 @@ msgstr "" msgid "Please log in to access this page." msgstr "Veuillez vous connecter pour accéder à cette page." -#: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374 -#: app/routes/users/contracts.py:96 +#: app/forms.py:37 app/routes/auth.py:229 app/routes/auth.py:388 +#: app/routes/auth.py:402 app/routes/users/contracts.py:98 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s : %(msg)s" @@ -63,7 +63,7 @@ msgstr "Le nom d’utilisateur est obligatoire." msgid "Password is required." msgstr "Le mot de passe est obligatoire." -#: app/validators.py:227 app/validators.py:299 +#: app/validators.py:227 app/validators.py:324 msgid "Username must be 3-80 characters." msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères." @@ -71,169 +71,209 @@ msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères." msgid "Email must be 120 characters or less." msgstr "L’adresse courriel ne doit pas dépasser 120 caractères." -#: app/validators.py:246 app/validators.py:314 app/validators.py:345 -#: app/validators.py:409 +#: app/validators.py:246 app/validators.py:339 app/validators.py:370 +#: app/validators.py:433 msgid "Full name is required." msgstr "Le nom complet est obligatoire." -#: app/validators.py:281 +#: app/validators.py:276 msgid "Passwords do not match." msgstr "Les mots de passe ne concordent pas." -#: app/validators.py:318 app/validators.py:353 -msgid "Invalid role selected." -msgstr "Rôle sélectionné invalide." - -#: app/validators.py:454 -msgid "Player must be selected." -msgstr "Vous devez choisir un joueur." - -#: app/validators.py:457 -msgid "Notes must be 2000 characters or less." -msgstr "Les notes ne doivent pas dépasser 2000 caractères." - -#: app/validators.py:494 app/validators.py:853 -msgid "Invalid coach selection." -msgstr "Sélection de coach invalide." - -#: app/validators.py:500 app/validators.py:859 -msgid "Invalid manager selection." -msgstr "Sélection de gérant invalide." - -#: app/validators.py:511 -msgid "Invalid player selection." -msgstr "Sélection de joueur invalide." - -#: app/validators.py:515 -msgid "Unknown roster status." -msgstr "Statut d'effectif inconnu." - -#: app/validators.py:538 -msgid "Date must be in YYYY-MM-DD format." -msgstr "La date doit être au format AAAA-MM-JJ." - -#: app/validators.py:539 -msgid "A date is required." -msgstr "Une date est requise." - -#: app/validators.py:545 app/validators.py:610 -msgid "Start time must be in HH:MM format." -msgstr "L’heure de début doit être au format HH:MM." - -#: app/validators.py:546 app/validators.py:611 -msgid "A start time is required." -msgstr "Une heure de début est requise." - -#: app/validators.py:552 -msgid "End time must be in HH:MM format." -msgstr "L’heure de fin doit être au format HH:MM." - -#: app/validators.py:553 -msgid "An end time is required." -msgstr "Une heure de fin est requise." - -#: app/validators.py:557 -msgid "Points must be 2000 characters or less." -msgstr "Les points ne doivent pas dépasser 2000 caractères." - -#: app/validators.py:572 -msgid "End time must be after start time." -msgstr "L'heure de fin doit être postérieure à l'heure de début." - -#: app/validators.py:601 app/validators.py:603 -msgid "Day must be 0 (Monday) to 6 (Sunday)." -msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)." - -#: app/validators.py:604 -msgid "A day is required." -msgstr "Un jour est requis." - -#: app/validators.py:639 -msgid "Player selection is malformed." -msgstr "La sélection de joueurs est mal formée." - -#: app/validators.py:665 app/validators.py:775 -msgid "A title is required." -msgstr "Un titre est requis." - -#: app/validators.py:674 -msgid "Invalid date format." -msgstr "Format de date invalide." - -#: app/validators.py:679 app/validators.py:686 -msgid "Invalid time format." -msgstr "Format d’heure invalide." - -#: app/validators.py:680 -msgid "Start time is required. Please select a time slot." -msgstr "L’heure de début est obligatoire. Choisissez une plage horaire." - -#: app/validators.py:694 -msgid "Unknown match status." -msgstr "Statut de match inconnu." - -#: app/validators.py:710 -msgid "The end time must come after the start time." -msgstr "L'heure de fin doit être postérieure à l'heure de début." - -#: app/validators.py:724 -msgid "Unknown match type." -msgstr "Type de match inconnu." - -#: app/validators.py:736 -msgid "A team cannot play against itself." -msgstr "Une équipe ne peut pas jouer contre elle-même." - -#: app/validators.py:784 +#: app/validators.py:284 app/validators.py:924 msgid "Unknown game." msgstr "Jeu inconnu." -#: app/validators.py:789 +#: app/validators.py:291 +msgid "Gamertag must be between 1 and 120 characters." +msgstr "Le gamertag doit compter de 1 à 120 caractères." + +#: app/validators.py:297 +msgid "Platform must be 30 characters or less." +msgstr "La plateforme ne doit pas dépasser 30 caractères." + +#: app/validators.py:306 +msgid "Unknown platform for this game." +msgstr "Plateforme inconnue pour ce jeu." + +#: app/validators.py:343 app/validators.py:378 +msgid "Invalid role selected." +msgstr "Rôle sélectionné invalide." + +#: app/validators.py:473 app/validators.py:596 app/validators.py:629 +msgid "Player must be selected." +msgstr "Vous devez choisir un joueur." + +#: app/validators.py:476 +msgid "Notes must be 2000 characters or less." +msgstr "Les notes ne doivent pas dépasser 2000 caractères." + +#: app/validators.py:513 app/validators.py:993 +msgid "Invalid coach selection." +msgstr "Sélection de coach invalide." + +#: app/validators.py:519 app/validators.py:999 +msgid "Invalid manager selection." +msgstr "Sélection de gérant invalide." + +#: app/validators.py:537 app/validators.py:595 app/validators.py:628 +msgid "Invalid player selection." +msgstr "Sélection de joueur invalide." + +#: app/validators.py:546 +msgid "Unknown roster status." +msgstr "Statut d'effectif inconnu." + +#: app/validators.py:559 +msgid "Unknown tryout status." +msgstr "Statut de sélection inconnu." + +#: app/validators.py:570 +msgid "Unknown registration status." +msgstr "Statut d’inscription inconnu." + +#: app/validators.py:583 +msgid "Team name must be between 1 and 100 characters." +msgstr "Le nom de l’équipe doit compter de 1 à 100 caractères." + +#: app/validators.py:603 +msgid "Position must be 50 characters or less." +msgstr "La position ne doit pas dépasser 50 caractères." + +#: app/validators.py:616 +msgid "Note content must be between 1 and 5000 characters." +msgstr "La note doit compter de 1 à 5000 caractères." + +#: app/validators.py:642 +msgid "Select at most one note context." +msgstr "Sélectionnez au plus un contexte pour la note." + +#: app/validators.py:654 +msgid "Rejection reason must be 2000 characters or less." +msgstr "Le motif de refus ne doit pas dépasser 2000 caractères." + +#: app/validators.py:678 +msgid "Date must be in YYYY-MM-DD format." +msgstr "La date doit être au format AAAA-MM-JJ." + +#: app/validators.py:679 +msgid "A date is required." +msgstr "Une date est requise." + +#: app/validators.py:685 app/validators.py:750 +msgid "Start time must be in HH:MM format." +msgstr "L’heure de début doit être au format HH:MM." + +#: app/validators.py:686 app/validators.py:751 +msgid "A start time is required." +msgstr "Une heure de début est requise." + +#: app/validators.py:692 +msgid "End time must be in HH:MM format." +msgstr "L’heure de fin doit être au format HH:MM." + +#: app/validators.py:693 +msgid "An end time is required." +msgstr "Une heure de fin est requise." + +#: app/validators.py:697 +msgid "Points must be 2000 characters or less." +msgstr "Les points ne doivent pas dépasser 2000 caractères." + +#: app/validators.py:712 +msgid "End time must be after start time." +msgstr "L'heure de fin doit être postérieure à l'heure de début." + +#: app/validators.py:741 app/validators.py:743 +msgid "Day must be 0 (Monday) to 6 (Sunday)." +msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)." + +#: app/validators.py:744 +msgid "A day is required." +msgstr "Un jour est requis." + +#: app/validators.py:779 +msgid "Player selection is malformed." +msgstr "La sélection de joueurs est mal formée." + +#: app/validators.py:805 app/validators.py:915 +msgid "A title is required." +msgstr "Un titre est requis." + +#: app/validators.py:814 +msgid "Invalid date format." +msgstr "Format de date invalide." + +#: app/validators.py:819 app/validators.py:826 +msgid "Invalid time format." +msgstr "Format d’heure invalide." + +#: app/validators.py:820 +msgid "Start time is required. Please select a time slot." +msgstr "L’heure de début est obligatoire. Choisissez une plage horaire." + +#: app/validators.py:834 +msgid "Unknown match status." +msgstr "Statut de match inconnu." + +#: app/validators.py:850 +msgid "The end time must come after the start time." +msgstr "L'heure de fin doit être postérieure à l'heure de début." + +#: app/validators.py:864 +msgid "Unknown match type." +msgstr "Type de match inconnu." + +#: app/validators.py:876 +msgid "A team cannot play against itself." +msgstr "Une équipe ne peut pas jouer contre elle-même." + +#: app/validators.py:929 msgid "Invalid start date format." msgstr "Format de date de début invalide." -#: app/validators.py:790 +#: app/validators.py:930 msgid "A start date is required." msgstr "Une date de début est requise." -#: app/validators.py:796 +#: app/validators.py:936 msgid "Invalid end date format." msgstr "Format de date de fin invalide." -#: app/validators.py:804 +#: 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:807 +#: 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:819 +#: 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:846 app/validators.py:847 +#: app/validators.py:986 app/validators.py:987 msgid "Team name is required." msgstr "Le nom de l’équipe est obligatoire." -#: app/validators.py:871 +#: app/validators.py:1011 msgid "Scores run from 1 to 10." msgstr "Les notes vont de 1 à 10." -#: app/validators.py:872 +#: 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:241 +#: app/routes/auth.py:246 msgid "This account has been deactivated." msgstr "Ce compte a été désactivé." -#: app/routes/auth.py:276 +#: app/routes/auth.py:281 #, python-format msgid "Welcome back, %(username)s!" msgstr "Bon retour, %(username)s !" -#: app/routes/auth.py:306 +#: app/routes/auth.py:311 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." @@ -241,27 +281,32 @@ msgstr "" "Échec de la connexion. Vérifiez le nom d’utilisateur et le mot de passe, " "ou demandez de l’aide à un président." -#: app/routes/auth.py:363 +#: 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:388 app/routes/users/accounts.py:329 +#: app/routes/auth.py:419 app/routes/users/accounts.py:343 msgid "Username already exists." msgstr "Ce nom d’utilisateur est déjà pris." -#: app/routes/auth.py:392 app/routes/users/accounts.py:333 +#: 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:436 +#: 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: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:457 +#: app/routes/auth.py:497 msgid "Discord OAuth2 is not configured." msgstr "La connexion Discord n’est pas configurée." -#: app/routes/auth.py:498 +#: app/routes/auth.py:546 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -269,27 +314,35 @@ msgstr "" "L’autorisation Discord n’a pas pu être vérifiée. Relancez la connexion " "depuis cette page." -#: app/routes/auth.py:507 +#: app/routes/auth.py:555 msgid "Discord authorization failed. No code received." msgstr "L’autorisation Discord a échoué : aucun code reçu." -#: app/routes/auth.py:531 +#: app/routes/auth.py:579 msgid "Failed to connect to Discord. Please try again." msgstr "Impossible de joindre Discord. Veuillez réessayer." -#: app/routes/auth.py:535 +#: app/routes/auth.py:583 msgid "Failed to obtain Discord access token." msgstr "Impossible d’obtenir le jeton d’accès Discord." -#: app/routes/auth.py:550 +#: app/routes/auth.py: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:597 +#: 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:634 +msgid "Discord account connected!" +msgstr "Compte Discord connecté !" + +#: 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:625 +#: app/routes/auth.py:709 msgid "You have been logged out." msgstr "Vous avez été déconnecté." @@ -323,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:519 app/routes/tryouts.py:555 -#: app/routes/tryouts.py:574 +#: 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é." @@ -334,37 +387,37 @@ msgstr "Accès refusé." msgid "That language is not available." msgstr "Cette langue n’est pas disponible." -#: app/routes/matches.py:364 +#: app/routes/matches.py:383 msgid "You do not have permission to schedule matches for this tryout." msgstr "Vous n’avez pas les droits pour planifier des matchs pour cette sélection." -#: app/routes/matches.py:368 app/routes/matches.py:448 +#: app/routes/matches.py:387 app/routes/matches.py:463 msgid "This tryout has ended. Matches can no longer be created or modified." msgstr "" "Cette sélection est terminée. Les matchs ne peuvent plus être créés ni " "modifiés." -#: app/routes/matches.py:430 +#: app/routes/matches.py:445 msgid "Match scheduled successfully!" msgstr "Match planifié." -#: app/routes/matches.py:444 app/routes/team_matches.py:220 +#: app/routes/matches.py:459 app/routes/team_matches.py:220 msgid "You do not have permission to edit this match." msgstr "Vous n’avez pas les droits pour modifier ce match." -#: app/routes/matches.py:539 app/routes/team_matches.py:250 +#: app/routes/matches.py:552 app/routes/team_matches.py:250 msgid "Match updated successfully!" msgstr "Match mis à jour." -#: app/routes/matches.py:575 app/routes/team_matches.py:265 +#: app/routes/matches.py:588 app/routes/team_matches.py:265 msgid "You do not have permission to delete this match." msgstr "Vous n’avez pas les droits pour supprimer ce match." -#: app/routes/matches.py:578 +#: app/routes/matches.py:591 msgid "This tryout has ended. Matches can no longer be deleted." msgstr "Cette sélection est terminée. Les matchs ne peuvent plus être supprimés." -#: app/routes/matches.py:591 app/routes/team_matches.py:269 +#: app/routes/matches.py:604 app/routes/team_matches.py:269 msgid "Match deleted successfully." msgstr "Match supprimé." @@ -467,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:484 app/routes/tryouts.py:585 +#: app/routes/teams.py:490 app/routes/tryouts.py:524 msgid "Please select a player." msgstr "Veuillez choisir un joueur." @@ -485,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." @@ -495,130 +548,130 @@ msgstr "%(username)s ne fait pas partie de %(name)s." msgid "%(username)s removed from %(name)s." msgstr "%(username)s a été retiré de %(name)s." -#: app/routes/teams.py:572 app/routes/teams.py:590 +#: app/routes/teams.py:572 app/routes/teams.py:594 msgid "You do not have permission to add notes to this team." msgstr "Vous n’avez pas les droits pour ajouter des notes à cette équipe." -#: app/routes/teams.py:580 +#: app/routes/teams.py:584 msgid "Team notes added successfully!" msgstr "Notes d’équipe ajoutées." -#: app/routes/teams.py:595 app/routes/users/notes.py:207 -#: app/routes/users/notes.py:250 +#: app/routes/teams.py:599 app/routes/users/notes.py:222 +#: app/routes/users/notes.py:262 msgid "Can only add notes for players." msgstr "Il n’est possible d’ajouter des notes que pour des joueurs." -#: app/routes/teams.py:611 +#: app/routes/teams.py:619 #, python-format msgid "Note added for %(username)s!" msgstr "Note ajoutée pour %(username)s." -#: app/routes/tryouts.py:98 +#: app/routes/tryouts.py:125 msgid "You do not have permission to create tryouts." msgstr "Vous n’avez pas les droits pour créer une sélection." -#: app/routes/tryouts.py:145 +#: app/routes/tryouts.py:172 msgid "Tryout created successfully!" msgstr "Sélection créée." -#: app/routes/tryouts.py:158 +#: app/routes/tryouts.py:185 msgid "You do not have permission to edit this tryout." msgstr "Vous n’avez pas les droits pour modifier cette sélection." -#: app/routes/tryouts.py:162 +#: app/routes/tryouts.py:189 msgid "This tryout has ended and can no longer be modified." msgstr "Cette sélection est terminée et ne peut plus être modifiée." -#: app/routes/tryouts.py:202 +#: app/routes/tryouts.py:229 msgid "Tryout updated successfully!" msgstr "Sélection mise à jour." -#: app/routes/tryouts.py:242 +#: app/routes/tryouts.py:269 msgid "You do not have permission to view this tryout." msgstr "Vous n’avez pas les droits pour consulter cette sélection." -#: app/routes/tryouts.py:411 +#: app/routes/tryouts.py:437 msgid "Only players can register for tryouts." msgstr "Seuls les joueurs peuvent s’inscrire à une sélection." -#: app/routes/tryouts.py:415 +#: app/routes/tryouts.py:442 msgid "This tryout is not accepting registrations." msgstr "Cette sélection n’accepte pas d’inscriptions." -#: app/routes/tryouts.py:422 +#: app/routes/tryouts.py:449 msgid "You are already registered for this tryout." msgstr "Vous êtes déjà inscrit à cette sélection." -#: app/routes/tryouts.py:428 app/routes/tryouts.py:503 +#: app/routes/tryouts.py:455 app/routes/tryouts.py:546 msgid "This tryout is full." msgstr "Cette sélection est complète." -#: app/routes/tryouts.py:434 +#: app/routes/tryouts.py:461 msgid "Successfully registered for tryout!" msgstr "Inscription à la sélection réussie." -#: app/routes/tryouts.py:450 +#: app/routes/tryouts.py:481 #, python-format msgid "Tryout status updated to %(new_status)s." msgstr "Statut de la sélection mis à jour : %(new_status)s." -#: app/routes/tryouts.py:470 +#: app/routes/tryouts.py:505 msgid "Registration status updated." msgstr "Statut d’inscription mis à jour." -#: app/routes/tryouts.py:489 +#: app/routes/tryouts.py:532 msgid "Can only register players." msgstr "Seuls des joueurs peuvent être inscrits." -#: app/routes/tryouts.py:495 +#: 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:509 +#: 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:545 +#: 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:563 +#: app/routes/tryouts.py:610 #, python-format msgid "Team \"%(team_name)s\" created!" msgstr "Équipe « %(team_name)s » créée." -#: app/routes/tryouts.py:594 +#: app/routes/tryouts.py:643 app/routes/users/notes.py:350 msgid "That player is not registered for this tryout." msgstr "Ce joueur n’est pas inscrit à cette sélection." -#: app/routes/tryouts.py:600 +#: 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:605 +#: app/routes/tryouts.py:653 msgid "Player added to team!" msgstr "Joueur ajouté à l’équipe." -#: app/routes/tryouts.py:615 +#: app/routes/tryouts.py:663 msgid "You do not have permission to delete this tryout." msgstr "Vous n’avez pas les droits pour supprimer cette sélection." -#: app/routes/tryouts.py:651 +#: app/routes/tryouts.py:699 msgid "Tryout deleted successfully." msgstr "Sélection supprimée." -#: app/routes/users/_shared.py:51 +#: app/routes/users/_shared.py:50 msgid "No file selected." msgstr "Aucun fichier sélectionné." -#: app/routes/users/_shared.py:55 +#: app/routes/users/_shared.py:54 msgid "Only PDF files are allowed for contracts." msgstr "Seuls les fichiers PDF sont acceptés pour les contrats." -#: app/routes/users/_shared.py:60 +#: app/routes/users/_shared.py:59 msgid "That file is not a PDF, whatever its name says." msgstr "Ce fichier n’est pas un PDF, quel que soit son nom." @@ -634,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:122 +#: 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:135 +#: app/routes/users/accounts.py:151 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -648,29 +701,29 @@ msgstr "" "C’est le dernier président actif. Promouvez un autre compte avant de " "modifier celui-ci." -#: app/routes/users/accounts.py:214 +#: 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:235 +#: 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:239 +#: 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:298 +#: app/routes/users/accounts.py:312 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "L’utilisateur %(deleted_username)s a été supprimé." -#: app/routes/users/accounts.py:309 +#: 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:357 +#: 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." @@ -679,85 +732,124 @@ msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s." msgid "Only coaches can manage availability." msgstr "Seuls les coachs peuvent gérer leurs disponibilités." -#: app/routes/users/contracts.py:84 +#: app/routes/users/contracts.py:86 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Seuls les présidents, gérants et coachs peuvent téléverser un contrat." -#: app/routes/users/contracts.py:103 +#: app/routes/users/contracts.py:105 msgid "You do not have permission to upload a contract for this player." msgstr "Vous n’avez pas les droits pour téléverser un contrat pour ce joueur." -#: app/routes/users/contracts.py:143 +#: app/routes/users/contracts.py:145 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contrat téléversé pour %(username)s." -#: app/routes/users/contracts.py:157 +#: app/routes/users/contracts.py:159 msgid "Only the player can upload their signed contract." msgstr "Seul le joueur peut téléverser son contrat signé." -#: app/routes/users/contracts.py:175 +#: app/routes/users/contracts.py:177 msgid "Signed contract uploaded successfully!" msgstr "Contrat signé téléversé." -#: app/routes/users/contracts.py:185 app/routes/users/contracts.py:200 +#: app/routes/users/contracts.py:187 app/routes/users/contracts.py:202 msgid "You do not have permission to download this contract." msgstr "Vous n’avez pas les droits pour télécharger ce contrat." -#: app/routes/users/contracts.py:203 +#: app/routes/users/contracts.py:205 msgid "No signed contract available." msgstr "Aucun contrat signé disponible." -#: app/routes/users/notes.py:34 +#: app/routes/users/notes.py:43 msgid "This page is for players only." msgstr "Cette page est réservée aux joueurs." -#: app/routes/users/notes.py:71 +#: app/routes/users/notes.py:80 msgid "Only coaches can access the notes dashboard." msgstr "Seuls les coachs ont accès au tableau des notes." -#: app/routes/users/notes.py:161 +#: app/routes/users/notes.py:172 msgid "Only coaches can manage team notes." msgstr "Seuls les coachs peuvent gérer les notes d’équipe." -#: app/routes/users/notes.py:167 +#: app/routes/users/notes.py:178 msgid "You are not assigned to a team." msgstr "Vous n’êtes assigné à aucune équipe." -#: app/routes/users/notes.py:180 +#: app/routes/users/notes.py:195 msgid "Team notes saved successfully!" msgstr "Notes d’équipe enregistrées." -#: app/routes/users/notes.py:195 +#: app/routes/users/notes.py:210 msgid "Only coaches can manage personal notes." msgstr "Seuls les coachs peuvent gérer les notes personnelles." -#: app/routes/users/notes.py:202 app/routes/users/notes.py:245 -#: app/routes/users/notes.py:296 app/routes/users/notes.py:350 -msgid "Player and content are required." -msgstr "Le joueur et le contenu sont obligatoires." - -#: app/routes/users/notes.py:211 app/routes/users/notes.py:254 -#: app/routes/users/notes.py:300 app/routes/users/notes.py:354 +#: app/routes/users/notes.py:226 app/routes/users/notes.py:266 +#: app/routes/users/notes.py:346 app/routes/users/notes.py:411 msgid "You can only write notes about players you work with." msgstr "" "Vous ne pouvez écrire des notes que sur les joueurs avec qui vous " "travaillez." -#: app/routes/users/notes.py:221 app/routes/users/notes.py:267 +#: app/routes/users/notes.py:236 app/routes/users/notes.py:306 #, python-format msgid "Note added for %(username)s." msgstr "Note ajoutée pour %(username)s." -#: app/routes/users/notes.py:235 app/routes/users/notes.py:281 -#: app/routes/users/notes.py:334 +#: app/routes/users/notes.py:250 app/routes/users/notes.py:320 +#: app/routes/users/notes.py:384 msgid "Only coaches can add personal notes." msgstr "Seuls les coachs peuvent ajouter des notes personnelles." -#: app/routes/users/notes.py:311 app/routes/users/notes.py:365 +#: app/routes/users/notes.py:272 +msgid "You cannot use that match as note context." +msgstr "Vous ne pouvez pas utiliser ce match comme contexte de note." + +#: app/routes/users/notes.py:275 +msgid "That player did not participate in the selected match." +msgstr "Ce joueur n’a pas participé au match sélectionné." + +#: app/routes/users/notes.py:281 +msgid "You cannot use that tryout as note context." +msgstr "Vous ne pouvez pas utiliser cette sélection comme contexte de note." + +#: app/routes/users/notes.py:284 +msgid "That player is not registered for the selected tryout." +msgstr "Ce joueur n’est pas inscrit à la sélection choisie." + +#: app/routes/users/notes.py:290 +msgid "You cannot use that team as note context." +msgstr "Vous ne pouvez pas utiliser cette équipe comme contexte de note." + +#: app/routes/users/notes.py:293 +msgid "That player is not on the selected team." +msgstr "Ce joueur ne fait pas partie de l’équipe sélectionnée." + +#: app/routes/users/notes.py:325 +msgid "You do not have permission to add notes for this tryout." +msgstr "Vous n’avez pas les droits pour ajouter des notes à cette sélection." + +#: app/routes/users/notes.py:342 +msgid "Invalid tryout context." +msgstr "Contexte de sélection invalide." + +#: app/routes/users/notes.py:361 app/routes/users/notes.py:426 msgid "Note added successfully." msgstr "Note ajoutée." +#: app/routes/users/notes.py:389 +msgid "You do not have permission to add notes for this match." +msgstr "Vous n’avez pas les droits pour ajouter des notes à ce match." + +#: app/routes/users/notes.py:407 +msgid "Invalid match context." +msgstr "Contexte de match invalide." + +#: app/routes/users/notes.py:415 +msgid "That player did not participate in this match." +msgstr "Ce joueur n’a pas participé à ce match." + #: app/routes/users/one_on_one.py:23 msgid "Only players can request One on One sessions." msgstr "Seuls les joueurs peuvent demander une rencontre individuelle." @@ -799,20 +891,20 @@ 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." -#: app/routes/users/profile.py:83 +#: app/routes/users/profile.py:82 msgid "Username already taken." msgstr "Ce nom d’utilisateur est déjà pris." -#: app/routes/users/profile.py:93 +#: app/routes/users/profile.py:92 msgid "Email already in use." msgstr "Cette adresse courriel est déjà utilisée." -#: app/routes/users/profile.py:123 +#: app/routes/users/profile.py:131 msgid "Profile updated successfully!" msgstr "Profil mis à jour." @@ -959,7 +1051,7 @@ msgstr "%(total)s au total" msgid "Next" msgstr "Suivant" -#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:154 +#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:148 #: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3 msgid "Dashboard" msgstr "Tableau de bord" @@ -1001,38 +1093,34 @@ msgid "My Notes" msgstr "Mes notes" #: app/templates/layouts/base.html:106 -msgid "Availability" -msgstr "Disponibilités" - -#: app/templates/layouts/base.html:112 msgid "Notes & One on One" msgstr "Notes et rencontres individuelles" -#: app/templates/layouts/base.html:119 app/templates/pages/contracts.html:2 +#: app/templates/layouts/base.html:113 app/templates/pages/contracts.html:2 #: app/templates/pages/contracts.html:3 app/templates/pages/profile.html:9 msgid "Contracts" msgstr "Contrats" -#: app/templates/layouts/base.html:126 app/templates/pages/profile.html:2 +#: app/templates/layouts/base.html:120 app/templates/pages/profile.html:2 #: app/templates/pages/profile.html:3 msgid "My Profile" msgstr "Mon profil" -#: app/templates/layouts/base.html:137 +#: app/templates/layouts/base.html:131 msgid "Logout" msgstr "Déconnexion" -#: app/templates/layouts/base.html:158 +#: app/templates/layouts/base.html:152 msgid "Toggle dark mode" msgstr "Basculer le mode sombre" -#: app/templates/layouts/base.html:170 app/templates/layouts/base.html:189 +#: app/templates/layouts/base.html:164 app/templates/layouts/base.html:183 msgid "Dismiss" msgstr "Fermer" -#: app/templates/layouts/base.html:199 -msgid "Team Tryout Management System" -msgstr "Système de gestion des sélections d’équipe" +#: app/templates/layouts/base.html:193 +msgid "UdeS team manager" +msgstr "UdeS team manager" #: app/templates/layouts/macros.html:116 msgid "Close" @@ -1212,12 +1300,16 @@ 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..." #: app/templates/pages/coach_availability.html:19 -#: app/templates/pages/profile.html:200 app/templates/pages/profile.html:220 +msgid "Save Availability" +msgstr "Enregistrer les disponibilités" + +#: app/templates/pages/coach_availability.html:22 +#: app/templates/pages/profile.html:197 app/templates/pages/profile.html:217 msgid "Clear All" msgstr "Tout effacer" @@ -1378,7 +1470,7 @@ msgid "Role" msgstr "Rôle" #: app/templates/pages/create_user.html:41 app/templates/pages/login.html:11 -#: app/templates/pages/register.html:139 +#: app/templates/pages/register.html:137 msgid "Password" msgstr "Mot de passe" @@ -1541,7 +1633,7 @@ msgstr "Modifier le profil" #: app/templates/pages/edit_profile.html:33 #: app/templates/pages/edit_user.html:43 app/templates/pages/profile.html:63 -#: app/templates/pages/register.html:83 +#: app/templates/pages/register.html:81 msgid "E-Sports Profile" msgstr "Profil e-sport" @@ -1550,12 +1642,12 @@ msgid "Update your competitive gaming profile for tryouts." msgstr "Mettez à jour votre profil de joueur compétitif pour les sélections." #: app/templates/pages/edit_profile.html:37 -#: app/templates/pages/register.html:87 +#: app/templates/pages/register.html:85 msgid "Games You Play" msgstr "Jeux auxquels vous jouez" #: app/templates/pages/edit_profile.html:47 -#: app/templates/pages/register.html:101 +#: app/templates/pages/register.html:99 msgid "Select all games you're signing in for." msgstr "Sélectionnez tous les jeux pour lesquels vous vous inscrivez." @@ -1599,50 +1691,43 @@ msgid "e.g. Name#1234" msgstr "ex. : Nom#1234" #: app/templates/pages/edit_profile.html:87 -#: app/templates/pages/edit_user.html:95 -msgid "(for DMs)" -msgstr "(pour les messages privés)" +#: app/templates/pages/register.html:57 +msgid "Reconnect" +msgstr "Reconnecter" -#: app/templates/pages/edit_profile.html:88 -#: app/templates/pages/edit_user.html:96 -msgid "Numeric ID (e.g. 123456789012345678)" -msgstr "Identifiant numérique (ex. : 123456789012345678)" +#: app/templates/pages/edit_profile.html:87 +#: app/templates/pages/register.html:67 +msgid "Connect Discord Account" +msgstr "Connecter un compte Discord" -#: app/templates/pages/edit_profile.html:89 -#: app/templates/pages/edit_user.html:97 -msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID" -msgstr "" -"Activez le mode développeur dans Discord → clic droit sur le profil → " -"Copier l’identifiant" - -#: app/templates/pages/edit_profile.html:94 +#: app/templates/pages/edit_profile.html:93 #: app/templates/pages/edit_user.html:100 msgid "League OS Connection" msgstr "Connexion League OS" -#: app/templates/pages/edit_profile.html:95 -#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:131 +#: app/templates/pages/edit_profile.html:94 +#: app/templates/pages/edit_user.html:101 app/templates/pages/register.html:129 msgid "League OS profile link or ID" msgstr "Lien ou identifiant du profil League OS" -#: app/templates/pages/edit_profile.html:100 +#: app/templates/pages/edit_profile.html:99 msgid "Change Password" msgstr "Changer le mot de passe" -#: app/templates/pages/edit_profile.html:101 +#: app/templates/pages/edit_profile.html:100 msgid "Leave blank to keep your current password." msgstr "Laissez vide pour conserver votre mot de passe actuel." -#: app/templates/pages/edit_profile.html:103 +#: app/templates/pages/edit_profile.html:102 msgid "New Password" msgstr "Nouveau mot de passe" -#: app/templates/pages/edit_profile.html:104 +#: app/templates/pages/edit_profile.html:103 #: app/templates/pages/edit_user.html:107 msgid "Enter new password" msgstr "Saisir le nouveau mot de passe" -#: app/templates/pages/edit_profile.html:109 app/templates/pages/teams.html:296 +#: app/templates/pages/edit_profile.html:108 app/templates/pages/teams.html:296 msgid "Save Changes" msgstr "Enregistrer les modifications" @@ -1658,6 +1743,20 @@ msgstr "Jeux" msgid "Enter gamertag for each selected game to link to Tracker Network." msgstr "Saisissez un pseudo par jeu sélectionné pour le lier à Tracker Network." +#: app/templates/pages/edit_user.html:95 +msgid "(for DMs)" +msgstr "(pour les messages privés)" + +#: app/templates/pages/edit_user.html:96 +msgid "Numeric ID (e.g. 123456789012345678)" +msgstr "Identifiant numérique (ex. : 123456789012345678)" + +#: app/templates/pages/edit_user.html:97 +msgid "Enable Developer Mode in Discord → Right-click profile → Copy ID" +msgstr "" +"Activez le mode développeur dans Discord → clic droit sur le profil → " +"Copier l’identifiant" + #: app/templates/pages/edit_user.html:106 msgid "(leave blank to keep current)" msgstr "(laisser vide pour conserver l’actuel)" @@ -1991,23 +2090,23 @@ msgstr "" "Les indicateurs verts signalent les joueurs disponibles à la date et à " "l’heure du match" -#: app/templates/pages/match_form.html:625 +#: app/templates/pages/match_form.html:632 msgid "Click time slots consecutively to set match duration" msgstr "Cliquez des plages consécutives pour définir la durée du match" -#: app/templates/pages/match_form.html:892 +#: app/templates/pages/match_form.html:899 msgid "No players registered" msgstr "Aucun joueur inscrit" -#: app/templates/pages/match_form.html:925 +#: app/templates/pages/match_form.html:932 msgid "T1" msgstr "É1" -#: app/templates/pages/match_form.html:926 +#: app/templates/pages/match_form.html:933 msgid "T2" msgstr "É2" -#: app/templates/pages/match_form.html:931 +#: app/templates/pages/match_form.html:938 msgid "No players available" msgstr "Aucun joueur disponible" @@ -2362,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)." @@ -2378,7 +2473,7 @@ msgstr "" "Choisissez les plages où vous êtes disponible pour des rencontres " "individuelles (8 h à 22 h)." -#: app/templates/pages/profile.html:454 +#: app/templates/pages/profile.html:457 msgid "Click or click-and-drag to select your available hours" msgstr "Cliquez ou faites glisser pour choisir vos heures de disponibilité" @@ -2426,11 +2521,7 @@ msgstr "" msgid "Connected" msgstr "Connecté" -#: app/templates/pages/register.html:57 -msgid "Reconnect" -msgstr "Reconnecter" - -#: app/templates/pages/register.html:63 +#: app/templates/pages/register.html:61 msgid "" "Discord connected. Game connections have been used to pre-fill your " "profile below." @@ -2439,52 +2530,48 @@ msgstr "" "ci-dessous." #: app/templates/pages/register.html:69 -msgid "Connect Discord Account" -msgstr "Connecter un compte Discord" - -#: app/templates/pages/register.html:71 msgid "Connect to pre-fill your gamertags from Steam, Battle.net, Xbox, etc." msgstr "" "Connectez-vous pour pré-remplir vos pseudos depuis Steam, Battle.net, " "Xbox, etc." -#: app/templates/pages/register.html:74 +#: app/templates/pages/register.html:72 msgid "Discord Username (Manual)" msgstr "Nom d’utilisateur Discord (saisie manuelle)" -#: app/templates/pages/register.html:75 +#: app/templates/pages/register.html:73 msgid "e.g. YourName" msgstr "ex. : VotrePseudo" -#: app/templates/pages/register.html:84 +#: app/templates/pages/register.html:82 msgid "Set up your competitive gaming profile for tryouts." msgstr "Configurez votre profil de joueur compétitif pour les sélections." -#: app/templates/pages/register.html:129 +#: app/templates/pages/register.html:127 msgid "League OS Connection (Optional)" msgstr "Connexion League OS (facultative)" -#: app/templates/pages/register.html:133 +#: app/templates/pages/register.html:131 msgid "Connect your League OS profile for organized play." msgstr "Liez votre profil League OS pour le jeu organisé." -#: app/templates/pages/register.html:137 +#: app/templates/pages/register.html:135 msgid "Security" msgstr "Sécurité" -#: app/templates/pages/register.html:140 +#: app/templates/pages/register.html:138 msgid "Create a password" msgstr "Créez un mot de passe" -#: app/templates/pages/register.html:144 +#: app/templates/pages/register.html:142 msgid "Confirm Password" msgstr "Confirmer le mot de passe" -#: app/templates/pages/register.html:145 +#: app/templates/pages/register.html:143 msgid "Confirm your password" msgstr "Confirmez votre mot de passe" -#: app/templates/pages/register.html:159 +#: app/templates/pages/register.html:157 msgid "Create Account" msgstr "Créer le compte" @@ -3008,3 +3095,14 @@ msgstr "Voir le profil" #~ msgid "Invalid date or time format." #~ msgstr "Format de date ou d’heure invalide." +#~ msgid "Availability" +#~ msgstr "Disponibilités" + +#~ msgid "Team Tryout Management System" +#~ msgstr "Système de gestion des sélections d’équipe" + +#~ msgid "Player and content are required." +#~ msgstr "Le joueur et le contenu sont obligatoires." + +#~ msgid "Save Disponibilities" +#~ msgstr "Enregistrer mes disponibilités" diff --git a/app/validators.py b/app/validators.py index 7c25b24..4271819 100644 --- a/app/validators.py +++ b/app/validators.py @@ -23,7 +23,7 @@ from marshmallow import ( validates_schema, ) -from app.models import ESPORT_GAMES, USER_TYPES +from app.models import ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES # ============================================================================= # Custom Validators @@ -256,11 +256,6 @@ class RegisterSchema(StripMixin): allow_none=True, load_default=None, ) - discord_user_id = fields.String( - validate=validate_discord_user_id, - allow_none=True, - load_default=None, - ) league_os_profile = fields.String( validate=validate.Length(max=256), allow_none=True, @@ -281,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. @@ -392,7 +417,6 @@ class EditProfileSchema(StripMixin): phone: Optional. password: Optional (only if changing). discord_username: Optional. - discord_user_id: Optional. league_os_profile: Optional. games: Optional list. """ @@ -428,11 +452,6 @@ class EditProfileSchema(StripMixin): allow_none=True, load_default=None, ) - discord_user_id = fields.String( - validate=validate_discord_user_id, - allow_none=True, - load_default=None, - ) league_os_profile = fields.String( validate=validate.Length(max=256), allow_none=True, @@ -528,6 +547,115 @@ class TeamPlayerSchema(PlayerSelectionSchema): ) +TRYOUT_STATUSES = ('upcoming', 'in_progress', 'completed') +TRYOUT_REGISTRATION_STATUSES = ('registered', 'attended', 'no_show') + + +class TryoutStatusSchema(StripMixin): + """A state transition requested from the tryout detail page.""" + + status = fields.String( + required=True, + validate=validate.OneOf(TRYOUT_STATUSES, error=_l('Unknown tryout status.')), + ) + + +class TryoutRegistrationStatusSchema(StripMixin): + """Attendance state for one tryout registration.""" + + status = fields.String( + required=True, + validate=validate.OneOf( + TRYOUT_REGISTRATION_STATUSES, + error=_l('Unknown registration status.'), + ), + ) + + +class TryoutTeamSchema(StripMixin): + """A tryout-local team created from its compact inline form.""" + + team_name = fields.String( + required=True, + validate=validate.Length( + min=1, + max=100, + error=_l('Team name must be between 1 and 100 characters.'), + ), + ) + + +class TryoutTeamMemberSchema(StripMixin): + """A registered player and their optional position on a tryout team.""" + + player_id = fields.Integer( + required=True, + validate=validate.Range(min=1), + error_messages={ + 'invalid': _l('Invalid player selection.'), + 'required': _l('Player must be selected.'), + }, + ) + position = fields.String( + load_default='', + validate=validate.Length( + max=50, + error=_l('Position must be 50 characters or less.'), + ), + ) + + +class NoteContentSchema(StripMixin): + """Bounded text stored as a team or personal coaching note.""" + + content = fields.String( + required=True, + validate=validate.Length( + min=1, + max=5000, + error=_l('Note content must be between 1 and 5000 characters.'), + ), + ) + + +class PersonalNoteSchema(NoteContentSchema): + """A personal note with at most one optional, typed context.""" + + player_id = fields.Integer( + required=True, + validate=validate.Range(min=1), + error_messages={ + 'invalid': _l('Invalid player selection.'), + 'required': _l('Player must be selected.'), + }, + ) + match_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1)) + tryout_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1)) + team_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1)) + + @validates_schema + def validate_one_context(self, data, **kwargs): + """A note cannot claim several unrelated contexts at once.""" + contexts = [data.get(name) for name in ('match_id', 'tryout_id', 'team_id')] + if sum(value is not None for value in contexts) > 1: + raise ValidationError( + _l('Select at most one note context.'), + field_name='context', + ) + + +class OneOnOneRejectionSchema(StripMixin): + """Optional explanation sent to a player when a request is rejected.""" + + rejection_reason = fields.String( + load_default='', + validate=validate.Length( + max=2000, + error=_l('Rejection reason must be 2000 characters or less.'), + ), + ) + + class OneOnOneRequestSchema(StripMixin): """A player asking their coach for a session (MNT-12). diff --git a/docs/database-schema.md b/docs/database-schema.md index b65ff54..4e747b0 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -1,8 +1,9 @@ # Le schéma réel, et comment sortir de `create_all()` -> **État au 2026-08-11** : l'outil de relevé existe et est testé. Le relevé -> lui-même n'a pas été exécuté — il demande un accès à la base de production, -> qui ne peut pas venir du dépôt. Tout ce qui suit attend cette exécution. +> **État au 2026-08-16** : l'outil de relevé existe et est testé, y compris +> pour les collisions d'identité Discord. Le relevé lui-même n'a pas été +> exécuté — il demande un accès à la base de production, qui ne peut pas venir +> du dépôt. Tout changement de schéma ci-dessous attend cette exécution. ## Pourquoi c'est le nœud @@ -60,6 +61,14 @@ existe-t-il encore ? (`SEC-003`) : python app/supporting_scripts/schema_report.py --check-seed-accounts ``` +L'identité Discord est désormais conservée côté serveur et toute nouvelle +collision est refusée par l'application. Les doublons historiques restent à +identifier avant d'ajouter la contrainte `UNIQUE` de `SEC-012` : + +```bash +python app/supporting_scripts/schema_report.py --check-discord-identities +``` + ## Étape 3 — Alembic, décrivant le schéma **réel** (`DB-002`) Le piège de cette étape tient en une phrase : **la migration initiale doit @@ -106,12 +115,12 @@ 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 | | `ARCH-001` | Fusionner coach/équipe sur la relation m2m | Migration de données ; `app/permissions.py` rend la duplication inoffensive **en lecture** seulement, l'écriture crée toujours les deux | -| `SEC-012` | Identité Discord côté serveur, `unique=True` | La colonne doit être unique, donc dédoublonnée d'abord | +| `SEC-012` | Ajouter `unique=True` sur l'identité Discord | La valeur OAuth reste côté serveur et les nouvelles collisions sont refusées ; les lignes historiques doivent être dédoublonnées d'abord | ## Ce qu'on ne fait pas diff --git a/docs/deployment.md b/docs/deployment.md index 24329a3..a8e37f4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -282,11 +282,11 @@ have sent new contracts to a new tree and made the existing ones unreadable `logs/` and `backups/` were built from `os.getcwd()` too (OBS-006), and the backup script kept its own copy of the document path — so it archived `./documents` no matter what `DOCUMENTS_ROOT` said. Following prerequisite 2 -was therefore enough, on its own, to make every contract backup empty; the -script prints `No documents directory…` and still exits 0, so a scheduled -task watching the exit code would have seen green indefinitely. All three -roots now come from `app/storage.py`, and the backup run prints the document -source it used. +was therefore enough, on its own, to make every contract backup empty. All +three roots now come from `app/storage.py`, and the backup run prints the +document source it used. A missing or unarchivable document store makes the +run exit non-zero even when the database dump itself is valid, so a scheduler +cannot report a database-only recovery point as a complete backup. **After setting `DOCUMENTS_ROOT` on the node, run the backup once by hand** and check the `Document source:` line and the size of the resulting @@ -327,4 +327,4 @@ Monitor these logs regularly for suspicious activity. - Run `python security_scan.py` after any configuration changes - Test backup restoration quarterly - Review and rotate `SECRET_KEY` if compromised -- Keep Python and system packages updated \ No newline at end of file +- Keep Python and system packages updated diff --git a/pyproject.toml b/pyproject.toml index 101fff0..91a98b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,11 +21,14 @@ filterwarnings = [ "default", # discord.py imports audioop, removed from the stdlib in 3.13. "ignore:'audioop' is deprecated:DeprecationWarning", - # Every model uses datetime.utcnow as a column default. Tracked as - # DB-009; the warning would otherwise drown the run. - "ignore:datetime.datetime.utcnow:DeprecationWarning", ] +[tool.coverage.report] +# The exhaustive audit established a 71% baseline. Keep one point of margin +# for platform-specific branches while making any material regression fail CI. +fail_under = 70 +show_missing = true + [tool.ruff] line-length = 100 target-version = "py312" diff --git a/tests/test_backup.py b/tests/test_backup.py index 65d004b..7441f1b 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -119,3 +119,15 @@ class TestExitCodes: def test_verifying_a_missing_archive_fails(self, tmp_path): assert backup_module.main(['--verify-only', str(tmp_path / 'nope.dump')]) == 1 + + def test_a_missing_document_store_makes_an_otherwise_valid_run_incomplete( + self, monkeypatch, tmp_path + ): + monkeypatch.setenv('DATABASE_URL', URL) + monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'missing-documents')) + monkeypatch.setattr(backup_module, 'BACKUP_DIR', str(tmp_path / 'backups')) + monkeypatch.setattr(backup_module, 'backup_database', lambda conn: 'database.dump') + monkeypatch.setattr(backup_module, 'verify_backup', lambda path: True) + monkeypatch.setattr(backup_module, 'cleanup_old_backups', lambda: None) + + assert backup_module.main([]) == 1 diff --git a/tests/test_bot_error_families.py b/tests/test_bot_error_families.py index 919626c..8eba102 100644 --- a/tests/test_bot_error_families.py +++ b/tests/test_bot_error_families.py @@ -150,9 +150,10 @@ def _pending(bot, message_id, row_id): def _confirmed(row_id): + from app.extensions import db from app.models import MatchParticipant - return MatchParticipant.query.get(row_id).attendance_confirmed + return db.session.get(MatchParticipant, row_id).attendance_confirmed class TestTheDatabaseRefusedTheWrite: diff --git a/tests/test_csp.py b/tests/test_csp.py index 3002992..dc55df3 100644 --- a/tests/test_csp.py +++ b/tests/test_csp.py @@ -31,6 +31,14 @@ INLINE_HANDLER = re.compile( re.I, ) +# An event attribute assembled inside a JavaScript string is absent from the +# template DOM, so the expression above cannot see it. Once assigned through +# innerHTML it is still an inline handler and the CSP still refuses to run it. +DYNAMIC_INLINE_HANDLER = re.compile( + r'''["']on(?:click|change|submit|input|load|keyup|keydown|mouseover|focus|blur)\s*=''', + re.I, +) + #: Remaining inline handlers, per template. Lower these as you migrate; #: never raise one. Templates absent from this map must have none. #: No template may carry an inline event handler. The migration is done; @@ -52,7 +60,8 @@ def _templates(): def _count_handlers(path): with open(path, encoding='utf-8') as handle: - return len(INLINE_HANDLER.findall(handle.read())) + content = handle.read() + return len(INLINE_HANDLER.findall(content)) + len(DYNAMIC_INLINE_HANDLER.findall(content)) class TestPolicyHeader: @@ -102,6 +111,29 @@ class TestPolicyHeader: class TestInlineHandlerRatchet: + def test_a_handler_built_inside_a_javascript_string_is_counted(self, tmp_path): + template = tmp_path / 'dynamic-handler.html' + template.write_text("html += '