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
CI - Security, Lint & Tests / validate (push) Failing after 54s
This commit is contained in:
+13
-8
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+112
-27
@@ -8,7 +8,7 @@ password policy enforcement and sign-up screening.
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import requests
|
||||
@@ -18,13 +18,19 @@ from flask_login import current_user, login_required, login_user, logout_user
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import check_password, db, hash_password, limiter
|
||||
from app.forms import form_gamertags
|
||||
from app.i18n import LOCALE_SESSION_KEY
|
||||
from app.logging_config import log_auth_event
|
||||
from app.models import ESPORT_GAMES, Player, User
|
||||
from app.validators import LoginSchema, RegisterSchema
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id
|
||||
|
||||
#: Session key holding the pending OAuth2 anti-forgery token.
|
||||
DISCORD_STATE_KEY = 'discord_oauth_state'
|
||||
#: Whether the OAuth result should create a registration draft or relink the
|
||||
#: signed-in account. Kept server-side and covered by the same signed session
|
||||
#: as the anti-forgery state.
|
||||
DISCORD_PURPOSE_KEY = 'discord_oauth_purpose'
|
||||
|
||||
# Failed-attempt tracking. The tally is kept for the audit trail and for the
|
||||
# cool-off marker below; it no longer refuses a correct password (SEC-018).
|
||||
@@ -289,7 +295,7 @@ def login():
|
||||
)
|
||||
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
||||
minutes = cooloff_minutes(user.failed_login_attempts)
|
||||
user.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
user.locked_until = utc_now_naive() + timedelta(minutes=minutes)
|
||||
log_auth_event(
|
||||
'account.throttled',
|
||||
username=username,
|
||||
@@ -354,6 +360,15 @@ def register():
|
||||
form_data = dict(request.form)
|
||||
form_data['games'] = request.form.getlist('games')
|
||||
|
||||
# Once Discord has authenticated the identity, neither its display
|
||||
# name nor its snowflake is input data anymore. Remove any client
|
||||
# copies before validation as well as before persistence: otherwise a
|
||||
# forged, malformed hidden value can still make the verified flow fail.
|
||||
discord_oauth = session.get('discord_oauth') or {}
|
||||
if discord_oauth.get('id'):
|
||||
form_data.pop('discord_username', None)
|
||||
form_data.pop('discord_user_id', None)
|
||||
|
||||
refusal = check_registration_challenge(request.form)
|
||||
if refusal is not None:
|
||||
# Logged, because this is the only place abuse of the sign-up
|
||||
@@ -380,8 +395,25 @@ def register():
|
||||
full_name = validated['full_name']
|
||||
phone = validated.get('phone')
|
||||
selected_games = validated.get('games', [])
|
||||
discord_username = validated.get('discord_username')
|
||||
discord_user_id = validated.get('discord_user_id')
|
||||
try:
|
||||
submitted_gamertags = form_gamertags(selected_games)
|
||||
except ValidationError as err:
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
# The OAuth identity is server-side state. It used to be copied into
|
||||
# hidden inputs and read back from request.form, which let anyone
|
||||
# replace the verified Discord account before submitting (SEC-AUTH-005).
|
||||
# A manual registration may still provide a display name, but never a
|
||||
# Discord snowflake: that identifier is an authentication factor for
|
||||
# bot reactions and must come from Discord itself.
|
||||
discord_user_id = discord_oauth.get('id')
|
||||
if discord_user_id:
|
||||
discord_user_id = str(discord_user_id)
|
||||
discord_username = (
|
||||
discord_oauth.get('username') if discord_user_id else validated.get('discord_username')
|
||||
)
|
||||
league_os_profile = validated.get('league_os_profile')
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
@@ -392,6 +424,13 @@ def register():
|
||||
flash(_('Email already registered.'), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
# The database constraint belongs to DB-002, after production has
|
||||
# been backed up and deduplicated. Refuse new duplicates now instead
|
||||
# of leaving the critical impersonation path open until then.
|
||||
if discord_user_id and User.query.filter_by(discord_user_id=discord_user_id).first():
|
||||
flash(_('This Discord account is already linked to another account.'), 'danger')
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
hashed_password = hash_password(password)
|
||||
user = Player(
|
||||
username=username,
|
||||
@@ -415,16 +454,14 @@ def register():
|
||||
# Create UserGamertag records for each selected game
|
||||
from app.models import UserGamertag
|
||||
|
||||
for game in selected_games:
|
||||
field_name = f'gamertag_{game}'
|
||||
gamertag_value = request.form.get(field_name, '').strip()
|
||||
if gamertag_value:
|
||||
gamertag = UserGamertag(
|
||||
user_id=user.id,
|
||||
game=game,
|
||||
gamertag=gamertag_value,
|
||||
)
|
||||
db.session.add(gamertag)
|
||||
for game, gamertag_data in submitted_gamertags.items():
|
||||
gamertag = UserGamertag(
|
||||
user_id=user.id,
|
||||
game=game,
|
||||
gamertag=gamertag_data['gamertag'],
|
||||
platform=gamertag_data['platform'],
|
||||
)
|
||||
db.session.add(gamertag)
|
||||
db.session.commit()
|
||||
|
||||
# Clear Discord OAuth data from session after successful registration
|
||||
@@ -451,11 +488,15 @@ def discord_login():
|
||||
Returns:
|
||||
Response: Redirect to Discord authorization URL.
|
||||
"""
|
||||
purpose = 'profile' if current_user.is_authenticated else 'registration'
|
||||
session[DISCORD_PURPOSE_KEY] = purpose
|
||||
return_endpoint = 'users.edit_profile' if purpose == 'profile' else 'auth.register'
|
||||
|
||||
# DISCORD_REDIRECT_URI is checked too: quoting it when unset used to
|
||||
# raise inside the query builder rather than report a configuration error.
|
||||
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
|
||||
flash(_('Discord OAuth2 is not configured.'), 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
# Anti-forgery token, required by RFC 6749 §10.12. Without it, an
|
||||
# attacker could have the victim's browser consume an authorization code
|
||||
@@ -479,16 +520,24 @@ def discord_login():
|
||||
def discord_callback():
|
||||
"""Handle the OAuth2 callback from Discord.
|
||||
|
||||
Exchanges the authorization code for an access token, then fetches
|
||||
the user's profile (/users/@me) and connections (/users/@me/connections).
|
||||
Results are stored in the session and the user is redirected back to
|
||||
the registration form where fields will be pre-filled.
|
||||
Exchanges the authorization code for an access token, then fetches the
|
||||
user's profile. During registration, connected game accounts are also
|
||||
loaded into server-side draft state. For a signed-in profile relink, the
|
||||
verified identity is written directly without passing through a form.
|
||||
|
||||
Returns:
|
||||
Response: Redirect to registration page.
|
||||
Response: Redirect to the registration form or profile editor.
|
||||
"""
|
||||
# The state is consumed whatever happens next: a token is single-use, and
|
||||
# leaving it in the session would allow a replay.
|
||||
purpose = session.pop(DISCORD_PURPOSE_KEY, 'registration')
|
||||
if purpose == 'profile' and current_user.is_authenticated:
|
||||
return_endpoint = 'users.edit_profile'
|
||||
elif purpose == 'profile':
|
||||
return_endpoint = 'auth.login'
|
||||
else:
|
||||
return_endpoint = 'auth.register'
|
||||
|
||||
expected_state = session.pop(DISCORD_STATE_KEY, None)
|
||||
received_state = request.args.get('state', '')
|
||||
|
||||
@@ -500,12 +549,12 @@ def discord_callback():
|
||||
),
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('auth.register'))
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
code = request.args.get('code')
|
||||
if not code:
|
||||
flash(_('Discord authorization failed. No code received.'), 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
# Exchange the authorization code for an access token
|
||||
token_data = {
|
||||
@@ -529,11 +578,11 @@ def discord_callback():
|
||||
access_token = token_json.get('access_token')
|
||||
except requests.RequestException:
|
||||
flash(_('Failed to connect to Discord. Please try again.'), 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
if not access_token:
|
||||
flash(_('Failed to obtain Discord access token.'), 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
auth_headers = {'Authorization': f'Bearer {access_token}'}
|
||||
|
||||
@@ -548,7 +597,43 @@ def discord_callback():
|
||||
user_data = user_response.json()
|
||||
except requests.RequestException:
|
||||
flash(_('Failed to fetch Discord user profile.'), 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
discord_user_id = user_data.get('id')
|
||||
try:
|
||||
if not discord_user_id:
|
||||
raise ValidationError('missing Discord user id')
|
||||
discord_user_id = str(discord_user_id)
|
||||
validate_discord_user_id(discord_user_id)
|
||||
except ValidationError:
|
||||
flash(_('Failed to fetch Discord user profile.'), 'danger')
|
||||
return redirect(url_for(return_endpoint))
|
||||
|
||||
if purpose == 'profile':
|
||||
# If the session expired while Discord was open, do not turn a profile
|
||||
# relink into registration state for an anonymous browser.
|
||||
if not current_user.is_authenticated:
|
||||
flash(_('Please log in to connect your Discord account.'), 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
clash = User.query.filter(
|
||||
User.discord_user_id == discord_user_id,
|
||||
User.id != current_user.id,
|
||||
).first()
|
||||
if clash:
|
||||
flash(_('This Discord account is already linked to another account.'), 'danger')
|
||||
return redirect(url_for('users.edit_profile'))
|
||||
|
||||
current_user.discord_user_id = discord_user_id
|
||||
current_user.discord_username = user_data.get('username') or None
|
||||
db.session.commit()
|
||||
log_auth_event(
|
||||
'account.discord_linked',
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
flash(_('Discord account connected!'), 'success')
|
||||
return redirect(url_for('users.edit_profile'))
|
||||
|
||||
# Fetch the user's connected gaming accounts
|
||||
connections = []
|
||||
@@ -587,7 +672,7 @@ def discord_callback():
|
||||
|
||||
# Store in session for the registration form to use
|
||||
session['discord_oauth'] = {
|
||||
'id': user_data.get('id'),
|
||||
'id': discord_user_id,
|
||||
'username': user_data.get('username'),
|
||||
'avatar': user_data.get('avatar'),
|
||||
'gamertag_suggestions': gamertag_suggestions,
|
||||
|
||||
+30
-41
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+37
-30
@@ -3,9 +3,7 @@
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
@@ -29,7 +27,8 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.permissions import visible_org_teams
|
||||
from app.validators import OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||
|
||||
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
|
||||
|
||||
@@ -82,7 +81,7 @@ def my_teams():
|
||||
from app.models import TeamMatch, TeamMatchParticipant
|
||||
|
||||
player_teams = current_user.get_org_teams()
|
||||
now = datetime.utcnow()
|
||||
now = utc_now_naive()
|
||||
team_data = []
|
||||
|
||||
for org_team in player_teams:
|
||||
@@ -223,7 +222,7 @@ def create_team():
|
||||
@login_required
|
||||
def edit_team(team_id):
|
||||
"""Edit an existing organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('You do not have permission to edit this team.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
@@ -294,7 +293,7 @@ def delete_team(team_id):
|
||||
day `Manager.can_manage_this_org_team` is narrowed — which it should be —
|
||||
deletion narrows with it instead of staying the one way in.
|
||||
"""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
|
||||
if not (current_user.can_manage_teams() and current_user.can_manage_this_org_team(team)):
|
||||
flash(_('You do not have permission to delete teams.'), 'danger')
|
||||
@@ -336,7 +335,7 @@ def delete_team(team_id):
|
||||
@login_required
|
||||
def add_coach(team_id):
|
||||
"""Add a coach to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
@@ -379,7 +378,7 @@ def add_coach(team_id):
|
||||
@login_required
|
||||
def add_manager(team_id):
|
||||
"""Add a manager to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
@@ -422,7 +421,7 @@ def add_manager(team_id):
|
||||
@login_required
|
||||
def remove_coach(team_id):
|
||||
"""Remove a coach from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
@@ -450,7 +449,7 @@ def remove_coach(team_id):
|
||||
@login_required
|
||||
def remove_manager(team_id):
|
||||
"""Remove a manager from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
@@ -478,7 +477,7 @@ def remove_manager(team_id):
|
||||
@login_required
|
||||
def add_player(team_id):
|
||||
"""Add a player to an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
@@ -515,12 +514,12 @@ def add_player(team_id):
|
||||
@login_required
|
||||
def remove_player(team_id, player_id):
|
||||
"""Remove a player from an organization team."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
player = db.get_or_404(User, player_id)
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||
if not tp:
|
||||
flash(
|
||||
@@ -543,7 +542,7 @@ def remove_player(team_id, player_id):
|
||||
@login_required
|
||||
def toggle_player_status(team_id, player_id):
|
||||
"""Toggle a player's status between starter and substitute."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
@@ -567,17 +566,21 @@ def toggle_player_status(team_id, player_id):
|
||||
@login_required
|
||||
def add_team_note(team_id):
|
||||
"""Add a team improvement note (coaches only)."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
if content:
|
||||
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Team notes added successfully!'), 'success')
|
||||
try:
|
||||
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=data['content'])
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Team notes added successfully!'), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@@ -585,12 +588,12 @@ def add_team_note(team_id):
|
||||
@login_required
|
||||
def add_player_note(team_id, player_id):
|
||||
"""Add a personal note for a player (coaches only)."""
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
team = db.get_or_404(OrgTeam, team_id)
|
||||
if not current_user.can_manage_this_org_team(team):
|
||||
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
player = db.get_or_404(User, player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash(_('Can only add notes for players.'), 'danger')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
@@ -603,10 +606,14 @@ def add_player_note(team_id, player_id):
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
if content:
|
||||
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Note added for %(username)s!', username=player.username), 'success')
|
||||
try:
|
||||
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=data['content'])
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Note added for %(username)s!', username=player.username), 'success')
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
+77
-38
@@ -4,12 +4,11 @@ This module handles CRUD operations for tryouts and player registrations.
|
||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
@@ -32,7 +31,15 @@ from app.models import (
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.validators import PlayerSelectionSchema, TryoutSchema
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import (
|
||||
PlayerSelectionSchema,
|
||||
TryoutRegistrationStatusSchema,
|
||||
TryoutSchema,
|
||||
TryoutStatusSchema,
|
||||
TryoutTeamMemberSchema,
|
||||
TryoutTeamSchema,
|
||||
)
|
||||
|
||||
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
|
||||
|
||||
@@ -79,6 +86,25 @@ def _users_by_id(user_ids):
|
||||
return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()}
|
||||
|
||||
|
||||
def registration_lock_statement(tryout_id):
|
||||
"""The PostgreSQL row lock used by both registration entry points."""
|
||||
return select(Tryout).where(Tryout.id == tryout_id).with_for_update()
|
||||
|
||||
|
||||
def locked_tryout_or_404(tryout_id):
|
||||
"""Load and row-lock a tryout while a registration slot is decided.
|
||||
|
||||
PostgreSQL serializes concurrent registration attempts on this row. The
|
||||
duplicate check, capacity count and insert that follow therefore form
|
||||
one decision instead of three independently racing statements. SQLite
|
||||
ignores ``FOR UPDATE`` in tests, but production does not.
|
||||
"""
|
||||
tryout = db.session.execute(registration_lock_statement(tryout_id)).scalar_one_or_none()
|
||||
if tryout is None:
|
||||
abort(404)
|
||||
return tryout
|
||||
|
||||
|
||||
@tryouts_bp.route('')
|
||||
@login_required
|
||||
def list_tryouts():
|
||||
@@ -87,7 +113,7 @@ def list_tryouts():
|
||||
Delegates to the polymorphic User subclass's get_visible_tryouts() method.
|
||||
"""
|
||||
tryouts = current_user.get_visible_tryouts()
|
||||
return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow())
|
||||
return render_template('pages/tryouts.html', tryouts=tryouts, now=utc_now_naive())
|
||||
|
||||
|
||||
@tryouts_bp.route('/create', methods=['GET', 'POST'])
|
||||
@@ -152,7 +178,7 @@ def create_tryout():
|
||||
@login_required
|
||||
def edit_tryout(tryout_id):
|
||||
"""Edit an existing tryout event. Permission based on can_manage_this_tryout."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to edit this tryout.'), 'danger')
|
||||
@@ -209,7 +235,7 @@ def edit_tryout(tryout_id):
|
||||
@login_required
|
||||
def view_tryout(tryout_id):
|
||||
"""View a specific tryout with all details. Permission via polymorphic dispatch."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
|
||||
can_view = False
|
||||
if isinstance(current_user, Admin):
|
||||
@@ -398,7 +424,7 @@ def view_tryout(tryout_id):
|
||||
matches=matches,
|
||||
match_data=match_data,
|
||||
game_positions=GAME_POSITIONS,
|
||||
now=datetime.utcnow(),
|
||||
now=utc_now_naive(),
|
||||
)
|
||||
|
||||
|
||||
@@ -406,10 +432,10 @@ def view_tryout(tryout_id):
|
||||
@login_required
|
||||
def register_for_tryout(tryout_id):
|
||||
"""Register a player for a tryout. Only Players can self-register."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
if not isinstance(current_user, Player):
|
||||
flash(_('Only players can register for tryouts.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
tryout = locked_tryout_or_404(tryout_id)
|
||||
|
||||
if tryout.status not in ['upcoming', 'in_progress']:
|
||||
flash(_('This tryout is not accepting registrations.'), 'danger')
|
||||
@@ -439,15 +465,19 @@ def register_for_tryout(tryout_id):
|
||||
@login_required
|
||||
def update_status(tryout_id):
|
||||
"""Update the status of a tryout."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
new_status = request.form.get('status')
|
||||
if new_status in ['upcoming', 'in_progress', 'completed']:
|
||||
tryout.status = new_status
|
||||
db.session.commit()
|
||||
flash(_('Tryout status updated to %(new_status)s.', new_status=new_status), 'success')
|
||||
try:
|
||||
data = TryoutStatusSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
tryout.status = data['status']
|
||||
db.session.commit()
|
||||
flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@@ -455,7 +485,7 @@ def update_status(tryout_id):
|
||||
@login_required
|
||||
def update_registration_status(tryout_id, player_id):
|
||||
"""Update a registration's attendance status."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
@@ -463,11 +493,15 @@ def update_registration_status(tryout_id, player_id):
|
||||
registration = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id
|
||||
).first_or_404()
|
||||
new_status = request.form.get('status')
|
||||
if new_status in ['registered', 'attended', 'no_show']:
|
||||
registration.status = new_status
|
||||
db.session.commit()
|
||||
flash(_('Registration status updated.'), 'success')
|
||||
try:
|
||||
data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
registration.status = data['status']
|
||||
db.session.commit()
|
||||
flash(_('Registration status updated.'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@@ -475,7 +509,7 @@ def update_registration_status(tryout_id, player_id):
|
||||
@login_required
|
||||
def register_player(tryout_id):
|
||||
"""Manually register a player for a tryout (by managers/coaches)."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = locked_tryout_or_404(tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
@@ -522,12 +556,12 @@ def register_player(tryout_id):
|
||||
@login_required
|
||||
def remove_player(tryout_id, player_id):
|
||||
"""Remove a registered player from a tryout (cascades to teams/matches)."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
player = db.get_or_404(User, player_id)
|
||||
|
||||
registration = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id
|
||||
@@ -558,17 +592,21 @@ def remove_player(tryout_id, player_id):
|
||||
@login_required
|
||||
def create_team(tryout_id):
|
||||
"""Create a tryout-specific team."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
team_name = request.form.get('team_name')
|
||||
if team_name:
|
||||
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id)
|
||||
db.session.add(team)
|
||||
db.session.commit()
|
||||
flash(_('Team "%(team_name)s" created!', team_name=team_name), 'success')
|
||||
try:
|
||||
data = TryoutTeamSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id)
|
||||
db.session.add(team)
|
||||
db.session.commit()
|
||||
flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@@ -576,8 +614,8 @@ def create_team(tryout_id):
|
||||
@login_required
|
||||
def add_to_team(tryout_id, team_id):
|
||||
"""Add a player to a tryout team."""
|
||||
team = Team.query.get_or_404(team_id)
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
team = db.get_or_404(Team, team_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
@@ -588,10 +626,12 @@ def add_to_team(tryout_id, team_id):
|
||||
if team.tryout_id != tryout_id:
|
||||
abort(404)
|
||||
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
if not player_id:
|
||||
flash(_('Please select a player.'), 'danger')
|
||||
try:
|
||||
data = TryoutTeamMemberSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
player_id = data['player_id']
|
||||
|
||||
# Only players registered for this tryout may be placed on its teams.
|
||||
is_registered = (
|
||||
@@ -602,12 +642,11 @@ def add_to_team(tryout_id, team_id):
|
||||
flash(_('That player is not registered for this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
position = request.form.get('position', '')
|
||||
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
|
||||
if existing:
|
||||
flash(_('Player is already on this team.'), 'info')
|
||||
else:
|
||||
member = TeamMember(team_id=team_id, player_id=player_id, position=position)
|
||||
member = TeamMember(team_id=team_id, player_id=player_id, position=data['position'])
|
||||
db.session.add(member)
|
||||
db.session.commit()
|
||||
flash(_('Player added to team!'), 'success')
|
||||
@@ -618,7 +657,7 @@ def add_to_team(tryout_id, team_id):
|
||||
@login_required
|
||||
def delete_tryout(tryout_id):
|
||||
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to delete this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
+15
-11
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -141,41 +141,41 @@ def add_disponibility():
|
||||
@json_endpoint
|
||||
@login_required
|
||||
def add_disponibilities_bulk():
|
||||
"""Add multiple disponibility blocks at once."""
|
||||
"""Replace the current player's disponibility blocks atomically."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
accepted, rejected = _load_slots(data.get('slots'))
|
||||
|
||||
if rejected:
|
||||
return jsonify(
|
||||
{
|
||||
'error': 'Invalid slots; nothing was changed.',
|
||||
'rejected': rejected,
|
||||
}
|
||||
), 400
|
||||
|
||||
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
|
||||
|
||||
created = []
|
||||
for slot in accepted:
|
||||
start_time = slot['start_time']
|
||||
existing = PlayerDisponibility.query.filter_by(
|
||||
disponibility = PlayerDisponibility(
|
||||
player_id=current_user.id,
|
||||
day_of_week=slot['day_of_week'],
|
||||
start_time=start_time,
|
||||
).first()
|
||||
if not existing:
|
||||
disponibility = PlayerDisponibility(
|
||||
player_id=current_user.id,
|
||||
day_of_week=slot['day_of_week'],
|
||||
start_time=start_time,
|
||||
end_time=slot_end(start_time),
|
||||
)
|
||||
db.session.add(disponibility)
|
||||
db.session.flush()
|
||||
created.append(
|
||||
{
|
||||
'id': disponibility.id,
|
||||
'day_of_week': disponibility.day_of_week,
|
||||
'day_name': day_name(disponibility.day_of_week),
|
||||
'start_time': disponibility.start_time.strftime('%H:%M'),
|
||||
}
|
||||
)
|
||||
end_time=slot_end(start_time),
|
||||
)
|
||||
db.session.add(disponibility)
|
||||
db.session.flush()
|
||||
created.append(
|
||||
{
|
||||
'id': disponibility.id,
|
||||
'day_of_week': disponibility.day_of_week,
|
||||
'day_name': day_name(disponibility.day_of_week),
|
||||
'start_time': disponibility.start_time.strftime('%H:%M'),
|
||||
}
|
||||
)
|
||||
db.session.commit()
|
||||
# `rejected` is reported rather than swallowed. What was accepted is
|
||||
# still saved — dropping a whole batch because one cell was malformed
|
||||
# would be its own kind of surprise — but the client can now tell the
|
||||
# difference between "nine slots saved" and "ten sent, nine saved".
|
||||
return jsonify({'success': True, 'created': created, 'rejected': rejected})
|
||||
return jsonify({'success': True, 'created': created, 'rejected': []})
|
||||
|
||||
|
||||
@users_bp.route('/disponibilities/clear', methods=['POST'])
|
||||
@@ -193,7 +193,7 @@ def clear_disponibilities():
|
||||
@login_required
|
||||
def delete_disponibility(disponibility_id):
|
||||
"""Delete a disponibility block."""
|
||||
disponibility = PlayerDisponibility.query.get_or_404(disponibility_id)
|
||||
disponibility = db.get_or_404(PlayerDisponibility, disponibility_id)
|
||||
if disponibility.player_id != current_user.id:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
db.session.delete(disponibility)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
+116
-55
@@ -7,23 +7,32 @@ it reads exactly what the coach routes write.
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import db
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
Coach,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
OneOnOneRequest,
|
||||
OrgTeam,
|
||||
PersonalNote,
|
||||
Player,
|
||||
Team,
|
||||
TeamMember,
|
||||
TeamNote,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.permissions import coach_can_access_player, coach_org_teams, coach_player_ids
|
||||
from app.permissions import (
|
||||
coach_can_access_player,
|
||||
coach_org_teams,
|
||||
coach_player_ids,
|
||||
coach_tryouts,
|
||||
)
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.validators import NoteContentSchema, PersonalNoteSchema
|
||||
|
||||
|
||||
@users_bp.route('/my-notes')
|
||||
@@ -116,23 +125,25 @@ def notes_dashboard():
|
||||
)
|
||||
|
||||
# For context selectors in the form
|
||||
# PersonalNote.team_id references a tryout-local Team, not OrgTeam. The
|
||||
# previous selector mixed the two namespaces and could either attach the
|
||||
# note to an unrelated team with the same integer id or fail its FK.
|
||||
# Every context list now comes from the tryouts this coach may manage.
|
||||
tryouts = list(reversed(coach_tryouts(current_user)))[:20]
|
||||
tryout_ids = [tryout.id for tryout in tryouts]
|
||||
matches = (
|
||||
Match.query.filter(
|
||||
db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'),
|
||||
)
|
||||
Match.query.filter(Match.tryout_id.in_(tryout_ids))
|
||||
.order_by(Match.date.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
if tryout_ids
|
||||
else []
|
||||
)
|
||||
tryouts = (
|
||||
Tryout.query.filter_by(
|
||||
created_by=current_user.id,
|
||||
)
|
||||
.order_by(Tryout.date.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
teams = (
|
||||
Team.query.filter(Team.tryout_id.in_(tryout_ids)).order_by(Team.name).all()
|
||||
if tryout_ids
|
||||
else []
|
||||
)
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
|
||||
return render_template(
|
||||
'pages/notes.html',
|
||||
@@ -168,16 +179,20 @@ def manage_team_notes():
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
org_team = org_teams[0]
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
if content:
|
||||
note = TeamNote(
|
||||
org_team_id=org_team.id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Team notes saved successfully!'), 'success')
|
||||
try:
|
||||
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
note = TeamNote(
|
||||
org_team_id=org_team.id,
|
||||
coach_id=current_user.id,
|
||||
content=data['content'],
|
||||
)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
flash(_('Team notes saved successfully!'), 'success')
|
||||
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
@@ -195,14 +210,14 @@ def manage_personal_notes():
|
||||
flash(_('Only coaches can manage personal notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
content = request.form.get('content', '').strip()
|
||||
|
||||
if not player_id or not content:
|
||||
flash(_('Player and content are required.'), 'danger')
|
||||
try:
|
||||
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
player_id = data['player_id']
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
player = db.get_or_404(User, player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash(_('Can only add notes for players.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
@@ -214,7 +229,7 @@ def manage_personal_notes():
|
||||
note = PersonalNote(
|
||||
player_id=player_id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
content=data['content'],
|
||||
)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
@@ -235,17 +250,14 @@ def add_personal_note():
|
||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
content = request.form.get('content', '').strip()
|
||||
match_id = request.form.get('match_id', type=int)
|
||||
tryout_id = request.form.get('tryout_id', type=int)
|
||||
team_id_str = request.form.get('team_id')
|
||||
|
||||
if not player_id or not content:
|
||||
flash(_('Player and content are required.'), 'danger')
|
||||
try:
|
||||
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
player_id = data['player_id']
|
||||
|
||||
player = User.query.get_or_404(player_id)
|
||||
player = db.get_or_404(User, player_id)
|
||||
if not isinstance(player, Player):
|
||||
flash(_('Can only add notes for players.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
@@ -254,13 +266,40 @@ def add_personal_note():
|
||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
if data['match_id']:
|
||||
match = db.get_or_404(Match, data['match_id'])
|
||||
if not current_user.can_manage_this_tryout(match.tryout):
|
||||
flash(_('You cannot use that match as note context.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
if not MatchParticipant.query.filter_by(match_id=match.id, player_id=player_id).first():
|
||||
flash(_('That player did not participate in the selected match.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
if data['tryout_id']:
|
||||
tryout = db.get_or_404(Tryout, data['tryout_id'])
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You cannot use that tryout as note context.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
if not TryoutRegistration.query.filter_by(tryout_id=tryout.id, player_id=player_id).first():
|
||||
flash(_('That player is not registered for the selected tryout.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
if data['team_id']:
|
||||
team = db.get_or_404(Team, data['team_id'])
|
||||
if not current_user.can_manage_this_tryout(team.tryout):
|
||||
flash(_('You cannot use that team as note context.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
if not TeamMember.query.filter_by(team_id=team.id, player_id=player_id).first():
|
||||
flash(_('That player is not on the selected team.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
note = PersonalNote(
|
||||
player_id=player_id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
match_id=match_id if match_id else None,
|
||||
tryout_id=tryout_id if tryout_id else None,
|
||||
team_id=int(team_id_str) if team_id_str and team_id_str.isdigit() else None,
|
||||
content=data['content'],
|
||||
match_id=data['match_id'],
|
||||
tryout_id=data['tryout_id'],
|
||||
team_id=data['team_id'],
|
||||
)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
@@ -281,7 +320,10 @@ def add_note_from_tryout(tryout_id):
|
||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
tryout = Tryout.query.get_or_404(tryout_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('You do not have permission to add notes for this tryout.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
preselected_player_id = request.args.get('player_id', type=int)
|
||||
|
||||
# Get registrations as players for the select list
|
||||
@@ -289,21 +331,29 @@ def add_note_from_tryout(tryout_id):
|
||||
players = [r.player for r in registrations if r.player]
|
||||
|
||||
if request.method == 'POST':
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
content = request.form.get('content', '').strip()
|
||||
try:
|
||||
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
|
||||
player_id = data['player_id']
|
||||
|
||||
if not player_id or not content:
|
||||
flash(_('Player and content are required.'), 'danger')
|
||||
if data['tryout_id'] not in (None, tryout_id):
|
||||
flash(_('Invalid tryout context.'), 'danger')
|
||||
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not coach_can_access_player(current_user, player_id):
|
||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
||||
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first():
|
||||
flash(_('That player is not registered for this tryout.'), 'danger')
|
||||
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
|
||||
|
||||
note = PersonalNote(
|
||||
player_id=player_id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
content=data['content'],
|
||||
tryout_id=tryout_id,
|
||||
)
|
||||
db.session.add(note)
|
||||
@@ -334,7 +384,10 @@ def add_note_from_match(match_id):
|
||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
match_obj = Match.query.get_or_404(match_id)
|
||||
match_obj = db.get_or_404(Match, match_id)
|
||||
if not current_user.can_manage_this_tryout(match_obj.tryout):
|
||||
flash(_('You do not have permission to add notes for this match.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
|
||||
# Get participants as players for the select list
|
||||
participants = MatchParticipant.query.filter_by(match_id=match_id).all()
|
||||
@@ -343,21 +396,29 @@ def add_note_from_match(match_id):
|
||||
preselected_player_id = request.args.get('player_id', type=int)
|
||||
|
||||
if request.method == 'POST':
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
content = request.form.get('content', '').strip()
|
||||
try:
|
||||
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('users.add_note_from_match', match_id=match_id))
|
||||
player_id = data['player_id']
|
||||
|
||||
if not player_id or not content:
|
||||
flash(_('Player and content are required.'), 'danger')
|
||||
if data['match_id'] not in (None, match_id):
|
||||
flash(_('Invalid match context.'), 'danger')
|
||||
return redirect(url_for('users.add_note_from_match', match_id=match_id))
|
||||
|
||||
if not coach_can_access_player(current_user, player_id):
|
||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
||||
return redirect(url_for('users.add_note_from_match', match_id=match_id))
|
||||
|
||||
if not MatchParticipant.query.filter_by(match_id=match_id, player_id=player_id).first():
|
||||
flash(_('That player did not participate in this match.'), 'danger')
|
||||
return redirect(url_for('users.add_note_from_match', match_id=match_id))
|
||||
|
||||
note = PersonalNote(
|
||||
player_id=player_id,
|
||||
coach_id=current_user.id,
|
||||
content=content,
|
||||
content=data['content'],
|
||||
match_id=match_id,
|
||||
)
|
||||
db.session.add(note)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -55,8 +55,8 @@ PG_RESTORE = os.getenv('PG_RESTORE', 'pg_restore')
|
||||
# wave G introduced DOCUMENTS_ROOT so a release-directory deployment could
|
||||
# keep uploads outside the releases, and docs/deployment.md now tells the
|
||||
# operator to set it — at which point this script archived a directory the
|
||||
# application had never written to. It does not fail on a missing directory
|
||||
# either; it prints "No documents directory found", skips, and exits 0.
|
||||
# application had never written to. A missing or unreadable document store
|
||||
# is now a failed full-backup run rather than a database-only green result.
|
||||
#
|
||||
# So the more correctly an operator followed the deployment documentation,
|
||||
# the more certainly their contract backups were empty (OBS-006).
|
||||
@@ -262,33 +262,31 @@ def backup_documents():
|
||||
module happened to be imported with.
|
||||
|
||||
Returns:
|
||||
str: Path to the created archive, or None if there is nothing to
|
||||
archive. Signed contracts live only on disk, so losing this
|
||||
directory loses the documents themselves.
|
||||
str: Path to the created archive.
|
||||
|
||||
Raises:
|
||||
BackupError: If the configured store is absent or cannot be archived.
|
||||
Signed contracts live only on disk, so a database-only run must
|
||||
never be reported as a complete backup.
|
||||
"""
|
||||
documents_dir = documents_root()
|
||||
|
||||
if not os.path.exists(documents_dir):
|
||||
# Says where it looked. The previous message named no path, so an
|
||||
# operator who had moved the documents read it as "there are no
|
||||
# documents" rather than "I am looking in the wrong place".
|
||||
print(f'[INFO] No documents directory at {documents_dir}. Skipping document backup.')
|
||||
return None
|
||||
raise BackupError(f'Documents directory does not exist: {documents_dir}')
|
||||
if not os.path.isdir(documents_dir):
|
||||
raise BackupError(f'Documents path is not a directory: {documents_dir}')
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}')
|
||||
|
||||
try:
|
||||
shutil.make_archive(archive_basename, 'zip', documents_dir)
|
||||
except Exception as exc: # noqa: BLE001 — a failed document archive must not lose the dump
|
||||
# This runs after the database dump has already succeeded. Letting
|
||||
# anything through here would abort the script with a traceback and
|
||||
# take the one part that worked down with it. Reported to stdout, in
|
||||
# the format the rest of this script uses; it has no logger.
|
||||
print(f'[ERROR] Document backup failed: {exc}')
|
||||
return None
|
||||
except Exception as exc: # noqa: BLE001 — normalize the shutil boundary
|
||||
raise BackupError(f'Document backup failed: {exc}') from exc
|
||||
|
||||
zip_path = f'{archive_basename}.zip'
|
||||
if not os.path.exists(zip_path) or os.path.getsize(zip_path) == 0:
|
||||
raise BackupError('Document archiver reported success but produced an empty file.')
|
||||
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||||
print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)')
|
||||
return zip_path
|
||||
@@ -361,15 +359,20 @@ def main(argv=None):
|
||||
return 1
|
||||
|
||||
verified = verify_backup(backup_path)
|
||||
backup_documents()
|
||||
documents_ok = True
|
||||
try:
|
||||
backup_documents()
|
||||
except BackupError as exc:
|
||||
documents_ok = False
|
||||
print(f'[ERROR] {exc}')
|
||||
cleanup_old_backups()
|
||||
|
||||
print()
|
||||
if verified:
|
||||
if verified and documents_ok:
|
||||
print('=== Backup completed successfully ===')
|
||||
return 0
|
||||
|
||||
print('=== Backup FAILED verification — do not rely on this archive ===')
|
||||
print('=== Backup INCOMPLETE — do not treat this run as a full recovery point ===')
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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">×</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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -450,11 +450,18 @@
|
||||
var playerDataById = {
|
||||
player_data: {
|
||||
{%- for p in all_players %}
|
||||
{{ p.id }}: "{{ p.username | escape }}",
|
||||
{{ p.id }}: {{ p.username | tojson }},
|
||||
{%- endfor %}
|
||||
}
|
||||
};
|
||||
|
||||
var HTML_ESCAPES = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''};
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, function (character) {
|
||||
return HTML_ESCAPES[character];
|
||||
});
|
||||
}
|
||||
|
||||
// All registered player IDs
|
||||
var allRegisteredPlayers = [
|
||||
{%- for p in all_players %}
|
||||
@@ -555,7 +562,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
var playerName = playerDataById.player_data[pid];
|
||||
if (playerName) {
|
||||
var html = '<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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user