32 changed files with 1664 additions and 116 deletions
+4 -2
View File
@@ -65,6 +65,8 @@ from discord.ext import commands
from dotenv import load_dotenv from dotenv import load_dotenv
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.time_utils import utc_now_naive
load_dotenv() load_dotenv()
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN') DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
@@ -780,7 +782,7 @@ class TeamTryoutsBot(commands.Bot):
coach_obj = request.coach coach_obj = request.coach
request.status = 'approved' request.status = 'approved'
request.responded_at = datetime.utcnow() request.responded_at = utc_now_naive()
except SQLAlchemyError: except SQLAlchemyError:
db.session.rollback() db.session.rollback()
logger.exception('Could not read One on One request %s to approve it', request_id) logger.exception('Could not read One on One request %s to approve it', request_id)
@@ -875,7 +877,7 @@ class TeamTryoutsBot(commands.Bot):
try: try:
request.status = 'rejected' request.status = 'rejected'
request.responded_at = datetime.utcnow() request.responded_at = utc_now_naive()
if refusal_note: if refusal_note:
request.coach_rejection_message = refusal_note request.coach_rejection_message = refusal_note
except SQLAlchemyError: except SQLAlchemyError:
+1
View File
@@ -20,6 +20,7 @@ from app.models._constants import (
GAME_PLATFORMS, GAME_PLATFORMS,
PLATFORM_CODES, PLATFORM_CODES,
TRN_URLS, TRN_URLS,
EVALUATION_CRITERIA,
) )
# ========================================================================= # =========================================================================
+15
View File
@@ -9,6 +9,21 @@ Contains game lists, position mappings, platform codes, and TRN URL templates.
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout'] USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
# Ordered list of (field_name, human_label) pairs for the player evaluation
# score criteria. Kept in a single place so the evaluation forms, batch
# evaluation page, and any future reporting all stay in sync.
EVALUATION_CRITERIA = [
('mecanics_score', 'Mecanics'),
('cohesion_score', 'Cohesion'),
('communication_score', 'Communication'),
('gamesense_score', 'Gamesense'),
('versatility_score', 'Versatility'),
('discipline_score', 'Discipline'),
('analysis_score', 'Analysis'),
('sport_ethics_score', 'Sport Ethics'),
('mental_score', 'Mental'),
]
ESPORT_GAMES = [ ESPORT_GAMES = [
'Valorant', 'Valorant',
'League of Legends', 'League of Legends',
+3 -4
View File
@@ -1,8 +1,7 @@
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability).""" """Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class BaseAvailability(db.Model): class BaseAvailability(db.Model):
@@ -13,5 +12,5 @@ class BaseAvailability(db.Model):
day_of_week = db.Column(db.Integer, nullable=False) day_of_week = db.Column(db.Integer, nullable=False)
start_time = db.Column(db.Time, nullable=False) start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False) end_time = db.Column(db.Time, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
+2 -3
View File
@@ -1,8 +1,7 @@
"""Contract documents for players to sign.""" """Contract documents for players to sign."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class Contract(db.Model): class Contract(db.Model):
@@ -23,7 +22,7 @@ class Contract(db.Model):
status = db.Column(db.String(20), default='pending') status = db.Column(db.String(20), default='pending')
notes = db.Column(db.Text, nullable=True) 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) signed_at = db.Column(db.DateTime, nullable=True)
player = db.relationship('User', foreign_keys=[player_id], backref='contracts') player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
+3 -4
View File
@@ -1,8 +1,7 @@
"""Player evaluation record.""" """Player evaluation record."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class Evaluation(db.Model): class Evaluation(db.Model):
@@ -25,8 +24,8 @@ class Evaluation(db.Model):
overall_score = db.Column(db.Float, nullable=True) overall_score = db.Column(db.Float, nullable=True)
comments = db.Column(db.Text, nullable=True) comments = db.Column(db.Text, nullable=True)
position_recommendation = db.Column(db.String(50), nullable=True) position_recommendation = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
__table_args__ = ( __table_args__ = (
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'), db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
+2 -3
View File
@@ -1,8 +1,7 @@
"""Abstract base class for match models (Match + TeamMatch).""" """Abstract base class for match models (Match + TeamMatch)."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class BaseMatch(db.Model): class BaseMatch(db.Model):
@@ -18,4 +17,4 @@ class BaseMatch(db.Model):
location = db.Column(db.String(200), nullable=True) location = db.Column(db.String(200), nullable=True)
status = db.Column(db.String(20), default='scheduled') status = db.Column(db.String(20), default='scheduled')
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), 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)
+2 -3
View File
@@ -1,8 +1,7 @@
"""Request from player to coach for a One on One session.""" """Request from player to coach for a One on One session."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class OneOnOneRequest(db.Model): class OneOnOneRequest(db.Model):
@@ -18,7 +17,7 @@ class OneOnOneRequest(db.Model):
end_time = db.Column(db.Time, nullable=False) end_time = db.Column(db.Time, nullable=False)
points = db.Column(db.Text, nullable=True) points = db.Column(db.Text, nullable=True)
status = db.Column(db.String(20), default='pending') 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) responded_at = db.Column(db.DateTime, nullable=True)
discord_message_id = db.Column(db.BigInteger, nullable=True) discord_message_id = db.Column(db.BigInteger, nullable=True)
coach_rejection_message = db.Column(db.Text, nullable=True) coach_rejection_message = db.Column(db.Text, nullable=True)
+2 -3
View File
@@ -1,9 +1,8 @@
"""Persistent organisation team (e.g. Varsity, JV).""" """Persistent organisation team (e.g. Varsity, JV)."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.models._associations import org_team_coaches, org_team_managers from app.models._associations import org_team_coaches, org_team_managers
from app.time_utils import utc_now_naive
class OrgTeam(db.Model): class OrgTeam(db.Model):
@@ -13,7 +12,7 @@ class OrgTeam(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True) name = db.Column(db.String(100), nullable=False, unique=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), 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)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
+2 -3
View File
@@ -1,8 +1,7 @@
"""Many-to-many junction: player to org-team.""" """Many-to-many junction: player to org-team."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class TeamPlayer(db.Model): 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) org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
status = db.Column(db.String(20), nullable=False, default='starter') status = db.Column(db.String(20), nullable=False, default='starter')
position = db.Column(db.String(50), nullable=True) 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') player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players') org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
+2 -3
View File
@@ -1,8 +1,7 @@
"""Abstract base class for match participant models.""" """Abstract base class for match participant models."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class BaseParticipant(db.Model): class BaseParticipant(db.Model):
@@ -11,4 +10,4 @@ class BaseParticipant(db.Model):
__abstract__ = True __abstract__ = True
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
added_at = db.Column(db.DateTime, default=datetime.utcnow) added_at = db.Column(db.DateTime, default=utc_now_naive)
+3 -4
View File
@@ -1,8 +1,7 @@
"""Personal notes from coach to individual player.""" """Personal notes from coach to individual player."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class PersonalNote(db.Model): class PersonalNote(db.Model):
@@ -13,8 +12,8 @@ class PersonalNote(db.Model):
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
coach_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) content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 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) match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
+2 -3
View File
@@ -1,8 +1,7 @@
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" """Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class Team(db.Model): class Team(db.Model):
@@ -13,7 +12,7 @@ class Team(db.Model):
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
name = db.Column(db.String(100), nullable=False) name = db.Column(db.String(100), nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), 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') creator = db.relationship('User', backref='created_teams')
members = db.relationship('TeamMember', backref='team', lazy='dynamic') members = db.relationship('TeamMember', backref='team', lazy='dynamic')
+2 -3
View File
@@ -1,8 +1,7 @@
"""Link between a player and a tryout-specific team.""" """Link between a player and a tryout-specific team."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class TeamMember(db.Model): class TeamMember(db.Model):
@@ -13,6 +12,6 @@ class TeamMember(db.Model):
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False) team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
position = db.Column(db.String(50), nullable=True) 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") player = db.relationship('User', overlaps="player_ref,team_assignments")
+3 -4
View File
@@ -1,8 +1,7 @@
"""Team improvement notes from coach.""" """Team improvement notes from coach."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class TeamNote(db.Model): 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) 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) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False) content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=utc_now_naive)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive)
team = db.relationship('OrgTeam', backref='team_notes') team = db.relationship('OrgTeam', backref='team_notes')
coach = db.relationship('User', foreign_keys=[coach_id]) coach = db.relationship('User', foreign_keys=[coach_id])
+2 -3
View File
@@ -1,9 +1,8 @@
"""Tryout event for player evaluations and team formation.""" """Tryout event for player evaluations and team formation."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.models._associations import tryout_coaches from app.models._associations import tryout_coaches
from app.time_utils import utc_now_naive
class Tryout(db.Model): class Tryout(db.Model):
@@ -25,7 +24,7 @@ class Tryout(db.Model):
coach_id = db.Column( coach_id = db.Column(
db.Integer, db.ForeignKey('users.id'), nullable=True db.Integer, db.ForeignKey('users.id'), nullable=True
) # deprecated, kept for migration ) # 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') creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
+2 -3
View File
@@ -1,8 +1,7 @@
"""Registration linking a player to a tryout.""" """Registration linking a player to a tryout."""
from datetime import datetime
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class TryoutRegistration(db.Model): class TryoutRegistration(db.Model):
@@ -12,6 +11,6 @@ class TryoutRegistration(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.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') status = db.Column(db.String(20), default='registered')
notes = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True)
+2 -3
View File
@@ -1,10 +1,9 @@
"""Base User model — shared fields and polymorphic configuration.""" """Base User model — shared fields and polymorphic configuration."""
from datetime import datetime
from flask_login import UserMixin from flask_login import UserMixin
from app.extensions import db from app.extensions import db
from app.time_utils import utc_now_naive
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
@@ -25,7 +24,7 @@ class User(UserMixin, db.Model):
email = db.Column(db.String(120), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False)
phone = db.Column(db.String(20), nullable=True) phone = db.Column(db.String(20), nullable=True)
is_active_account = db.Column(db.Boolean, default=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) failed_login_attempts = db.Column(db.Integer, default=0)
locked_until = db.Column(db.DateTime, nullable=True) locked_until = db.Column(db.DateTime, nullable=True)
+3 -2
View File
@@ -8,7 +8,7 @@ password policy enforcement and sign-up screening.
import os import os
import secrets import secrets
import time import time
from datetime import datetime, timedelta from datetime import timedelta
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode, urlparse
import requests import requests
@@ -22,6 +22,7 @@ from app.forms import form_gamertags
from app.i18n import LOCALE_SESSION_KEY from app.i18n import LOCALE_SESSION_KEY
from app.logging_config import log_auth_event from app.logging_config import log_auth_event
from app.models import ESPORT_GAMES, Player, User from app.models import ESPORT_GAMES, Player, User
from app.time_utils import utc_now_naive
from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id
#: Session key holding the pending OAuth2 anti-forgery token. #: Session key holding the pending OAuth2 anti-forgery token.
@@ -294,7 +295,7 @@ def login():
) )
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS: if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
minutes = cooloff_minutes(user.failed_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( log_auth_event(
'account.throttled', 'account.throttled',
username=username, username=username,
+99
View File
@@ -7,6 +7,14 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user, login_required from flask_login import current_user, login_required
from marshmallow import ValidationError from marshmallow import ValidationError
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app.extensions import db
from app.models import (
Admin, Coach, Manager, Player,
User, Tryout, Evaluation, TryoutRegistration,
OrgTeam, GAME_POSITIONS, EVALUATION_CRITERIA,
)
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
@@ -245,3 +253,94 @@ def players_to_evaluate(tryout_id):
] ]
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players) return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
@evaluations_bp.route('/<int:tryout_id>/batch', methods=['GET', 'POST'])
@login_required
def batch_evaluate(tryout_id):
"""Evaluate multiple players at once in a tryout.
GET renders a single form listing every selected player with their
evaluation criteria. POST saves (creates or updates) all of them.
"""
if not current_user.can_evaluate():
flash('You do not have permission to evaluate players.', 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to evaluate players in this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
# Resolve selected player ids (query string on GET, hidden fields on POST).
player_ids = []
for raw in request.values.getlist('player_ids'):
try:
pid = int(raw)
except (ValueError, TypeError):
continue
if pid not in player_ids:
player_ids.append(pid)
if not player_ids:
flash('Please select at least one player to evaluate.', 'warning')
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
players = []
for pid in player_ids:
player = User.query.get(pid)
if not player or not isinstance(player, Player):
continue
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=pid,
).first() is not None
if not is_registered:
continue
existing = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=pid, evaluator_id=current_user.id,
).first()
existing_scores = {
field_name: getattr(existing, field_name) if existing else None
for field_name, _ in EVALUATION_CRITERIA
}
players.append({
'player': player,
'existing': existing,
'existing_scores': existing_scores,
})
if not players:
flash('No valid players selected for evaluation.', 'danger')
return redirect(url_for('evaluations.players_to_evaluate', tryout_id=tryout_id))
if request.method == 'POST':
saved = 0
for entry in players:
pid = entry['player'].id
scores = {
field_name: validate_score(request.form.get(f'{field_name}_{pid}'))
for field_name, _ in EVALUATION_CRITERIA
}
comments = request.form.get(f'comments_{pid}')
position = request.form.get(f'position_recommendation_{pid}')
existing = entry['existing']
if existing:
_apply_evaluation(existing, scores, comments, position)
else:
evaluation = Evaluation(
tryout_id=tryout_id, player_id=pid,
evaluator_id=current_user.id,
)
_apply_evaluation(evaluation, scores, comments, position)
db.session.add(evaluation)
saved += 1
db.session.commit()
flash(f'Saved evaluations for {saved} player(s).', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_template('pages/batch_evaluate.html',
tryout=tryout, players=players,
evaluation_criteria=EVALUATION_CRITERIA,
game_positions=GAME_POSITIONS)
+2 -3
View File
@@ -3,8 +3,6 @@
Uses polymorphic isinstance checks instead of role-string comparisons. 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, request, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user, login_required 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.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
from app.routes.matches import default_end_time from app.routes.matches import default_end_time
from app.services.scheduling import notify_participants, zip_participants from app.services.scheduling import notify_participants, zip_participants
from app.time_utils import utc_now_naive
from app.validators import TeamMatchSchema from app.validators import TeamMatchSchema
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches') team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
@@ -100,7 +99,7 @@ def list_matches():
teams=teams, teams=teams,
match_data=match_data, match_data=match_data,
pagination=matches_page, pagination=matches_page,
now=datetime.utcnow(), now=utc_now_naive(),
) )
+2 -3
View File
@@ -3,8 +3,6 @@
Uses polymorphic isinstance checks instead of role-string comparisons. Uses polymorphic isinstance checks instead of role-string comparisons.
""" """
from datetime import datetime
from flask import Blueprint, flash, jsonify, redirect, render_template, url_for from flask import Blueprint, flash, jsonify, redirect, render_template, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user, login_required from flask_login import current_user, login_required
@@ -29,6 +27,7 @@ from app.models import (
User, User,
) )
from app.permissions import visible_org_teams from app.permissions import visible_org_teams
from app.time_utils import utc_now_naive
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
teams_bp = Blueprint('teams', __name__, url_prefix='/teams') teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
@@ -82,7 +81,7 @@ def my_teams():
from app.models import TeamMatch, TeamMatchParticipant from app.models import TeamMatch, TeamMatchParticipant
player_teams = current_user.get_org_teams() player_teams = current_user.get_org_teams()
now = datetime.utcnow() now = utc_now_naive()
team_data = [] team_data = []
for org_team in player_teams: for org_team in player_teams:
+3 -4
View File
@@ -4,8 +4,6 @@ This module handles CRUD operations for tryouts and player registrations.
Uses polymorphic isinstance checks instead of role-string comparisons. 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 import Blueprint, abort, flash, redirect, render_template, request, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user, login_required from flask_login import current_user, login_required
@@ -33,6 +31,7 @@ from app.models import (
TryoutRegistration, TryoutRegistration,
User, User,
) )
from app.time_utils import utc_now_naive
from app.validators import ( from app.validators import (
PlayerSelectionSchema, PlayerSelectionSchema,
TryoutRegistrationStatusSchema, TryoutRegistrationStatusSchema,
@@ -114,7 +113,7 @@ def list_tryouts():
Delegates to the polymorphic User subclass's get_visible_tryouts() method. Delegates to the polymorphic User subclass's get_visible_tryouts() method.
""" """
tryouts = current_user.get_visible_tryouts() 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']) @tryouts_bp.route('/create', methods=['GET', 'POST'])
@@ -425,7 +424,7 @@ def view_tryout(tryout_id):
matches=matches, matches=matches,
match_data=match_data, match_data=match_data,
game_positions=GAME_POSITIONS, game_positions=GAME_POSITIONS,
now=datetime.utcnow(), now=utc_now_naive(),
) )
+1270
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -2,7 +2,6 @@
import os import os
import uuid import uuid
from datetime import datetime
from flask import flash, redirect, render_template, request, send_file, url_for from flask import flash, redirect, render_template, request, send_file, url_for
from flask_babel import gettext as _ 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.routes.users.blueprint import users_bp
from app.storage import CONTRACTS_DIR, document_path from app.storage import CONTRACTS_DIR, document_path
from app.time_utils import utc_now_naive
from app.validators import UploadContractSchema from app.validators import UploadContractSchema
@@ -172,7 +172,7 @@ def upload_signed_contract(contract_id):
contract.signed_filename = signed_filename contract.signed_filename = signed_filename
contract.signed_file_path = signed_path contract.signed_file_path = signed_path
contract.status = 'signed' contract.status = 'signed'
contract.signed_at = datetime.utcnow() contract.signed_at = utc_now_naive()
db.session.commit() db.session.commit()
flash(_('Signed contract uploaded successfully!'), 'success') flash(_('Signed contract uploaded successfully!'), 'success')
return redirect(url_for('users.list_contracts')) return redirect(url_for('users.list_contracts'))
+3 -4
View File
@@ -1,7 +1,5 @@
"""One-on-one sessions between a player and their coach.""" """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 import flash, redirect, render_template, request, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user, login_required from flask_login import current_user, login_required
@@ -12,6 +10,7 @@ from app.forms import flash_validation_errors, form_payload
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
from app.routes.users.blueprint import users_bp from app.routes.users.blueprint import users_bp
from app.services.notifications import send_discord_notification from app.services.notifications import send_discord_notification
from app.time_utils import utc_now_naive
from app.validators import OneOnOneRejectionSchema, OneOnOneRequestSchema from app.validators import OneOnOneRejectionSchema, OneOnOneRequestSchema
@@ -178,7 +177,7 @@ def accept_one_on_one(request_id):
player = request_obj.player player = request_obj.player
request_obj.status = 'approved' request_obj.status = 'approved'
request_obj.responded_at = datetime.utcnow() request_obj.responded_at = utc_now_naive()
db.session.commit() db.session.commit()
# Notify player via Discord (same message as if approved through Discord reactions) # Notify player via Discord (same message as if approved through Discord reactions)
@@ -235,7 +234,7 @@ def reject_one_on_one(request_id):
player = request_obj.player player = request_obj.player
request_obj.status = 'rejected' request_obj.status = 'rejected'
request_obj.responded_at = datetime.utcnow() request_obj.responded_at = utc_now_naive()
if rejection_reason: if rejection_reason:
request_obj.coach_rejection_message = rejection_reason request_obj.coach_rejection_message = rejection_reason
db.session.commit() db.session.commit()
+27
View File
@@ -2037,3 +2037,30 @@ a:hover { color: var(--primary-dark); }
.honeypot { .honeypot {
display: none; display: none;
} }
/* Batch Evaluation - 2 cards wide layout */
.batch-eval-form {
max-width: none;
}
.batch-eval-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-bottom: 20px;
}
.batch-eval-grid .card {
margin-bottom: 0;
}
/* Allow criteria rows to wrap for a 3x3 grid inside each card */
.batch-eval-grid .form-row {
flex-wrap: wrap;
}
@media (max-width: 1100px) {
.batch-eval-grid {
grid-template-columns: 1fr;
}
}
+85
View File
@@ -0,0 +1,85 @@
{% extends "layouts/base.html" %}
{% block title %}Evaluate Players - UdeS team manager{% endblock %}
{% block page_title %}Evaluate Players{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}">{{ tryout.title }}</a> / Evaluate</span>{% endblock %}
{% block content %}
<form method="POST" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}" class="form batch-eval-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% for entry in players %}
<input type="hidden" name="player_ids" value="{{ entry.player.id }}"/>
{% endfor %}
{% set positions = game_positions.get(tryout.game, []) %}
<div class="batch-eval-grid">
{% for entry in players %}
<div class="card">
<div class="card-header">
<h3>
<i class="fas fa-user"></i> {{ entry.player.username }}
{% if entry.existing %}
<span class="badge badge-success">Already Evaluated</span>
{% else %}
<span class="badge badge-warning">Not Evaluated</span>
{% endif %}
</h3>
</div>
<div class="card-body">
<div class="eval-player-info mb-4">
<div class="user-avatar avatar-lg">{{ entry.player.username[:2] | upper }}</div>
<div>
<h3>{{ entry.player.username }}</h3>
<p class="text-muted">{{ entry.player.email }} | {{ entry.player.phone or 'No phone' }}</p>
</div>
</div>
{% set pid = entry.player.id %}
{% set existing = entry.existing %}
{% set existing_scores = entry.existing_scores %}
<div class="form-row">
{% for field_name, label in evaluation_criteria %}
<div class="form-group col-4">
<label for="{{ field_name }}_{{ pid }}">{{ label }} (1-10)</label>
<div class="score-input">
<input type="range" id="{{ field_name }}_{{ pid }}" name="{{ field_name }}_{{ pid }}" min="1" max="10" value="{{ existing_scores[field_name] or 5 }}" oninput="this.nextElementSibling.textContent = this.value">
<span class="range-value">{{ existing_scores[field_name] or 5 }}</span>
</div>
</div>
{% endfor %}
</div>
<div class="form-row">
<div class="form-group col-12">
<label for="position_recommendation_{{ pid }}">Recommended Position</label>
{% if positions %}
<select id="position_recommendation_{{ pid }}" name="position_recommendation_{{ pid }}" class="form-select">
<option value="">-- Select Position --</option>
{% for pos in positions %}
<option value="{{ pos }}" {% if existing and existing.position_recommendation == pos %}selected{% endif %}>{{ pos }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" id="position_recommendation_{{ pid }}" name="position_recommendation_{{ pid }}" value="{{ existing.position_recommendation if existing else '' }}" placeholder="Enter position (optional)">
{% endif %}
</div>
</div>
<div class="form-group">
<label for="comments_{{ pid }}">Comments</label>
<textarea id="comments_{{ pid }}" name="comments_{{ pid }}" rows="3" placeholder="Enter your evaluation notes...">{{ existing.comments if existing else '' }}</textarea>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="form-actions">
<a href="{{ url_for('evaluations.players_to_evaluate', tryout_id=tryout.id) }}" class="btn btn-secondary">Back</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save All Evaluations
</button>
</div>
</form>
{% endblock %}
+66 -44
View File
@@ -6,54 +6,76 @@
{% block content %} {% block content %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3><i class="fas fa-users"></i> Players in {{ tryout.title }}</h3> <h3><i class="fas fa-users"></i> Select players to evaluate in {{ tryout.title }}</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="table-container"> <p class="text-muted mb-3">Choose the players you want to evaluate, then load all of them on a single page.</p>
<table class="table"> <form method="GET" action="{{ url_for('evaluations.batch_evaluate', tryout_id=tryout.id) }}">
<thead> <div class="table-container">
<tr> <table class="table">
<th>{{ _('Player') }}</th> <thead>
<th>{{ _('Contact') }}</th> <tr>
<th>{{ _('Attendance') }}</th> <th><input type="checkbox" id="select-all" onclick="toggleAll(this)"></th>
<th>{{ _('Status') }}</th> <th>Player</th>
<th>{{ _('Actions') }}</th> <th>Contact</th>
</tr> <th>Attendance</th>
</thead> <th>Status</th>
<tbody> <th>Actions</th>
{% for entry in players %} </tr>
<tr> </thead>
<td> <tbody>
<div class="user-mini"> {% for entry in players %}
<tr>
<td>
<input type="checkbox" name="player_ids" value="{{ entry.player.id }}" class="player-check">
</td>
<td>
<div class="user-mini">
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div> <div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
<span>{{ entry.player.username }}</span> <span>{{ entry.player.username }}</span>
</div> </div>
</td> </td>
<td>{{ entry.player.email }}</td> <td>{{ entry.player.email }}</td>
<td> <td>
<span class="badge badge-{{ entry.registration.status }}">{{ entry.registration.status }}</span> <span class="badge badge-{{ entry.registration.status }}">{{ entry.registration.status }}</span>
</td> </td>
<td> <td>
{% if entry.evaluated %} {% if entry.evaluated %}
<span class="badge badge-success">Evaluated</span> <span class="badge badge-success">Evaluated</span>
{% else %} {% else %}
<span class="badge badge-warning">Not Evaluated</span> <span class="badge badge-warning">Not Evaluated</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-primary"> <a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=entry.player.id) }}" class="btn btn-sm btn-outline">
<i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Evaluate{% endif %} <i class="fas fa-clipboard"></i> {% if entry.evaluated %}View/Edit{% else %}Single{% endif %}
</a> </a>
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="5" class="text-center">{{ _('No players registered for this tryout.') }}</td> <td colspan="6" class="text-center">No players registered for this tryout.</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="form-actions">
<a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-clipboard-check"></i> Evaluate Selected
</button>
</div>
</form>
</div> </div>
</div> </div>
<script>
function toggleAll(master) {
var boxes = document.querySelectorAll('.player-check');
for (var i = 0; i < boxes.length; i++) {
boxes[i].checked = master.checked;
}
}
</script>
{% endblock %} {% endblock %}
+19
View File
@@ -0,0 +1,19 @@
"""Time helpers with explicit storage semantics.
The deployed schema currently stores timestamps in ``DateTime`` columns
without timezone information. Until the real PostgreSQL schema is restored
and migrated, application timestamps must therefore remain naive values.
They are nevertheless generated from an aware UTC clock so the convention is
explicit and does not rely on the deprecated :meth:`datetime.utcnow` API.
"""
from datetime import UTC, datetime
def utc_now_naive() -> datetime:
"""Return the current UTC instant without ``tzinfo`` for legacy columns.
Replace this compatibility boundary with aware UTC values when the
corresponding columns are migrated to timezone-aware types.
"""
return datetime.now(UTC).replace(tzinfo=None)
-3
View File
@@ -21,9 +21,6 @@ filterwarnings = [
"default", "default",
# discord.py imports audioop, removed from the stdlib in 3.13. # discord.py imports audioop, removed from the stdlib in 3.13.
"ignore:'audioop' is deprecated:DeprecationWarning", "ignore:'audioop' is deprecated:DeprecationWarning",
# Every model uses datetime.utcnow as a column default. Tracked as
# DB-009; the warning would otherwise drown the run.
"ignore:datetime.datetime.utcnow:DeprecationWarning",
] ]
[tool.coverage.report] [tool.coverage.report]
+29
View File
@@ -0,0 +1,29 @@
"""Regression tests for the application's timestamp convention."""
import ast
from datetime import UTC, datetime
from pathlib import Path
from app.time_utils import utc_now_naive
def test_utc_now_naive_is_an_explicit_utc_value():
before = datetime.now(UTC).replace(tzinfo=None)
actual = utc_now_naive()
after = datetime.now(UTC).replace(tzinfo=None)
assert actual.tzinfo is None
assert before <= actual <= after
def test_application_does_not_call_deprecated_utcnow():
app_root = Path(__file__).parents[1] / 'app'
offenders = []
for path in app_root.rglob('*.py'):
tree = ast.parse(path.read_text(encoding='utf-8'), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Attribute) and node.attr == 'utcnow':
offenders.append(f'{path.relative_to(app_root)}:{node.lineno}')
assert offenders == []