Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48ca62cdd0 | ||
|
|
1bef716a12 | ||
|
|
a0e31d1a2f | ||
|
|
e15b3c1293 | ||
|
|
d7a8907953 | ||
|
|
105a72700f | ||
|
|
f84cb4e3b6 |
@@ -0,0 +1,45 @@
|
|||||||
|
name: CI - Security, Lint & Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# This workflow validates branches only. It has no deployment step and no
|
||||||
|
# write permission, so an audit-branch push cannot alter main or production.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -r requirements.txt -r requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Audit declared dependencies
|
||||||
|
run: pip-audit -r requirements.txt
|
||||||
|
|
||||||
|
- name: Lint and check formatting
|
||||||
|
run: |
|
||||||
|
ruff check .
|
||||||
|
ruff format --check .
|
||||||
|
|
||||||
|
- name: Run tests with coverage gate
|
||||||
|
run: pytest --cov=app --cov-report=term-missing --cov-report=xml
|
||||||
|
|
||||||
|
- name: Run repository security checks
|
||||||
|
env:
|
||||||
|
SECRET_KEY: audit-ci-key-not-for-production-1234567890
|
||||||
|
DATABASE_URL: 'sqlite:///:memory:'
|
||||||
|
FLASK_DEBUG: 'false'
|
||||||
|
run: python app/supporting_scripts/security_scan.py --skip-http
|
||||||
@@ -2,7 +2,7 @@ name: CI - Security & Lint
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, master]
|
branches: [main, master, 'audit/**']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, master]
|
branches: [main, master]
|
||||||
workflow_dispatch: # Allow manual triggers
|
workflow_dispatch: # Allow manual triggers
|
||||||
@@ -94,6 +94,7 @@ jobs:
|
|||||||
- name: Run security scan
|
- name: Run security scan
|
||||||
env:
|
env:
|
||||||
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
|
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
|
||||||
|
DATABASE_URL: 'sqlite:///:memory:'
|
||||||
FLASK_DEBUG: 'false'
|
FLASK_DEBUG: 'false'
|
||||||
run: python app/supporting_scripts/security_scan.py --skip-http
|
run: python app/supporting_scripts/security_scan.py --skip-http
|
||||||
|
|
||||||
|
|||||||
+13
-8
@@ -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')
|
||||||
|
|
||||||
@@ -683,9 +685,10 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
reference_id: ID of the MatchParticipant or TryoutRegistration record.
|
reference_id: ID of the MatchParticipant or TryoutRegistration record.
|
||||||
"""
|
"""
|
||||||
# Look up the DB user to get their Discord user ID
|
# Look up the DB user to get their Discord user ID
|
||||||
|
from app.extensions import db
|
||||||
from app.models import User as DBUser
|
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:
|
if not db_user:
|
||||||
logger.warning(f"DB user {user_id} not found for schedule notification")
|
logger.warning(f"DB user {user_id} not found for schedule notification")
|
||||||
return None
|
return None
|
||||||
@@ -754,7 +757,7 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
from app.models import OneOnOneRequest
|
from app.models import OneOnOneRequest
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request = OneOnOneRequest.query.get(request_id)
|
request = db.session.get(OneOnOneRequest, request_id)
|
||||||
if not request:
|
if not request:
|
||||||
# The row is gone; no reaction on this message can ever mean
|
# The row is gone; no reaction on this message can ever mean
|
||||||
# anything again. Keeping the mapping is what PENDING_MAX_AGE_DAYS
|
# anything again. Keeping the mapping is what PENDING_MAX_AGE_DAYS
|
||||||
@@ -779,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)
|
||||||
@@ -825,7 +828,7 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
from app.models import OneOnOneRequest
|
from app.models import OneOnOneRequest
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request = OneOnOneRequest.query.get(request_id)
|
request = db.session.get(OneOnOneRequest, request_id)
|
||||||
if not request:
|
if not request:
|
||||||
logger.info(
|
logger.info(
|
||||||
'One on One request %s no longer exists; its pending message was dropped.',
|
'One on One request %s no longer exists; its pending message was dropped.',
|
||||||
@@ -874,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:
|
||||||
@@ -918,12 +921,13 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
Returns:
|
Returns:
|
||||||
tuple: (row, player_id) — either may be None.
|
tuple: (row, player_id) — either may be None.
|
||||||
"""
|
"""
|
||||||
|
from app.extensions import db
|
||||||
from app.models import MatchParticipant, TryoutRegistration
|
from app.models import MatchParticipant, TryoutRegistration
|
||||||
|
|
||||||
if event_type == 'match':
|
if event_type == 'match':
|
||||||
row = MatchParticipant.query.get(reference_id)
|
row = db.session.get(MatchParticipant, reference_id)
|
||||||
elif event_type == 'tryout':
|
elif event_type == 'tryout':
|
||||||
row = TryoutRegistration.query.get(reference_id)
|
row = db.session.get(TryoutRegistration, reference_id)
|
||||||
else:
|
else:
|
||||||
row = None
|
row = None
|
||||||
return row, getattr(row, 'player_id', 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
|
attendance handlers did not (OPS-009) — same message shape, same
|
||||||
threat, one of them checked. The asymmetry was the bug.
|
threat, one of them checked. The asymmetry was the bug.
|
||||||
"""
|
"""
|
||||||
|
from app.extensions import db
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
|
||||||
if not player_id:
|
if not player_id:
|
||||||
return False
|
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))
|
return bool(owner and owner.discord_user_id == str(reacting_user.id))
|
||||||
|
|
||||||
async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
|
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):
|
if not payload.get(name):
|
||||||
payload.pop(name, None)
|
payload.pop(name, None)
|
||||||
return payload
|
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
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.models._constants import (
|
|||||||
GAME_PLATFORMS,
|
GAME_PLATFORMS,
|
||||||
PLATFORM_CODES,
|
PLATFORM_CODES,
|
||||||
TRN_URLS,
|
TRN_URLS,
|
||||||
|
EVALUATION_CRITERIA,
|
||||||
)
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -57,7 +56,7 @@ class Contract(db.Model):
|
|||||||
if isinstance(user, Admin):
|
if isinstance(user, Admin):
|
||||||
return True
|
return True
|
||||||
if isinstance(user, Manager):
|
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():
|
if player and player.get_org_teams():
|
||||||
return True
|
return True
|
||||||
if isinstance(user, Coach):
|
if isinstance(user, Coach):
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+19
-12
@@ -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
|
||||||
@@ -18,9 +18,11 @@ from flask_login import current_user, login_required, login_user, logout_user
|
|||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
from app.extensions import check_password, db, hash_password, limiter
|
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.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.
|
||||||
@@ -293,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,
|
||||||
@@ -393,6 +395,13 @@ def register():
|
|||||||
full_name = validated['full_name']
|
full_name = validated['full_name']
|
||||||
phone = validated.get('phone')
|
phone = validated.get('phone')
|
||||||
selected_games = validated.get('games', [])
|
selected_games = validated.get('games', [])
|
||||||
|
try:
|
||||||
|
submitted_gamertags = form_gamertags(selected_games)
|
||||||
|
except ValidationError as err:
|
||||||
|
for field, messages in err.messages.items():
|
||||||
|
for msg in messages:
|
||||||
|
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||||
|
return _rerender_registration(form_data)
|
||||||
# The OAuth identity is server-side state. It used to be copied into
|
# The OAuth identity is server-side state. It used to be copied into
|
||||||
# hidden inputs and read back from request.form, which let anyone
|
# hidden inputs and read back from request.form, which let anyone
|
||||||
# replace the verified Discord account before submitting (SEC-AUTH-005).
|
# replace the verified Discord account before submitting (SEC-AUTH-005).
|
||||||
@@ -445,16 +454,14 @@ def register():
|
|||||||
# Create UserGamertag records for each selected game
|
# Create UserGamertag records for each selected game
|
||||||
from app.models import UserGamertag
|
from app.models import UserGamertag
|
||||||
|
|
||||||
for game in selected_games:
|
for game, gamertag_data in submitted_gamertags.items():
|
||||||
field_name = f'gamertag_{game}'
|
gamertag = UserGamertag(
|
||||||
gamertag_value = request.form.get(field_name, '').strip()
|
user_id=user.id,
|
||||||
if gamertag_value:
|
game=game,
|
||||||
gamertag = UserGamertag(
|
gamertag=gamertag_data['gamertag'],
|
||||||
user_id=user.id,
|
platform=gamertag_data['platform'],
|
||||||
game=game,
|
)
|
||||||
gamertag=gamertag_value,
|
db.session.add(gamertag)
|
||||||
)
|
|
||||||
db.session.add(gamertag)
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Clear Discord OAuth data from session after successful registration
|
# Clear Discord OAuth data from session after successful registration
|
||||||
|
|||||||
+131
-15
@@ -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
|
||||||
|
|
||||||
@@ -27,6 +35,14 @@ from app.validators import EvaluationSchema
|
|||||||
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
||||||
|
|
||||||
|
|
||||||
|
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('')
|
@evaluations_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def list_evaluations():
|
def list_evaluations():
|
||||||
@@ -85,9 +101,10 @@ def list_evaluations():
|
|||||||
.group_by(Evaluation.player_id)
|
.group_by(Evaluation.player_id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
players_by_id = _users_by_id(row.player_id for row in avg_scores)
|
||||||
player_scores = {}
|
player_scores = {}
|
||||||
for row in avg_scores:
|
for row in avg_scores:
|
||||||
p = User.query.get(row.player_id)
|
p = players_by_id.get(row.player_id)
|
||||||
if p:
|
if p:
|
||||||
player_scores[p.id] = {
|
player_scores[p.id] = {
|
||||||
'player': p,
|
'player': p,
|
||||||
@@ -126,7 +143,7 @@ def evaluate_player(tryout_id, player_id):
|
|||||||
flash(_('You do not have permission to evaluate players.'), 'danger')
|
flash(_('You do not have permission to evaluate players.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
@@ -142,7 +159,7 @@ def evaluate_player(tryout_id, player_id):
|
|||||||
flash(_('Player is not registered for this tryout.'), 'danger')
|
flash(_('Player is not registered for this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
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):
|
if not isinstance(player, Player):
|
||||||
flash(_('Can only evaluate players.'), 'danger')
|
flash(_('Can only evaluate players.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
@@ -160,8 +177,10 @@ def evaluate_player(tryout_id, player_id):
|
|||||||
tryout_id=tryout_id,
|
tryout_id=tryout_id,
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
).all()
|
).all()
|
||||||
|
evaluators_by_id = _users_by_id(e.evaluator_id for e in all_evaluations)
|
||||||
evaluators = [
|
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(
|
return render_template(
|
||||||
@@ -210,21 +229,118 @@ def players_to_evaluate(tryout_id):
|
|||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
|
|
||||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||||
players = []
|
players_by_id = _users_by_id(reg.player_id for reg in registrations)
|
||||||
for reg in registrations:
|
evaluated_player_ids = {
|
||||||
p = User.query.get(reg.player_id)
|
player_id
|
||||||
if p and isinstance(p, Player):
|
for (player_id,) in db.session.query(Evaluation.player_id)
|
||||||
existing = Evaluation.query.filter_by(
|
.filter_by(tryout_id=tryout_id, evaluator_id=current_user.id)
|
||||||
tryout_id=tryout_id,
|
.all()
|
||||||
player_id=p.id,
|
}
|
||||||
evaluator_id=current_user.id,
|
players = [
|
||||||
).first()
|
{
|
||||||
players.append({'player': p, 'evaluated': existing is not None, 'registration': reg})
|
'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)
|
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)
|
||||||
|
|||||||
+7
-9
@@ -223,20 +223,18 @@ def dashboard():
|
|||||||
elif isinstance(user, Scout):
|
elif isinstance(user, Scout):
|
||||||
stats['total_players'] = User.query.filter_by(role='player').count()
|
stats['total_players'] = User.query.filter_by(role='player').count()
|
||||||
stats['total_evaluations'] = Evaluation.query.count()
|
stats['total_evaluations'] = Evaluation.query.count()
|
||||||
stats['avg_scores'] = (
|
top_rows = (
|
||||||
db.session.query(
|
db.session.query(
|
||||||
Evaluation.player_id,
|
User,
|
||||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||||
)
|
)
|
||||||
.group_by(Evaluation.player_id)
|
.join(Evaluation, Evaluation.player_id == User.id)
|
||||||
.order_by(func.avg(Evaluation.overall_score).desc())
|
.filter(User.role == 'player')
|
||||||
|
.group_by(User.id)
|
||||||
|
.order_by(func.avg(Evaluation.overall_score).desc(), User.id)
|
||||||
.limit(5)
|
.limit(5)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
stats['top_players'] = []
|
stats['top_players'] = [(player, round(avg_score, 1)) for player, avg_score in top_rows]
|
||||||
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)))
|
|
||||||
|
|
||||||
return render_template('pages/dashboard.html', user=user, stats=stats)
|
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=())
|
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.
|
#: How long a match lasts when the form gives a start and no end.
|
||||||
DEFAULT_MATCH_MINUTES = 30
|
DEFAULT_MATCH_MINUTES = 30
|
||||||
|
|
||||||
@@ -270,7 +289,7 @@ def api_events():
|
|||||||
@login_required
|
@login_required
|
||||||
def api_events_for_tryout(tryout_id):
|
def api_events_for_tryout(tryout_id):
|
||||||
"""API endpoint returning calendar events for a specific tryout."""
|
"""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)
|
can_view = current_user.can_manage_this_tryout(tryout)
|
||||||
|
|
||||||
is_registered = False
|
is_registered = False
|
||||||
@@ -359,7 +378,7 @@ def api_events_for_tryout(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def create_match(tryout_id):
|
def create_match(tryout_id):
|
||||||
"""Create a new match / scrimmage within a tryout."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
|
flash(_('You do not have permission to schedule matches for this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
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))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
||||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
all_players = registered_players(tryout_id)
|
||||||
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)
|
|
||||||
prefill_date = request.args.get('date', '')
|
prefill_date = request.args.get('date', '')
|
||||||
|
|
||||||
def rerender():
|
def rerender():
|
||||||
@@ -437,7 +452,7 @@ def create_match(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def edit_match(match_id):
|
def edit_match(match_id):
|
||||||
"""Edit an existing match."""
|
"""Edit an existing match."""
|
||||||
match = Match.query.get_or_404(match_id)
|
match = db.get_or_404(Match, match_id)
|
||||||
tryout = match.tryout
|
tryout = match.tryout
|
||||||
|
|
||||||
if not current_user.can_manage_this_tryout(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))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||||
|
|
||||||
teams = Team.query.filter_by(tryout_id=tryout.id).all()
|
teams = Team.query.filter_by(tryout_id=tryout.id).all()
|
||||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all()
|
all_players = registered_players(tryout.id)
|
||||||
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)
|
|
||||||
current_player_ids = [p.player_id for p in match.participants.all()]
|
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()]
|
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()]
|
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
|
@login_required
|
||||||
def delete_match(match_id):
|
def delete_match(match_id):
|
||||||
"""Delete a match."""
|
"""Delete a match."""
|
||||||
match = Match.query.get_or_404(match_id)
|
match = db.get_or_404(Match, match_id)
|
||||||
tryout = match.tryout
|
tryout = match.tryout
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to delete this match.'), 'danger')
|
flash(_('You do not have permission to delete this match.'), 'danger')
|
||||||
@@ -656,10 +669,10 @@ def api_available_players(date, time):
|
|||||||
@login_required
|
@login_required
|
||||||
def toggle_presence(match_id, participant_id):
|
def toggle_presence(match_id, participant_id):
|
||||||
"""Toggle attendance_confirmed for a match participant."""
|
"""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
|
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:
|
if participant.match_id != match_id:
|
||||||
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -108,7 +107,7 @@ def list_matches():
|
|||||||
@login_required
|
@login_required
|
||||||
def create_match(team_id):
|
def create_match(team_id):
|
||||||
"""Create a new regular-season team match."""
|
"""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):
|
if not can_manage_team_match(team):
|
||||||
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
|
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
|
||||||
return redirect(url_for('team_matches.list_matches'))
|
return redirect(url_for('team_matches.list_matches'))
|
||||||
@@ -213,7 +212,7 @@ def create_match(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def edit_match(match_id):
|
def edit_match(match_id):
|
||||||
"""Edit an existing team match."""
|
"""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
|
team = team_match.org_team
|
||||||
|
|
||||||
if not can_manage_team_match(team):
|
if not can_manage_team_match(team):
|
||||||
@@ -259,7 +258,7 @@ def edit_match(match_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def delete_match(match_id):
|
def delete_match(match_id):
|
||||||
"""Delete a team match."""
|
"""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
|
team = team_match.org_team
|
||||||
if not can_manage_team_match(team):
|
if not can_manage_team_match(team):
|
||||||
flash(_('You do not have permission to delete this match.'), 'danger')
|
flash(_('You do not have permission to delete this match.'), 'danger')
|
||||||
@@ -293,10 +292,10 @@ def api_manageable_teams():
|
|||||||
@login_required
|
@login_required
|
||||||
def toggle_presence(match_id, participant_id):
|
def toggle_presence(match_id, participant_id):
|
||||||
"""Toggle is_confirmed for a team match participant."""
|
"""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
|
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:
|
if participant.team_match_id != match_id:
|
||||||
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
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.
|
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, 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
|
||||||
@@ -29,7 +27,8 @@ from app.models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.permissions import visible_org_teams
|
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')
|
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:
|
||||||
@@ -223,7 +222,7 @@ def create_team():
|
|||||||
@login_required
|
@login_required
|
||||||
def edit_team(team_id):
|
def edit_team(team_id):
|
||||||
"""Edit an existing organization team."""
|
"""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):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('You do not have permission to edit this team.'), 'danger')
|
flash(_('You do not have permission to edit this team.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
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 —
|
day `Manager.can_manage_this_org_team` is narrowed — which it should be —
|
||||||
deletion narrows with it instead of staying the one way in.
|
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)):
|
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')
|
flash(_('You do not have permission to delete teams.'), 'danger')
|
||||||
@@ -336,7 +335,7 @@ def delete_team(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def add_coach(team_id):
|
def add_coach(team_id):
|
||||||
"""Add a coach to an organization team."""
|
"""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):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
@@ -379,7 +378,7 @@ def add_coach(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def add_manager(team_id):
|
def add_manager(team_id):
|
||||||
"""Add a manager to an organization team."""
|
"""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):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
@@ -422,7 +421,7 @@ def add_manager(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def remove_coach(team_id):
|
def remove_coach(team_id):
|
||||||
"""Remove a coach from an organization team."""
|
"""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):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
@@ -450,7 +449,7 @@ def remove_coach(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def remove_manager(team_id):
|
def remove_manager(team_id):
|
||||||
"""Remove a manager from an organization team."""
|
"""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):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
@@ -478,7 +477,7 @@ def remove_manager(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def add_player(team_id):
|
def add_player(team_id):
|
||||||
"""Add a player to an organization team."""
|
"""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):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
@@ -515,12 +514,12 @@ def add_player(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def remove_player(team_id, player_id):
|
def remove_player(team_id, player_id):
|
||||||
"""Remove a player from an organization team."""
|
"""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):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
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()
|
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||||
if not tp:
|
if not tp:
|
||||||
flash(
|
flash(
|
||||||
@@ -543,7 +542,7 @@ def remove_player(team_id, player_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def toggle_player_status(team_id, player_id):
|
def toggle_player_status(team_id, player_id):
|
||||||
"""Toggle a player's status between starter and substitute."""
|
"""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):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
return jsonify({'error': 'Permission denied'}), 403
|
return jsonify({'error': 'Permission denied'}), 403
|
||||||
|
|
||||||
@@ -567,17 +566,21 @@ def toggle_player_status(team_id, player_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def add_team_note(team_id):
|
def add_team_note(team_id):
|
||||||
"""Add a team improvement note (coaches only)."""
|
"""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):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
content = request.form.get('content', '').strip()
|
try:
|
||||||
if content:
|
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||||
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
|
except ValidationError as err:
|
||||||
db.session.add(note)
|
flash_validation_errors(err)
|
||||||
db.session.commit()
|
return redirect(url_for('teams.list_teams'))
|
||||||
flash(_('Team notes added successfully!'), 'success')
|
|
||||||
|
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'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
|
|
||||||
@@ -585,12 +588,12 @@ def add_team_note(team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def add_player_note(team_id, player_id):
|
def add_player_note(team_id, player_id):
|
||||||
"""Add a personal note for a player (coaches only)."""
|
"""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):
|
if not current_user.can_manage_this_org_team(team):
|
||||||
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
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):
|
if not isinstance(player, Player):
|
||||||
flash(_('Can only add notes for players.'), 'danger')
|
flash(_('Can only add notes for players.'), 'danger')
|
||||||
return redirect(url_for('teams.list_teams'))
|
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'))
|
return redirect(url_for('teams.list_teams'))
|
||||||
|
|
||||||
content = request.form.get('content', '').strip()
|
try:
|
||||||
if content:
|
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||||
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
|
except ValidationError as err:
|
||||||
db.session.add(note)
|
flash_validation_errors(err)
|
||||||
db.session.commit()
|
return redirect(url_for('teams.list_teams'))
|
||||||
flash(_('Note added for %(username)s!', username=player.username), 'success')
|
|
||||||
|
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'))
|
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.
|
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
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.forms import flash_validation_errors, form_payload
|
from app.forms import flash_validation_errors, form_payload
|
||||||
@@ -32,7 +31,15 @@ from app.models import (
|
|||||||
TryoutRegistration,
|
TryoutRegistration,
|
||||||
User,
|
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')
|
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()}
|
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('')
|
@tryouts_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def list_tryouts():
|
def list_tryouts():
|
||||||
@@ -87,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'])
|
||||||
@@ -152,7 +178,7 @@ def create_tryout():
|
|||||||
@login_required
|
@login_required
|
||||||
def edit_tryout(tryout_id):
|
def edit_tryout(tryout_id):
|
||||||
"""Edit an existing tryout event. Permission based on can_manage_this_tryout."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to edit this tryout.'), 'danger')
|
flash(_('You do not have permission to edit this tryout.'), 'danger')
|
||||||
@@ -209,7 +235,7 @@ def edit_tryout(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def view_tryout(tryout_id):
|
def view_tryout(tryout_id):
|
||||||
"""View a specific tryout with all details. Permission via polymorphic dispatch."""
|
"""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
|
can_view = False
|
||||||
if isinstance(current_user, Admin):
|
if isinstance(current_user, Admin):
|
||||||
@@ -398,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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -406,10 +432,10 @@ def view_tryout(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def register_for_tryout(tryout_id):
|
def register_for_tryout(tryout_id):
|
||||||
"""Register a player for a tryout. Only Players can self-register."""
|
"""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):
|
if not isinstance(current_user, Player):
|
||||||
flash(_('Only players can register for tryouts.'), 'danger')
|
flash(_('Only players can register for tryouts.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
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']:
|
if tryout.status not in ['upcoming', 'in_progress']:
|
||||||
flash(_('This tryout is not accepting registrations.'), 'danger')
|
flash(_('This tryout is not accepting registrations.'), 'danger')
|
||||||
@@ -439,15 +465,19 @@ def register_for_tryout(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def update_status(tryout_id):
|
def update_status(tryout_id):
|
||||||
"""Update the status of a tryout."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
new_status = request.form.get('status')
|
try:
|
||||||
if new_status in ['upcoming', 'in_progress', 'completed']:
|
data = TryoutStatusSchema().load(form_payload(list_fields=()))
|
||||||
tryout.status = new_status
|
except ValidationError as err:
|
||||||
db.session.commit()
|
flash_validation_errors(err)
|
||||||
flash(_('Tryout status updated to %(new_status)s.', new_status=new_status), 'success')
|
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))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -455,7 +485,7 @@ def update_status(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def update_registration_status(tryout_id, player_id):
|
def update_registration_status(tryout_id, player_id):
|
||||||
"""Update a registration's attendance status."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
@@ -463,11 +493,15 @@ def update_registration_status(tryout_id, player_id):
|
|||||||
registration = TryoutRegistration.query.filter_by(
|
registration = TryoutRegistration.query.filter_by(
|
||||||
tryout_id=tryout_id, player_id=player_id
|
tryout_id=tryout_id, player_id=player_id
|
||||||
).first_or_404()
|
).first_or_404()
|
||||||
new_status = request.form.get('status')
|
try:
|
||||||
if new_status in ['registered', 'attended', 'no_show']:
|
data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=()))
|
||||||
registration.status = new_status
|
except ValidationError as err:
|
||||||
db.session.commit()
|
flash_validation_errors(err)
|
||||||
flash(_('Registration status updated.'), 'success')
|
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))
|
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
|
@login_required
|
||||||
def register_player(tryout_id):
|
def register_player(tryout_id):
|
||||||
"""Manually register a player for a tryout (by managers/coaches)."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
@@ -522,12 +556,12 @@ def register_player(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def remove_player(tryout_id, player_id):
|
def remove_player(tryout_id, player_id):
|
||||||
"""Remove a registered player from a tryout (cascades to teams/matches)."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
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(
|
registration = TryoutRegistration.query.filter_by(
|
||||||
tryout_id=tryout_id, player_id=player_id
|
tryout_id=tryout_id, player_id=player_id
|
||||||
@@ -558,17 +592,21 @@ def remove_player(tryout_id, player_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def create_team(tryout_id):
|
def create_team(tryout_id):
|
||||||
"""Create a tryout-specific team."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
team_name = request.form.get('team_name')
|
try:
|
||||||
if team_name:
|
data = TryoutTeamSchema().load(form_payload(list_fields=()))
|
||||||
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id)
|
except ValidationError as err:
|
||||||
db.session.add(team)
|
flash_validation_errors(err)
|
||||||
db.session.commit()
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
flash(_('Team "%(team_name)s" created!', team_name=team_name), 'success')
|
|
||||||
|
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))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -576,8 +614,8 @@ def create_team(tryout_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def add_to_team(tryout_id, team_id):
|
def add_to_team(tryout_id, team_id):
|
||||||
"""Add a player to a tryout team."""
|
"""Add a player to a tryout team."""
|
||||||
team = Team.query.get_or_404(team_id)
|
team = db.get_or_404(Team, team_id)
|
||||||
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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('Permission denied.'), 'danger')
|
flash(_('Permission denied.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
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:
|
if team.tryout_id != tryout_id:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
player_id = request.form.get('player_id', type=int)
|
try:
|
||||||
if not player_id:
|
data = TryoutTeamMemberSchema().load(form_payload(list_fields=()))
|
||||||
flash(_('Please select a player.'), 'danger')
|
except ValidationError as err:
|
||||||
|
flash_validation_errors(err)
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
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.
|
# Only players registered for this tryout may be placed on its teams.
|
||||||
is_registered = (
|
is_registered = (
|
||||||
@@ -602,12 +642,11 @@ def add_to_team(tryout_id, team_id):
|
|||||||
flash(_('That player is not registered for this tryout.'), 'danger')
|
flash(_('That player is not registered for this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
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()
|
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
|
||||||
if existing:
|
if existing:
|
||||||
flash(_('Player is already on this team.'), 'info')
|
flash(_('Player is already on this team.'), 'info')
|
||||||
else:
|
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.add(member)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_('Player added to team!'), 'success')
|
flash(_('Player added to team!'), 'success')
|
||||||
@@ -618,7 +657,7 @@ def add_to_team(tryout_id, team_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def delete_tryout(tryout_id):
|
def delete_tryout(tryout_id):
|
||||||
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
|
"""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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash(_('You do not have permission to delete this tryout.'), 'danger')
|
flash(_('You do not have permission to delete this tryout.'), 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
|
|||||||
+1270
File diff suppressed because it is too large
Load Diff
+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.
|
can call them with a request context and nothing else.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import request
|
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from app.extensions import db
|
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
|
# 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.
|
# 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.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_CONTRACT_EXTENSIONS = {'pdf'}
|
||||||
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
|
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
|
||||||
@@ -63,20 +62,25 @@ def pdf_upload_error(file, allowed_extensions):
|
|||||||
|
|
||||||
|
|
||||||
def update_user_gamertags(user, selected_games):
|
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}
|
existing_gamertags = {gt.game: gt for gt in user.gamertags}
|
||||||
for game in selected_games:
|
for game in selected_games:
|
||||||
gamertag = request.form.get(f'gamertag_{game}', '').strip()
|
payload = submitted.get(game)
|
||||||
platform = (
|
|
||||||
request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None
|
|
||||||
)
|
|
||||||
existing = existing_gamertags.get(game)
|
existing = existing_gamertags.get(game)
|
||||||
if gamertag:
|
if payload:
|
||||||
if existing:
|
if existing:
|
||||||
existing.gamertag = gamertag
|
existing.gamertag = payload['gamertag']
|
||||||
existing.platform = platform
|
existing.platform = payload['platform']
|
||||||
else:
|
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)
|
db.session.add(gt)
|
||||||
elif existing:
|
elif existing:
|
||||||
db.session.delete(existing)
|
db.session.delete(existing)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ def edit_user(user_id):
|
|||||||
flash(_('Only the president can edit users.'), 'danger')
|
flash(_('Only the president can edit users.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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':
|
if request.method == 'POST':
|
||||||
actor_name, actor_id = current_user.username, current_user.id
|
actor_name, actor_id = current_user.username, current_user.id
|
||||||
@@ -121,6 +121,12 @@ def edit_user(user_id):
|
|||||||
flash(_('This Discord account is already linked to another account.'), 'danger')
|
flash(_('This Discord account is already linked to another account.'), 'danger')
|
||||||
return _rerender()
|
return _rerender()
|
||||||
|
|
||||||
|
try:
|
||||||
|
update_user_gamertags(user, selected_games)
|
||||||
|
except ValidationError as err:
|
||||||
|
flash_validation_errors(err)
|
||||||
|
return _rerender()
|
||||||
|
|
||||||
role_changed = user.role != role
|
role_changed = user.role != role
|
||||||
previous_role = user.role
|
previous_role = user.role
|
||||||
|
|
||||||
@@ -183,8 +189,6 @@ def edit_user(user_id):
|
|||||||
user.discord_user_id = discord_user_id or None
|
user.discord_user_id = discord_user_id or None
|
||||||
user.league_os_profile = league_os_profile 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
|
# Blank means "keep the current password"; anything else has already
|
||||||
# been checked against the policy by the schema.
|
# been checked against the policy by the schema.
|
||||||
password = validated.get('password')
|
password = validated.get('password')
|
||||||
@@ -249,7 +253,7 @@ def delete_user(user_id):
|
|||||||
flash(_('You cannot delete your own account.'), 'danger')
|
flash(_('You cannot delete your own account.'), 'danger')
|
||||||
return redirect(url_for('users.list_users'))
|
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(
|
Evaluation.query.filter(
|
||||||
db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id),
|
db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id),
|
||||||
@@ -375,5 +379,5 @@ def create_user():
|
|||||||
@login_required
|
@login_required
|
||||||
def view_user(user_id):
|
def view_user(user_id):
|
||||||
"""View a public profile for any user."""
|
"""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)
|
return render_template('pages/view_user.html', profile_user=user)
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ def clear_disponibilities():
|
|||||||
@login_required
|
@login_required
|
||||||
def delete_disponibility(disponibility_id):
|
def delete_disponibility(disponibility_id):
|
||||||
"""Delete a disponibility block."""
|
"""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:
|
if disponibility.player_id != current_user.id:
|
||||||
return jsonify({'error': 'Unauthorized'}), 403
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
db.session.delete(disponibility)
|
db.session.delete(disponibility)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ def upload_contract():
|
|||||||
flash(error, 'danger')
|
flash(error, 'danger')
|
||||||
return redirect(url_for('users.upload_contract'))
|
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()
|
player_teams = player.get_org_teams()
|
||||||
team = player_teams[0] if player_teams else None
|
team = player_teams[0] if player_teams else None
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ def upload_contract():
|
|||||||
@login_required
|
@login_required
|
||||||
def upload_signed_contract(contract_id):
|
def upload_signed_contract(contract_id):
|
||||||
"""Upload a signed contract (player only)."""
|
"""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):
|
if not contract.can_upload_signed(current_user):
|
||||||
flash(_('Only the player can upload their signed contract.'), 'danger')
|
flash(_('Only the player can upload their signed contract.'), 'danger')
|
||||||
return redirect(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
@@ -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'))
|
||||||
@@ -182,7 +182,7 @@ def upload_signed_contract(contract_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def download_contract(contract_id):
|
def download_contract(contract_id):
|
||||||
"""Download a contract file."""
|
"""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):
|
if not contract.can_view(current_user):
|
||||||
flash(_('You do not have permission to download this contract.'), 'danger')
|
flash(_('You do not have permission to download this contract.'), 'danger')
|
||||||
return redirect(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
@@ -197,7 +197,7 @@ def download_contract(contract_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def download_signed_contract(contract_id):
|
def download_signed_contract(contract_id):
|
||||||
"""Download a signed contract file."""
|
"""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):
|
if not contract.can_view(current_user):
|
||||||
flash(_('You do not have permission to download this contract.'), 'danger')
|
flash(_('You do not have permission to download this contract.'), 'danger')
|
||||||
return redirect(url_for('users.list_contracts'))
|
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 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
|
||||||
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
|
from app.forms import flash_validation_errors, form_payload
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Coach,
|
Coach,
|
||||||
Match,
|
Match,
|
||||||
MatchParticipant,
|
MatchParticipant,
|
||||||
OneOnOneRequest,
|
OneOnOneRequest,
|
||||||
OrgTeam,
|
|
||||||
PersonalNote,
|
PersonalNote,
|
||||||
Player,
|
Player,
|
||||||
|
Team,
|
||||||
|
TeamMember,
|
||||||
TeamNote,
|
TeamNote,
|
||||||
Tryout,
|
Tryout,
|
||||||
TryoutRegistration,
|
TryoutRegistration,
|
||||||
User,
|
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.routes.users.blueprint import users_bp
|
||||||
|
from app.validators import NoteContentSchema, PersonalNoteSchema
|
||||||
|
|
||||||
|
|
||||||
@users_bp.route('/my-notes')
|
@users_bp.route('/my-notes')
|
||||||
@@ -116,23 +125,25 @@ def notes_dashboard():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# For context selectors in the form
|
# 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 = (
|
matches = (
|
||||||
Match.query.filter(
|
Match.query.filter(Match.tryout_id.in_(tryout_ids))
|
||||||
db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'),
|
|
||||||
)
|
|
||||||
.order_by(Match.date.desc())
|
.order_by(Match.date.desc())
|
||||||
.limit(20)
|
.limit(20)
|
||||||
.all()
|
.all()
|
||||||
|
if tryout_ids
|
||||||
|
else []
|
||||||
)
|
)
|
||||||
tryouts = (
|
teams = (
|
||||||
Tryout.query.filter_by(
|
Team.query.filter(Team.tryout_id.in_(tryout_ids)).order_by(Team.name).all()
|
||||||
created_by=current_user.id,
|
if tryout_ids
|
||||||
)
|
else []
|
||||||
.order_by(Tryout.date.desc())
|
|
||||||
.limit(20)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'pages/notes.html',
|
'pages/notes.html',
|
||||||
@@ -168,16 +179,20 @@ def manage_team_notes():
|
|||||||
return redirect(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
org_team = org_teams[0]
|
org_team = org_teams[0]
|
||||||
|
|
||||||
content = request.form.get('content', '').strip()
|
try:
|
||||||
if content:
|
data = NoteContentSchema().load(form_payload(list_fields=()))
|
||||||
note = TeamNote(
|
except ValidationError as err:
|
||||||
org_team_id=org_team.id,
|
flash_validation_errors(err)
|
||||||
coach_id=current_user.id,
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
content=content,
|
|
||||||
)
|
note = TeamNote(
|
||||||
db.session.add(note)
|
org_team_id=org_team.id,
|
||||||
db.session.commit()
|
coach_id=current_user.id,
|
||||||
flash(_('Team notes saved successfully!'), 'success')
|
content=data['content'],
|
||||||
|
)
|
||||||
|
db.session.add(note)
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Team notes saved successfully!'), 'success')
|
||||||
|
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
|
||||||
@@ -195,14 +210,14 @@ def manage_personal_notes():
|
|||||||
flash(_('Only coaches can manage personal notes.'), 'danger')
|
flash(_('Only coaches can manage personal notes.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
player_id = request.form.get('player_id', type=int)
|
try:
|
||||||
content = request.form.get('content', '').strip()
|
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||||
|
except ValidationError as err:
|
||||||
if not player_id or not content:
|
flash_validation_errors(err)
|
||||||
flash(_('Player and content are required.'), 'danger')
|
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
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):
|
if not isinstance(player, Player):
|
||||||
flash(_('Can only add notes for players.'), 'danger')
|
flash(_('Can only add notes for players.'), 'danger')
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
@@ -214,7 +229,7 @@ def manage_personal_notes():
|
|||||||
note = PersonalNote(
|
note = PersonalNote(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
coach_id=current_user.id,
|
coach_id=current_user.id,
|
||||||
content=content,
|
content=data['content'],
|
||||||
)
|
)
|
||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -235,17 +250,14 @@ def add_personal_note():
|
|||||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
player_id = request.form.get('player_id', type=int)
|
try:
|
||||||
content = request.form.get('content', '').strip()
|
data = PersonalNoteSchema().load(form_payload(list_fields=()))
|
||||||
match_id = request.form.get('match_id', type=int)
|
except ValidationError as err:
|
||||||
tryout_id = request.form.get('tryout_id', type=int)
|
flash_validation_errors(err)
|
||||||
team_id_str = request.form.get('team_id')
|
|
||||||
|
|
||||||
if not player_id or not content:
|
|
||||||
flash(_('Player and content are required.'), 'danger')
|
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
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):
|
if not isinstance(player, Player):
|
||||||
flash(_('Can only add notes for players.'), 'danger')
|
flash(_('Can only add notes for players.'), 'danger')
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
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')
|
flash(_('You can only write notes about players you work with.'), 'danger')
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
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(
|
note = PersonalNote(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
coach_id=current_user.id,
|
coach_id=current_user.id,
|
||||||
content=content,
|
content=data['content'],
|
||||||
match_id=match_id if match_id else None,
|
match_id=data['match_id'],
|
||||||
tryout_id=tryout_id if tryout_id else None,
|
tryout_id=data['tryout_id'],
|
||||||
team_id=int(team_id_str) if team_id_str and team_id_str.isdigit() else None,
|
team_id=data['team_id'],
|
||||||
)
|
)
|
||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -281,7 +320,10 @@ def add_note_from_tryout(tryout_id):
|
|||||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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)
|
preselected_player_id = request.args.get('player_id', type=int)
|
||||||
|
|
||||||
# Get registrations as players for the select list
|
# 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]
|
players = [r.player for r in registrations if r.player]
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
player_id = request.form.get('player_id', type=int)
|
try:
|
||||||
content = request.form.get('content', '').strip()
|
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:
|
if data['tryout_id'] not in (None, tryout_id):
|
||||||
flash(_('Player and content are required.'), 'danger')
|
flash(_('Invalid tryout context.'), 'danger')
|
||||||
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
|
return redirect(url_for('users.add_note_from_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
if not coach_can_access_player(current_user, player_id):
|
if not coach_can_access_player(current_user, player_id):
|
||||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
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))
|
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(
|
note = PersonalNote(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
coach_id=current_user.id,
|
coach_id=current_user.id,
|
||||||
content=content,
|
content=data['content'],
|
||||||
tryout_id=tryout_id,
|
tryout_id=tryout_id,
|
||||||
)
|
)
|
||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
@@ -334,7 +384,10 @@ def add_note_from_match(match_id):
|
|||||||
flash(_('Only coaches can add personal notes.'), 'danger')
|
flash(_('Only coaches can add personal notes.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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
|
# Get participants as players for the select list
|
||||||
participants = MatchParticipant.query.filter_by(match_id=match_id).all()
|
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)
|
preselected_player_id = request.args.get('player_id', type=int)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
player_id = request.form.get('player_id', type=int)
|
try:
|
||||||
content = request.form.get('content', '').strip()
|
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:
|
if data['match_id'] not in (None, match_id):
|
||||||
flash(_('Player and content are required.'), 'danger')
|
flash(_('Invalid match context.'), 'danger')
|
||||||
return redirect(url_for('users.add_note_from_match', match_id=match_id))
|
return redirect(url_for('users.add_note_from_match', match_id=match_id))
|
||||||
|
|
||||||
if not coach_can_access_player(current_user, player_id):
|
if not coach_can_access_player(current_user, player_id):
|
||||||
flash(_('You can only write notes about players you work with.'), 'danger')
|
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))
|
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(
|
note = PersonalNote(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
coach_id=current_user.id,
|
coach_id=current_user.id,
|
||||||
content=content,
|
content=data['content'],
|
||||||
match_id=match_id,
|
match_id=match_id,
|
||||||
)
|
)
|
||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
|
|||||||
@@ -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,7 +10,8 @@ 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.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'])
|
@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')
|
flash(_('Only coaches can accept One on One requests.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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:
|
if request_obj.coach_id != current_user.id:
|
||||||
flash(_('This request is not for you.'), 'danger')
|
flash(_('This request is not for you.'), 'danger')
|
||||||
@@ -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)
|
||||||
@@ -216,7 +215,7 @@ def reject_one_on_one(request_id):
|
|||||||
flash(_('Only coaches can reject One on One requests.'), 'danger')
|
flash(_('Only coaches can reject One on One requests.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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:
|
if request_obj.coach_id != current_user.id:
|
||||||
flash(_('This request is not for you.'), 'danger')
|
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')
|
flash(_('This request has already been processed.'), 'info')
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
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
|
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()
|
||||||
|
|||||||
@@ -98,6 +98,18 @@ def edit_profile():
|
|||||||
user_gamertags=current_user.get_gamertags(),
|
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.username = username
|
||||||
current_user.full_name = full_name
|
current_user.full_name = full_name
|
||||||
current_user.email = email
|
current_user.email = email
|
||||||
@@ -106,8 +118,6 @@ def edit_profile():
|
|||||||
current_user.discord_username = discord_username or None
|
current_user.discord_username = discord_username or None
|
||||||
current_user.league_os_profile = league_os_profile 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
|
# Blank means "keep the current password"; anything else has already
|
||||||
# been checked against the policy by the schema.
|
# been checked against the policy by the schema.
|
||||||
password = validated.get('password')
|
password = validated.get('password')
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ PG_RESTORE = os.getenv('PG_RESTORE', 'pg_restore')
|
|||||||
# wave G introduced DOCUMENTS_ROOT so a release-directory deployment could
|
# wave G introduced DOCUMENTS_ROOT so a release-directory deployment could
|
||||||
# keep uploads outside the releases, and docs/deployment.md now tells the
|
# 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
|
# 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
|
# application had never written to. A missing or unreadable document store
|
||||||
# either; it prints "No documents directory found", skips, and exits 0.
|
# is now a failed full-backup run rather than a database-only green result.
|
||||||
#
|
#
|
||||||
# So the more correctly an operator followed the deployment documentation,
|
# So the more correctly an operator followed the deployment documentation,
|
||||||
# the more certainly their contract backups were empty (OBS-006).
|
# the more certainly their contract backups were empty (OBS-006).
|
||||||
@@ -262,33 +262,31 @@ def backup_documents():
|
|||||||
module happened to be imported with.
|
module happened to be imported with.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Path to the created archive, or None if there is nothing to
|
str: Path to the created archive.
|
||||||
archive. Signed contracts live only on disk, so losing this
|
|
||||||
directory loses the documents themselves.
|
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()
|
documents_dir = documents_root()
|
||||||
|
|
||||||
if not os.path.exists(documents_dir):
|
if not os.path.exists(documents_dir):
|
||||||
# Says where it looked. The previous message named no path, so an
|
raise BackupError(f'Documents directory does not exist: {documents_dir}')
|
||||||
# operator who had moved the documents read it as "there are no
|
if not os.path.isdir(documents_dir):
|
||||||
# documents" rather than "I am looking in the wrong place".
|
raise BackupError(f'Documents path is not a directory: {documents_dir}')
|
||||||
print(f'[INFO] No documents directory at {documents_dir}. Skipping document backup.')
|
|
||||||
return None
|
|
||||||
|
|
||||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}')
|
archive_basename = os.path.join(BACKUP_DIR, f'documents_backup_{timestamp}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
shutil.make_archive(archive_basename, 'zip', documents_dir)
|
shutil.make_archive(archive_basename, 'zip', documents_dir)
|
||||||
except Exception as exc: # noqa: BLE001 — a failed document archive must not lose the dump
|
except Exception as exc: # noqa: BLE001 — normalize the shutil boundary
|
||||||
# This runs after the database dump has already succeeded. Letting
|
raise BackupError(f'Document backup failed: {exc}') from exc
|
||||||
# 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
|
|
||||||
|
|
||||||
zip_path = f'{archive_basename}.zip'
|
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)
|
size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||||||
print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)')
|
print(f'[OK] Documents backed up to: {zip_path} ({size_mb:.1f} MB)')
|
||||||
return zip_path
|
return zip_path
|
||||||
@@ -361,15 +359,20 @@ def main(argv=None):
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
verified = verify_backup(backup_path)
|
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()
|
cleanup_old_backups()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if verified:
|
if verified and documents_ok:
|
||||||
print('=== Backup completed successfully ===')
|
print('=== Backup completed successfully ===')
|
||||||
return 0
|
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
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This script performs pre-deployment security checks to validate:
|
|||||||
- Debug mode status
|
- Debug mode status
|
||||||
- HTTPS configuration
|
- HTTPS configuration
|
||||||
- Dependency vulnerabilities
|
- Dependency vulnerabilities
|
||||||
- Database connectivity
|
- Required database configuration
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python security_scan.py [--url http://localhost:5000]
|
python security_scan.py [--url http://localhost:5000]
|
||||||
@@ -19,6 +19,10 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
REQUIREMENTS_FILE = PROJECT_ROOT / 'requirements.txt'
|
||||||
|
|
||||||
|
|
||||||
def check_environment():
|
def check_environment():
|
||||||
@@ -31,8 +35,8 @@ def check_environment():
|
|||||||
print('1. ENVIRONMENT VARIABLES CHECK')
|
print('1. ENVIRONMENT VARIABLES CHECK')
|
||||||
print('=' * 60)
|
print('=' * 60)
|
||||||
|
|
||||||
critical_vars = ['SECRET_KEY']
|
critical_vars = ['SECRET_KEY', 'DATABASE_URL']
|
||||||
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS']
|
recommended_vars = ['CORS_ALLOWED_ORIGINS']
|
||||||
all_ok = True
|
all_ok = True
|
||||||
|
|
||||||
for var in critical_vars:
|
for var in critical_vars:
|
||||||
@@ -58,7 +62,8 @@ def check_environment():
|
|||||||
# Check FLASK_DEBUG
|
# Check FLASK_DEBUG
|
||||||
debug = os.getenv('FLASK_DEBUG', 'false').lower()
|
debug = os.getenv('FLASK_DEBUG', 'false').lower()
|
||||||
if debug == 'true':
|
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:
|
else:
|
||||||
print('[OK] FLASK_DEBUG is disabled')
|
print('[OK] FLASK_DEBUG is disabled')
|
||||||
|
|
||||||
@@ -91,10 +96,11 @@ def check_https_headers(url):
|
|||||||
all_ok = True
|
all_ok = True
|
||||||
|
|
||||||
try:
|
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 = ssl.create_default_context()
|
||||||
ctx.check_hostname = False
|
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
|
||||||
|
|
||||||
req = urllib.request.Request(url, method='HEAD')
|
req = urllib.request.Request(url, method='HEAD')
|
||||||
|
|
||||||
@@ -148,9 +154,9 @@ def check_https_headers(url):
|
|||||||
all_ok = False
|
all_ok = False
|
||||||
|
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
print(f'[SKIP] Cannot connect to {url}: {e.reason}')
|
print(f'[FAIL] Cannot connect to {url}: {e.reason}')
|
||||||
print('[SKIP] Run with --url <application_url> to check headers')
|
print('[INFO] Use --skip-http only when the live check is intentionally out of scope.')
|
||||||
return True # Not a failure, just can't check
|
return False
|
||||||
|
|
||||||
return all_ok
|
return all_ok
|
||||||
|
|
||||||
@@ -167,7 +173,15 @@ def check_dependencies():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, '-m', 'pip_audit', '--format', 'json'],
|
[
|
||||||
|
sys.executable,
|
||||||
|
'-m',
|
||||||
|
'pip_audit',
|
||||||
|
'--requirement',
|
||||||
|
str(REQUIREMENTS_FILE),
|
||||||
|
'--format',
|
||||||
|
'json',
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
@@ -195,14 +209,16 @@ def check_dependencies():
|
|||||||
if result.stdout:
|
if result.stdout:
|
||||||
print(f'[INFO] {result.stdout.strip()}')
|
print(f'[INFO] {result.stdout.strip()}')
|
||||||
if result.stderr:
|
if result.stderr:
|
||||||
print(f'[WARN] {result.stderr.strip()}')
|
print(f'[FAIL] {result.stderr.strip()}')
|
||||||
return True
|
else:
|
||||||
|
print(f'[FAIL] pip-audit exited with status {result.returncode}.')
|
||||||
|
return False
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print('[SKIP] pip-audit not installed. Run: pip install pip-audit')
|
print('[FAIL] pip-audit not installed. Run: pip install pip-audit')
|
||||||
return True
|
return False
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
print('[WARN] pip-audit timed out')
|
print('[FAIL] pip-audit timed out')
|
||||||
return True
|
return False
|
||||||
|
|
||||||
|
|
||||||
def check_file_permissions():
|
def check_file_permissions():
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -216,7 +216,14 @@ function flash(message, type) {
|
|||||||
const flashContainer = document.querySelector('.flash-messages');
|
const flashContainer = document.querySelector('.flash-messages');
|
||||||
const alert = document.createElement('div');
|
const alert = document.createElement('div');
|
||||||
alert.className = 'alert alert-' + type + ' alert-dismissible';
|
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);
|
flashContainer.appendChild(alert);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -450,11 +450,18 @@
|
|||||||
var playerDataById = {
|
var playerDataById = {
|
||||||
player_data: {
|
player_data: {
|
||||||
{%- for p in all_players %}
|
{%- for p in all_players %}
|
||||||
{{ p.id }}: "{{ p.username | escape }}",
|
{{ p.id }}: {{ p.username | tojson }},
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var HTML_ESCAPES = {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''};
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replace(/[&<>"']/g, function (character) {
|
||||||
|
return HTML_ESCAPES[character];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// All registered player IDs
|
// All registered player IDs
|
||||||
var allRegisteredPlayers = [
|
var allRegisteredPlayers = [
|
||||||
{%- for p in all_players %}
|
{%- for p in all_players %}
|
||||||
@@ -555,7 +562,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
var playerName = playerDataById.player_data[pid];
|
var playerName = playerDataById.player_data[pid];
|
||||||
if (playerName) {
|
if (playerName) {
|
||||||
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
|
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 += '<span class="remove-btn">↺</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
teamDiv.insertAdjacentHTML('beforeend', html);
|
teamDiv.insertAdjacentHTML('beforeend', html);
|
||||||
@@ -568,7 +575,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
var playerName = playerDataById.player_data[pid];
|
var playerName = playerDataById.player_data[pid];
|
||||||
if (playerName) {
|
if (playerName) {
|
||||||
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
|
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 += '<span class="remove-btn">↺</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
teamDiv.insertAdjacentHTML('beforeend', html);
|
teamDiv.insertAdjacentHTML('beforeend', html);
|
||||||
@@ -661,7 +668,7 @@ function renderMergedDisponibilityGrid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dayRow += '<div class="' + cssClass + '" data-day="' + day.value + '" data-time="' + slotData.time + '" ' +
|
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 +
|
slotData.display +
|
||||||
'<span class="merged-disponibility-count">' + count + '</span>' +
|
'<span class="merged-disponibility-count">' + count + '</span>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
@@ -920,7 +927,7 @@ function updatePlayerPool() {
|
|||||||
var availabilityClass = isAvailable ? 'available' : 'unavailable';
|
var availabilityClass = isAvailable ? 'available' : 'unavailable';
|
||||||
|
|
||||||
html += '<div class="player-item ' + availabilityClass + '" data-player-id="' + pid + '">';
|
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 += '<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-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>';
|
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;
|
if (!playerName) return;
|
||||||
|
|
||||||
var html = '<div class="player-item" data-player-id="' + playerId + '" data-action="return-to-pool">';
|
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 += '<span class="remove-btn">↺</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
@@ -1062,7 +1069,7 @@ function randomizeTeams() {
|
|||||||
var playerName = playerDataById.player_data[pid];
|
var playerName = playerDataById.player_data[pid];
|
||||||
if (playerName) {
|
if (playerName) {
|
||||||
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
|
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 += '<span class="remove-btn">↺</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
teamDiv.insertAdjacentHTML('beforeend', html);
|
teamDiv.insertAdjacentHTML('beforeend', html);
|
||||||
@@ -1074,7 +1081,7 @@ function randomizeTeams() {
|
|||||||
var playerName = playerDataById.player_data[pid];
|
var playerName = playerDataById.player_data[pid];
|
||||||
if (playerName) {
|
if (playerName) {
|
||||||
var html = '<div class="player-item" data-player-id="' + pid + '" data-action="return-to-pool">';
|
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 += '<span class="remove-btn">↺</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
teamDiv.insertAdjacentHTML('beforeend', html);
|
teamDiv.insertAdjacentHTML('beforeend', html);
|
||||||
@@ -1120,6 +1127,11 @@ function togglePresence(matchId, participantId, badgeEl) {
|
|||||||
registerActions({
|
registerActions({
|
||||||
'toggle-match-type': toggleMatchType,
|
'toggle-match-type': toggleMatchType,
|
||||||
'clear-time-selection': clearTimeSelection,
|
'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,
|
'update-randomize-preview': updateRandomizePreview,
|
||||||
'randomize-teams': randomizeTeams,
|
'randomize-teams': randomizeTeams,
|
||||||
'return-to-pool': returnToPool,
|
'return-to-pool': returnToPool,
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: team-tryouts VERSION\n"
|
"Project-Id-Version: team-tryouts VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2026-08-16 23:22-0400\n"
|
"POT-Creation-Date: 2026-08-17 14:18-0400\n"
|
||||||
"PO-Revision-Date: 2026-08-07 20:22-0400\n"
|
"PO-Revision-Date: 2026-08-07 20:22-0400\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: en\n"
|
"Language: en\n"
|
||||||
@@ -23,8 +23,8 @@ msgstr ""
|
|||||||
msgid "Please log in to access this page."
|
msgid "Please log in to access this page."
|
||||||
msgstr "Please log in to access this page."
|
msgstr "Please log in to access this page."
|
||||||
|
|
||||||
#: app/forms.py:37 app/routes/auth.py:228 app/routes/auth.py:387
|
#: app/forms.py:37 app/routes/auth.py:229 app/routes/auth.py:388
|
||||||
#: app/routes/users/contracts.py:98
|
#: app/routes/auth.py:402 app/routes/users/contracts.py:98
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(field)s: %(msg)s"
|
msgid "%(field)s: %(msg)s"
|
||||||
msgstr "%(field)s: %(msg)s"
|
msgstr "%(field)s: %(msg)s"
|
||||||
@@ -61,7 +61,7 @@ msgstr "Username is required."
|
|||||||
msgid "Password is required."
|
msgid "Password is required."
|
||||||
msgstr "Password is required."
|
msgstr "Password is required."
|
||||||
|
|
||||||
#: app/validators.py:227 app/validators.py:294
|
#: app/validators.py:227 app/validators.py:324
|
||||||
msgid "Username must be 3-80 characters."
|
msgid "Username must be 3-80 characters."
|
||||||
msgstr "Username must be 3-80 characters."
|
msgstr "Username must be 3-80 characters."
|
||||||
|
|
||||||
@@ -69,8 +69,8 @@ msgstr "Username must be 3-80 characters."
|
|||||||
msgid "Email must be 120 characters or less."
|
msgid "Email must be 120 characters or less."
|
||||||
msgstr "Email must be 120 characters or less."
|
msgstr "Email must be 120 characters or less."
|
||||||
|
|
||||||
#: app/validators.py:246 app/validators.py:309 app/validators.py:340
|
#: app/validators.py:246 app/validators.py:339 app/validators.py:370
|
||||||
#: app/validators.py:403
|
#: app/validators.py:433
|
||||||
msgid "Full name is required."
|
msgid "Full name is required."
|
||||||
msgstr "Full name is required."
|
msgstr "Full name is required."
|
||||||
|
|
||||||
@@ -78,160 +78,200 @@ msgstr "Full name is required."
|
|||||||
msgid "Passwords do not match."
|
msgid "Passwords do not match."
|
||||||
msgstr "Passwords do not match."
|
msgstr "Passwords do not match."
|
||||||
|
|
||||||
#: app/validators.py:313 app/validators.py:348
|
#: app/validators.py:284 app/validators.py:924
|
||||||
msgid "Invalid role selected."
|
|
||||||
msgstr "Invalid role selected."
|
|
||||||
|
|
||||||
#: app/validators.py:443
|
|
||||||
msgid "Player must be selected."
|
|
||||||
msgstr "Player must be selected."
|
|
||||||
|
|
||||||
#: app/validators.py:446
|
|
||||||
msgid "Notes must be 2000 characters or less."
|
|
||||||
msgstr "Notes must be 2000 characters or less."
|
|
||||||
|
|
||||||
#: app/validators.py:483 app/validators.py:854
|
|
||||||
msgid "Invalid coach selection."
|
|
||||||
msgstr "Invalid coach selection."
|
|
||||||
|
|
||||||
#: app/validators.py:489 app/validators.py:860
|
|
||||||
msgid "Invalid manager selection."
|
|
||||||
msgstr "Invalid manager selection."
|
|
||||||
|
|
||||||
#: app/validators.py:507
|
|
||||||
msgid "Invalid player selection."
|
|
||||||
msgstr "Invalid player selection."
|
|
||||||
|
|
||||||
#: app/validators.py:516
|
|
||||||
msgid "Unknown roster status."
|
|
||||||
msgstr "Unknown roster status."
|
|
||||||
|
|
||||||
#: app/validators.py:539
|
|
||||||
msgid "Date must be in YYYY-MM-DD format."
|
|
||||||
msgstr "Date must be in YYYY-MM-DD format."
|
|
||||||
|
|
||||||
#: app/validators.py:540
|
|
||||||
msgid "A date is required."
|
|
||||||
msgstr "A date is required."
|
|
||||||
|
|
||||||
#: app/validators.py:546 app/validators.py:611
|
|
||||||
msgid "Start time must be in HH:MM format."
|
|
||||||
msgstr "Start time must be in HH:MM format."
|
|
||||||
|
|
||||||
#: app/validators.py:547 app/validators.py:612
|
|
||||||
msgid "A start time is required."
|
|
||||||
msgstr "A start time is required."
|
|
||||||
|
|
||||||
#: app/validators.py:553
|
|
||||||
msgid "End time must be in HH:MM format."
|
|
||||||
msgstr "End time must be in HH:MM format."
|
|
||||||
|
|
||||||
#: app/validators.py:554
|
|
||||||
msgid "An end time is required."
|
|
||||||
msgstr "An end time is required."
|
|
||||||
|
|
||||||
#: app/validators.py:558
|
|
||||||
msgid "Points must be 2000 characters or less."
|
|
||||||
msgstr "Points must be 2000 characters or less."
|
|
||||||
|
|
||||||
#: app/validators.py:573
|
|
||||||
msgid "End time must be after start time."
|
|
||||||
msgstr "End time must be after start time."
|
|
||||||
|
|
||||||
#: app/validators.py:602 app/validators.py:604
|
|
||||||
msgid "Day must be 0 (Monday) to 6 (Sunday)."
|
|
||||||
msgstr "Day must be 0 (Monday) to 6 (Sunday)."
|
|
||||||
|
|
||||||
#: app/validators.py:605
|
|
||||||
msgid "A day is required."
|
|
||||||
msgstr "A day is required."
|
|
||||||
|
|
||||||
#: app/validators.py:640
|
|
||||||
msgid "Player selection is malformed."
|
|
||||||
msgstr "Player selection is malformed."
|
|
||||||
|
|
||||||
#: app/validators.py:666 app/validators.py:776
|
|
||||||
msgid "A title is required."
|
|
||||||
msgstr "A title is required."
|
|
||||||
|
|
||||||
#: app/validators.py:675
|
|
||||||
msgid "Invalid date format."
|
|
||||||
msgstr "Invalid date format."
|
|
||||||
|
|
||||||
#: app/validators.py:680 app/validators.py:687
|
|
||||||
msgid "Invalid time format."
|
|
||||||
msgstr "Invalid time format."
|
|
||||||
|
|
||||||
#: app/validators.py:681
|
|
||||||
msgid "Start time is required. Please select a time slot."
|
|
||||||
msgstr "Start time is required. Please select a time slot."
|
|
||||||
|
|
||||||
#: app/validators.py:695
|
|
||||||
msgid "Unknown match status."
|
|
||||||
msgstr "Unknown match status."
|
|
||||||
|
|
||||||
#: app/validators.py:711
|
|
||||||
msgid "The end time must come after the start time."
|
|
||||||
msgstr "The end time must come after the start time."
|
|
||||||
|
|
||||||
#: app/validators.py:725
|
|
||||||
msgid "Unknown match type."
|
|
||||||
msgstr "Unknown match type."
|
|
||||||
|
|
||||||
#: app/validators.py:737
|
|
||||||
msgid "A team cannot play against itself."
|
|
||||||
msgstr "A team cannot play against itself."
|
|
||||||
|
|
||||||
#: app/validators.py:785
|
|
||||||
msgid "Unknown game."
|
msgid "Unknown game."
|
||||||
msgstr "Unknown game."
|
msgstr "Unknown game."
|
||||||
|
|
||||||
#: app/validators.py:790
|
#: app/validators.py:291
|
||||||
|
msgid "Gamertag must be between 1 and 120 characters."
|
||||||
|
msgstr "Gamertag must be between 1 and 120 characters."
|
||||||
|
|
||||||
|
#: app/validators.py:297
|
||||||
|
msgid "Platform must be 30 characters or less."
|
||||||
|
msgstr "Platform must be 30 characters or less."
|
||||||
|
|
||||||
|
#: app/validators.py:306
|
||||||
|
msgid "Unknown platform for this game."
|
||||||
|
msgstr "Unknown platform for this game."
|
||||||
|
|
||||||
|
#: app/validators.py:343 app/validators.py:378
|
||||||
|
msgid "Invalid role selected."
|
||||||
|
msgstr "Invalid role selected."
|
||||||
|
|
||||||
|
#: app/validators.py:473 app/validators.py:596 app/validators.py:629
|
||||||
|
msgid "Player must be selected."
|
||||||
|
msgstr "Player must be selected."
|
||||||
|
|
||||||
|
#: app/validators.py:476
|
||||||
|
msgid "Notes must be 2000 characters or less."
|
||||||
|
msgstr "Notes must be 2000 characters or less."
|
||||||
|
|
||||||
|
#: app/validators.py:513 app/validators.py:993
|
||||||
|
msgid "Invalid coach selection."
|
||||||
|
msgstr "Invalid coach selection."
|
||||||
|
|
||||||
|
#: app/validators.py:519 app/validators.py:999
|
||||||
|
msgid "Invalid manager selection."
|
||||||
|
msgstr "Invalid manager selection."
|
||||||
|
|
||||||
|
#: app/validators.py:537 app/validators.py:595 app/validators.py:628
|
||||||
|
msgid "Invalid player selection."
|
||||||
|
msgstr "Invalid player selection."
|
||||||
|
|
||||||
|
#: app/validators.py:546
|
||||||
|
msgid "Unknown roster status."
|
||||||
|
msgstr "Unknown roster status."
|
||||||
|
|
||||||
|
#: app/validators.py:559
|
||||||
|
msgid "Unknown tryout status."
|
||||||
|
msgstr "Unknown tryout status."
|
||||||
|
|
||||||
|
#: app/validators.py:570
|
||||||
|
msgid "Unknown registration status."
|
||||||
|
msgstr "Unknown registration status."
|
||||||
|
|
||||||
|
#: app/validators.py:583
|
||||||
|
msgid "Team name must be between 1 and 100 characters."
|
||||||
|
msgstr "Team name must be between 1 and 100 characters."
|
||||||
|
|
||||||
|
#: app/validators.py:603
|
||||||
|
msgid "Position must be 50 characters or less."
|
||||||
|
msgstr "Position must be 50 characters or less."
|
||||||
|
|
||||||
|
#: app/validators.py:616
|
||||||
|
msgid "Note content must be between 1 and 5000 characters."
|
||||||
|
msgstr "Note content must be between 1 and 5000 characters."
|
||||||
|
|
||||||
|
#: app/validators.py:642
|
||||||
|
msgid "Select at most one note context."
|
||||||
|
msgstr "Select at most one note context."
|
||||||
|
|
||||||
|
#: app/validators.py:654
|
||||||
|
msgid "Rejection reason must be 2000 characters or less."
|
||||||
|
msgstr "Rejection reason must be 2000 characters or less."
|
||||||
|
|
||||||
|
#: app/validators.py:678
|
||||||
|
msgid "Date must be in YYYY-MM-DD format."
|
||||||
|
msgstr "Date must be in YYYY-MM-DD format."
|
||||||
|
|
||||||
|
#: app/validators.py:679
|
||||||
|
msgid "A date is required."
|
||||||
|
msgstr "A date is required."
|
||||||
|
|
||||||
|
#: app/validators.py:685 app/validators.py:750
|
||||||
|
msgid "Start time must be in HH:MM format."
|
||||||
|
msgstr "Start time must be in HH:MM format."
|
||||||
|
|
||||||
|
#: app/validators.py:686 app/validators.py:751
|
||||||
|
msgid "A start time is required."
|
||||||
|
msgstr "A start time is required."
|
||||||
|
|
||||||
|
#: app/validators.py:692
|
||||||
|
msgid "End time must be in HH:MM format."
|
||||||
|
msgstr "End time must be in HH:MM format."
|
||||||
|
|
||||||
|
#: app/validators.py:693
|
||||||
|
msgid "An end time is required."
|
||||||
|
msgstr "An end time is required."
|
||||||
|
|
||||||
|
#: app/validators.py:697
|
||||||
|
msgid "Points must be 2000 characters or less."
|
||||||
|
msgstr "Points must be 2000 characters or less."
|
||||||
|
|
||||||
|
#: app/validators.py:712
|
||||||
|
msgid "End time must be after start time."
|
||||||
|
msgstr "End time must be after start time."
|
||||||
|
|
||||||
|
#: app/validators.py:741 app/validators.py:743
|
||||||
|
msgid "Day must be 0 (Monday) to 6 (Sunday)."
|
||||||
|
msgstr "Day must be 0 (Monday) to 6 (Sunday)."
|
||||||
|
|
||||||
|
#: app/validators.py:744
|
||||||
|
msgid "A day is required."
|
||||||
|
msgstr "A day is required."
|
||||||
|
|
||||||
|
#: app/validators.py:779
|
||||||
|
msgid "Player selection is malformed."
|
||||||
|
msgstr "Player selection is malformed."
|
||||||
|
|
||||||
|
#: app/validators.py:805 app/validators.py:915
|
||||||
|
msgid "A title is required."
|
||||||
|
msgstr "A title is required."
|
||||||
|
|
||||||
|
#: app/validators.py:814
|
||||||
|
msgid "Invalid date format."
|
||||||
|
msgstr "Invalid date format."
|
||||||
|
|
||||||
|
#: app/validators.py:819 app/validators.py:826
|
||||||
|
msgid "Invalid time format."
|
||||||
|
msgstr "Invalid time format."
|
||||||
|
|
||||||
|
#: app/validators.py:820
|
||||||
|
msgid "Start time is required. Please select a time slot."
|
||||||
|
msgstr "Start time is required. Please select a time slot."
|
||||||
|
|
||||||
|
#: app/validators.py:834
|
||||||
|
msgid "Unknown match status."
|
||||||
|
msgstr "Unknown match status."
|
||||||
|
|
||||||
|
#: app/validators.py:850
|
||||||
|
msgid "The end time must come after the start time."
|
||||||
|
msgstr "The end time must come after the start time."
|
||||||
|
|
||||||
|
#: app/validators.py:864
|
||||||
|
msgid "Unknown match type."
|
||||||
|
msgstr "Unknown match type."
|
||||||
|
|
||||||
|
#: app/validators.py:876
|
||||||
|
msgid "A team cannot play against itself."
|
||||||
|
msgstr "A team cannot play against itself."
|
||||||
|
|
||||||
|
#: app/validators.py:929
|
||||||
msgid "Invalid start date format."
|
msgid "Invalid start date format."
|
||||||
msgstr "Invalid start date format."
|
msgstr "Invalid start date format."
|
||||||
|
|
||||||
#: app/validators.py:791
|
#: app/validators.py:930
|
||||||
msgid "A start date is required."
|
msgid "A start date is required."
|
||||||
msgstr "A start date is required."
|
msgstr "A start date is required."
|
||||||
|
|
||||||
#: app/validators.py:797
|
#: app/validators.py:936
|
||||||
msgid "Invalid end date format."
|
msgid "Invalid end date format."
|
||||||
msgstr "Invalid end date format."
|
msgstr "Invalid end date format."
|
||||||
|
|
||||||
#: app/validators.py:805
|
#: app/validators.py:944
|
||||||
msgid "A tryout must allow at least one player."
|
msgid "A tryout must allow at least one player."
|
||||||
msgstr "A tryout must allow at least one player."
|
msgstr "A tryout must allow at least one player."
|
||||||
|
|
||||||
#: app/validators.py:808
|
#: app/validators.py:947
|
||||||
msgid "The player limit must be a whole number."
|
msgid "The player limit must be a whole number."
|
||||||
msgstr "The player limit must be a whole number."
|
msgstr "The player limit must be a whole number."
|
||||||
|
|
||||||
#: app/validators.py:820
|
#: app/validators.py:959
|
||||||
msgid "End date cannot be before start date."
|
msgid "End date cannot be before start date."
|
||||||
msgstr "End date cannot be before start date."
|
msgstr "End date cannot be before start date."
|
||||||
|
|
||||||
#: app/validators.py:847 app/validators.py:848
|
#: app/validators.py:986 app/validators.py:987
|
||||||
msgid "Team name is required."
|
msgid "Team name is required."
|
||||||
msgstr "Team name is required."
|
msgstr "Team name is required."
|
||||||
|
|
||||||
#: app/validators.py:872
|
#: app/validators.py:1011
|
||||||
msgid "Scores run from 1 to 10."
|
msgid "Scores run from 1 to 10."
|
||||||
msgstr "Scores run from 1 to 10."
|
msgstr "Scores run from 1 to 10."
|
||||||
|
|
||||||
#: app/validators.py:873
|
#: app/validators.py:1012
|
||||||
msgid "A score must be a whole number from 1 to 10."
|
msgid "A score must be a whole number from 1 to 10."
|
||||||
msgstr "A score must be a whole number from 1 to 10."
|
msgstr "A score must be a whole number from 1 to 10."
|
||||||
|
|
||||||
#: app/routes/auth.py:245
|
#: app/routes/auth.py:246
|
||||||
msgid "This account has been deactivated."
|
msgid "This account has been deactivated."
|
||||||
msgstr "This account has been deactivated."
|
msgstr "This account has been deactivated."
|
||||||
|
|
||||||
#: app/routes/auth.py:280
|
#: app/routes/auth.py:281
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Welcome back, %(username)s!"
|
msgid "Welcome back, %(username)s!"
|
||||||
msgstr "Welcome back, %(username)s!"
|
msgstr "Welcome back, %(username)s!"
|
||||||
|
|
||||||
#: app/routes/auth.py:310
|
#: app/routes/auth.py:311
|
||||||
msgid ""
|
msgid ""
|
||||||
"Login unsuccessful. Please check your username and password, or ask a "
|
"Login unsuccessful. Please check your username and password, or ask a "
|
||||||
"president for help."
|
"president for help."
|
||||||
@@ -239,32 +279,32 @@ msgstr ""
|
|||||||
"Login unsuccessful. Please check your username and password, or ask a "
|
"Login unsuccessful. Please check your username and password, or ask a "
|
||||||
"president for help."
|
"president for help."
|
||||||
|
|
||||||
#: app/routes/auth.py:376
|
#: app/routes/auth.py:377
|
||||||
msgid "Your registration could not be processed. Please try again."
|
msgid "Your registration could not be processed. Please try again."
|
||||||
msgstr "Your registration could not be processed. Please try again."
|
msgstr "Your registration could not be processed. Please try again."
|
||||||
|
|
||||||
#: app/routes/auth.py:411 app/routes/users/accounts.py:339
|
#: app/routes/auth.py:419 app/routes/users/accounts.py:343
|
||||||
msgid "Username already exists."
|
msgid "Username already exists."
|
||||||
msgstr "Username already exists."
|
msgstr "Username already exists."
|
||||||
|
|
||||||
#: app/routes/auth.py:415 app/routes/users/accounts.py:343
|
#: app/routes/auth.py:423 app/routes/users/accounts.py:347
|
||||||
msgid "Email already registered."
|
msgid "Email already registered."
|
||||||
msgstr "Email already registered."
|
msgstr "Email already registered."
|
||||||
|
|
||||||
#: app/routes/auth.py:422 app/routes/auth.py:617
|
#: app/routes/auth.py:430 app/routes/auth.py:623
|
||||||
#: app/routes/users/accounts.py:121
|
#: app/routes/users/accounts.py:121
|
||||||
msgid "This Discord account is already linked to another account."
|
msgid "This Discord account is already linked to another account."
|
||||||
msgstr "This Discord account is already linked to another account."
|
msgstr "This Discord account is already linked to another account."
|
||||||
|
|
||||||
#: app/routes/auth.py:466
|
#: app/routes/auth.py:472
|
||||||
msgid "Your account has been created! You can now log in."
|
msgid "Your account has been created! You can now log in."
|
||||||
msgstr "Your account has been created! You can now log in."
|
msgstr "Your account has been created! You can now log in."
|
||||||
|
|
||||||
#: app/routes/auth.py:491
|
#: app/routes/auth.py:497
|
||||||
msgid "Discord OAuth2 is not configured."
|
msgid "Discord OAuth2 is not configured."
|
||||||
msgstr "Discord OAuth2 is not configured."
|
msgstr "Discord OAuth2 is not configured."
|
||||||
|
|
||||||
#: app/routes/auth.py:540
|
#: app/routes/auth.py:546
|
||||||
msgid ""
|
msgid ""
|
||||||
"Discord authorization could not be verified. Please start the connection "
|
"Discord authorization could not be verified. Please start the connection "
|
||||||
"again from this page."
|
"again from this page."
|
||||||
@@ -272,35 +312,35 @@ msgstr ""
|
|||||||
"Discord authorization could not be verified. Please start the connection "
|
"Discord authorization could not be verified. Please start the connection "
|
||||||
"again from this page."
|
"again from this page."
|
||||||
|
|
||||||
#: app/routes/auth.py:549
|
#: app/routes/auth.py:555
|
||||||
msgid "Discord authorization failed. No code received."
|
msgid "Discord authorization failed. No code received."
|
||||||
msgstr "Discord authorization failed. No code received."
|
msgstr "Discord authorization failed. No code received."
|
||||||
|
|
||||||
#: app/routes/auth.py:573
|
#: app/routes/auth.py:579
|
||||||
msgid "Failed to connect to Discord. Please try again."
|
msgid "Failed to connect to Discord. Please try again."
|
||||||
msgstr "Failed to connect to Discord. Please try again."
|
msgstr "Failed to connect to Discord. Please try again."
|
||||||
|
|
||||||
#: app/routes/auth.py:577
|
#: app/routes/auth.py:583
|
||||||
msgid "Failed to obtain Discord access token."
|
msgid "Failed to obtain Discord access token."
|
||||||
msgstr "Failed to obtain Discord access token."
|
msgstr "Failed to obtain Discord access token."
|
||||||
|
|
||||||
#: app/routes/auth.py:592 app/routes/auth.py:602
|
#: app/routes/auth.py:598 app/routes/auth.py:608
|
||||||
msgid "Failed to fetch Discord user profile."
|
msgid "Failed to fetch Discord user profile."
|
||||||
msgstr "Failed to fetch Discord user profile."
|
msgstr "Failed to fetch Discord user profile."
|
||||||
|
|
||||||
#: app/routes/auth.py:609
|
#: app/routes/auth.py:615
|
||||||
msgid "Please log in to connect your Discord account."
|
msgid "Please log in to connect your Discord account."
|
||||||
msgstr "Please log in to connect your Discord account."
|
msgstr "Please log in to connect your Discord account."
|
||||||
|
|
||||||
#: app/routes/auth.py:628
|
#: app/routes/auth.py:634
|
||||||
msgid "Discord account connected!"
|
msgid "Discord account connected!"
|
||||||
msgstr "Discord account connected!"
|
msgstr "Discord account connected!"
|
||||||
|
|
||||||
#: app/routes/auth.py:675
|
#: app/routes/auth.py:681
|
||||||
msgid "Discord account connected! Your profile has been pre-filled."
|
msgid "Discord account connected! Your profile has been pre-filled."
|
||||||
msgstr "Discord account connected! Your profile has been pre-filled."
|
msgstr "Discord account connected! Your profile has been pre-filled."
|
||||||
|
|
||||||
#: app/routes/auth.py:703
|
#: app/routes/auth.py:709
|
||||||
msgid "You have been logged out."
|
msgid "You have been logged out."
|
||||||
msgstr "You have been logged out."
|
msgstr "You have been logged out."
|
||||||
|
|
||||||
@@ -334,10 +374,10 @@ msgstr "Evaluation updated!"
|
|||||||
|
|
||||||
#: app/routes/evaluations.py:210 app/routes/teams.py:341
|
#: app/routes/evaluations.py:210 app/routes/teams.py:341
|
||||||
#: app/routes/teams.py:384 app/routes/teams.py:427 app/routes/teams.py:455
|
#: app/routes/teams.py:384 app/routes/teams.py:427 app/routes/teams.py:455
|
||||||
#: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:444
|
#: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:471
|
||||||
#: app/routes/tryouts.py:460 app/routes/tryouts.py:480
|
#: app/routes/tryouts.py:491 app/routes/tryouts.py:515
|
||||||
#: app/routes/tryouts.py:527 app/routes/tryouts.py:563
|
#: app/routes/tryouts.py:562 app/routes/tryouts.py:598
|
||||||
#: app/routes/tryouts.py:582
|
#: app/routes/tryouts.py:621
|
||||||
msgid "Permission denied."
|
msgid "Permission denied."
|
||||||
msgstr "Permission denied."
|
msgstr "Permission denied."
|
||||||
|
|
||||||
@@ -345,35 +385,35 @@ msgstr "Permission denied."
|
|||||||
msgid "That language is not available."
|
msgid "That language is not available."
|
||||||
msgstr "That language is not available."
|
msgstr "That language is not available."
|
||||||
|
|
||||||
#: app/routes/matches.py:364
|
#: app/routes/matches.py:383
|
||||||
msgid "You do not have permission to schedule matches for this tryout."
|
msgid "You do not have permission to schedule matches for this tryout."
|
||||||
msgstr "You do not have permission to schedule matches for this tryout."
|
msgstr "You do not have permission to schedule matches for this tryout."
|
||||||
|
|
||||||
#: app/routes/matches.py:368 app/routes/matches.py:448
|
#: app/routes/matches.py:387 app/routes/matches.py:463
|
||||||
msgid "This tryout has ended. Matches can no longer be created or modified."
|
msgid "This tryout has ended. Matches can no longer be created or modified."
|
||||||
msgstr "This tryout has ended. Matches can no longer be created or modified."
|
msgstr "This tryout has ended. Matches can no longer be created or modified."
|
||||||
|
|
||||||
#: app/routes/matches.py:430
|
#: app/routes/matches.py:445
|
||||||
msgid "Match scheduled successfully!"
|
msgid "Match scheduled successfully!"
|
||||||
msgstr "Match scheduled successfully!"
|
msgstr "Match scheduled successfully!"
|
||||||
|
|
||||||
#: app/routes/matches.py:444 app/routes/team_matches.py:220
|
#: app/routes/matches.py:459 app/routes/team_matches.py:220
|
||||||
msgid "You do not have permission to edit this match."
|
msgid "You do not have permission to edit this match."
|
||||||
msgstr "You do not have permission to edit this match."
|
msgstr "You do not have permission to edit this match."
|
||||||
|
|
||||||
#: app/routes/matches.py:539 app/routes/team_matches.py:250
|
#: app/routes/matches.py:552 app/routes/team_matches.py:250
|
||||||
msgid "Match updated successfully!"
|
msgid "Match updated successfully!"
|
||||||
msgstr "Match updated successfully!"
|
msgstr "Match updated successfully!"
|
||||||
|
|
||||||
#: app/routes/matches.py:575 app/routes/team_matches.py:265
|
#: app/routes/matches.py:588 app/routes/team_matches.py:265
|
||||||
msgid "You do not have permission to delete this match."
|
msgid "You do not have permission to delete this match."
|
||||||
msgstr "You do not have permission to delete this match."
|
msgstr "You do not have permission to delete this match."
|
||||||
|
|
||||||
#: app/routes/matches.py:578
|
#: app/routes/matches.py:591
|
||||||
msgid "This tryout has ended. Matches can no longer be deleted."
|
msgid "This tryout has ended. Matches can no longer be deleted."
|
||||||
msgstr "This tryout has ended. Matches can no longer be deleted."
|
msgstr "This tryout has ended. Matches can no longer be deleted."
|
||||||
|
|
||||||
#: app/routes/matches.py:591 app/routes/team_matches.py:269
|
#: app/routes/matches.py:604 app/routes/team_matches.py:269
|
||||||
msgid "Match deleted successfully."
|
msgid "Match deleted successfully."
|
||||||
msgstr "Match deleted successfully."
|
msgstr "Match deleted successfully."
|
||||||
|
|
||||||
@@ -476,7 +516,7 @@ msgstr "Coach removed from %(name)s."
|
|||||||
msgid "Manager removed from %(name)s."
|
msgid "Manager removed from %(name)s."
|
||||||
msgstr "Manager removed from %(name)s."
|
msgstr "Manager removed from %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:490 app/routes/tryouts.py:489 app/routes/tryouts.py:593
|
#: app/routes/teams.py:490 app/routes/tryouts.py:524
|
||||||
msgid "Please select a player."
|
msgid "Please select a player."
|
||||||
msgstr "Please select a player."
|
msgstr "Please select a player."
|
||||||
|
|
||||||
@@ -494,7 +534,7 @@ msgstr "%(username)s is already on %(name)s."
|
|||||||
msgid "%(username)s added to %(name)s!"
|
msgid "%(username)s added to %(name)s!"
|
||||||
msgstr "%(username)s added to %(name)s!"
|
msgstr "%(username)s added to %(name)s!"
|
||||||
|
|
||||||
#: app/routes/teams.py:527 app/routes/teams.py:601
|
#: app/routes/teams.py:527 app/routes/teams.py:605
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s is not on %(name)s."
|
msgid "%(username)s is not on %(name)s."
|
||||||
msgstr "%(username)s is not on %(name)s."
|
msgstr "%(username)s is not on %(name)s."
|
||||||
@@ -504,130 +544,130 @@ msgstr "%(username)s is not on %(name)s."
|
|||||||
msgid "%(username)s removed from %(name)s."
|
msgid "%(username)s removed from %(name)s."
|
||||||
msgstr "%(username)s removed from %(name)s."
|
msgstr "%(username)s removed from %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:572 app/routes/teams.py:590
|
#: app/routes/teams.py:572 app/routes/teams.py:594
|
||||||
msgid "You do not have permission to add notes to this team."
|
msgid "You do not have permission to add notes to this team."
|
||||||
msgstr "You do not have permission to add notes to this team."
|
msgstr "You do not have permission to add notes to this team."
|
||||||
|
|
||||||
#: app/routes/teams.py:580
|
#: app/routes/teams.py:584
|
||||||
msgid "Team notes added successfully!"
|
msgid "Team notes added successfully!"
|
||||||
msgstr "Team notes added successfully!"
|
msgstr "Team notes added successfully!"
|
||||||
|
|
||||||
#: app/routes/teams.py:595 app/routes/users/notes.py:207
|
#: app/routes/teams.py:599 app/routes/users/notes.py:222
|
||||||
#: app/routes/users/notes.py:250
|
#: app/routes/users/notes.py:262
|
||||||
msgid "Can only add notes for players."
|
msgid "Can only add notes for players."
|
||||||
msgstr "Can only add notes for players."
|
msgstr "Can only add notes for players."
|
||||||
|
|
||||||
#: app/routes/teams.py:611
|
#: app/routes/teams.py:619
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Note added for %(username)s!"
|
msgid "Note added for %(username)s!"
|
||||||
msgstr "Note added for %(username)s!"
|
msgstr "Note added for %(username)s!"
|
||||||
|
|
||||||
#: app/routes/tryouts.py:98
|
#: app/routes/tryouts.py:125
|
||||||
msgid "You do not have permission to create tryouts."
|
msgid "You do not have permission to create tryouts."
|
||||||
msgstr "You do not have permission to create tryouts."
|
msgstr "You do not have permission to create tryouts."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:145
|
#: app/routes/tryouts.py:172
|
||||||
msgid "Tryout created successfully!"
|
msgid "Tryout created successfully!"
|
||||||
msgstr "Tryout created successfully!"
|
msgstr "Tryout created successfully!"
|
||||||
|
|
||||||
#: app/routes/tryouts.py:158
|
#: app/routes/tryouts.py:185
|
||||||
msgid "You do not have permission to edit this tryout."
|
msgid "You do not have permission to edit this tryout."
|
||||||
msgstr "You do not have permission to edit this tryout."
|
msgstr "You do not have permission to edit this tryout."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:162
|
#: app/routes/tryouts.py:189
|
||||||
msgid "This tryout has ended and can no longer be modified."
|
msgid "This tryout has ended and can no longer be modified."
|
||||||
msgstr "This tryout has ended and can no longer be modified."
|
msgstr "This tryout has ended and can no longer be modified."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:202
|
#: app/routes/tryouts.py:229
|
||||||
msgid "Tryout updated successfully!"
|
msgid "Tryout updated successfully!"
|
||||||
msgstr "Tryout updated successfully!"
|
msgstr "Tryout updated successfully!"
|
||||||
|
|
||||||
#: app/routes/tryouts.py:242
|
#: app/routes/tryouts.py:269
|
||||||
msgid "You do not have permission to view this tryout."
|
msgid "You do not have permission to view this tryout."
|
||||||
msgstr "You do not have permission to view this tryout."
|
msgstr "You do not have permission to view this tryout."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:411
|
#: app/routes/tryouts.py:437
|
||||||
msgid "Only players can register for tryouts."
|
msgid "Only players can register for tryouts."
|
||||||
msgstr "Only players can register for tryouts."
|
msgstr "Only players can register for tryouts."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:415
|
#: app/routes/tryouts.py:442
|
||||||
msgid "This tryout is not accepting registrations."
|
msgid "This tryout is not accepting registrations."
|
||||||
msgstr "This tryout is not accepting registrations."
|
msgstr "This tryout is not accepting registrations."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:422
|
#: app/routes/tryouts.py:449
|
||||||
msgid "You are already registered for this tryout."
|
msgid "You are already registered for this tryout."
|
||||||
msgstr "You are already registered for this tryout."
|
msgstr "You are already registered for this tryout."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:428 app/routes/tryouts.py:511
|
#: app/routes/tryouts.py:455 app/routes/tryouts.py:546
|
||||||
msgid "This tryout is full."
|
msgid "This tryout is full."
|
||||||
msgstr "This tryout is full."
|
msgstr "This tryout is full."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:434
|
#: app/routes/tryouts.py:461
|
||||||
msgid "Successfully registered for tryout!"
|
msgid "Successfully registered for tryout!"
|
||||||
msgstr "Successfully registered for tryout!"
|
msgstr "Successfully registered for tryout!"
|
||||||
|
|
||||||
#: app/routes/tryouts.py:450
|
#: app/routes/tryouts.py:481
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Tryout status updated to %(new_status)s."
|
msgid "Tryout status updated to %(new_status)s."
|
||||||
msgstr "Tryout status updated to %(new_status)s."
|
msgstr "Tryout status updated to %(new_status)s."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:470
|
#: app/routes/tryouts.py:505
|
||||||
msgid "Registration status updated."
|
msgid "Registration status updated."
|
||||||
msgstr "Registration status updated."
|
msgstr "Registration status updated."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:497
|
#: app/routes/tryouts.py:532
|
||||||
msgid "Can only register players."
|
msgid "Can only register players."
|
||||||
msgstr "Can only register players."
|
msgstr "Can only register players."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:503
|
#: app/routes/tryouts.py:538
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s is already registered for this tryout."
|
msgid "%(username)s is already registered for this tryout."
|
||||||
msgstr "%(username)s is already registered for this tryout."
|
msgstr "%(username)s is already registered for this tryout."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:517
|
#: app/routes/tryouts.py:552
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s registered for tryout!"
|
msgid "%(username)s registered for tryout!"
|
||||||
msgstr "%(username)s registered for tryout!"
|
msgstr "%(username)s registered for tryout!"
|
||||||
|
|
||||||
#: app/routes/tryouts.py:553
|
#: app/routes/tryouts.py:588
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s removed from tryout."
|
msgid "%(username)s removed from tryout."
|
||||||
msgstr "%(username)s removed from tryout."
|
msgstr "%(username)s removed from tryout."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:571
|
#: app/routes/tryouts.py:610
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Team \"%(team_name)s\" created!"
|
msgid "Team \"%(team_name)s\" created!"
|
||||||
msgstr "Team \"%(team_name)s\" created!"
|
msgstr "Team \"%(team_name)s\" created!"
|
||||||
|
|
||||||
#: app/routes/tryouts.py:602
|
#: app/routes/tryouts.py:643 app/routes/users/notes.py:350
|
||||||
msgid "That player is not registered for this tryout."
|
msgid "That player is not registered for this tryout."
|
||||||
msgstr "That player is not registered for this tryout."
|
msgstr "That player is not registered for this tryout."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:608
|
#: app/routes/tryouts.py:648
|
||||||
msgid "Player is already on this team."
|
msgid "Player is already on this team."
|
||||||
msgstr "Player is already on this team."
|
msgstr "Player is already on this team."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:613
|
#: app/routes/tryouts.py:653
|
||||||
msgid "Player added to team!"
|
msgid "Player added to team!"
|
||||||
msgstr "Player added to team!"
|
msgstr "Player added to team!"
|
||||||
|
|
||||||
#: app/routes/tryouts.py:623
|
#: app/routes/tryouts.py:663
|
||||||
msgid "You do not have permission to delete this tryout."
|
msgid "You do not have permission to delete this tryout."
|
||||||
msgstr "You do not have permission to delete this tryout."
|
msgstr "You do not have permission to delete this tryout."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:659
|
#: app/routes/tryouts.py:699
|
||||||
msgid "Tryout deleted successfully."
|
msgid "Tryout deleted successfully."
|
||||||
msgstr "Tryout deleted successfully."
|
msgstr "Tryout deleted successfully."
|
||||||
|
|
||||||
#: app/routes/users/_shared.py:51
|
#: app/routes/users/_shared.py:50
|
||||||
msgid "No file selected."
|
msgid "No file selected."
|
||||||
msgstr "No file selected."
|
msgstr "No file selected."
|
||||||
|
|
||||||
#: app/routes/users/_shared.py:55
|
#: app/routes/users/_shared.py:54
|
||||||
msgid "Only PDF files are allowed for contracts."
|
msgid "Only PDF files are allowed for contracts."
|
||||||
msgstr "Only PDF files are allowed for contracts."
|
msgstr "Only PDF files are allowed for contracts."
|
||||||
|
|
||||||
#: app/routes/users/_shared.py:60
|
#: app/routes/users/_shared.py:59
|
||||||
msgid "That file is not a PDF, whatever its name says."
|
msgid "That file is not a PDF, whatever its name says."
|
||||||
msgstr "That file is not a PDF, whatever its name says."
|
msgstr "That file is not a PDF, whatever its name says."
|
||||||
|
|
||||||
@@ -643,11 +683,11 @@ msgstr "Only the president can edit users."
|
|||||||
msgid "Email already in use by another account."
|
msgid "Email already in use by another account."
|
||||||
msgstr "Email already in use by another account."
|
msgstr "Email already in use by another account."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:132
|
#: app/routes/users/accounts.py:138
|
||||||
msgid "You cannot change your own role. Ask another president to do it."
|
msgid "You cannot change your own role. Ask another president to do it."
|
||||||
msgstr "You cannot change your own role. Ask another president to do it."
|
msgstr "You cannot change your own role. Ask another president to do it."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:145
|
#: app/routes/users/accounts.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"This is the last active president. Promote another account before "
|
"This is the last active president. Promote another account before "
|
||||||
"changing this one."
|
"changing this one."
|
||||||
@@ -655,29 +695,29 @@ msgstr ""
|
|||||||
"This is the last active president. Promote another account before "
|
"This is the last active president. Promote another account before "
|
||||||
"changing this one."
|
"changing this one."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:224
|
#: app/routes/users/accounts.py:228
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "User %(username)s updated successfully!"
|
msgid "User %(username)s updated successfully!"
|
||||||
msgstr "User %(username)s updated successfully!"
|
msgstr "User %(username)s updated successfully!"
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:245
|
#: app/routes/users/accounts.py:249
|
||||||
msgid "Only the president can delete users."
|
msgid "Only the president can delete users."
|
||||||
msgstr "Only the president can delete users."
|
msgstr "Only the president can delete users."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:249
|
#: app/routes/users/accounts.py:253
|
||||||
msgid "You cannot delete your own account."
|
msgid "You cannot delete your own account."
|
||||||
msgstr "You cannot delete your own account."
|
msgstr "You cannot delete your own account."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:308
|
#: app/routes/users/accounts.py:312
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "User %(deleted_username)s has been removed."
|
msgid "User %(deleted_username)s has been removed."
|
||||||
msgstr "User %(deleted_username)s has been removed."
|
msgstr "User %(deleted_username)s has been removed."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:319
|
#: app/routes/users/accounts.py:323
|
||||||
msgid "Only the president can create users."
|
msgid "Only the president can create users."
|
||||||
msgstr "Only the president can create users."
|
msgstr "Only the president can create users."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:367
|
#: app/routes/users/accounts.py:371
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "User %(full_name)s created as %(role)s!"
|
msgid "User %(full_name)s created as %(role)s!"
|
||||||
msgstr "User %(full_name)s created as %(role)s!"
|
msgstr "User %(full_name)s created as %(role)s!"
|
||||||
@@ -715,54 +755,93 @@ msgstr "You do not have permission to download this contract."
|
|||||||
msgid "No signed contract available."
|
msgid "No signed contract available."
|
||||||
msgstr "No signed contract available."
|
msgstr "No signed contract available."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:34
|
#: app/routes/users/notes.py:43
|
||||||
msgid "This page is for players only."
|
msgid "This page is for players only."
|
||||||
msgstr "This page is for players only."
|
msgstr "This page is for players only."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:71
|
#: app/routes/users/notes.py:80
|
||||||
msgid "Only coaches can access the notes dashboard."
|
msgid "Only coaches can access the notes dashboard."
|
||||||
msgstr "Only coaches can access the notes dashboard."
|
msgstr "Only coaches can access the notes dashboard."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:161
|
#: app/routes/users/notes.py:172
|
||||||
msgid "Only coaches can manage team notes."
|
msgid "Only coaches can manage team notes."
|
||||||
msgstr "Only coaches can manage team notes."
|
msgstr "Only coaches can manage team notes."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:167
|
#: app/routes/users/notes.py:178
|
||||||
msgid "You are not assigned to a team."
|
msgid "You are not assigned to a team."
|
||||||
msgstr "You are not assigned to a team."
|
msgstr "You are not assigned to a team."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:180
|
#: app/routes/users/notes.py:195
|
||||||
msgid "Team notes saved successfully!"
|
msgid "Team notes saved successfully!"
|
||||||
msgstr "Team notes saved successfully!"
|
msgstr "Team notes saved successfully!"
|
||||||
|
|
||||||
#: app/routes/users/notes.py:195
|
#: app/routes/users/notes.py:210
|
||||||
msgid "Only coaches can manage personal notes."
|
msgid "Only coaches can manage personal notes."
|
||||||
msgstr "Only coaches can manage personal notes."
|
msgstr "Only coaches can manage personal notes."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:202 app/routes/users/notes.py:245
|
#: app/routes/users/notes.py:226 app/routes/users/notes.py:266
|
||||||
#: app/routes/users/notes.py:296 app/routes/users/notes.py:350
|
#: app/routes/users/notes.py:346 app/routes/users/notes.py:411
|
||||||
msgid "Player and content are required."
|
|
||||||
msgstr "Player and content are required."
|
|
||||||
|
|
||||||
#: app/routes/users/notes.py:211 app/routes/users/notes.py:254
|
|
||||||
#: app/routes/users/notes.py:300 app/routes/users/notes.py:354
|
|
||||||
msgid "You can only write notes about players you work with."
|
msgid "You can only write notes about players you work with."
|
||||||
msgstr "You can only write notes about players you work with."
|
msgstr "You can only write notes about players you work with."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:221 app/routes/users/notes.py:267
|
#: app/routes/users/notes.py:236 app/routes/users/notes.py:306
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Note added for %(username)s."
|
msgid "Note added for %(username)s."
|
||||||
msgstr "Note added for %(username)s."
|
msgstr "Note added for %(username)s."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:235 app/routes/users/notes.py:281
|
#: app/routes/users/notes.py:250 app/routes/users/notes.py:320
|
||||||
#: app/routes/users/notes.py:334
|
#: app/routes/users/notes.py:384
|
||||||
msgid "Only coaches can add personal notes."
|
msgid "Only coaches can add personal notes."
|
||||||
msgstr "Only coaches can add personal notes."
|
msgstr "Only coaches can add personal notes."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:311 app/routes/users/notes.py:365
|
#: app/routes/users/notes.py:272
|
||||||
|
msgid "You cannot use that match as note context."
|
||||||
|
msgstr "You cannot use that match as note context."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:275
|
||||||
|
msgid "That player did not participate in the selected match."
|
||||||
|
msgstr "That player did not participate in the selected match."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:281
|
||||||
|
msgid "You cannot use that tryout as note context."
|
||||||
|
msgstr "You cannot use that tryout as note context."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:284
|
||||||
|
msgid "That player is not registered for the selected tryout."
|
||||||
|
msgstr "That player is not registered for the selected tryout."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:290
|
||||||
|
msgid "You cannot use that team as note context."
|
||||||
|
msgstr "You cannot use that team as note context."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:293
|
||||||
|
msgid "That player is not on the selected team."
|
||||||
|
msgstr "That player is not on the selected team."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:325
|
||||||
|
msgid "You do not have permission to add notes for this tryout."
|
||||||
|
msgstr "You do not have permission to add notes for this tryout."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:342
|
||||||
|
msgid "Invalid tryout context."
|
||||||
|
msgstr "Invalid tryout context."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:361 app/routes/users/notes.py:426
|
||||||
msgid "Note added successfully."
|
msgid "Note added successfully."
|
||||||
msgstr "Note added successfully."
|
msgstr "Note added successfully."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:389
|
||||||
|
msgid "You do not have permission to add notes for this match."
|
||||||
|
msgstr "You do not have permission to add notes for this match."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:407
|
||||||
|
msgid "Invalid match context."
|
||||||
|
msgstr "Invalid match context."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:415
|
||||||
|
msgid "That player did not participate in this match."
|
||||||
|
msgstr "That player did not participate in this match."
|
||||||
|
|
||||||
#: app/routes/users/one_on_one.py:23
|
#: app/routes/users/one_on_one.py:23
|
||||||
msgid "Only players can request One on One sessions."
|
msgid "Only players can request One on One sessions."
|
||||||
msgstr "Only players can request One on One sessions."
|
msgstr "Only players can request One on One sessions."
|
||||||
@@ -804,7 +883,7 @@ msgstr "One on One request from %(player)s has been approved!"
|
|||||||
msgid "Only coaches can reject One on One requests."
|
msgid "Only coaches can reject One on One requests."
|
||||||
msgstr "Only coaches can reject One on One requests."
|
msgstr "Only coaches can reject One on One requests."
|
||||||
|
|
||||||
#: app/routes/users/one_on_one.py:258
|
#: app/routes/users/one_on_one.py:263
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "One on One request from %(player)s has been rejected."
|
msgid "One on One request from %(player)s has been rejected."
|
||||||
msgstr "One on One request from %(player)s has been rejected."
|
msgstr "One on One request from %(player)s has been rejected."
|
||||||
@@ -817,7 +896,7 @@ msgstr "Username already taken."
|
|||||||
msgid "Email already in use."
|
msgid "Email already in use."
|
||||||
msgstr "Email already in use."
|
msgstr "Email already in use."
|
||||||
|
|
||||||
#: app/routes/users/profile.py:121
|
#: app/routes/users/profile.py:131
|
||||||
msgid "Profile updated successfully!"
|
msgid "Profile updated successfully!"
|
||||||
msgstr "Profile updated successfully!"
|
msgstr "Profile updated successfully!"
|
||||||
|
|
||||||
@@ -1213,7 +1292,7 @@ msgid "Select time slots when you're available for One on One sessions"
|
|||||||
msgstr "Select time slots when you're available for One on One sessions"
|
msgstr "Select time slots when you're available for One on One sessions"
|
||||||
|
|
||||||
#: app/templates/pages/coach_availability.html:14
|
#: app/templates/pages/coach_availability.html:14
|
||||||
#: app/templates/pages/profile.html:216
|
#: app/templates/pages/profile.html:213
|
||||||
msgid "Loading availability grid..."
|
msgid "Loading availability grid..."
|
||||||
msgstr "Loading availability grid..."
|
msgstr "Loading availability grid..."
|
||||||
|
|
||||||
@@ -1222,7 +1301,7 @@ msgid "Save Availability"
|
|||||||
msgstr "Save Availability"
|
msgstr "Save Availability"
|
||||||
|
|
||||||
#: app/templates/pages/coach_availability.html:22
|
#: app/templates/pages/coach_availability.html:22
|
||||||
#: app/templates/pages/profile.html:200 app/templates/pages/profile.html:220
|
#: app/templates/pages/profile.html:197 app/templates/pages/profile.html:217
|
||||||
msgid "Clear All"
|
msgid "Clear All"
|
||||||
msgstr "Clear All"
|
msgstr "Clear All"
|
||||||
|
|
||||||
@@ -1998,23 +2077,23 @@ msgstr ""
|
|||||||
msgid "Green indicators show player availability for the match date/time"
|
msgid "Green indicators show player availability for the match date/time"
|
||||||
msgstr "Green indicators show player availability for the match date/time"
|
msgstr "Green indicators show player availability for the match date/time"
|
||||||
|
|
||||||
#: app/templates/pages/match_form.html:625
|
#: app/templates/pages/match_form.html:632
|
||||||
msgid "Click time slots consecutively to set match duration"
|
msgid "Click time slots consecutively to set match duration"
|
||||||
msgstr "Click time slots consecutively to set match duration"
|
msgstr "Click time slots consecutively to set match duration"
|
||||||
|
|
||||||
#: app/templates/pages/match_form.html:892
|
#: app/templates/pages/match_form.html:899
|
||||||
msgid "No players registered"
|
msgid "No players registered"
|
||||||
msgstr "No players registered"
|
msgstr "No players registered"
|
||||||
|
|
||||||
#: app/templates/pages/match_form.html:925
|
#: app/templates/pages/match_form.html:932
|
||||||
msgid "T1"
|
msgid "T1"
|
||||||
msgstr "T1"
|
msgstr "T1"
|
||||||
|
|
||||||
#: app/templates/pages/match_form.html:926
|
#: app/templates/pages/match_form.html:933
|
||||||
msgid "T2"
|
msgid "T2"
|
||||||
msgstr "T2"
|
msgstr "T2"
|
||||||
|
|
||||||
#: app/templates/pages/match_form.html:931
|
#: app/templates/pages/match_form.html:938
|
||||||
msgid "No players available"
|
msgid "No players available"
|
||||||
msgstr "No players available"
|
msgstr "No players available"
|
||||||
|
|
||||||
@@ -2369,15 +2448,11 @@ msgstr ""
|
|||||||
"Select your available time blocks for matches (5pm to 12am). Green = "
|
"Select your available time blocks for matches (5pm to 12am). Green = "
|
||||||
"selected, Gray = available to select."
|
"selected, Gray = available to select."
|
||||||
|
|
||||||
#: app/templates/pages/profile.html:197
|
#: app/templates/pages/profile.html:208
|
||||||
msgid "Save Disponibilities"
|
|
||||||
msgstr "Save Disponibilities"
|
|
||||||
|
|
||||||
#: app/templates/pages/profile.html:211
|
|
||||||
msgid "My Coaching Availability"
|
msgid "My Coaching Availability"
|
||||||
msgstr "My Coaching Availability"
|
msgstr "My Coaching Availability"
|
||||||
|
|
||||||
#: app/templates/pages/profile.html:212
|
#: app/templates/pages/profile.html:209
|
||||||
msgid ""
|
msgid ""
|
||||||
"Select time slots when you're available for One on One sessions (8am to "
|
"Select time slots when you're available for One on One sessions (8am to "
|
||||||
"10pm)."
|
"10pm)."
|
||||||
@@ -2385,7 +2460,7 @@ msgstr ""
|
|||||||
"Select time slots when you're available for One on One sessions (8am to "
|
"Select time slots when you're available for One on One sessions (8am to "
|
||||||
"10pm)."
|
"10pm)."
|
||||||
|
|
||||||
#: app/templates/pages/profile.html:460
|
#: app/templates/pages/profile.html:457
|
||||||
msgid "Click or click-and-drag to select your available hours"
|
msgid "Click or click-and-drag to select your available hours"
|
||||||
msgstr "Click or click-and-drag to select your available hours"
|
msgstr "Click or click-and-drag to select your available hours"
|
||||||
|
|
||||||
@@ -3001,3 +3076,9 @@ msgstr "View Profile"
|
|||||||
|
|
||||||
#~ msgid "Team Tryout Management System"
|
#~ msgid "Team Tryout Management System"
|
||||||
#~ msgstr "Team Tryout Management System"
|
#~ msgstr "Team Tryout Management System"
|
||||||
|
|
||||||
|
#~ msgid "Player and content are required."
|
||||||
|
#~ msgstr "Player and content are required."
|
||||||
|
|
||||||
|
#~ msgid "Save Disponibilities"
|
||||||
|
#~ msgstr "Save Disponibilities"
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: team-tryouts VERSION\n"
|
"Project-Id-Version: team-tryouts VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2026-08-16 23:22-0400\n"
|
"POT-Creation-Date: 2026-08-17 14:18-0400\n"
|
||||||
"PO-Revision-Date: 2026-08-07 20:22-0400\n"
|
"PO-Revision-Date: 2026-08-07 20:22-0400\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: fr\n"
|
"Language: fr\n"
|
||||||
@@ -23,8 +23,8 @@ msgstr ""
|
|||||||
msgid "Please log in to access this page."
|
msgid "Please log in to access this page."
|
||||||
msgstr "Veuillez vous connecter pour accéder à cette page."
|
msgstr "Veuillez vous connecter pour accéder à cette page."
|
||||||
|
|
||||||
#: app/forms.py:37 app/routes/auth.py:228 app/routes/auth.py:387
|
#: app/forms.py:37 app/routes/auth.py:229 app/routes/auth.py:388
|
||||||
#: app/routes/users/contracts.py:98
|
#: app/routes/auth.py:402 app/routes/users/contracts.py:98
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(field)s: %(msg)s"
|
msgid "%(field)s: %(msg)s"
|
||||||
msgstr "%(field)s : %(msg)s"
|
msgstr "%(field)s : %(msg)s"
|
||||||
@@ -63,7 +63,7 @@ msgstr "Le nom d’utilisateur est obligatoire."
|
|||||||
msgid "Password is required."
|
msgid "Password is required."
|
||||||
msgstr "Le mot de passe est obligatoire."
|
msgstr "Le mot de passe est obligatoire."
|
||||||
|
|
||||||
#: app/validators.py:227 app/validators.py:294
|
#: app/validators.py:227 app/validators.py:324
|
||||||
msgid "Username must be 3-80 characters."
|
msgid "Username must be 3-80 characters."
|
||||||
msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères."
|
msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères."
|
||||||
|
|
||||||
@@ -71,8 +71,8 @@ msgstr "Le nom d’utilisateur doit compter de 3 à 80 caractères."
|
|||||||
msgid "Email must be 120 characters or less."
|
msgid "Email must be 120 characters or less."
|
||||||
msgstr "L’adresse courriel ne doit pas dépasser 120 caractères."
|
msgstr "L’adresse courriel ne doit pas dépasser 120 caractères."
|
||||||
|
|
||||||
#: app/validators.py:246 app/validators.py:309 app/validators.py:340
|
#: app/validators.py:246 app/validators.py:339 app/validators.py:370
|
||||||
#: app/validators.py:403
|
#: app/validators.py:433
|
||||||
msgid "Full name is required."
|
msgid "Full name is required."
|
||||||
msgstr "Le nom complet est obligatoire."
|
msgstr "Le nom complet est obligatoire."
|
||||||
|
|
||||||
@@ -80,160 +80,200 @@ msgstr "Le nom complet est obligatoire."
|
|||||||
msgid "Passwords do not match."
|
msgid "Passwords do not match."
|
||||||
msgstr "Les mots de passe ne concordent pas."
|
msgstr "Les mots de passe ne concordent pas."
|
||||||
|
|
||||||
#: app/validators.py:313 app/validators.py:348
|
#: app/validators.py:284 app/validators.py:924
|
||||||
msgid "Invalid role selected."
|
|
||||||
msgstr "Rôle sélectionné invalide."
|
|
||||||
|
|
||||||
#: app/validators.py:443
|
|
||||||
msgid "Player must be selected."
|
|
||||||
msgstr "Vous devez choisir un joueur."
|
|
||||||
|
|
||||||
#: app/validators.py:446
|
|
||||||
msgid "Notes must be 2000 characters or less."
|
|
||||||
msgstr "Les notes ne doivent pas dépasser 2000 caractères."
|
|
||||||
|
|
||||||
#: app/validators.py:483 app/validators.py:854
|
|
||||||
msgid "Invalid coach selection."
|
|
||||||
msgstr "Sélection de coach invalide."
|
|
||||||
|
|
||||||
#: app/validators.py:489 app/validators.py:860
|
|
||||||
msgid "Invalid manager selection."
|
|
||||||
msgstr "Sélection de gérant invalide."
|
|
||||||
|
|
||||||
#: app/validators.py:507
|
|
||||||
msgid "Invalid player selection."
|
|
||||||
msgstr "Sélection de joueur invalide."
|
|
||||||
|
|
||||||
#: app/validators.py:516
|
|
||||||
msgid "Unknown roster status."
|
|
||||||
msgstr "Statut d'effectif inconnu."
|
|
||||||
|
|
||||||
#: app/validators.py:539
|
|
||||||
msgid "Date must be in YYYY-MM-DD format."
|
|
||||||
msgstr "La date doit être au format AAAA-MM-JJ."
|
|
||||||
|
|
||||||
#: app/validators.py:540
|
|
||||||
msgid "A date is required."
|
|
||||||
msgstr "Une date est requise."
|
|
||||||
|
|
||||||
#: app/validators.py:546 app/validators.py:611
|
|
||||||
msgid "Start time must be in HH:MM format."
|
|
||||||
msgstr "L’heure de début doit être au format HH:MM."
|
|
||||||
|
|
||||||
#: app/validators.py:547 app/validators.py:612
|
|
||||||
msgid "A start time is required."
|
|
||||||
msgstr "Une heure de début est requise."
|
|
||||||
|
|
||||||
#: app/validators.py:553
|
|
||||||
msgid "End time must be in HH:MM format."
|
|
||||||
msgstr "L’heure de fin doit être au format HH:MM."
|
|
||||||
|
|
||||||
#: app/validators.py:554
|
|
||||||
msgid "An end time is required."
|
|
||||||
msgstr "Une heure de fin est requise."
|
|
||||||
|
|
||||||
#: app/validators.py:558
|
|
||||||
msgid "Points must be 2000 characters or less."
|
|
||||||
msgstr "Les points ne doivent pas dépasser 2000 caractères."
|
|
||||||
|
|
||||||
#: app/validators.py:573
|
|
||||||
msgid "End time must be after start time."
|
|
||||||
msgstr "L'heure de fin doit être postérieure à l'heure de début."
|
|
||||||
|
|
||||||
#: app/validators.py:602 app/validators.py:604
|
|
||||||
msgid "Day must be 0 (Monday) to 6 (Sunday)."
|
|
||||||
msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)."
|
|
||||||
|
|
||||||
#: app/validators.py:605
|
|
||||||
msgid "A day is required."
|
|
||||||
msgstr "Un jour est requis."
|
|
||||||
|
|
||||||
#: app/validators.py:640
|
|
||||||
msgid "Player selection is malformed."
|
|
||||||
msgstr "La sélection de joueurs est mal formée."
|
|
||||||
|
|
||||||
#: app/validators.py:666 app/validators.py:776
|
|
||||||
msgid "A title is required."
|
|
||||||
msgstr "Un titre est requis."
|
|
||||||
|
|
||||||
#: app/validators.py:675
|
|
||||||
msgid "Invalid date format."
|
|
||||||
msgstr "Format de date invalide."
|
|
||||||
|
|
||||||
#: app/validators.py:680 app/validators.py:687
|
|
||||||
msgid "Invalid time format."
|
|
||||||
msgstr "Format d’heure invalide."
|
|
||||||
|
|
||||||
#: app/validators.py:681
|
|
||||||
msgid "Start time is required. Please select a time slot."
|
|
||||||
msgstr "L’heure de début est obligatoire. Choisissez une plage horaire."
|
|
||||||
|
|
||||||
#: app/validators.py:695
|
|
||||||
msgid "Unknown match status."
|
|
||||||
msgstr "Statut de match inconnu."
|
|
||||||
|
|
||||||
#: app/validators.py:711
|
|
||||||
msgid "The end time must come after the start time."
|
|
||||||
msgstr "L'heure de fin doit être postérieure à l'heure de début."
|
|
||||||
|
|
||||||
#: app/validators.py:725
|
|
||||||
msgid "Unknown match type."
|
|
||||||
msgstr "Type de match inconnu."
|
|
||||||
|
|
||||||
#: app/validators.py:737
|
|
||||||
msgid "A team cannot play against itself."
|
|
||||||
msgstr "Une équipe ne peut pas jouer contre elle-même."
|
|
||||||
|
|
||||||
#: app/validators.py:785
|
|
||||||
msgid "Unknown game."
|
msgid "Unknown game."
|
||||||
msgstr "Jeu inconnu."
|
msgstr "Jeu inconnu."
|
||||||
|
|
||||||
#: app/validators.py:790
|
#: app/validators.py:291
|
||||||
|
msgid "Gamertag must be between 1 and 120 characters."
|
||||||
|
msgstr "Le gamertag doit compter de 1 à 120 caractères."
|
||||||
|
|
||||||
|
#: app/validators.py:297
|
||||||
|
msgid "Platform must be 30 characters or less."
|
||||||
|
msgstr "La plateforme ne doit pas dépasser 30 caractères."
|
||||||
|
|
||||||
|
#: app/validators.py:306
|
||||||
|
msgid "Unknown platform for this game."
|
||||||
|
msgstr "Plateforme inconnue pour ce jeu."
|
||||||
|
|
||||||
|
#: app/validators.py:343 app/validators.py:378
|
||||||
|
msgid "Invalid role selected."
|
||||||
|
msgstr "Rôle sélectionné invalide."
|
||||||
|
|
||||||
|
#: app/validators.py:473 app/validators.py:596 app/validators.py:629
|
||||||
|
msgid "Player must be selected."
|
||||||
|
msgstr "Vous devez choisir un joueur."
|
||||||
|
|
||||||
|
#: app/validators.py:476
|
||||||
|
msgid "Notes must be 2000 characters or less."
|
||||||
|
msgstr "Les notes ne doivent pas dépasser 2000 caractères."
|
||||||
|
|
||||||
|
#: app/validators.py:513 app/validators.py:993
|
||||||
|
msgid "Invalid coach selection."
|
||||||
|
msgstr "Sélection de coach invalide."
|
||||||
|
|
||||||
|
#: app/validators.py:519 app/validators.py:999
|
||||||
|
msgid "Invalid manager selection."
|
||||||
|
msgstr "Sélection de gérant invalide."
|
||||||
|
|
||||||
|
#: app/validators.py:537 app/validators.py:595 app/validators.py:628
|
||||||
|
msgid "Invalid player selection."
|
||||||
|
msgstr "Sélection de joueur invalide."
|
||||||
|
|
||||||
|
#: app/validators.py:546
|
||||||
|
msgid "Unknown roster status."
|
||||||
|
msgstr "Statut d'effectif inconnu."
|
||||||
|
|
||||||
|
#: app/validators.py:559
|
||||||
|
msgid "Unknown tryout status."
|
||||||
|
msgstr "Statut de sélection inconnu."
|
||||||
|
|
||||||
|
#: app/validators.py:570
|
||||||
|
msgid "Unknown registration status."
|
||||||
|
msgstr "Statut d’inscription inconnu."
|
||||||
|
|
||||||
|
#: app/validators.py:583
|
||||||
|
msgid "Team name must be between 1 and 100 characters."
|
||||||
|
msgstr "Le nom de l’équipe doit compter de 1 à 100 caractères."
|
||||||
|
|
||||||
|
#: app/validators.py:603
|
||||||
|
msgid "Position must be 50 characters or less."
|
||||||
|
msgstr "La position ne doit pas dépasser 50 caractères."
|
||||||
|
|
||||||
|
#: app/validators.py:616
|
||||||
|
msgid "Note content must be between 1 and 5000 characters."
|
||||||
|
msgstr "La note doit compter de 1 à 5000 caractères."
|
||||||
|
|
||||||
|
#: app/validators.py:642
|
||||||
|
msgid "Select at most one note context."
|
||||||
|
msgstr "Sélectionnez au plus un contexte pour la note."
|
||||||
|
|
||||||
|
#: app/validators.py:654
|
||||||
|
msgid "Rejection reason must be 2000 characters or less."
|
||||||
|
msgstr "Le motif de refus ne doit pas dépasser 2000 caractères."
|
||||||
|
|
||||||
|
#: app/validators.py:678
|
||||||
|
msgid "Date must be in YYYY-MM-DD format."
|
||||||
|
msgstr "La date doit être au format AAAA-MM-JJ."
|
||||||
|
|
||||||
|
#: app/validators.py:679
|
||||||
|
msgid "A date is required."
|
||||||
|
msgstr "Une date est requise."
|
||||||
|
|
||||||
|
#: app/validators.py:685 app/validators.py:750
|
||||||
|
msgid "Start time must be in HH:MM format."
|
||||||
|
msgstr "L’heure de début doit être au format HH:MM."
|
||||||
|
|
||||||
|
#: app/validators.py:686 app/validators.py:751
|
||||||
|
msgid "A start time is required."
|
||||||
|
msgstr "Une heure de début est requise."
|
||||||
|
|
||||||
|
#: app/validators.py:692
|
||||||
|
msgid "End time must be in HH:MM format."
|
||||||
|
msgstr "L’heure de fin doit être au format HH:MM."
|
||||||
|
|
||||||
|
#: app/validators.py:693
|
||||||
|
msgid "An end time is required."
|
||||||
|
msgstr "Une heure de fin est requise."
|
||||||
|
|
||||||
|
#: app/validators.py:697
|
||||||
|
msgid "Points must be 2000 characters or less."
|
||||||
|
msgstr "Les points ne doivent pas dépasser 2000 caractères."
|
||||||
|
|
||||||
|
#: app/validators.py:712
|
||||||
|
msgid "End time must be after start time."
|
||||||
|
msgstr "L'heure de fin doit être postérieure à l'heure de début."
|
||||||
|
|
||||||
|
#: app/validators.py:741 app/validators.py:743
|
||||||
|
msgid "Day must be 0 (Monday) to 6 (Sunday)."
|
||||||
|
msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)."
|
||||||
|
|
||||||
|
#: app/validators.py:744
|
||||||
|
msgid "A day is required."
|
||||||
|
msgstr "Un jour est requis."
|
||||||
|
|
||||||
|
#: app/validators.py:779
|
||||||
|
msgid "Player selection is malformed."
|
||||||
|
msgstr "La sélection de joueurs est mal formée."
|
||||||
|
|
||||||
|
#: app/validators.py:805 app/validators.py:915
|
||||||
|
msgid "A title is required."
|
||||||
|
msgstr "Un titre est requis."
|
||||||
|
|
||||||
|
#: app/validators.py:814
|
||||||
|
msgid "Invalid date format."
|
||||||
|
msgstr "Format de date invalide."
|
||||||
|
|
||||||
|
#: app/validators.py:819 app/validators.py:826
|
||||||
|
msgid "Invalid time format."
|
||||||
|
msgstr "Format d’heure invalide."
|
||||||
|
|
||||||
|
#: app/validators.py:820
|
||||||
|
msgid "Start time is required. Please select a time slot."
|
||||||
|
msgstr "L’heure de début est obligatoire. Choisissez une plage horaire."
|
||||||
|
|
||||||
|
#: app/validators.py:834
|
||||||
|
msgid "Unknown match status."
|
||||||
|
msgstr "Statut de match inconnu."
|
||||||
|
|
||||||
|
#: app/validators.py:850
|
||||||
|
msgid "The end time must come after the start time."
|
||||||
|
msgstr "L'heure de fin doit être postérieure à l'heure de début."
|
||||||
|
|
||||||
|
#: app/validators.py:864
|
||||||
|
msgid "Unknown match type."
|
||||||
|
msgstr "Type de match inconnu."
|
||||||
|
|
||||||
|
#: app/validators.py:876
|
||||||
|
msgid "A team cannot play against itself."
|
||||||
|
msgstr "Une équipe ne peut pas jouer contre elle-même."
|
||||||
|
|
||||||
|
#: app/validators.py:929
|
||||||
msgid "Invalid start date format."
|
msgid "Invalid start date format."
|
||||||
msgstr "Format de date de début invalide."
|
msgstr "Format de date de début invalide."
|
||||||
|
|
||||||
#: app/validators.py:791
|
#: app/validators.py:930
|
||||||
msgid "A start date is required."
|
msgid "A start date is required."
|
||||||
msgstr "Une date de début est requise."
|
msgstr "Une date de début est requise."
|
||||||
|
|
||||||
#: app/validators.py:797
|
#: app/validators.py:936
|
||||||
msgid "Invalid end date format."
|
msgid "Invalid end date format."
|
||||||
msgstr "Format de date de fin invalide."
|
msgstr "Format de date de fin invalide."
|
||||||
|
|
||||||
#: app/validators.py:805
|
#: app/validators.py:944
|
||||||
msgid "A tryout must allow at least one player."
|
msgid "A tryout must allow at least one player."
|
||||||
msgstr "Une sélection doit accepter au moins un joueur."
|
msgstr "Une sélection doit accepter au moins un joueur."
|
||||||
|
|
||||||
#: app/validators.py:808
|
#: app/validators.py:947
|
||||||
msgid "The player limit must be a whole number."
|
msgid "The player limit must be a whole number."
|
||||||
msgstr "La limite de joueurs doit être un nombre entier."
|
msgstr "La limite de joueurs doit être un nombre entier."
|
||||||
|
|
||||||
#: app/validators.py:820
|
#: app/validators.py:959
|
||||||
msgid "End date cannot be before start date."
|
msgid "End date cannot be before start date."
|
||||||
msgstr "La date de fin ne peut pas précéder la date de début."
|
msgstr "La date de fin ne peut pas précéder la date de début."
|
||||||
|
|
||||||
#: app/validators.py:847 app/validators.py:848
|
#: app/validators.py:986 app/validators.py:987
|
||||||
msgid "Team name is required."
|
msgid "Team name is required."
|
||||||
msgstr "Le nom de l’équipe est obligatoire."
|
msgstr "Le nom de l’équipe est obligatoire."
|
||||||
|
|
||||||
#: app/validators.py:872
|
#: app/validators.py:1011
|
||||||
msgid "Scores run from 1 to 10."
|
msgid "Scores run from 1 to 10."
|
||||||
msgstr "Les notes vont de 1 à 10."
|
msgstr "Les notes vont de 1 à 10."
|
||||||
|
|
||||||
#: app/validators.py:873
|
#: app/validators.py:1012
|
||||||
msgid "A score must be a whole number from 1 to 10."
|
msgid "A score must be a whole number from 1 to 10."
|
||||||
msgstr "Une note doit être un nombre entier de 1 à 10."
|
msgstr "Une note doit être un nombre entier de 1 à 10."
|
||||||
|
|
||||||
#: app/routes/auth.py:245
|
#: app/routes/auth.py:246
|
||||||
msgid "This account has been deactivated."
|
msgid "This account has been deactivated."
|
||||||
msgstr "Ce compte a été désactivé."
|
msgstr "Ce compte a été désactivé."
|
||||||
|
|
||||||
#: app/routes/auth.py:280
|
#: app/routes/auth.py:281
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Welcome back, %(username)s!"
|
msgid "Welcome back, %(username)s!"
|
||||||
msgstr "Bon retour, %(username)s !"
|
msgstr "Bon retour, %(username)s !"
|
||||||
|
|
||||||
#: app/routes/auth.py:310
|
#: app/routes/auth.py:311
|
||||||
msgid ""
|
msgid ""
|
||||||
"Login unsuccessful. Please check your username and password, or ask a "
|
"Login unsuccessful. Please check your username and password, or ask a "
|
||||||
"president for help."
|
"president for help."
|
||||||
@@ -241,32 +281,32 @@ msgstr ""
|
|||||||
"Échec de la connexion. Vérifiez le nom d’utilisateur et le mot de passe, "
|
"Échec de la connexion. Vérifiez le nom d’utilisateur et le mot de passe, "
|
||||||
"ou demandez de l’aide à un président."
|
"ou demandez de l’aide à un président."
|
||||||
|
|
||||||
#: app/routes/auth.py:376
|
#: app/routes/auth.py:377
|
||||||
msgid "Your registration could not be processed. Please try again."
|
msgid "Your registration could not be processed. Please try again."
|
||||||
msgstr "Votre inscription n'a pas pu être traitée. Veuillez réessayer."
|
msgstr "Votre inscription n'a pas pu être traitée. Veuillez réessayer."
|
||||||
|
|
||||||
#: app/routes/auth.py:411 app/routes/users/accounts.py:339
|
#: app/routes/auth.py:419 app/routes/users/accounts.py:343
|
||||||
msgid "Username already exists."
|
msgid "Username already exists."
|
||||||
msgstr "Ce nom d’utilisateur est déjà pris."
|
msgstr "Ce nom d’utilisateur est déjà pris."
|
||||||
|
|
||||||
#: app/routes/auth.py:415 app/routes/users/accounts.py:343
|
#: app/routes/auth.py:423 app/routes/users/accounts.py:347
|
||||||
msgid "Email already registered."
|
msgid "Email already registered."
|
||||||
msgstr "Cette adresse courriel est déjà enregistrée."
|
msgstr "Cette adresse courriel est déjà enregistrée."
|
||||||
|
|
||||||
#: app/routes/auth.py:422 app/routes/auth.py:617
|
#: app/routes/auth.py:430 app/routes/auth.py:623
|
||||||
#: app/routes/users/accounts.py:121
|
#: app/routes/users/accounts.py:121
|
||||||
msgid "This Discord account is already linked to another account."
|
msgid "This Discord account is already linked to another account."
|
||||||
msgstr "Ce compte Discord est déjà lié à un autre compte."
|
msgstr "Ce compte Discord est déjà lié à un autre compte."
|
||||||
|
|
||||||
#: app/routes/auth.py:466
|
#: app/routes/auth.py:472
|
||||||
msgid "Your account has been created! You can now log in."
|
msgid "Your account has been created! You can now log in."
|
||||||
msgstr "Votre compte a été créé. Vous pouvez maintenant vous connecter."
|
msgstr "Votre compte a été créé. Vous pouvez maintenant vous connecter."
|
||||||
|
|
||||||
#: app/routes/auth.py:491
|
#: app/routes/auth.py:497
|
||||||
msgid "Discord OAuth2 is not configured."
|
msgid "Discord OAuth2 is not configured."
|
||||||
msgstr "La connexion Discord n’est pas configurée."
|
msgstr "La connexion Discord n’est pas configurée."
|
||||||
|
|
||||||
#: app/routes/auth.py:540
|
#: app/routes/auth.py:546
|
||||||
msgid ""
|
msgid ""
|
||||||
"Discord authorization could not be verified. Please start the connection "
|
"Discord authorization could not be verified. Please start the connection "
|
||||||
"again from this page."
|
"again from this page."
|
||||||
@@ -274,35 +314,35 @@ msgstr ""
|
|||||||
"L’autorisation Discord n’a pas pu être vérifiée. Relancez la connexion "
|
"L’autorisation Discord n’a pas pu être vérifiée. Relancez la connexion "
|
||||||
"depuis cette page."
|
"depuis cette page."
|
||||||
|
|
||||||
#: app/routes/auth.py:549
|
#: app/routes/auth.py:555
|
||||||
msgid "Discord authorization failed. No code received."
|
msgid "Discord authorization failed. No code received."
|
||||||
msgstr "L’autorisation Discord a échoué : aucun code reçu."
|
msgstr "L’autorisation Discord a échoué : aucun code reçu."
|
||||||
|
|
||||||
#: app/routes/auth.py:573
|
#: app/routes/auth.py:579
|
||||||
msgid "Failed to connect to Discord. Please try again."
|
msgid "Failed to connect to Discord. Please try again."
|
||||||
msgstr "Impossible de joindre Discord. Veuillez réessayer."
|
msgstr "Impossible de joindre Discord. Veuillez réessayer."
|
||||||
|
|
||||||
#: app/routes/auth.py:577
|
#: app/routes/auth.py:583
|
||||||
msgid "Failed to obtain Discord access token."
|
msgid "Failed to obtain Discord access token."
|
||||||
msgstr "Impossible d’obtenir le jeton d’accès Discord."
|
msgstr "Impossible d’obtenir le jeton d’accès Discord."
|
||||||
|
|
||||||
#: app/routes/auth.py:592 app/routes/auth.py:602
|
#: app/routes/auth.py:598 app/routes/auth.py:608
|
||||||
msgid "Failed to fetch Discord user profile."
|
msgid "Failed to fetch Discord user profile."
|
||||||
msgstr "Impossible de récupérer le profil Discord."
|
msgstr "Impossible de récupérer le profil Discord."
|
||||||
|
|
||||||
#: app/routes/auth.py:609
|
#: app/routes/auth.py:615
|
||||||
msgid "Please log in to connect your Discord account."
|
msgid "Please log in to connect your Discord account."
|
||||||
msgstr "Veuillez vous connecter pour lier votre compte Discord."
|
msgstr "Veuillez vous connecter pour lier votre compte Discord."
|
||||||
|
|
||||||
#: app/routes/auth.py:628
|
#: app/routes/auth.py:634
|
||||||
msgid "Discord account connected!"
|
msgid "Discord account connected!"
|
||||||
msgstr "Compte Discord connecté !"
|
msgstr "Compte Discord connecté !"
|
||||||
|
|
||||||
#: app/routes/auth.py:675
|
#: app/routes/auth.py:681
|
||||||
msgid "Discord account connected! Your profile has been pre-filled."
|
msgid "Discord account connected! Your profile has been pre-filled."
|
||||||
msgstr "Compte Discord connecté. Votre profil a été pré-rempli."
|
msgstr "Compte Discord connecté. Votre profil a été pré-rempli."
|
||||||
|
|
||||||
#: app/routes/auth.py:703
|
#: app/routes/auth.py:709
|
||||||
msgid "You have been logged out."
|
msgid "You have been logged out."
|
||||||
msgstr "Vous avez été déconnecté."
|
msgstr "Vous avez été déconnecté."
|
||||||
|
|
||||||
@@ -336,10 +376,10 @@ msgstr "Évaluation mise à jour."
|
|||||||
|
|
||||||
#: app/routes/evaluations.py:210 app/routes/teams.py:341
|
#: app/routes/evaluations.py:210 app/routes/teams.py:341
|
||||||
#: app/routes/teams.py:384 app/routes/teams.py:427 app/routes/teams.py:455
|
#: app/routes/teams.py:384 app/routes/teams.py:427 app/routes/teams.py:455
|
||||||
#: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:444
|
#: app/routes/teams.py:483 app/routes/teams.py:520 app/routes/tryouts.py:471
|
||||||
#: app/routes/tryouts.py:460 app/routes/tryouts.py:480
|
#: app/routes/tryouts.py:491 app/routes/tryouts.py:515
|
||||||
#: app/routes/tryouts.py:527 app/routes/tryouts.py:563
|
#: app/routes/tryouts.py:562 app/routes/tryouts.py:598
|
||||||
#: app/routes/tryouts.py:582
|
#: app/routes/tryouts.py:621
|
||||||
msgid "Permission denied."
|
msgid "Permission denied."
|
||||||
msgstr "Accès refusé."
|
msgstr "Accès refusé."
|
||||||
|
|
||||||
@@ -347,37 +387,37 @@ msgstr "Accès refusé."
|
|||||||
msgid "That language is not available."
|
msgid "That language is not available."
|
||||||
msgstr "Cette langue n’est pas disponible."
|
msgstr "Cette langue n’est pas disponible."
|
||||||
|
|
||||||
#: app/routes/matches.py:364
|
#: app/routes/matches.py:383
|
||||||
msgid "You do not have permission to schedule matches for this tryout."
|
msgid "You do not have permission to schedule matches for this tryout."
|
||||||
msgstr "Vous n’avez pas les droits pour planifier des matchs pour cette sélection."
|
msgstr "Vous n’avez pas les droits pour planifier des matchs pour cette sélection."
|
||||||
|
|
||||||
#: app/routes/matches.py:368 app/routes/matches.py:448
|
#: app/routes/matches.py:387 app/routes/matches.py:463
|
||||||
msgid "This tryout has ended. Matches can no longer be created or modified."
|
msgid "This tryout has ended. Matches can no longer be created or modified."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Cette sélection est terminée. Les matchs ne peuvent plus être créés ni "
|
"Cette sélection est terminée. Les matchs ne peuvent plus être créés ni "
|
||||||
"modifiés."
|
"modifiés."
|
||||||
|
|
||||||
#: app/routes/matches.py:430
|
#: app/routes/matches.py:445
|
||||||
msgid "Match scheduled successfully!"
|
msgid "Match scheduled successfully!"
|
||||||
msgstr "Match planifié."
|
msgstr "Match planifié."
|
||||||
|
|
||||||
#: app/routes/matches.py:444 app/routes/team_matches.py:220
|
#: app/routes/matches.py:459 app/routes/team_matches.py:220
|
||||||
msgid "You do not have permission to edit this match."
|
msgid "You do not have permission to edit this match."
|
||||||
msgstr "Vous n’avez pas les droits pour modifier ce match."
|
msgstr "Vous n’avez pas les droits pour modifier ce match."
|
||||||
|
|
||||||
#: app/routes/matches.py:539 app/routes/team_matches.py:250
|
#: app/routes/matches.py:552 app/routes/team_matches.py:250
|
||||||
msgid "Match updated successfully!"
|
msgid "Match updated successfully!"
|
||||||
msgstr "Match mis à jour."
|
msgstr "Match mis à jour."
|
||||||
|
|
||||||
#: app/routes/matches.py:575 app/routes/team_matches.py:265
|
#: app/routes/matches.py:588 app/routes/team_matches.py:265
|
||||||
msgid "You do not have permission to delete this match."
|
msgid "You do not have permission to delete this match."
|
||||||
msgstr "Vous n’avez pas les droits pour supprimer ce match."
|
msgstr "Vous n’avez pas les droits pour supprimer ce match."
|
||||||
|
|
||||||
#: app/routes/matches.py:578
|
#: app/routes/matches.py:591
|
||||||
msgid "This tryout has ended. Matches can no longer be deleted."
|
msgid "This tryout has ended. Matches can no longer be deleted."
|
||||||
msgstr "Cette sélection est terminée. Les matchs ne peuvent plus être supprimés."
|
msgstr "Cette sélection est terminée. Les matchs ne peuvent plus être supprimés."
|
||||||
|
|
||||||
#: app/routes/matches.py:591 app/routes/team_matches.py:269
|
#: app/routes/matches.py:604 app/routes/team_matches.py:269
|
||||||
msgid "Match deleted successfully."
|
msgid "Match deleted successfully."
|
||||||
msgstr "Match supprimé."
|
msgstr "Match supprimé."
|
||||||
|
|
||||||
@@ -480,7 +520,7 @@ msgstr "Coach retiré de %(name)s."
|
|||||||
msgid "Manager removed from %(name)s."
|
msgid "Manager removed from %(name)s."
|
||||||
msgstr "Gérant retiré de %(name)s."
|
msgstr "Gérant retiré de %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:490 app/routes/tryouts.py:489 app/routes/tryouts.py:593
|
#: app/routes/teams.py:490 app/routes/tryouts.py:524
|
||||||
msgid "Please select a player."
|
msgid "Please select a player."
|
||||||
msgstr "Veuillez choisir un joueur."
|
msgstr "Veuillez choisir un joueur."
|
||||||
|
|
||||||
@@ -498,7 +538,7 @@ msgstr "%(username)s fait déjà partie de %(name)s."
|
|||||||
msgid "%(username)s added to %(name)s!"
|
msgid "%(username)s added to %(name)s!"
|
||||||
msgstr "%(username)s a été ajouté à %(name)s."
|
msgstr "%(username)s a été ajouté à %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:527 app/routes/teams.py:601
|
#: app/routes/teams.py:527 app/routes/teams.py:605
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s is not on %(name)s."
|
msgid "%(username)s is not on %(name)s."
|
||||||
msgstr "%(username)s ne fait pas partie de %(name)s."
|
msgstr "%(username)s ne fait pas partie de %(name)s."
|
||||||
@@ -508,130 +548,130 @@ msgstr "%(username)s ne fait pas partie de %(name)s."
|
|||||||
msgid "%(username)s removed from %(name)s."
|
msgid "%(username)s removed from %(name)s."
|
||||||
msgstr "%(username)s a été retiré de %(name)s."
|
msgstr "%(username)s a été retiré de %(name)s."
|
||||||
|
|
||||||
#: app/routes/teams.py:572 app/routes/teams.py:590
|
#: app/routes/teams.py:572 app/routes/teams.py:594
|
||||||
msgid "You do not have permission to add notes to this team."
|
msgid "You do not have permission to add notes to this team."
|
||||||
msgstr "Vous n’avez pas les droits pour ajouter des notes à cette équipe."
|
msgstr "Vous n’avez pas les droits pour ajouter des notes à cette équipe."
|
||||||
|
|
||||||
#: app/routes/teams.py:580
|
#: app/routes/teams.py:584
|
||||||
msgid "Team notes added successfully!"
|
msgid "Team notes added successfully!"
|
||||||
msgstr "Notes d’équipe ajoutées."
|
msgstr "Notes d’équipe ajoutées."
|
||||||
|
|
||||||
#: app/routes/teams.py:595 app/routes/users/notes.py:207
|
#: app/routes/teams.py:599 app/routes/users/notes.py:222
|
||||||
#: app/routes/users/notes.py:250
|
#: app/routes/users/notes.py:262
|
||||||
msgid "Can only add notes for players."
|
msgid "Can only add notes for players."
|
||||||
msgstr "Il n’est possible d’ajouter des notes que pour des joueurs."
|
msgstr "Il n’est possible d’ajouter des notes que pour des joueurs."
|
||||||
|
|
||||||
#: app/routes/teams.py:611
|
#: app/routes/teams.py:619
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Note added for %(username)s!"
|
msgid "Note added for %(username)s!"
|
||||||
msgstr "Note ajoutée pour %(username)s."
|
msgstr "Note ajoutée pour %(username)s."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:98
|
#: app/routes/tryouts.py:125
|
||||||
msgid "You do not have permission to create tryouts."
|
msgid "You do not have permission to create tryouts."
|
||||||
msgstr "Vous n’avez pas les droits pour créer une sélection."
|
msgstr "Vous n’avez pas les droits pour créer une sélection."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:145
|
#: app/routes/tryouts.py:172
|
||||||
msgid "Tryout created successfully!"
|
msgid "Tryout created successfully!"
|
||||||
msgstr "Sélection créée."
|
msgstr "Sélection créée."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:158
|
#: app/routes/tryouts.py:185
|
||||||
msgid "You do not have permission to edit this tryout."
|
msgid "You do not have permission to edit this tryout."
|
||||||
msgstr "Vous n’avez pas les droits pour modifier cette sélection."
|
msgstr "Vous n’avez pas les droits pour modifier cette sélection."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:162
|
#: app/routes/tryouts.py:189
|
||||||
msgid "This tryout has ended and can no longer be modified."
|
msgid "This tryout has ended and can no longer be modified."
|
||||||
msgstr "Cette sélection est terminée et ne peut plus être modifiée."
|
msgstr "Cette sélection est terminée et ne peut plus être modifiée."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:202
|
#: app/routes/tryouts.py:229
|
||||||
msgid "Tryout updated successfully!"
|
msgid "Tryout updated successfully!"
|
||||||
msgstr "Sélection mise à jour."
|
msgstr "Sélection mise à jour."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:242
|
#: app/routes/tryouts.py:269
|
||||||
msgid "You do not have permission to view this tryout."
|
msgid "You do not have permission to view this tryout."
|
||||||
msgstr "Vous n’avez pas les droits pour consulter cette sélection."
|
msgstr "Vous n’avez pas les droits pour consulter cette sélection."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:411
|
#: app/routes/tryouts.py:437
|
||||||
msgid "Only players can register for tryouts."
|
msgid "Only players can register for tryouts."
|
||||||
msgstr "Seuls les joueurs peuvent s’inscrire à une sélection."
|
msgstr "Seuls les joueurs peuvent s’inscrire à une sélection."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:415
|
#: app/routes/tryouts.py:442
|
||||||
msgid "This tryout is not accepting registrations."
|
msgid "This tryout is not accepting registrations."
|
||||||
msgstr "Cette sélection n’accepte pas d’inscriptions."
|
msgstr "Cette sélection n’accepte pas d’inscriptions."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:422
|
#: app/routes/tryouts.py:449
|
||||||
msgid "You are already registered for this tryout."
|
msgid "You are already registered for this tryout."
|
||||||
msgstr "Vous êtes déjà inscrit à cette sélection."
|
msgstr "Vous êtes déjà inscrit à cette sélection."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:428 app/routes/tryouts.py:511
|
#: app/routes/tryouts.py:455 app/routes/tryouts.py:546
|
||||||
msgid "This tryout is full."
|
msgid "This tryout is full."
|
||||||
msgstr "Cette sélection est complète."
|
msgstr "Cette sélection est complète."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:434
|
#: app/routes/tryouts.py:461
|
||||||
msgid "Successfully registered for tryout!"
|
msgid "Successfully registered for tryout!"
|
||||||
msgstr "Inscription à la sélection réussie."
|
msgstr "Inscription à la sélection réussie."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:450
|
#: app/routes/tryouts.py:481
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Tryout status updated to %(new_status)s."
|
msgid "Tryout status updated to %(new_status)s."
|
||||||
msgstr "Statut de la sélection mis à jour : %(new_status)s."
|
msgstr "Statut de la sélection mis à jour : %(new_status)s."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:470
|
#: app/routes/tryouts.py:505
|
||||||
msgid "Registration status updated."
|
msgid "Registration status updated."
|
||||||
msgstr "Statut d’inscription mis à jour."
|
msgstr "Statut d’inscription mis à jour."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:497
|
#: app/routes/tryouts.py:532
|
||||||
msgid "Can only register players."
|
msgid "Can only register players."
|
||||||
msgstr "Seuls des joueurs peuvent être inscrits."
|
msgstr "Seuls des joueurs peuvent être inscrits."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:503
|
#: app/routes/tryouts.py:538
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s is already registered for this tryout."
|
msgid "%(username)s is already registered for this tryout."
|
||||||
msgstr "%(username)s est déjà inscrit à cette sélection."
|
msgstr "%(username)s est déjà inscrit à cette sélection."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:517
|
#: app/routes/tryouts.py:552
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s registered for tryout!"
|
msgid "%(username)s registered for tryout!"
|
||||||
msgstr "%(username)s est inscrit à la sélection."
|
msgstr "%(username)s est inscrit à la sélection."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:553
|
#: app/routes/tryouts.py:588
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(username)s removed from tryout."
|
msgid "%(username)s removed from tryout."
|
||||||
msgstr "%(username)s a été retiré de la sélection."
|
msgstr "%(username)s a été retiré de la sélection."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:571
|
#: app/routes/tryouts.py:610
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Team \"%(team_name)s\" created!"
|
msgid "Team \"%(team_name)s\" created!"
|
||||||
msgstr "Équipe « %(team_name)s » créée."
|
msgstr "Équipe « %(team_name)s » créée."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:602
|
#: app/routes/tryouts.py:643 app/routes/users/notes.py:350
|
||||||
msgid "That player is not registered for this tryout."
|
msgid "That player is not registered for this tryout."
|
||||||
msgstr "Ce joueur n’est pas inscrit à cette sélection."
|
msgstr "Ce joueur n’est pas inscrit à cette sélection."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:608
|
#: app/routes/tryouts.py:648
|
||||||
msgid "Player is already on this team."
|
msgid "Player is already on this team."
|
||||||
msgstr "Ce joueur est déjà dans cette équipe."
|
msgstr "Ce joueur est déjà dans cette équipe."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:613
|
#: app/routes/tryouts.py:653
|
||||||
msgid "Player added to team!"
|
msgid "Player added to team!"
|
||||||
msgstr "Joueur ajouté à l’équipe."
|
msgstr "Joueur ajouté à l’équipe."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:623
|
#: app/routes/tryouts.py:663
|
||||||
msgid "You do not have permission to delete this tryout."
|
msgid "You do not have permission to delete this tryout."
|
||||||
msgstr "Vous n’avez pas les droits pour supprimer cette sélection."
|
msgstr "Vous n’avez pas les droits pour supprimer cette sélection."
|
||||||
|
|
||||||
#: app/routes/tryouts.py:659
|
#: app/routes/tryouts.py:699
|
||||||
msgid "Tryout deleted successfully."
|
msgid "Tryout deleted successfully."
|
||||||
msgstr "Sélection supprimée."
|
msgstr "Sélection supprimée."
|
||||||
|
|
||||||
#: app/routes/users/_shared.py:51
|
#: app/routes/users/_shared.py:50
|
||||||
msgid "No file selected."
|
msgid "No file selected."
|
||||||
msgstr "Aucun fichier sélectionné."
|
msgstr "Aucun fichier sélectionné."
|
||||||
|
|
||||||
#: app/routes/users/_shared.py:55
|
#: app/routes/users/_shared.py:54
|
||||||
msgid "Only PDF files are allowed for contracts."
|
msgid "Only PDF files are allowed for contracts."
|
||||||
msgstr "Seuls les fichiers PDF sont acceptés pour les contrats."
|
msgstr "Seuls les fichiers PDF sont acceptés pour les contrats."
|
||||||
|
|
||||||
#: app/routes/users/_shared.py:60
|
#: app/routes/users/_shared.py:59
|
||||||
msgid "That file is not a PDF, whatever its name says."
|
msgid "That file is not a PDF, whatever its name says."
|
||||||
msgstr "Ce fichier n’est pas un PDF, quel que soit son nom."
|
msgstr "Ce fichier n’est pas un PDF, quel que soit son nom."
|
||||||
|
|
||||||
@@ -647,13 +687,13 @@ msgstr "Seul le président peut modifier des utilisateurs."
|
|||||||
msgid "Email already in use by another account."
|
msgid "Email already in use by another account."
|
||||||
msgstr "Cette adresse courriel est déjà utilisée par un autre compte."
|
msgstr "Cette adresse courriel est déjà utilisée par un autre compte."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:132
|
#: app/routes/users/accounts.py:138
|
||||||
msgid "You cannot change your own role. Ask another president to do it."
|
msgid "You cannot change your own role. Ask another president to do it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Vous ne pouvez pas modifier votre propre rôle. Demandez à un autre "
|
"Vous ne pouvez pas modifier votre propre rôle. Demandez à un autre "
|
||||||
"président de le faire."
|
"président de le faire."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:145
|
#: app/routes/users/accounts.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"This is the last active president. Promote another account before "
|
"This is the last active president. Promote another account before "
|
||||||
"changing this one."
|
"changing this one."
|
||||||
@@ -661,29 +701,29 @@ msgstr ""
|
|||||||
"C’est le dernier président actif. Promouvez un autre compte avant de "
|
"C’est le dernier président actif. Promouvez un autre compte avant de "
|
||||||
"modifier celui-ci."
|
"modifier celui-ci."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:224
|
#: app/routes/users/accounts.py:228
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "User %(username)s updated successfully!"
|
msgid "User %(username)s updated successfully!"
|
||||||
msgstr "Utilisateur %(username)s mis à jour."
|
msgstr "Utilisateur %(username)s mis à jour."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:245
|
#: app/routes/users/accounts.py:249
|
||||||
msgid "Only the president can delete users."
|
msgid "Only the president can delete users."
|
||||||
msgstr "Seul le président peut supprimer des utilisateurs."
|
msgstr "Seul le président peut supprimer des utilisateurs."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:249
|
#: app/routes/users/accounts.py:253
|
||||||
msgid "You cannot delete your own account."
|
msgid "You cannot delete your own account."
|
||||||
msgstr "Vous ne pouvez pas supprimer votre propre compte."
|
msgstr "Vous ne pouvez pas supprimer votre propre compte."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:308
|
#: app/routes/users/accounts.py:312
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "User %(deleted_username)s has been removed."
|
msgid "User %(deleted_username)s has been removed."
|
||||||
msgstr "L’utilisateur %(deleted_username)s a été supprimé."
|
msgstr "L’utilisateur %(deleted_username)s a été supprimé."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:319
|
#: app/routes/users/accounts.py:323
|
||||||
msgid "Only the president can create users."
|
msgid "Only the president can create users."
|
||||||
msgstr "Seul le président peut créer des utilisateurs."
|
msgstr "Seul le président peut créer des utilisateurs."
|
||||||
|
|
||||||
#: app/routes/users/accounts.py:367
|
#: app/routes/users/accounts.py:371
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "User %(full_name)s created as %(role)s!"
|
msgid "User %(full_name)s created as %(role)s!"
|
||||||
msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s."
|
msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s."
|
||||||
@@ -721,56 +761,95 @@ msgstr "Vous n’avez pas les droits pour télécharger ce contrat."
|
|||||||
msgid "No signed contract available."
|
msgid "No signed contract available."
|
||||||
msgstr "Aucun contrat signé disponible."
|
msgstr "Aucun contrat signé disponible."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:34
|
#: app/routes/users/notes.py:43
|
||||||
msgid "This page is for players only."
|
msgid "This page is for players only."
|
||||||
msgstr "Cette page est réservée aux joueurs."
|
msgstr "Cette page est réservée aux joueurs."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:71
|
#: app/routes/users/notes.py:80
|
||||||
msgid "Only coaches can access the notes dashboard."
|
msgid "Only coaches can access the notes dashboard."
|
||||||
msgstr "Seuls les coachs ont accès au tableau des notes."
|
msgstr "Seuls les coachs ont accès au tableau des notes."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:161
|
#: app/routes/users/notes.py:172
|
||||||
msgid "Only coaches can manage team notes."
|
msgid "Only coaches can manage team notes."
|
||||||
msgstr "Seuls les coachs peuvent gérer les notes d’équipe."
|
msgstr "Seuls les coachs peuvent gérer les notes d’équipe."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:167
|
#: app/routes/users/notes.py:178
|
||||||
msgid "You are not assigned to a team."
|
msgid "You are not assigned to a team."
|
||||||
msgstr "Vous n’êtes assigné à aucune équipe."
|
msgstr "Vous n’êtes assigné à aucune équipe."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:180
|
#: app/routes/users/notes.py:195
|
||||||
msgid "Team notes saved successfully!"
|
msgid "Team notes saved successfully!"
|
||||||
msgstr "Notes d’équipe enregistrées."
|
msgstr "Notes d’équipe enregistrées."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:195
|
#: app/routes/users/notes.py:210
|
||||||
msgid "Only coaches can manage personal notes."
|
msgid "Only coaches can manage personal notes."
|
||||||
msgstr "Seuls les coachs peuvent gérer les notes personnelles."
|
msgstr "Seuls les coachs peuvent gérer les notes personnelles."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:202 app/routes/users/notes.py:245
|
#: app/routes/users/notes.py:226 app/routes/users/notes.py:266
|
||||||
#: app/routes/users/notes.py:296 app/routes/users/notes.py:350
|
#: app/routes/users/notes.py:346 app/routes/users/notes.py:411
|
||||||
msgid "Player and content are required."
|
|
||||||
msgstr "Le joueur et le contenu sont obligatoires."
|
|
||||||
|
|
||||||
#: app/routes/users/notes.py:211 app/routes/users/notes.py:254
|
|
||||||
#: app/routes/users/notes.py:300 app/routes/users/notes.py:354
|
|
||||||
msgid "You can only write notes about players you work with."
|
msgid "You can only write notes about players you work with."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Vous ne pouvez écrire des notes que sur les joueurs avec qui vous "
|
"Vous ne pouvez écrire des notes que sur les joueurs avec qui vous "
|
||||||
"travaillez."
|
"travaillez."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:221 app/routes/users/notes.py:267
|
#: app/routes/users/notes.py:236 app/routes/users/notes.py:306
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Note added for %(username)s."
|
msgid "Note added for %(username)s."
|
||||||
msgstr "Note ajoutée pour %(username)s."
|
msgstr "Note ajoutée pour %(username)s."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:235 app/routes/users/notes.py:281
|
#: app/routes/users/notes.py:250 app/routes/users/notes.py:320
|
||||||
#: app/routes/users/notes.py:334
|
#: app/routes/users/notes.py:384
|
||||||
msgid "Only coaches can add personal notes."
|
msgid "Only coaches can add personal notes."
|
||||||
msgstr "Seuls les coachs peuvent ajouter des notes personnelles."
|
msgstr "Seuls les coachs peuvent ajouter des notes personnelles."
|
||||||
|
|
||||||
#: app/routes/users/notes.py:311 app/routes/users/notes.py:365
|
#: app/routes/users/notes.py:272
|
||||||
|
msgid "You cannot use that match as note context."
|
||||||
|
msgstr "Vous ne pouvez pas utiliser ce match comme contexte de note."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:275
|
||||||
|
msgid "That player did not participate in the selected match."
|
||||||
|
msgstr "Ce joueur n’a pas participé au match sélectionné."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:281
|
||||||
|
msgid "You cannot use that tryout as note context."
|
||||||
|
msgstr "Vous ne pouvez pas utiliser cette sélection comme contexte de note."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:284
|
||||||
|
msgid "That player is not registered for the selected tryout."
|
||||||
|
msgstr "Ce joueur n’est pas inscrit à la sélection choisie."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:290
|
||||||
|
msgid "You cannot use that team as note context."
|
||||||
|
msgstr "Vous ne pouvez pas utiliser cette équipe comme contexte de note."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:293
|
||||||
|
msgid "That player is not on the selected team."
|
||||||
|
msgstr "Ce joueur ne fait pas partie de l’équipe sélectionnée."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:325
|
||||||
|
msgid "You do not have permission to add notes for this tryout."
|
||||||
|
msgstr "Vous n’avez pas les droits pour ajouter des notes à cette sélection."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:342
|
||||||
|
msgid "Invalid tryout context."
|
||||||
|
msgstr "Contexte de sélection invalide."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:361 app/routes/users/notes.py:426
|
||||||
msgid "Note added successfully."
|
msgid "Note added successfully."
|
||||||
msgstr "Note ajoutée."
|
msgstr "Note ajoutée."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:389
|
||||||
|
msgid "You do not have permission to add notes for this match."
|
||||||
|
msgstr "Vous n’avez pas les droits pour ajouter des notes à ce match."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:407
|
||||||
|
msgid "Invalid match context."
|
||||||
|
msgstr "Contexte de match invalide."
|
||||||
|
|
||||||
|
#: app/routes/users/notes.py:415
|
||||||
|
msgid "That player did not participate in this match."
|
||||||
|
msgstr "Ce joueur n’a pas participé à ce match."
|
||||||
|
|
||||||
#: app/routes/users/one_on_one.py:23
|
#: app/routes/users/one_on_one.py:23
|
||||||
msgid "Only players can request One on One sessions."
|
msgid "Only players can request One on One sessions."
|
||||||
msgstr "Seuls les joueurs peuvent demander une rencontre individuelle."
|
msgstr "Seuls les joueurs peuvent demander une rencontre individuelle."
|
||||||
@@ -812,7 +891,7 @@ msgstr "La demande de rencontre de %(player)s a été approuvée."
|
|||||||
msgid "Only coaches can reject One on One requests."
|
msgid "Only coaches can reject One on One requests."
|
||||||
msgstr "Seuls les coachs peuvent refuser une demande de rencontre."
|
msgstr "Seuls les coachs peuvent refuser une demande de rencontre."
|
||||||
|
|
||||||
#: app/routes/users/one_on_one.py:258
|
#: app/routes/users/one_on_one.py:263
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "One on One request from %(player)s has been rejected."
|
msgid "One on One request from %(player)s has been rejected."
|
||||||
msgstr "La demande de rencontre de %(player)s a été refusée."
|
msgstr "La demande de rencontre de %(player)s a été refusée."
|
||||||
@@ -825,7 +904,7 @@ msgstr "Ce nom d’utilisateur est déjà pris."
|
|||||||
msgid "Email already in use."
|
msgid "Email already in use."
|
||||||
msgstr "Cette adresse courriel est déjà utilisée."
|
msgstr "Cette adresse courriel est déjà utilisée."
|
||||||
|
|
||||||
#: app/routes/users/profile.py:121
|
#: app/routes/users/profile.py:131
|
||||||
msgid "Profile updated successfully!"
|
msgid "Profile updated successfully!"
|
||||||
msgstr "Profil mis à jour."
|
msgstr "Profil mis à jour."
|
||||||
|
|
||||||
@@ -1221,7 +1300,7 @@ msgstr ""
|
|||||||
"individuelles"
|
"individuelles"
|
||||||
|
|
||||||
#: app/templates/pages/coach_availability.html:14
|
#: app/templates/pages/coach_availability.html:14
|
||||||
#: app/templates/pages/profile.html:216
|
#: app/templates/pages/profile.html:213
|
||||||
msgid "Loading availability grid..."
|
msgid "Loading availability grid..."
|
||||||
msgstr "Chargement de la grille de disponibilités..."
|
msgstr "Chargement de la grille de disponibilités..."
|
||||||
|
|
||||||
@@ -1230,7 +1309,7 @@ msgid "Save Availability"
|
|||||||
msgstr "Enregistrer les disponibilités"
|
msgstr "Enregistrer les disponibilités"
|
||||||
|
|
||||||
#: app/templates/pages/coach_availability.html:22
|
#: app/templates/pages/coach_availability.html:22
|
||||||
#: app/templates/pages/profile.html:200 app/templates/pages/profile.html:220
|
#: app/templates/pages/profile.html:197 app/templates/pages/profile.html:217
|
||||||
msgid "Clear All"
|
msgid "Clear All"
|
||||||
msgstr "Tout effacer"
|
msgstr "Tout effacer"
|
||||||
|
|
||||||
@@ -2011,23 +2090,23 @@ msgstr ""
|
|||||||
"Les indicateurs verts signalent les joueurs disponibles à la date et à "
|
"Les indicateurs verts signalent les joueurs disponibles à la date et à "
|
||||||
"l’heure du match"
|
"l’heure du match"
|
||||||
|
|
||||||
#: app/templates/pages/match_form.html:625
|
#: app/templates/pages/match_form.html:632
|
||||||
msgid "Click time slots consecutively to set match duration"
|
msgid "Click time slots consecutively to set match duration"
|
||||||
msgstr "Cliquez des plages consécutives pour définir la durée du match"
|
msgstr "Cliquez des plages consécutives pour définir la durée du match"
|
||||||
|
|
||||||
#: app/templates/pages/match_form.html:892
|
#: app/templates/pages/match_form.html:899
|
||||||
msgid "No players registered"
|
msgid "No players registered"
|
||||||
msgstr "Aucun joueur inscrit"
|
msgstr "Aucun joueur inscrit"
|
||||||
|
|
||||||
#: app/templates/pages/match_form.html:925
|
#: app/templates/pages/match_form.html:932
|
||||||
msgid "T1"
|
msgid "T1"
|
||||||
msgstr "É1"
|
msgstr "É1"
|
||||||
|
|
||||||
#: app/templates/pages/match_form.html:926
|
#: app/templates/pages/match_form.html:933
|
||||||
msgid "T2"
|
msgid "T2"
|
||||||
msgstr "É2"
|
msgstr "É2"
|
||||||
|
|
||||||
#: app/templates/pages/match_form.html:931
|
#: app/templates/pages/match_form.html:938
|
||||||
msgid "No players available"
|
msgid "No players available"
|
||||||
msgstr "Aucun joueur disponible"
|
msgstr "Aucun joueur disponible"
|
||||||
|
|
||||||
@@ -2382,15 +2461,11 @@ msgstr ""
|
|||||||
"Choisissez vos plages disponibles pour les matchs (17 h à minuit). Vert ="
|
"Choisissez vos plages disponibles pour les matchs (17 h à minuit). Vert ="
|
||||||
" sélectionné, gris = disponible."
|
" sélectionné, gris = disponible."
|
||||||
|
|
||||||
#: app/templates/pages/profile.html:197
|
#: app/templates/pages/profile.html:208
|
||||||
msgid "Save Disponibilities"
|
|
||||||
msgstr "Enregistrer mes disponibilités"
|
|
||||||
|
|
||||||
#: app/templates/pages/profile.html:211
|
|
||||||
msgid "My Coaching Availability"
|
msgid "My Coaching Availability"
|
||||||
msgstr "Mes disponibilités de coaching"
|
msgstr "Mes disponibilités de coaching"
|
||||||
|
|
||||||
#: app/templates/pages/profile.html:212
|
#: app/templates/pages/profile.html:209
|
||||||
msgid ""
|
msgid ""
|
||||||
"Select time slots when you're available for One on One sessions (8am to "
|
"Select time slots when you're available for One on One sessions (8am to "
|
||||||
"10pm)."
|
"10pm)."
|
||||||
@@ -2398,7 +2473,7 @@ msgstr ""
|
|||||||
"Choisissez les plages où vous êtes disponible pour des rencontres "
|
"Choisissez les plages où vous êtes disponible pour des rencontres "
|
||||||
"individuelles (8 h à 22 h)."
|
"individuelles (8 h à 22 h)."
|
||||||
|
|
||||||
#: app/templates/pages/profile.html:460
|
#: app/templates/pages/profile.html:457
|
||||||
msgid "Click or click-and-drag to select your available hours"
|
msgid "Click or click-and-drag to select your available hours"
|
||||||
msgstr "Cliquez ou faites glisser pour choisir vos heures de disponibilité"
|
msgstr "Cliquez ou faites glisser pour choisir vos heures de disponibilité"
|
||||||
|
|
||||||
@@ -3025,3 +3100,9 @@ msgstr "Voir le profil"
|
|||||||
|
|
||||||
#~ msgid "Team Tryout Management System"
|
#~ msgid "Team Tryout Management System"
|
||||||
#~ msgstr "Système de gestion des sélections d’équipe"
|
#~ msgstr "Système de gestion des sélections d’équipe"
|
||||||
|
|
||||||
|
#~ msgid "Player and content are required."
|
||||||
|
#~ msgstr "Le joueur et le contenu sont obligatoires."
|
||||||
|
|
||||||
|
#~ msgid "Save Disponibilities"
|
||||||
|
#~ msgstr "Enregistrer mes disponibilités"
|
||||||
|
|||||||
+140
-1
@@ -23,7 +23,7 @@ from marshmallow import (
|
|||||||
validates_schema,
|
validates_schema,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.models import ESPORT_GAMES, USER_TYPES
|
from app.models import ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Custom Validators
|
# Custom Validators
|
||||||
@@ -276,6 +276,36 @@ class RegisterSchema(StripMixin):
|
|||||||
raise ValidationError(_l('Passwords do not match.'), field_name='confirm_password')
|
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):
|
class CreateUserSchema(StripMixin):
|
||||||
"""Validate president-created user form input.
|
"""Validate president-created user form input.
|
||||||
|
|
||||||
@@ -517,6 +547,115 @@ class TeamPlayerSchema(PlayerSelectionSchema):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TRYOUT_STATUSES = ('upcoming', 'in_progress', 'completed')
|
||||||
|
TRYOUT_REGISTRATION_STATUSES = ('registered', 'attended', 'no_show')
|
||||||
|
|
||||||
|
|
||||||
|
class TryoutStatusSchema(StripMixin):
|
||||||
|
"""A state transition requested from the tryout detail page."""
|
||||||
|
|
||||||
|
status = fields.String(
|
||||||
|
required=True,
|
||||||
|
validate=validate.OneOf(TRYOUT_STATUSES, error=_l('Unknown tryout status.')),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TryoutRegistrationStatusSchema(StripMixin):
|
||||||
|
"""Attendance state for one tryout registration."""
|
||||||
|
|
||||||
|
status = fields.String(
|
||||||
|
required=True,
|
||||||
|
validate=validate.OneOf(
|
||||||
|
TRYOUT_REGISTRATION_STATUSES,
|
||||||
|
error=_l('Unknown registration status.'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TryoutTeamSchema(StripMixin):
|
||||||
|
"""A tryout-local team created from its compact inline form."""
|
||||||
|
|
||||||
|
team_name = fields.String(
|
||||||
|
required=True,
|
||||||
|
validate=validate.Length(
|
||||||
|
min=1,
|
||||||
|
max=100,
|
||||||
|
error=_l('Team name must be between 1 and 100 characters.'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TryoutTeamMemberSchema(StripMixin):
|
||||||
|
"""A registered player and their optional position on a tryout team."""
|
||||||
|
|
||||||
|
player_id = fields.Integer(
|
||||||
|
required=True,
|
||||||
|
validate=validate.Range(min=1),
|
||||||
|
error_messages={
|
||||||
|
'invalid': _l('Invalid player selection.'),
|
||||||
|
'required': _l('Player must be selected.'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
position = fields.String(
|
||||||
|
load_default='',
|
||||||
|
validate=validate.Length(
|
||||||
|
max=50,
|
||||||
|
error=_l('Position must be 50 characters or less.'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NoteContentSchema(StripMixin):
|
||||||
|
"""Bounded text stored as a team or personal coaching note."""
|
||||||
|
|
||||||
|
content = fields.String(
|
||||||
|
required=True,
|
||||||
|
validate=validate.Length(
|
||||||
|
min=1,
|
||||||
|
max=5000,
|
||||||
|
error=_l('Note content must be between 1 and 5000 characters.'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PersonalNoteSchema(NoteContentSchema):
|
||||||
|
"""A personal note with at most one optional, typed context."""
|
||||||
|
|
||||||
|
player_id = fields.Integer(
|
||||||
|
required=True,
|
||||||
|
validate=validate.Range(min=1),
|
||||||
|
error_messages={
|
||||||
|
'invalid': _l('Invalid player selection.'),
|
||||||
|
'required': _l('Player must be selected.'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
match_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
|
||||||
|
tryout_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
|
||||||
|
team_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
|
||||||
|
|
||||||
|
@validates_schema
|
||||||
|
def validate_one_context(self, data, **kwargs):
|
||||||
|
"""A note cannot claim several unrelated contexts at once."""
|
||||||
|
contexts = [data.get(name) for name in ('match_id', 'tryout_id', 'team_id')]
|
||||||
|
if sum(value is not None for value in contexts) > 1:
|
||||||
|
raise ValidationError(
|
||||||
|
_l('Select at most one note context.'),
|
||||||
|
field_name='context',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OneOnOneRejectionSchema(StripMixin):
|
||||||
|
"""Optional explanation sent to a player when a request is rejected."""
|
||||||
|
|
||||||
|
rejection_reason = fields.String(
|
||||||
|
load_default='',
|
||||||
|
validate=validate.Length(
|
||||||
|
max=2000,
|
||||||
|
error=_l('Rejection reason must be 2000 characters or less.'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OneOnOneRequestSchema(StripMixin):
|
class OneOnOneRequestSchema(StripMixin):
|
||||||
"""A player asking their coach for a session (MNT-12).
|
"""A player asking their coach for a session (MNT-12).
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ Dans cet ordre, parce qu'ils dépendent tous de `DB-002` :
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DB-004` | Retirer `create_all()` de `create_app()` | Tant qu'il est là, deux mécanismes décrivent le schéma |
|
| `DB-004` | Retirer `create_all()` de `create_app()` | Tant qu'il est là, deux mécanismes décrivent le schéma |
|
||||||
| `DB-005` | Cascades de suppression au niveau base | Les cascades ORM sont en place ; PostgreSQL ne les connaît pas |
|
| `DB-005` | Cascades de suppression au niveau base | Les cascades ORM sont en place ; PostgreSQL ne les connaît pas |
|
||||||
| `DB-006` | Unicité sur `TryoutRegistration(tryout_id, player_id)` | Le plafond d'inscriptions est aujourd'hui un `count()` suivi d'un `add()` : deux requêtes simultanées passent toutes les deux |
|
| `DB-006` | Unicité sur `TryoutRegistration(tryout_id, player_id)` | Les deux routes verrouillent désormais la ligne `Tryout` avant le contrôle de doublon, le `count()` et l'`add()` : PostgreSQL sérialise donc leurs décisions de capacité. La contrainte reste nécessaire pour les scripts, imports et futurs chemins d'écriture qui ne passent pas par ces routes |
|
||||||
| `DB-007` | Index, `CheckConstraint` sur les statuts, `server_default` | — |
|
| `DB-007` | Index, `CheckConstraint` sur les statuts, `server_default` | — |
|
||||||
| `DB-008` | Trancher `attendance_confirmed` côté tryout | `discord_bot.py` écrit un attribut fantôme ; aujourd'hui journalisé en avertissement |
|
| `DB-008` | Trancher `attendance_confirmed` côté tryout | `discord_bot.py` écrit un attribut fantôme ; aujourd'hui journalisé en avertissement |
|
||||||
| `DB-009` | Horodatages avec fuseau | `datetime.utcnow` partout, déprécié en 3.12 |
|
| `DB-009` | Horodatages avec fuseau | `datetime.utcnow` partout, déprécié en 3.12 |
|
||||||
|
|||||||
+5
-5
@@ -282,11 +282,11 @@ have sent new contracts to a new tree and made the existing ones unreadable
|
|||||||
`logs/` and `backups/` were built from `os.getcwd()` too (OBS-006), and the
|
`logs/` and `backups/` were built from `os.getcwd()` too (OBS-006), and the
|
||||||
backup script kept its own copy of the document path — so it archived
|
backup script kept its own copy of the document path — so it archived
|
||||||
`./documents` no matter what `DOCUMENTS_ROOT` said. Following prerequisite 2
|
`./documents` no matter what `DOCUMENTS_ROOT` said. Following prerequisite 2
|
||||||
was therefore enough, on its own, to make every contract backup empty; the
|
was therefore enough, on its own, to make every contract backup empty. All
|
||||||
script prints `No documents directory…` and still exits 0, so a scheduled
|
three roots now come from `app/storage.py`, and the backup run prints the
|
||||||
task watching the exit code would have seen green indefinitely. All three
|
document source it used. A missing or unarchivable document store makes the
|
||||||
roots now come from `app/storage.py`, and the backup run prints the document
|
run exit non-zero even when the database dump itself is valid, so a scheduler
|
||||||
source it used.
|
cannot report a database-only recovery point as a complete backup.
|
||||||
|
|
||||||
**After setting `DOCUMENTS_ROOT` on the node, run the backup once by hand**
|
**After setting `DOCUMENTS_ROOT` on the node, run the backup once by hand**
|
||||||
and check the `Document source:` line and the size of the resulting
|
and check the `Document source:` line and the size of the resulting
|
||||||
|
|||||||
+6
-3
@@ -21,11 +21,14 @@ 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]
|
||||||
|
# The exhaustive audit established a 71% baseline. Keep one point of margin
|
||||||
|
# for platform-specific branches while making any material regression fail CI.
|
||||||
|
fail_under = 70
|
||||||
|
show_missing = true
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py312"
|
target-version = "py312"
|
||||||
|
|||||||
@@ -119,3 +119,15 @@ class TestExitCodes:
|
|||||||
|
|
||||||
def test_verifying_a_missing_archive_fails(self, tmp_path):
|
def test_verifying_a_missing_archive_fails(self, tmp_path):
|
||||||
assert backup_module.main(['--verify-only', str(tmp_path / 'nope.dump')]) == 1
|
assert backup_module.main(['--verify-only', str(tmp_path / 'nope.dump')]) == 1
|
||||||
|
|
||||||
|
def test_a_missing_document_store_makes_an_otherwise_valid_run_incomplete(
|
||||||
|
self, monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
monkeypatch.setenv('DATABASE_URL', URL)
|
||||||
|
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'missing-documents'))
|
||||||
|
monkeypatch.setattr(backup_module, 'BACKUP_DIR', str(tmp_path / 'backups'))
|
||||||
|
monkeypatch.setattr(backup_module, 'backup_database', lambda conn: 'database.dump')
|
||||||
|
monkeypatch.setattr(backup_module, 'verify_backup', lambda path: True)
|
||||||
|
monkeypatch.setattr(backup_module, 'cleanup_old_backups', lambda: None)
|
||||||
|
|
||||||
|
assert backup_module.main([]) == 1
|
||||||
|
|||||||
@@ -150,9 +150,10 @@ def _pending(bot, message_id, row_id):
|
|||||||
|
|
||||||
|
|
||||||
def _confirmed(row_id):
|
def _confirmed(row_id):
|
||||||
|
from app.extensions import db
|
||||||
from app.models import MatchParticipant
|
from app.models import MatchParticipant
|
||||||
|
|
||||||
return MatchParticipant.query.get(row_id).attendance_confirmed
|
return db.session.get(MatchParticipant, row_id).attendance_confirmed
|
||||||
|
|
||||||
|
|
||||||
class TestTheDatabaseRefusedTheWrite:
|
class TestTheDatabaseRefusedTheWrite:
|
||||||
|
|||||||
+33
-1
@@ -31,6 +31,14 @@ INLINE_HANDLER = re.compile(
|
|||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# An event attribute assembled inside a JavaScript string is absent from the
|
||||||
|
# template DOM, so the expression above cannot see it. Once assigned through
|
||||||
|
# innerHTML it is still an inline handler and the CSP still refuses to run it.
|
||||||
|
DYNAMIC_INLINE_HANDLER = re.compile(
|
||||||
|
r'''["']on(?:click|change|submit|input|load|keyup|keydown|mouseover|focus|blur)\s*=''',
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
#: Remaining inline handlers, per template. Lower these as you migrate;
|
#: Remaining inline handlers, per template. Lower these as you migrate;
|
||||||
#: never raise one. Templates absent from this map must have none.
|
#: never raise one. Templates absent from this map must have none.
|
||||||
#: No template may carry an inline event handler. The migration is done;
|
#: No template may carry an inline event handler. The migration is done;
|
||||||
@@ -52,7 +60,8 @@ def _templates():
|
|||||||
|
|
||||||
def _count_handlers(path):
|
def _count_handlers(path):
|
||||||
with open(path, encoding='utf-8') as handle:
|
with open(path, encoding='utf-8') as handle:
|
||||||
return len(INLINE_HANDLER.findall(handle.read()))
|
content = handle.read()
|
||||||
|
return len(INLINE_HANDLER.findall(content)) + len(DYNAMIC_INLINE_HANDLER.findall(content))
|
||||||
|
|
||||||
|
|
||||||
class TestPolicyHeader:
|
class TestPolicyHeader:
|
||||||
@@ -102,6 +111,29 @@ class TestPolicyHeader:
|
|||||||
|
|
||||||
|
|
||||||
class TestInlineHandlerRatchet:
|
class TestInlineHandlerRatchet:
|
||||||
|
def test_a_handler_built_inside_a_javascript_string_is_counted(self, tmp_path):
|
||||||
|
template = tmp_path / 'dynamic-handler.html'
|
||||||
|
template.write_text("html += '<button onclick=\"work()\">';", encoding='utf-8')
|
||||||
|
|
||||||
|
assert _count_handlers(template) == 1
|
||||||
|
|
||||||
|
def test_dynamic_player_names_are_escaped_before_html_insertion(self):
|
||||||
|
template = os.path.join(TEMPLATE_ROOT, 'pages', 'match_form.html')
|
||||||
|
with open(template, encoding='utf-8') as handle:
|
||||||
|
content = handle.read()
|
||||||
|
|
||||||
|
assert 'html += playerName;' not in content
|
||||||
|
assert "' + playerName + '" not in content
|
||||||
|
assert content.count('escapeHtml(playerName)') == 6
|
||||||
|
|
||||||
|
def test_api_messages_are_written_as_text(self):
|
||||||
|
template = os.path.join(TEMPLATE_ROOT, 'pages', 'coach_availability.html')
|
||||||
|
with open(template, encoding='utf-8') as handle:
|
||||||
|
content = handle.read()
|
||||||
|
|
||||||
|
assert 'text.textContent = message' in content
|
||||||
|
assert "alert.innerHTML = '<span>' + message" not in content
|
||||||
|
|
||||||
@pytest.mark.parametrize('relative,full', list(_templates()))
|
@pytest.mark.parametrize('relative,full', list(_templates()))
|
||||||
def test_a_template_never_gains_an_inline_handler(self, relative, full):
|
def test_a_template_never_gains_an_inline_handler(self, relative, full):
|
||||||
allowed = HANDLER_BUDGET.get(relative, 0)
|
allowed = HANDLER_BUDGET.get(relative, 0)
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ in the project directory.
|
|||||||
|
|
||||||
The document store was fixed in wave G. The other two were not, and the gap
|
The document store was fixed in wave G. The other two were not, and the gap
|
||||||
that opened between them is the reason this file exists: `backup.py` kept
|
that opened between them is the reason this file exists: `backup.py` kept
|
||||||
archiving `./documents` while the application wrote to `DOCUMENTS_ROOT`, and
|
archiving `./documents` while the application wrote to `DOCUMENTS_ROOT`.
|
||||||
the script's answer to a missing directory is to print a line and exit 0.
|
The script now resolves the shared root and fails the run when it is absent.
|
||||||
Following the deployment documentation was what broke it.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -106,16 +105,15 @@ class TestTheBackupScriptAgreesWithTheApplication:
|
|||||||
with zipfile.ZipFile(archive) as zf:
|
with zipfile.ZipFile(archive) as zf:
|
||||||
assert any(name.endswith('contrat.pdf') for name in zf.namelist())
|
assert any(name.endswith('contrat.pdf') for name in zf.namelist())
|
||||||
|
|
||||||
def test_a_missing_store_names_the_path_it_looked_in(self, tmp_path, monkeypatch, capsys):
|
def test_a_missing_store_names_the_path_it_looked_in(self, tmp_path, monkeypatch):
|
||||||
""" "No documents directory found" read as "there are no documents"
|
"""A missing configured store is an actionable failure, not a skip."""
|
||||||
rather than "I am looking in the wrong place"."""
|
|
||||||
from app.supporting_scripts import backup
|
from app.supporting_scripts import backup
|
||||||
|
|
||||||
missing = tmp_path / 'not-here'
|
missing = tmp_path / 'not-here'
|
||||||
monkeypatch.setenv('DOCUMENTS_ROOT', str(missing))
|
monkeypatch.setenv('DOCUMENTS_ROOT', str(missing))
|
||||||
|
|
||||||
assert backup.backup_documents() is None
|
with pytest.raises(backup.BackupError, match=str(missing).replace('\\', '\\\\')):
|
||||||
assert str(missing) in capsys.readouterr().out
|
backup.backup_documents()
|
||||||
|
|
||||||
|
|
||||||
class TestLogsFollowTheSameRule:
|
class TestLogsFollowTheSameRule:
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Regression tests for compact POST forms that bypassed the shared schemas."""
|
||||||
|
|
||||||
|
from datetime import date, time
|
||||||
|
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
Match,
|
||||||
|
OneOnOneRequest,
|
||||||
|
OrgTeam,
|
||||||
|
PersonalNote,
|
||||||
|
Team,
|
||||||
|
TeamMember,
|
||||||
|
TeamPlayer,
|
||||||
|
Tryout,
|
||||||
|
TryoutRegistration,
|
||||||
|
UserGamertag,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tryout(db, owner_id, *, coach_id=None):
|
||||||
|
row = Tryout(
|
||||||
|
title='Boundary tryout',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 4, 1),
|
||||||
|
created_by=owner_id,
|
||||||
|
coach_id=coach_id,
|
||||||
|
)
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.commit()
|
||||||
|
return row.id
|
||||||
|
|
||||||
|
|
||||||
|
def _give_coach_a_player(db, coach_id, player_id, owner_id):
|
||||||
|
org_team = OrgTeam(
|
||||||
|
name=f'Org {coach_id}-{player_id}',
|
||||||
|
created_by=owner_id,
|
||||||
|
coach_id=coach_id,
|
||||||
|
)
|
||||||
|
db.session.add(org_team)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(TeamPlayer(org_team_id=org_team.id, player_id=player_id))
|
||||||
|
db.session.commit()
|
||||||
|
return org_team.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_tryout_team_name_is_bounded(app, client, as_role):
|
||||||
|
admin_id = as_role('admin')
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout_id = _tryout(db, admin_id)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f'/tryouts/{tryout_id}/team/create',
|
||||||
|
data={'team_name': 'x' * 101},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
with app.app_context():
|
||||||
|
assert Team.query.filter_by(tryout_id=tryout_id).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_registration_decisions_lock_the_tryout_row():
|
||||||
|
from app.routes.tryouts import registration_lock_statement
|
||||||
|
|
||||||
|
sql = str(registration_lock_statement(42).compile(dialect=postgresql.dialect()))
|
||||||
|
|
||||||
|
assert 'FOR UPDATE' in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_tryout_team_position_is_bounded(app, client, as_role, make_user):
|
||||||
|
admin_id = as_role('admin')
|
||||||
|
player_id = make_user('player')
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout_id = _tryout(db, admin_id)
|
||||||
|
team = Team(tryout_id=tryout_id, name='Blue', created_by=admin_id)
|
||||||
|
db.session.add(team)
|
||||||
|
db.session.flush()
|
||||||
|
team_id = team.id
|
||||||
|
db.session.add(TryoutRegistration(tryout_id=tryout_id, player_id=player_id))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f'/tryouts/{tryout_id}/team/{team_id}/add',
|
||||||
|
data={'player_id': player_id, 'position': 'x' * 51},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
with app.app_context():
|
||||||
|
assert TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_coach_cannot_open_an_unrelated_tryout_note_form(app, client, as_role, make_user):
|
||||||
|
coach_id = as_role('coach')
|
||||||
|
other_coach_id = make_user('coach')
|
||||||
|
admin_id = make_user('admin')
|
||||||
|
player_id = make_user('player')
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout_id = _tryout(db, admin_id, coach_id=other_coach_id)
|
||||||
|
db.session.add(TryoutRegistration(tryout_id=tryout_id, player_id=player_id))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
response = client.get(f'/users/personal-notes/tryout/{tryout_id}')
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.headers['Location'].endswith('/users/notes-dashboard')
|
||||||
|
assert coach_id != other_coach_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_note_cannot_claim_a_team_that_does_not_contain_the_player(
|
||||||
|
app, client, as_role, make_user
|
||||||
|
):
|
||||||
|
coach_id = as_role('coach')
|
||||||
|
admin_id = make_user('admin')
|
||||||
|
player_id = make_user('player')
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
_give_coach_a_player(db, coach_id, player_id, admin_id)
|
||||||
|
tryout_id = _tryout(db, admin_id, coach_id=coach_id)
|
||||||
|
team = Team(tryout_id=tryout_id, name='No player here', created_by=admin_id)
|
||||||
|
db.session.add(team)
|
||||||
|
db.session.commit()
|
||||||
|
team_id = team.id
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/users/personal-notes/add',
|
||||||
|
data={'player_id': player_id, 'content': 'Private note', 'team_id': team_id},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
with app.app_context():
|
||||||
|
assert PersonalNote.query.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_personal_note_is_bounded(app, client, as_role, make_user):
|
||||||
|
coach_id = as_role('coach')
|
||||||
|
admin_id = make_user('admin')
|
||||||
|
player_id = make_user('player')
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
_give_coach_a_player(db, coach_id, player_id, admin_id)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/users/personal-notes/manage',
|
||||||
|
data={'player_id': player_id, 'content': 'x' * 5001},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
with app.app_context():
|
||||||
|
assert PersonalNote.query.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rejection_reason_is_bounded(app, client, as_role, make_user):
|
||||||
|
coach_id = as_role('coach')
|
||||||
|
player_id = make_user('player')
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
request = OneOnOneRequest(
|
||||||
|
player_id=player_id,
|
||||||
|
coach_id=coach_id,
|
||||||
|
date=date(2030, 4, 2),
|
||||||
|
start_time=time(18, 0),
|
||||||
|
end_time=time(18, 30),
|
||||||
|
)
|
||||||
|
db.session.add(request)
|
||||||
|
db.session.commit()
|
||||||
|
request_id = request.id
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f'/users/one-on-one/{request_id}/reject',
|
||||||
|
data={'rejection_reason': 'x' * 2001},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
with app.app_context():
|
||||||
|
assert db.session.get(OneOnOneRequest, request_id).status == 'pending'
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_match_context_must_contain_the_player(app, client, as_role, make_user):
|
||||||
|
coach_id = as_role('coach')
|
||||||
|
admin_id = make_user('admin')
|
||||||
|
player_id = make_user('player')
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
_give_coach_a_player(db, coach_id, player_id, admin_id)
|
||||||
|
tryout_id = _tryout(db, admin_id, coach_id=coach_id)
|
||||||
|
match = Match(
|
||||||
|
tryout_id=tryout_id,
|
||||||
|
title='Scrim',
|
||||||
|
date=date(2030, 4, 2),
|
||||||
|
match_type='player_vs_player',
|
||||||
|
created_by=coach_id,
|
||||||
|
)
|
||||||
|
db.session.add(match)
|
||||||
|
db.session.commit()
|
||||||
|
match_id = match.id
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/users/personal-notes/add',
|
||||||
|
data={'player_id': player_id, 'content': 'Private note', 'match_id': match_id},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
with app.app_context():
|
||||||
|
assert PersonalNote.query.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_note_dashboard_lists_tryout_teams_not_org_teams(app, client, as_role, make_user):
|
||||||
|
coach_id = as_role('coach')
|
||||||
|
admin_id = make_user('admin')
|
||||||
|
player_id = make_user('player')
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
_give_coach_a_player(db, coach_id, player_id, admin_id)
|
||||||
|
tryout_id = _tryout(db, admin_id, coach_id=coach_id)
|
||||||
|
team = Team(tryout_id=tryout_id, name='Tryout Alpha', created_by=admin_id)
|
||||||
|
db.session.add(team)
|
||||||
|
db.session.commit()
|
||||||
|
team_id = team.id
|
||||||
|
|
||||||
|
body = client.get('/users/notes-dashboard').get_data(as_text=True)
|
||||||
|
|
||||||
|
assert f'<option value="{team_id}">Tryout Alpha</option>' in body
|
||||||
|
assert f'>Org {coach_id}-{player_id}</option>' not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_oversized_dynamic_gamertag_is_rejected(app, client, as_role):
|
||||||
|
player_id = as_role('player')
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/users/profile/edit',
|
||||||
|
data={
|
||||||
|
'username': 'player1',
|
||||||
|
'full_name': 'Player One',
|
||||||
|
'email': '[email protected]',
|
||||||
|
'games': 'Valorant',
|
||||||
|
'gamertag_Valorant': 'x' * 121,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
with app.app_context():
|
||||||
|
assert UserGamertag.query.filter_by(user_id=player_id).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_platform_must_belong_to_the_selected_game(app, client, as_role):
|
||||||
|
player_id = as_role('player')
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/users/profile/edit',
|
||||||
|
data={
|
||||||
|
'username': 'player1',
|
||||||
|
'full_name': 'Player One',
|
||||||
|
'email': '[email protected]',
|
||||||
|
'games': 'Apex Legends',
|
||||||
|
'gamertag_Apex Legends': 'LegitName',
|
||||||
|
'platform_Apex Legends': 'Forged platform',
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
with app.app_context():
|
||||||
|
assert UserGamertag.query.filter_by(user_id=player_id).count() == 0
|
||||||
@@ -233,6 +233,114 @@ class TestPendingEvaluations:
|
|||||||
assert self._pending(client) == 1
|
assert self._pending(client) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegisteredPlayersForMatchForm:
|
||||||
|
"""The match forms used to issue one or two user lookups per registration."""
|
||||||
|
|
||||||
|
def test_the_result_is_unique_ordered_and_constant_cost(self, app, make_user, count_queries):
|
||||||
|
admin_id = make_user('admin')
|
||||||
|
player_ids = [make_user('player', username=name) for name in ('zulu', 'alpha', 'mike')]
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(
|
||||||
|
title='Match form',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 3, 1),
|
||||||
|
created_by=admin_id,
|
||||||
|
)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.flush()
|
||||||
|
for player_id in player_ids:
|
||||||
|
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
|
||||||
|
# DB-006 is pending, so prove the UI remains unique even when the
|
||||||
|
# current database already contains a duplicate registration.
|
||||||
|
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_ids[0]))
|
||||||
|
db.session.commit()
|
||||||
|
tryout_id = tryout.id
|
||||||
|
|
||||||
|
from app.routes.matches import registered_players
|
||||||
|
|
||||||
|
counter = count_queries()
|
||||||
|
try:
|
||||||
|
players = registered_players(tryout_id)
|
||||||
|
finally:
|
||||||
|
counter.stop()
|
||||||
|
|
||||||
|
assert [player.username for player in players] == ['alpha', 'mike', 'zulu']
|
||||||
|
assert counter.total == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvaluationLists:
|
||||||
|
"""Evaluation pages must not issue one lookup per player or evaluator."""
|
||||||
|
|
||||||
|
def test_players_to_evaluate_has_a_fixed_query_budget(
|
||||||
|
self, app, client, as_role, make_user, count_queries
|
||||||
|
):
|
||||||
|
coach_id = as_role('coach')
|
||||||
|
admin_id = make_user('admin')
|
||||||
|
player_ids = [make_user('player') for _ in range(12)]
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(
|
||||||
|
title='Evaluation budget',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 3, 1),
|
||||||
|
created_by=admin_id,
|
||||||
|
coach_id=coach_id,
|
||||||
|
)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.flush()
|
||||||
|
for player_id in player_ids:
|
||||||
|
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
|
||||||
|
db.session.commit()
|
||||||
|
tryout_id = tryout.id
|
||||||
|
|
||||||
|
counter = count_queries()
|
||||||
|
try:
|
||||||
|
response = client.get(f'/evaluations/{tryout_id}/players')
|
||||||
|
finally:
|
||||||
|
counter.stop()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 1 <= counter.total <= 10, f'{counter.total} SELECTs for 12 players'
|
||||||
|
|
||||||
|
def test_scout_top_players_are_loaded_with_the_aggregate(
|
||||||
|
self, app, client, as_role, make_user, count_queries
|
||||||
|
):
|
||||||
|
as_role('scout')
|
||||||
|
evaluator_id = make_user('coach')
|
||||||
|
admin_id = make_user('admin')
|
||||||
|
player_ids = [make_user('player') for _ in range(12)]
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(
|
||||||
|
title='Scout budget',
|
||||||
|
game='Valorant',
|
||||||
|
date=date(2030, 3, 1),
|
||||||
|
created_by=admin_id,
|
||||||
|
)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.flush()
|
||||||
|
for score, player_id in enumerate(player_ids, start=1):
|
||||||
|
db.session.add(
|
||||||
|
Evaluation(
|
||||||
|
player_id=player_id,
|
||||||
|
evaluator_id=evaluator_id,
|
||||||
|
tryout_id=tryout.id,
|
||||||
|
overall_score=score,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
counter = count_queries()
|
||||||
|
try:
|
||||||
|
response = client.get('/dashboard')
|
||||||
|
finally:
|
||||||
|
counter.stop()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 1 <= counter.total <= 6, f'{counter.total} SELECTs for the scout dashboard'
|
||||||
|
|
||||||
|
|
||||||
class TestViewTryout:
|
class TestViewTryout:
|
||||||
"""PERF-001 — the most-visited page in the application ran one query
|
"""PERF-001 — the most-visited page in the application ran one query
|
||||||
per registration, one per player evaluated, one per team, and one per
|
per registration, one per player evaluated, one per team, and one per
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""The security scanner must fail closed when its dependency audit cannot run."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
from app.supporting_scripts import security_scan
|
||||||
|
|
||||||
|
|
||||||
|
def _result(returncode, stdout='', stderr=''):
|
||||||
|
return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_mode_fails_the_environment_check(monkeypatch):
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'x' * 32)
|
||||||
|
monkeypatch.setenv('DATABASE_URL', 'sqlite:///:memory:')
|
||||||
|
monkeypatch.setenv('FLASK_DEBUG', 'true')
|
||||||
|
|
||||||
|
assert security_scan.check_environment() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_database_url_fails_the_environment_check(monkeypatch):
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'x' * 32)
|
||||||
|
monkeypatch.delenv('DATABASE_URL', raising=False)
|
||||||
|
monkeypatch.setenv('FLASK_DEBUG', 'false')
|
||||||
|
|
||||||
|
assert security_scan.check_environment() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreachable_http_target_cannot_report_success(monkeypatch):
|
||||||
|
def unreachable(*args, **kwargs):
|
||||||
|
raise urllib.error.URLError('connection refused')
|
||||||
|
|
||||||
|
monkeypatch.setattr(security_scan.urllib.request, 'urlopen', unreachable)
|
||||||
|
|
||||||
|
assert security_scan.check_https_headers('https://example.test') is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_clean_dependency_audit_passes(monkeypatch):
|
||||||
|
command = []
|
||||||
|
|
||||||
|
def clean_audit(args, **kwargs):
|
||||||
|
command.extend(args)
|
||||||
|
return _result(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
security_scan.subprocess,
|
||||||
|
'run',
|
||||||
|
clean_audit,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert security_scan.check_dependencies() is True
|
||||||
|
requirement_flag = command.index('--requirement')
|
||||||
|
assert command[requirement_flag + 1] == str(security_scan.REQUIREMENTS_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reported_vulnerabilities_fail_the_scan(monkeypatch):
|
||||||
|
report = {
|
||||||
|
'dependencies': [
|
||||||
|
{
|
||||||
|
'name': 'example',
|
||||||
|
'version': '1.0',
|
||||||
|
'vulns': [{'id': 'PYSEC-TEST'}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
security_scan.subprocess,
|
||||||
|
'run',
|
||||||
|
lambda *args, **kwargs: _result(1, stdout=json.dumps(report)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert security_scan.check_dependencies() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_audit_that_crashes_cannot_report_success(monkeypatch, capsys):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
security_scan.subprocess,
|
||||||
|
'run',
|
||||||
|
lambda *args, **kwargs: _result(1, stderr='audit unavailable'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert security_scan.check_dependencies() is False
|
||||||
|
assert '[FAIL] audit unavailable' in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_audit_timeout_cannot_report_success(monkeypatch):
|
||||||
|
def timeout(*args, **kwargs):
|
||||||
|
raise subprocess.TimeoutExpired('pip-audit', 60)
|
||||||
|
|
||||||
|
monkeypatch.setattr(security_scan.subprocess, 'run', timeout)
|
||||||
|
|
||||||
|
assert security_scan.check_dependencies() is False
|
||||||
@@ -135,9 +135,11 @@ class TestThroughTheUploadRoute:
|
|||||||
contract_id = Contract.query.one().id
|
contract_id = Contract.query.one().id
|
||||||
|
|
||||||
response = client.get(f'/users/contracts/{contract_id}/download')
|
response = client.get(f'/users/contracts/{contract_id}/download')
|
||||||
|
try:
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.data.startswith(b'%PDF-')
|
assert response.data.startswith(b'%PDF-')
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
|
||||||
|
|
||||||
def _pdf():
|
def _pdf():
|
||||||
|
|||||||
@@ -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 == []
|
||||||
Reference in New Issue
Block a user