Merge branch 'audit/securite-maintenabilite-standards' of https://git.immortal.host/clubesportsudes/team-tryouts into audit/securite-maintenabilite-standards
CI - Security, Lint & Tests / validate (push) Failing after 54s

This commit is contained in:
cedrick2711
2026-08-19 19:11:38 -04:00
64 changed files with 2673 additions and 1027 deletions
+45
View File
@@ -0,0 +1,45 @@
name: CI - Security, Lint & Tests
on:
push:
pull_request:
workflow_dispatch:
# This workflow validates branches only. It has no deployment step and no
# write permission, so an audit-branch push cannot alter main or production.
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
cache: pip
- name: Install dependencies
run: pip install -r requirements.txt -r requirements-dev.txt
- name: Audit declared dependencies
run: pip-audit -r requirements.txt
- name: Lint and check formatting
run: |
ruff check .
ruff format --check .
- name: Run tests with coverage gate
run: pytest --cov=app --cov-report=term-missing --cov-report=xml
- name: Run repository security checks
env:
SECRET_KEY: audit-ci-key-not-for-production-1234567890
DATABASE_URL: 'sqlite:///:memory:'
FLASK_DEBUG: 'false'
run: python app/supporting_scripts/security_scan.py --skip-http
+2 -1
View File
@@ -2,7 +2,7 @@ name: CI - Security & Lint
on:
push:
branches: [main, master]
branches: [main, master, 'audit/**']
pull_request:
branches: [main, master]
workflow_dispatch: # Allow manual triggers
@@ -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
+5 -2
View File
@@ -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.
+13 -8
View File
@@ -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):
+30
View File
@@ -61,3 +61,33 @@ def form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('pass
if not payload.get(name):
payload.pop(name, None)
return payload
def form_gamertags(selected_games):
"""Validate the dynamic gamertag fields for the selected games.
These fields cannot be declared statically on the account schemas: their
names contain the game label. They are still untrusted form data, so
every caller uses this shared boundary before adding or changing rows.
"""
from marshmallow import ValidationError
from app.models import GAME_PLATFORMS
from app.validators import GamertagSchema
validated = {}
for game in selected_games:
raw_gamertag = request.form.get(f'gamertag_{game}', '')
raw_platform = (
request.form.get(f'platform_{game}', '') if GAME_PLATFORMS.get(game) else None
)
if not raw_gamertag.strip():
continue
try:
validated[game] = GamertagSchema().load(
{'game': game, 'gamertag': raw_gamertag, 'platform': raw_platform}
)
except ValidationError as err:
messages = [message for values in err.messages.values() for message in values]
raise ValidationError({f'gamertag_{game}': messages}) from err
return validated
+3 -4
View File
@@ -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)
+3 -4
View File
@@ -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):
+3 -4
View File
@@ -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'),
+2 -3
View File
@@ -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)
+2 -3
View File
@@ -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)
+2 -3
View File
@@ -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)
+2 -3
View File
@@ -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')
+2 -3
View File
@@ -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)
+3 -4
View File
@@ -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)
+2 -3
View File
@@ -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')
+2 -3
View File
@@ -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")
+3 -4
View File
@@ -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])
+2 -3
View File
@@ -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')
+2 -3
View File
@@ -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)
+2 -3
View File
@@ -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)
+107 -22
View File
@@ -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,14 +454,12 @@ 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:
for game, gamertag_data in submitted_gamertags.items():
gamertag = UserGamertag(
user_id=user.id,
game=game,
gamertag=gamertag_value,
gamertag=gamertag_data['gamertag'],
platform=gamertag_data['platform'],
)
db.session.add(gamertag)
db.session.commit()
@@ -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,
+30 -41
View File
@@ -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)
+7 -9
View File
@@ -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)
+27 -14
View File
@@ -46,6 +46,25 @@ def match_form_payload():
return form_payload(list_fields=('player_ids',), optional_blank=())
def registered_players(tryout_id):
"""Players registered for one tryout, loaded in a single query.
The create form previously called ``User.query.get`` twice per
registration (once in the filter and once in the result expression),
and the edit form called it once per row. Besides scaling linearly, both
paths could return duplicates while DB-006 is still pending. The join is
bounded and ``distinct`` preserves the form's intended one-option-per-
player contract until the database constraint lands.
"""
return (
User.query.join(TryoutRegistration, TryoutRegistration.player_id == User.id)
.filter(TryoutRegistration.tryout_id == tryout_id)
.order_by(User.username)
.distinct()
.all()
)
#: How long a match lasts when the form gives a start and no end.
DEFAULT_MATCH_MINUTES = 30
@@ -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
+7 -8
View File
@@ -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
+31 -24
View File
@@ -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,14 +566,18 @@ 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)
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')
@@ -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,9 +606,13 @@ 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)
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')
+72 -33
View File
@@ -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
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=new_status), 'success')
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,9 +493,13 @@ 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
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)
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=team_name), 'success')
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'))
+15 -11
View File
@@ -4,7 +4,6 @@ Nothing here touches the blueprint: these are plain functions, so a test
can call them with a request context and nothing else.
"""
from flask import request
from flask_babel import gettext as _
from app.extensions import db
@@ -13,7 +12,7 @@ from app.extensions import db
# routes needed them as well (ARCH-005). Importing them from here still
# works, so the thirty call sites in this package did not have to move.
from app.forms import flash_validation_errors, form_payload # noqa: F401
from app.models import GAME_PLATFORMS, Admin, Coach, Manager, Player, Scout, UserGamertag
from app.models import Admin, Coach, Manager, Player, Scout, UserGamertag
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
@@ -63,20 +62,25 @@ def pdf_upload_error(file, allowed_extensions):
def update_user_gamertags(user, selected_games):
"""Update gamertags for a user based on form input."""
"""Update gamertags for a user from validated dynamic form fields."""
from app.forms import form_gamertags
submitted = form_gamertags(selected_games)
existing_gamertags = {gt.game: gt for gt in user.gamertags}
for game in selected_games:
gamertag = request.form.get(f'gamertag_{game}', '').strip()
platform = (
request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None
)
payload = submitted.get(game)
existing = existing_gamertags.get(game)
if gamertag:
if payload:
if existing:
existing.gamertag = gamertag
existing.platform = platform
existing.gamertag = payload['gamertag']
existing.platform = payload['platform']
else:
gt = UserGamertag(user_id=user.id, game=game, gamertag=gamertag, platform=platform)
gt = UserGamertag(
user_id=user.id,
game=game,
gamertag=payload['gamertag'],
platform=payload['platform'],
)
db.session.add(gt)
elif existing:
db.session.delete(existing)
+19 -5
View File
@@ -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)
+13 -13
View File
@@ -141,19 +141,23 @@ 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(
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'],
@@ -171,11 +175,7 @@ def add_disponibilities_bulk():
}
)
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)
+6 -6
View File
@@ -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'))
+109 -48
View File
@@ -7,23 +7,32 @@ it reads exactly what the coach routes write.
from flask import flash, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Coach,
Match,
MatchParticipant,
OneOnOneRequest,
OrgTeam,
PersonalNote,
Player,
Team,
TeamMember,
TeamNote,
Tryout,
TryoutRegistration,
User,
)
from app.permissions import coach_can_access_player, coach_org_teams, coach_player_ids
from app.permissions import (
coach_can_access_player,
coach_org_teams,
coach_player_ids,
coach_tryouts,
)
from app.routes.users.blueprint import users_bp
from app.validators import NoteContentSchema, PersonalNoteSchema
@users_bp.route('/my-notes')
@@ -116,23 +125,25 @@ def notes_dashboard():
)
# For context selectors in the form
# PersonalNote.team_id references a tryout-local Team, not OrgTeam. The
# previous selector mixed the two namespaces and could either attach the
# note to an unrelated team with the same integer id or fail its FK.
# Every context list now comes from the tryouts this coach may manage.
tryouts = list(reversed(coach_tryouts(current_user)))[:20]
tryout_ids = [tryout.id for tryout in tryouts]
matches = (
Match.query.filter(
db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'),
)
Match.query.filter(Match.tryout_id.in_(tryout_ids))
.order_by(Match.date.desc())
.limit(20)
.all()
if tryout_ids
else []
)
tryouts = (
Tryout.query.filter_by(
created_by=current_user.id,
teams = (
Team.query.filter(Team.tryout_id.in_(tryout_ids)).order_by(Team.name).all()
if tryout_ids
else []
)
.order_by(Tryout.date.desc())
.limit(20)
.all()
)
teams = OrgTeam.query.order_by(OrgTeam.name).all()
return render_template(
'pages/notes.html',
@@ -168,12 +179,16 @@ def manage_team_notes():
return redirect(url_for('users.notes_dashboard'))
org_team = org_teams[0]
content = request.form.get('content', '').strip()
if content:
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=content,
content=data['content'],
)
db.session.add(note)
db.session.commit()
@@ -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)
+12 -8
View File
@@ -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()
+12 -4
View File
@@ -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')
+22 -19
View File
@@ -55,8 +55,8 @@ PG_RESTORE = os.getenv('PG_RESTORE', 'pg_restore')
# wave G introduced DOCUMENTS_ROOT so a release-directory deployment could
# keep uploads outside the releases, and docs/deployment.md now tells the
# operator to set it — at which point this script archived a directory the
# application had never written to. It does not fail on a missing directory
# either; it prints "No documents directory found", skips, and exits 0.
# application had never written to. A missing or unreadable document store
# is now a failed full-backup run rather than a database-only green result.
#
# So the more correctly an operator followed the deployment documentation,
# the more certainly their contract backups were empty (OBS-006).
@@ -262,33 +262,31 @@ def backup_documents():
module happened to be imported with.
Returns:
str: Path to the created archive, or None if there is nothing to
archive. Signed contracts live only on disk, so losing this
directory loses the documents themselves.
str: Path to the created archive.
Raises:
BackupError: If the configured store is absent or cannot be archived.
Signed contracts live only on disk, so a database-only run must
never be reported as a complete backup.
"""
documents_dir = documents_root()
if not os.path.exists(documents_dir):
# Says where it looked. The previous message named no path, so an
# operator who had moved the documents read it as "there are no
# documents" rather than "I am looking in the wrong place".
print(f'[INFO] No documents directory at {documents_dir}. Skipping document backup.')
return None
raise BackupError(f'Documents directory does not exist: {documents_dir}')
if not os.path.isdir(documents_dir):
raise BackupError(f'Documents path is not a directory: {documents_dir}')
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}')
try:
shutil.make_archive(archive_basename, 'zip', documents_dir)
except Exception as exc: # noqa: BLE001 — a failed document archive must not lose the dump
# This runs after the database dump has already succeeded. Letting
# anything through here would abort the script with a traceback and
# take the one part that worked down with it. Reported to stdout, in
# the format the rest of this script uses; it has no logger.
print(f'[ERROR] Document backup failed: {exc}')
return None
except Exception as exc: # noqa: BLE001 — normalize the shutil boundary
raise BackupError(f'Document backup failed: {exc}') from exc
zip_path = f'{archive_basename}.zip'
if not os.path.exists(zip_path) or os.path.getsize(zip_path) == 0:
raise BackupError('Document archiver reported success but produced an empty file.')
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)')
return zip_path
@@ -361,15 +359,20 @@ def main(argv=None):
return 1
verified = verify_backup(backup_path)
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
+61 -2
View File
@@ -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
+33 -17
View File
@@ -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 <application_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():
+8 -1
View File
@@ -216,7 +216,14 @@ function flash(message, type) {
const flashContainer = document.querySelector('.flash-messages');
const alert = document.createElement('div');
alert.className = 'alert alert-' + type + ' alert-dismissible';
alert.innerHTML = '<span>' + message + '</span><button type="button" class="alert-close" data-action="remove-element">&times;</button>';
const text = document.createElement('span');
text.textContent = message;
const close = document.createElement('button');
close.type = 'button';
close.className = 'alert-close';
close.dataset.action = 'remove-element';
close.textContent = '×';
alert.append(text, close);
flashContainer.appendChild(alert);
}
+5 -6
View File
@@ -79,14 +79,13 @@
</div>
<div class="form-row">
<div class="form-group col-6">
<div class="form-group col-12">
<label for="discord_username"><i class="fab fa-discord"></i> {{ _('Discord Username') }}</label>
<input type="text" id="discord_username" name="discord_username" value="{{ user.discord_username or '' }}" placeholder="{{ _('e.g. Name#1234') }}">
</div>
<div class="form-group col-6">
<label for="discord_user_id"><i class="fab fa-discord"></i> Discord User ID <small>{{ _('(for DMs)') }}</small></label>
<input type="text" id="discord_user_id" name="discord_user_id" value="{{ user.discord_user_id or '' }}" placeholder="{{ _('Numeric ID (e.g. 123456789012345678)') }}">
<small class="text-muted">{{ _('Enable Developer Mode in Discord → Right-click profile → Copy ID') }}</small>
<a href="{{ url_for('auth.discord_login') }}" class="btn btn-secondary mt-2">
<i class="fab fa-discord"></i>
{% if user.discord_user_id %}{{ _('Reconnect') }}{% else %}{{ _('Connect Discord Account') }}{% endif %}
</a>
</div>
</div>
<div class="form-row">
+20 -8
View File
@@ -450,11 +450,18 @@
var playerDataById = {
player_data: {
{%- for p in all_players %}
{{ p.id }}: "{{ p.username | escape }}",
{{ p.id }}: {{ p.username | tojson }},
{%- endfor %}
}
};
var HTML_ESCAPES = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'};
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function (character) {
return HTML_ESCAPES[character];
});
}
// All registered player IDs
var allRegisteredPlayers = [
{%- for p in all_players %}
@@ -555,7 +562,7 @@ document.addEventListener('DOMContentLoaded', function() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn"></span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -568,7 +575,7 @@ document.addEventListener('DOMContentLoaded', function() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn"></span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -661,7 +668,7 @@ function renderMergedDisponibilityGrid() {
}
dayRow += '<div class="' + cssClass + '" data-day="' + day.value + '" data-time="' + slotData.time + '" ' +
'onclick="toggleTimeSlot(' + day.value + ', \'' + slotData.time + '\', this)">' +
'data-action="toggle-time-slot">' +
slotData.display +
'<span class="merged-disponibility-count">' + count + '</span>' +
'</div>';
@@ -920,7 +927,7 @@ function updatePlayerPool() {
var availabilityClass = isAvailable ? 'available' : 'unavailable';
html += '<div class="player-item ' + availabilityClass + '" data-player-id="' + pid + '">';
html += '<span class="player-name">' + playerName + '</span>';
html += '<span class="player-name">' + escapeHtml(playerName) + '</span>';
html += '<div class="player-actions">';
html += '<button type="button" class="btn btn-sm btn-primary" data-action="assign-team" data-team-side="1">{{ _('T1') }}</button>';
html += '<button type="button" class="btn btn-sm btn-secondary" data-action="assign-team" data-team-side="2">{{ _('T2') }}</button>';
@@ -943,7 +950,7 @@ function assignToTeam(playerId, teamSide) {
if (!playerName) return;
var html = '<div class="player-item" data-player-id="' + playerId + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn"></span>';
html += '</div>';
@@ -1062,7 +1069,7 @@ function randomizeTeams() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn"></span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -1074,7 +1081,7 @@ function randomizeTeams() {
var playerName = playerDataById.player_data[pid];
if (playerName) {
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
html += playerName;
html += escapeHtml(playerName);
html += '<span class="remove-btn"></span>';
html += '</div>';
teamDiv.insertAdjacentHTML('beforeend', html);
@@ -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,
+2 -3
View File
@@ -193,8 +193,8 @@
<p class="text-muted">{{ _('Loading...') }}</p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="clearDisponibilities()">
<i class="fas fa-trash"></i> Clear All
<button type="button" class="btn btn-secondary" data-action="clear-disponibilities">
<i class="fas fa-trash"></i> {{ _('Clear All') }}
</button>
</div>
</div>
@@ -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,
});
-2
View File
@@ -57,8 +57,6 @@
<i class="fas fa-sync-alt"></i> {{ _('Reconnect') }}
</a>
</div>
<input type="hidden" name="discord_username" value="{{ discord_data.username }}">
<input type="hidden" name="discord_user_id" value="{{ discord_data.id }}">
<small class="form-text text-success">
<i class="fas fa-check-circle"></i> {{ _('Discord connected. Game connections have been used to pre-fill your profile below.') }}
</small>
+19
View File
@@ -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)
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+140 -12
View File
@@ -23,7 +23,7 @@ from marshmallow import (
validates_schema,
)
from app.models import ESPORT_GAMES, USER_TYPES
from app.models import ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES
# =============================================================================
# Custom Validators
@@ -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).
+14 -5
View File
@@ -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
+5 -5
View File
@@ -282,11 +282,11 @@ have sent new contracts to a new tree and made the existing ones unreadable
`logs/` and `backups/` were built from `os.getcwd()` too (OBS-006), and the
backup script kept its own copy of the document path — so it archived
`./documents` no matter what `DOCUMENTS_ROOT` said. Following prerequisite 2
was therefore enough, on its own, to make every contract backup empty; the
script prints `No documents directory…` and still exits 0, so a scheduled
task watching the exit code would have seen green indefinitely. All three
roots now come from `app/storage.py`, and the backup run prints the document
source it used.
was therefore enough, on its own, to make every contract backup empty. All
three roots now come from `app/storage.py`, and the backup run prints the
document source it used. A missing or unarchivable document store makes the
run exit non-zero even when the database dump itself is valid, so a scheduler
cannot report a database-only recovery point as a complete backup.
**After setting `DOCUMENTS_ROOT` on the node, run the backup once by hand**
and check the `Document source:` line and the size of the resulting
+6 -3
View File
@@ -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"
+12
View File
@@ -119,3 +119,15 @@ class TestExitCodes:
def test_verifying_a_missing_archive_fails(self, tmp_path):
assert backup_module.main(['--verify-only', str(tmp_path / 'nope.dump')]) == 1
def test_a_missing_document_store_makes_an_otherwise_valid_run_incomplete(
self, monkeypatch, tmp_path
):
monkeypatch.setenv('DATABASE_URL', URL)
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'missing-documents'))
monkeypatch.setattr(backup_module, 'BACKUP_DIR', str(tmp_path / 'backups'))
monkeypatch.setattr(backup_module, 'backup_database', lambda conn: 'database.dump')
monkeypatch.setattr(backup_module, 'verify_backup', lambda path: True)
monkeypatch.setattr(backup_module, 'cleanup_old_backups', lambda: None)
assert backup_module.main([]) == 1
+2 -1
View File
@@ -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:
+33 -1
View File
@@ -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 += '<button onclick=\"work()\">';", encoding='utf-8')
assert _count_handlers(template) == 1
def test_dynamic_player_names_are_escaped_before_html_insertion(self):
template = os.path.join(TEMPLATE_ROOT, 'pages', 'match_form.html')
with open(template, encoding='utf-8') as handle:
content = handle.read()
assert 'html += playerName;' not in content
assert "' + playerName + '" not in content
assert content.count('escapeHtml(playerName)') == 6
def test_api_messages_are_written_as_text(self):
template = os.path.join(TEMPLATE_ROOT, 'pages', 'coach_availability.html')
with open(template, encoding='utf-8') as handle:
content = handle.read()
assert 'text.textContent = message' in content
assert "alert.innerHTML = '<span>' + message" not in content
@pytest.mark.parametrize('relative,full', list(_templates()))
def test_a_template_never_gains_an_inline_handler(self, relative, full):
allowed = HANDLER_BUDGET.get(relative, 0)
+222
View File
@@ -0,0 +1,222 @@
"""The Discord snowflake is a verified identity, not profile text.
SEC-AUTH-005. Discord OAuth used to put its result in two hidden inputs;
registration then trusted those client-controlled values, and edit_profile
let the account owner replace the snowflake later. The bot uses that value to
route private messages and authorize reaction-driven writes.
"""
import time
from urllib.parse import parse_qs, urlparse
from app.routes import auth as auth_module
from app.routes.auth import (
MIN_REGISTRATION_SECONDS,
REGISTRATION_ISSUED_KEY,
)
FORM = {
'username': 'brandnew',
'email': '[email protected]',
'password': 'Password123',
'confirm_password': 'Password123',
'full_name': 'Brand New',
}
def _allow_registration(client):
with client.session_transaction() as session:
session[REGISTRATION_ISSUED_KEY] = time.time() - MIN_REGISTRATION_SECONDS - 1
def _user(app, username='brandnew'):
from app.models import User
with app.app_context():
return User.query.filter_by(username=username).first()
class _DiscordResponse:
def __init__(self, payload):
self.payload = payload
def raise_for_status(self):
return None
def json(self):
return self.payload
def _complete_profile_oauth(client, monkeypatch, discord_user_id):
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', 'client-id')
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_SECRET', 'client-secret')
monkeypatch.setattr(
auth_module,
'DISCORD_REDIRECT_URI',
'https://example.test/auth/discord/callback',
)
start = client.get('/auth/discord/login')
state = parse_qs(urlparse(start.headers['Location']).query)['state'][0]
monkeypatch.setattr(
auth_module.requests,
'post',
lambda *args, **kwargs: _DiscordResponse({'access_token': 'token'}),
)
monkeypatch.setattr(
auth_module.requests,
'get',
lambda *args, **kwargs: _DiscordResponse(
{'id': discord_user_id, 'username': 'verified-user'}
),
)
return client.get(f'/auth/discord/callback?code=code&state={state}')
class TestRegistrationIdentity:
def test_a_posted_snowflake_without_oauth_is_ignored(self, app, client):
_allow_registration(client)
client.post(
'/auth/register',
data=dict(FORM, discord_user_id='111111111111111111'),
)
assert _user(app).discord_user_id is None
def test_the_server_side_oauth_identity_wins_over_the_form(self, app, client):
verified = '222222222222222222'
_allow_registration(client)
with client.session_transaction() as session:
session['discord_oauth'] = {
'id': verified,
'username': 'verified-user',
}
client.post(
'/auth/register',
data=dict(
FORM,
discord_user_id='111111111111111111',
discord_username='forged-user',
),
)
created = _user(app)
assert created.discord_user_id == verified
assert created.discord_username == 'verified-user'
def test_an_oauth_identity_already_in_use_is_refused(self, app, client, make_user):
verified = '222222222222222222'
make_user('player', discord_user_id=verified)
_allow_registration(client)
with client.session_transaction() as session:
session['discord_oauth'] = {
'id': verified,
'username': 'verified-user',
}
client.post('/auth/register', data=FORM)
assert _user(app) is None
def test_the_registration_page_has_no_client_identity_field(self, client):
with client.session_transaction() as session:
session['discord_oauth'] = {
'id': '222222222222222222',
'username': 'verified-user',
}
page = client.get('/auth/register').get_data(as_text=True)
assert 'name="discord_user_id"' not in page
class TestProfileIdentity:
def test_a_profile_post_cannot_replace_the_verified_snowflake(self, app, client, as_role):
original = '222222222222222222'
user_id = as_role('player', discord_user_id=original)
client.post(
'/users/profile/edit',
data={
'username': 'player1',
'full_name': 'Player 1',
'email': '[email protected]',
'discord_user_id': '111111111111111111',
},
)
from app.models import User
with app.app_context():
assert (
app.extensions['sqlalchemy'].session.get(User, user_id).discord_user_id == original
)
def test_the_profile_form_does_not_offer_the_snowflake(self, client, as_role):
as_role('player', discord_user_id='222222222222222222')
page = client.get('/users/profile/edit').get_data(as_text=True)
assert 'name="discord_user_id"' not in page
assert '/auth/discord/login' in page
def test_a_signed_in_user_can_relink_only_through_oauth(
self, app, client, as_role, monkeypatch
):
user_id = as_role('player', discord_user_id='111111111111111111')
response = _complete_profile_oauth(
client,
monkeypatch,
discord_user_id='222222222222222222',
)
from app.models import User
assert '/users/profile/edit' in response.headers['Location']
with app.app_context():
user = app.extensions['sqlalchemy'].session.get(User, user_id)
assert user.discord_user_id == '222222222222222222'
assert user.discord_username == 'verified-user'
def test_profile_oauth_refuses_an_identity_owned_by_someone_else(
self, app, client, as_role, make_user, monkeypatch
):
taken = '222222222222222222'
make_user('player', discord_user_id=taken)
user_id = as_role('player', discord_user_id='111111111111111111')
_complete_profile_oauth(client, monkeypatch, discord_user_id=taken)
from app.models import User
with app.app_context():
assert (
app.extensions['sqlalchemy'].session.get(User, user_id).discord_user_id
== '111111111111111111'
)
class TestAdministrativeFallback:
def test_an_admin_cannot_assign_a_snowflake_twice(self, app, client, as_role, make_user):
taken = '222222222222222222'
make_user('player', discord_user_id=taken)
target_id = make_user('player')
as_role('admin')
client.post(
f'/users/{target_id}/edit',
data={
'full_name': 'Target Player',
'email': '[email protected]',
'role': 'player',
'discord_user_id': taken,
},
)
from app.models import User
with app.app_context():
assert app.extensions['sqlalchemy'].session.get(User, target_id).discord_user_id is None
+6 -8
View File
@@ -9,9 +9,8 @@ in the project directory.
The document store was fixed in wave G. The other two were not, and the gap
that opened between them is the reason this file exists: `backup.py` kept
archiving `./documents` while the application wrote to `DOCUMENTS_ROOT`, and
the script's answer to a missing directory is to print a line and exit 0.
Following the deployment documentation was what broke it.
archiving `./documents` while the application wrote to `DOCUMENTS_ROOT`.
The script now resolves the shared root and fails the run when it is absent.
"""
import os
@@ -106,16 +105,15 @@ class TestTheBackupScriptAgreesWithTheApplication:
with zipfile.ZipFile(archive) as zf:
assert any(name.endswith('contrat.pdf') for name in zf.namelist())
def test_a_missing_store_names_the_path_it_looked_in(self, tmp_path, monkeypatch, capsys):
""" "No documents directory found" read as "there are no documents"
rather than "I am looking in the wrong place"."""
def test_a_missing_store_names_the_path_it_looked_in(self, tmp_path, monkeypatch):
"""A missing configured store is an actionable failure, not a skip."""
from app.supporting_scripts import backup
missing = tmp_path / 'not-here'
monkeypatch.setenv('DOCUMENTS_ROOT', str(missing))
assert backup.backup_documents() is None
assert str(missing) in capsys.readouterr().out
with pytest.raises(backup.BackupError, match=str(missing).replace('\\', '\\\\')):
backup.backup_documents()
class TestLogsFollowTheSameRule:
+281
View File
@@ -0,0 +1,281 @@
"""Regression tests for compact POST forms that bypassed the shared schemas."""
from datetime import date, time
from sqlalchemy.dialects import postgresql
from app.models import (
Match,
OneOnOneRequest,
OrgTeam,
PersonalNote,
Team,
TeamMember,
TeamPlayer,
Tryout,
TryoutRegistration,
UserGamertag,
)
def _tryout(db, owner_id, *, coach_id=None):
row = Tryout(
title='Boundary tryout',
game='Valorant',
date=date(2030, 4, 1),
created_by=owner_id,
coach_id=coach_id,
)
db.session.add(row)
db.session.commit()
return row.id
def _give_coach_a_player(db, coach_id, player_id, owner_id):
org_team = OrgTeam(
name=f'Org {coach_id}-{player_id}',
created_by=owner_id,
coach_id=coach_id,
)
db.session.add(org_team)
db.session.flush()
db.session.add(TeamPlayer(org_team_id=org_team.id, player_id=player_id))
db.session.commit()
return org_team.id
def test_tryout_team_name_is_bounded(app, client, as_role):
admin_id = as_role('admin')
from app.extensions import db
with app.app_context():
tryout_id = _tryout(db, admin_id)
response = client.post(
f'/tryouts/{tryout_id}/team/create',
data={'team_name': 'x' * 101},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert Team.query.filter_by(tryout_id=tryout_id).count() == 0
def test_registration_decisions_lock_the_tryout_row():
from app.routes.tryouts import registration_lock_statement
sql = str(registration_lock_statement(42).compile(dialect=postgresql.dialect()))
assert 'FOR UPDATE' in sql
def test_tryout_team_position_is_bounded(app, client, as_role, make_user):
admin_id = as_role('admin')
player_id = make_user('player')
from app.extensions import db
with app.app_context():
tryout_id = _tryout(db, admin_id)
team = Team(tryout_id=tryout_id, name='Blue', created_by=admin_id)
db.session.add(team)
db.session.flush()
team_id = team.id
db.session.add(TryoutRegistration(tryout_id=tryout_id, player_id=player_id))
db.session.commit()
response = client.post(
f'/tryouts/{tryout_id}/team/{team_id}/add',
data={'player_id': player_id, 'position': 'x' * 51},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first() is None
def test_a_coach_cannot_open_an_unrelated_tryout_note_form(app, client, as_role, make_user):
coach_id = as_role('coach')
other_coach_id = make_user('coach')
admin_id = make_user('admin')
player_id = make_user('player')
from app.extensions import db
with app.app_context():
tryout_id = _tryout(db, admin_id, coach_id=other_coach_id)
db.session.add(TryoutRegistration(tryout_id=tryout_id, player_id=player_id))
db.session.commit()
response = client.get(f'/users/personal-notes/tryout/{tryout_id}')
assert response.status_code == 302
assert response.headers['Location'].endswith('/users/notes-dashboard')
assert coach_id != other_coach_id
def test_a_note_cannot_claim_a_team_that_does_not_contain_the_player(
app, client, as_role, make_user
):
coach_id = as_role('coach')
admin_id = make_user('admin')
player_id = make_user('player')
from app.extensions import db
with app.app_context():
_give_coach_a_player(db, coach_id, player_id, admin_id)
tryout_id = _tryout(db, admin_id, coach_id=coach_id)
team = Team(tryout_id=tryout_id, name='No player here', created_by=admin_id)
db.session.add(team)
db.session.commit()
team_id = team.id
response = client.post(
'/users/personal-notes/add',
data={'player_id': player_id, 'content': 'Private note', 'team_id': team_id},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert PersonalNote.query.count() == 0
def test_a_personal_note_is_bounded(app, client, as_role, make_user):
coach_id = as_role('coach')
admin_id = make_user('admin')
player_id = make_user('player')
from app.extensions import db
with app.app_context():
_give_coach_a_player(db, coach_id, player_id, admin_id)
response = client.post(
'/users/personal-notes/manage',
data={'player_id': player_id, 'content': 'x' * 5001},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert PersonalNote.query.count() == 0
def test_a_rejection_reason_is_bounded(app, client, as_role, make_user):
coach_id = as_role('coach')
player_id = make_user('player')
from app.extensions import db
with app.app_context():
request = OneOnOneRequest(
player_id=player_id,
coach_id=coach_id,
date=date(2030, 4, 2),
start_time=time(18, 0),
end_time=time(18, 30),
)
db.session.add(request)
db.session.commit()
request_id = request.id
response = client.post(
f'/users/one-on-one/{request_id}/reject',
data={'rejection_reason': 'x' * 2001},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert db.session.get(OneOnOneRequest, request_id).status == 'pending'
def test_a_match_context_must_contain_the_player(app, client, as_role, make_user):
coach_id = as_role('coach')
admin_id = make_user('admin')
player_id = make_user('player')
from app.extensions import db
with app.app_context():
_give_coach_a_player(db, coach_id, player_id, admin_id)
tryout_id = _tryout(db, admin_id, coach_id=coach_id)
match = Match(
tryout_id=tryout_id,
title='Scrim',
date=date(2030, 4, 2),
match_type='player_vs_player',
created_by=coach_id,
)
db.session.add(match)
db.session.commit()
match_id = match.id
response = client.post(
'/users/personal-notes/add',
data={'player_id': player_id, 'content': 'Private note', 'match_id': match_id},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert PersonalNote.query.count() == 0
def test_the_note_dashboard_lists_tryout_teams_not_org_teams(app, client, as_role, make_user):
coach_id = as_role('coach')
admin_id = make_user('admin')
player_id = make_user('player')
from app.extensions import db
with app.app_context():
_give_coach_a_player(db, coach_id, player_id, admin_id)
tryout_id = _tryout(db, admin_id, coach_id=coach_id)
team = Team(tryout_id=tryout_id, name='Tryout Alpha', created_by=admin_id)
db.session.add(team)
db.session.commit()
team_id = team.id
body = client.get('/users/notes-dashboard').get_data(as_text=True)
assert f'<option value="{team_id}">Tryout Alpha</option>' in body
assert f'>Org {coach_id}-{player_id}</option>' not in body
def test_an_oversized_dynamic_gamertag_is_rejected(app, client, as_role):
player_id = as_role('player')
response = client.post(
'/users/profile/edit',
data={
'username': 'player1',
'full_name': 'Player One',
'email': '[email protected]',
'games': 'Valorant',
'gamertag_Valorant': 'x' * 121,
},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert UserGamertag.query.filter_by(user_id=player_id).count() == 0
def test_a_platform_must_belong_to_the_selected_game(app, client, as_role):
player_id = as_role('player')
response = client.post(
'/users/profile/edit',
data={
'username': 'player1',
'full_name': 'Player One',
'email': '[email protected]',
'games': 'Apex Legends',
'gamertag_Apex Legends': 'LegitName',
'platform_Apex Legends': 'Forged platform',
},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
assert UserGamertag.query.filter_by(user_id=player_id).count() == 0
+61
View File
@@ -202,6 +202,67 @@ class TestCatalogueIntegrity:
f'{len(untranslated)} untranslated string(s) in {locale}: {untranslated[:5]}'
)
# Babel keeps its guessed translation when it marks an entry fuzzy,
# but gettext deliberately ignores that guess at runtime. Merely
# checking m.string therefore let five English fallbacks through after
# the branding merge, including a dangerously wrong French label.
fuzzy = [m.id for m in catalog if m.id and 'fuzzy' in m.flags]
assert not fuzzy, f'{len(fuzzy)} fuzzy string(s) in {locale}: {fuzzy[:5]}'
def test_the_catalogue_contains_every_message_in_the_source(self, tmp_path):
"""A translated PO can still be stale.
The previous guard only inspected entries already in the catalogue.
A new `_()` in Python or Jinja therefore stayed English without any
failure until somebody happened to run extraction by hand.
"""
import os
import subprocess
import sys
from babel.messages.pofile import read_po
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
extracted_path = tmp_path / 'messages.pot'
subprocess.run(
[
sys.executable,
'-m',
'babel.messages.frontend',
'extract',
'-F',
'babel.cfg',
'-k',
'_l',
'-o',
str(extracted_path),
'.',
],
cwd=root,
check=True,
capture_output=True,
text=True,
)
with open(extracted_path, encoding='utf-8') as handle:
source_ids = {message.id for message in read_po(handle) if message.id}
for locale in SUPPORTED_LOCALES:
path = os.path.join(
root,
'app',
'translations',
locale,
'LC_MESSAGES',
'messages.po',
)
with open(path, encoding='utf-8') as handle:
catalogue_ids = {message.id for message in read_po(handle) if message.id}
missing = source_ids - catalogue_ids
assert not missing, (
f'{len(missing)} source string(s) absent from {locale}: '
f'{sorted(missing, key=str)[:5]}'
)
class TestSelectorUnit:
def test_select_locale_returns_a_supported_code(self, app):
+108
View File
@@ -233,6 +233,114 @@ class TestPendingEvaluations:
assert self._pending(client) == 1
class TestRegisteredPlayersForMatchForm:
"""The match forms used to issue one or two user lookups per registration."""
def test_the_result_is_unique_ordered_and_constant_cost(self, app, make_user, count_queries):
admin_id = make_user('admin')
player_ids = [make_user('player', username=name) for name in ('zulu', 'alpha', 'mike')]
with app.app_context():
tryout = Tryout(
title='Match form',
game='Valorant',
date=date(2030, 3, 1),
created_by=admin_id,
)
db.session.add(tryout)
db.session.flush()
for player_id in player_ids:
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
# DB-006 is pending, so prove the UI remains unique even when the
# current database already contains a duplicate registration.
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_ids[0]))
db.session.commit()
tryout_id = tryout.id
from app.routes.matches import registered_players
counter = count_queries()
try:
players = registered_players(tryout_id)
finally:
counter.stop()
assert [player.username for player in players] == ['alpha', 'mike', 'zulu']
assert counter.total == 1
class TestEvaluationLists:
"""Evaluation pages must not issue one lookup per player or evaluator."""
def test_players_to_evaluate_has_a_fixed_query_budget(
self, app, client, as_role, make_user, count_queries
):
coach_id = as_role('coach')
admin_id = make_user('admin')
player_ids = [make_user('player') for _ in range(12)]
with app.app_context():
tryout = Tryout(
title='Evaluation budget',
game='Valorant',
date=date(2030, 3, 1),
created_by=admin_id,
coach_id=coach_id,
)
db.session.add(tryout)
db.session.flush()
for player_id in player_ids:
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
db.session.commit()
tryout_id = tryout.id
counter = count_queries()
try:
response = client.get(f'/evaluations/{tryout_id}/players')
finally:
counter.stop()
assert response.status_code == 200
assert 1 <= counter.total <= 10, f'{counter.total} SELECTs for 12 players'
def test_scout_top_players_are_loaded_with_the_aggregate(
self, app, client, as_role, make_user, count_queries
):
as_role('scout')
evaluator_id = make_user('coach')
admin_id = make_user('admin')
player_ids = [make_user('player') for _ in range(12)]
with app.app_context():
tryout = Tryout(
title='Scout budget',
game='Valorant',
date=date(2030, 3, 1),
created_by=admin_id,
)
db.session.add(tryout)
db.session.flush()
for score, player_id in enumerate(player_ids, start=1):
db.session.add(
Evaluation(
player_id=player_id,
evaluator_id=evaluator_id,
tryout_id=tryout.id,
overall_score=score,
)
)
db.session.commit()
counter = count_queries()
try:
response = client.get('/dashboard')
finally:
counter.stop()
assert response.status_code == 200
assert 1 <= counter.total <= 6, f'{counter.total} SELECTs for the scout dashboard'
class TestViewTryout:
"""PERF-001 — the most-visited page in the application ran one query
per registration, one per player evaluated, one per team, and one per
+33
View File
@@ -257,3 +257,36 @@ class TestSeedAccountCheck:
output = capsys.readouterr().out
assert 'password has been changed' in output
assert 'PASSWORD IS STILL' not in output
class TestDiscordIdentityCheck:
def test_it_finds_identity_collisions_before_the_unique_migration(self, live_db, app, capsys):
engine, _tamper = live_db
path = str(engine.url).replace('sqlite:///', '')
connection = sqlite3.connect(path)
for username in ('alice', 'bob'):
connection.execute(
'INSERT INTO users '
'(username, password_hash, role, full_name, email, '
'is_active_account, discord_user_id) '
"VALUES (?, 'hash', 'player', ?, ?, 1, '222222222222222222')",
(username, username.title(), f'{username}@example.test'),
)
connection.commit()
connection.close()
with app.app_context():
result = main(['--url', str(engine.url), '--check-discord-identities'])
output = capsys.readouterr().out
assert result == 1
assert '222222222222222222: 2 accounts (alice, bob)' in output
def test_a_clean_identity_set_does_not_change_the_exit_code(self, live_db, app, capsys):
engine, _tamper = live_db
with app.app_context():
result = main(['--url', str(engine.url), '--check-discord-identities'])
assert result == 0
assert 'No Discord identity is shared' in capsys.readouterr().out
+93
View File
@@ -0,0 +1,93 @@
"""The security scanner must fail closed when its dependency audit cannot run."""
import json
import subprocess
import urllib.error
from app.supporting_scripts import security_scan
def _result(returncode, stdout='', stderr=''):
return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr)
def test_debug_mode_fails_the_environment_check(monkeypatch):
monkeypatch.setenv('SECRET_KEY', 'x' * 32)
monkeypatch.setenv('DATABASE_URL', 'sqlite:///:memory:')
monkeypatch.setenv('FLASK_DEBUG', 'true')
assert security_scan.check_environment() is False
def test_a_missing_database_url_fails_the_environment_check(monkeypatch):
monkeypatch.setenv('SECRET_KEY', 'x' * 32)
monkeypatch.delenv('DATABASE_URL', raising=False)
monkeypatch.setenv('FLASK_DEBUG', 'false')
assert security_scan.check_environment() is False
def test_an_unreachable_http_target_cannot_report_success(monkeypatch):
def unreachable(*args, **kwargs):
raise urllib.error.URLError('connection refused')
monkeypatch.setattr(security_scan.urllib.request, 'urlopen', unreachable)
assert security_scan.check_https_headers('https://example.test') is False
def test_a_clean_dependency_audit_passes(monkeypatch):
command = []
def clean_audit(args, **kwargs):
command.extend(args)
return _result(0)
monkeypatch.setattr(
security_scan.subprocess,
'run',
clean_audit,
)
assert security_scan.check_dependencies() is True
requirement_flag = command.index('--requirement')
assert command[requirement_flag + 1] == str(security_scan.REQUIREMENTS_FILE)
def test_reported_vulnerabilities_fail_the_scan(monkeypatch):
report = {
'dependencies': [
{
'name': 'example',
'version': '1.0',
'vulns': [{'id': 'PYSEC-TEST'}],
}
]
}
monkeypatch.setattr(
security_scan.subprocess,
'run',
lambda *args, **kwargs: _result(1, stdout=json.dumps(report)),
)
assert security_scan.check_dependencies() is False
def test_an_audit_that_crashes_cannot_report_success(monkeypatch, capsys):
monkeypatch.setattr(
security_scan.subprocess,
'run',
lambda *args, **kwargs: _result(1, stderr='audit unavailable'),
)
assert security_scan.check_dependencies() is False
assert '[FAIL] audit unavailable' in capsys.readouterr().out
def test_an_audit_timeout_cannot_report_success(monkeypatch):
def timeout(*args, **kwargs):
raise subprocess.TimeoutExpired('pip-audit', 60)
monkeypatch.setattr(security_scan.subprocess, 'run', timeout)
assert security_scan.check_dependencies() is False
+3 -1
View File
@@ -135,9 +135,11 @@ class TestThroughTheUploadRoute:
contract_id = Contract.query.one().id
response = client.get(f'/users/contracts/{contract_id}/download')
try:
assert response.status_code == 200
assert response.data.startswith(b'%PDF-')
finally:
response.close()
def _pdf():
+29
View File
@@ -0,0 +1,29 @@
"""Regression tests for the application's timestamp convention."""
import ast
from datetime import UTC, datetime
from pathlib import Path
from app.time_utils import utc_now_naive
def test_utc_now_naive_is_an_explicit_utc_value():
before = datetime.now(UTC).replace(tzinfo=None)
actual = utc_now_naive()
after = datetime.now(UTC).replace(tzinfo=None)
assert actual.tzinfo is None
assert before <= actual <= after
def test_application_does_not_call_deprecated_utcnow():
app_root = Path(__file__).parents[1] / 'app'
offenders = []
for path in app_root.rglob('*.py'):
tree = ast.parse(path.read_text(encoding='utf-8'), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Attribute) and node.attr == 'utcnow':
offenders.append(f'{path.relative_to(app_root)}:{node.lineno}')
assert offenders == []
+46 -10
View File
@@ -92,7 +92,7 @@ class TestPlayerAvailability:
assert PlayerDisponibility.query.count() == 0
class TestBulkAvailabilityReportsWhatItDropped:
class TestBulkAvailabilityReplacementIsAtomic:
def test_valid_slots_are_saved(self, app, client, as_role):
from app.models import PlayerDisponibility
@@ -107,28 +107,64 @@ class TestBulkAvailabilityReportsWhatItDropped:
with app.app_context():
assert PlayerDisponibility.query.count() == 1
def test_a_dropped_slot_is_named(self, app, client, as_role):
"""It used to `continue` and answer success, so the client could not
tell nine saved from ten sent."""
def test_a_good_payload_replaces_the_slots(self, app, client, as_role):
from app.models import PlayerDisponibility
as_role('player')
player_id = as_role('player')
client.post(
'/users/disponibilities/add_bulk',
json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]},
)
response = client.post(
'/users/disponibilities/add_bulk',
json={'slots': [{'day_of_week': 3, 'start_time': '18:00'}]},
)
assert response.status_code == 200
with app.app_context():
slots = PlayerDisponibility.query.filter_by(player_id=player_id).all()
assert [(s.day_of_week, s.start_time) for s in slots] == [(3, time(18, 0))]
def test_an_empty_payload_clears_the_slots(self, app, client, as_role):
from app.models import PlayerDisponibility
player_id = as_role('player')
client.post(
'/users/disponibilities/add_bulk',
json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]},
)
response = client.post('/users/disponibilities/add_bulk', json={'slots': []})
assert response.status_code == 200
with app.app_context():
assert PlayerDisponibility.query.filter_by(player_id=player_id).count() == 0
def test_a_malformed_slot_changes_nothing(self, app, client, as_role):
from app.models import PlayerDisponibility
player_id = as_role('player')
client.post(
'/users/disponibilities/add_bulk',
json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]},
)
response = client.post(
'/users/disponibilities/add_bulk',
json={
'slots': [
{'day_of_week': 1, 'start_time': '09:00'},
{'day_of_week': 3, 'start_time': '18:00'},
{'day_of_week': 1, 'start_time': 'nope'},
]
},
)
body = response.get_json()
assert len(body['created']) == 1
assert len(body['rejected']) == 1, 'the caller must learn a slot was dropped'
assert response.status_code == 400
assert len(response.get_json()['rejected']) == 1
with app.app_context():
assert PlayerDisponibility.query.count() == 1
slots = PlayerDisponibility.query.filter_by(player_id=player_id).all()
assert [(s.day_of_week, s.start_time) for s in slots] == [(1, time(9, 0))]
class TestCoachAvailabilityIsNotWipedByABadPayload: