style: formater le depot avec ruff format

QUA-002, premiere moitie. **Ce commit ne fait que reformater** : aucun
changement de comportement, aucune ligne de logique touchee. 72 fichiers,
4 restaient deja conformes. Il est isole exprès, pour que `git log -p` sur
les commits voisins reste lisible.

`quote-style = "preserve"` etait deja pose dans pyproject.toml, ce qui
evite le brassage guillemets simples / doubles : le diff porte sur les
retours a la ligne, l indentation des appels longs et les virgules
finales, pas sur le style de chaine.

Verification : 263 tests passent avant et apres, ruff check propre.

L activation en CI arrive dans le commit suivant, separement, pour que ce
diff-ci ne contienne rien d autre.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
GGThed
2026-08-08 15:53:10 -04:00
co-authored by Claude Opus 5
parent 2f40290f00
commit 7cec18c139
72 changed files with 2658 additions and 1449 deletions
+64 -52
View File
@@ -100,19 +100,21 @@ def build_csp(*, allow_inline_script, nonce=None):
else:
script_src = f"'self' 'nonce-{nonce}' https://cdn.jsdelivr.net"
return '; '.join([
"default-src 'self'",
f'script-src {script_src}',
# style-src is a separate migration: inline style="" attributes are
# spread across the templates and are not an XSS vector on their own.
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
"font-src 'self' https://cdnjs.cloudflare.com",
"img-src 'self' data: https://cdn.discordapp.com",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
])
return '; '.join(
[
"default-src 'self'",
f'script-src {script_src}',
# style-src is a separate migration: inline style="" attributes are
# spread across the templates and are not an XSS vector on their own.
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
"font-src 'self' https://cdnjs.cloudflare.com",
"img-src 'self' data: https://cdn.discordapp.com",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
]
)
def create_app(config=None):
@@ -170,12 +172,8 @@ def create_app(config=None):
# Side effects of create_app(), both on by default so that production and
# development behave exactly as before. Tests turn them off.
app.config['AUTO_CREATE_TABLES'] = (
os.getenv('AUTO_CREATE_TABLES', 'true').lower() == 'true'
)
app.config['ENABLE_DISCORD_BOT'] = (
os.getenv('ENABLE_DISCORD_BOT', 'true').lower() == 'true'
)
app.config['AUTO_CREATE_TABLES'] = os.getenv('AUTO_CREATE_TABLES', 'true').lower() == 'true'
app.config['ENABLE_DISCORD_BOT'] = os.getenv('ENABLE_DISCORD_BOT', 'true').lower() == 'true'
# --- caller overrides win ---------------------------------------------
if config:
@@ -198,7 +196,9 @@ def create_app(config=None):
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
# Secure session cookie settings
app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
app.config['SESSION_COOKIE_SECURE'] = (
os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
)
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout
@@ -245,12 +245,14 @@ def create_app(config=None):
@app.context_processor
def inject_csp_nonce():
return {'csp_nonce': '' if app.config['CSP_ALLOW_INLINE_SCRIPT']
else g.get('csp_nonce', '')}
return {
'csp_nonce': '' if app.config['CSP_ALLOW_INLINE_SCRIPT'] else g.get('csp_nonce', '')
}
@app.context_processor
def inject_locales():
from flask_babel import get_locale
return {
'current_locale': str(get_locale() or i18n.DEFAULT_LOCALE),
'supported_locales': i18n.SUPPORTED_LOCALES,
@@ -259,6 +261,7 @@ def create_app(config=None):
# Configure structured logging
from app.logging_config import configure_logging
configure_logging(app)
from app.routes.auth import auth_bp
@@ -303,8 +306,7 @@ def create_app(config=None):
response.headers['X-Frame-Options'] = 'DENY'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response.headers['Permissions-Policy'] = (
'camera=(), microphone=(), geolocation=(), '
'interest-cohort=(), payment=(), usb=()'
'camera=(), microphone=(), geolocation=(), interest-cohort=(), payment=(), usb=()'
)
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
response.headers['Content-Security-Policy'] = build_csp(
@@ -383,9 +385,11 @@ def create_app(config=None):
Returns:
Response: Rendered error page or JSON for API requests.
"""
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/coach-availability') or \
request.path.startswith('/users/api/'):
if (
request.path.startswith('/users/disponibilities')
or request.path.startswith('/users/coach-availability')
or request.path.startswith('/users/api/')
):
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
return render_template('errors/400.html', error=error), 400
@@ -399,10 +403,12 @@ def create_app(config=None):
Returns:
Response: Redirect to login for pages, JSON for API.
"""
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/api/'):
if request.path.startswith('/users/disponibilities') or request.path.startswith(
'/users/api/'
):
return jsonify({'error': 'Unauthorized'}), 401
from flask import flash as _flash
_flash('Please log in to access this page.', 'warning')
return redirect(url_for('auth.login'))
@@ -416,8 +422,9 @@ def create_app(config=None):
Returns:
Response: Rendered error page or JSON for API requests.
"""
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/api/'):
if request.path.startswith('/users/disponibilities') or request.path.startswith(
'/users/api/'
):
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
return render_template('errors/403.html', error=error), 403
@@ -431,8 +438,9 @@ def create_app(config=None):
Returns:
Response: Rendered error page or JSON for API requests.
"""
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/api/'):
if request.path.startswith('/users/disponibilities') or request.path.startswith(
'/users/api/'
):
return jsonify({'error': 'Not found'}), 404
return render_template('errors/404.html', error=error), 404
@@ -446,12 +454,12 @@ def create_app(config=None):
Returns:
Response: JSON error for API or rendered page.
"""
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/api/'):
return jsonify({
'error': 'Too many requests',
'message': 'Please try again later.'
}), 429
if request.path.startswith('/users/disponibilities') or request.path.startswith(
'/users/api/'
):
return jsonify(
{'error': 'Too many requests', 'message': 'Please try again later.'}
), 429
return render_template('errors/429.html', error=error), 429
@app.errorhandler(500)
@@ -472,12 +480,15 @@ def create_app(config=None):
# Roll back any failed database session
db.session.rollback()
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/api/'):
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred. Please try again later.'
}), 500
if request.path.startswith('/users/disponibilities') or request.path.startswith(
'/users/api/'
):
return jsonify(
{
'error': 'Internal server error',
'message': 'An unexpected error occurred. Please try again later.',
}
), 500
return render_template('errors/500.html'), 500
@app.errorhandler(HTTPException)
@@ -490,13 +501,12 @@ def create_app(config=None):
Returns:
Response: JSON error for API, re-raises for others.
"""
if request.path.startswith('/users/disponibilities') or \
request.path.startswith('/users/api/'):
return jsonify({
'error': error.name,
'message': error.description,
'code': error.code
}), error.code
if request.path.startswith('/users/disponibilities') or request.path.startswith(
'/users/api/'
):
return jsonify(
{'error': error.name, 'message': error.description, 'code': error.code}
), error.code
return error
# =========================================================================
@@ -504,6 +514,7 @@ def create_app(config=None):
# =========================================================================
with app.app_context():
import app.models as models # noqa: F401 — registers all models with SQLAlchemy
# NOTE: create_all() only ever creates missing tables. It never adds a
# column to an existing one, so a model change is silently absent from
# any database that already has the table. Replacing this with Alembic
@@ -515,6 +526,7 @@ def create_app(config=None):
if app.config['ENABLE_DISCORD_BOT']:
try:
from app.discord_bot import start_bot
start_bot(flask_app=app)
except Exception as e:
app.logger.warning('Could not start Discord bot: %s', e)
+281 -181
View File
@@ -28,19 +28,21 @@ DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
logger = logging.getLogger(__name__)
# File for persisting pending requests across bot restarts
PENDING_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'discord_pending.json')
PENDING_FILE = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'discord_pending.json'
)
# Emoji constants
CHECK_EMOJI = '' # Green checkmark
CROSS_EMOJI = '' # Red X
CROSS_EMOJI = '' # Red X
class TeamTryoutsBot(commands.Bot):
"""Unified Discord bot for Team Tryouts notifications.
Handles One on One requests, schedule additions, and daily reminders.
"""
def __init__(self, flask_app=None):
# Only what the bot actually reads. `members` — the privileged
# GUILD_MEMBERS intent — was requested and never used: nothing here
@@ -62,7 +64,7 @@ class TeamTryoutsBot(commands.Bot):
self.message_queue = Queue() # Thread-safe queue for messages from Flask
self.scheduler = AsyncIOScheduler()
self.timezone = ZoneInfo('America/Toronto') # EDT timezone
def _load_pending(self):
"""Load pending requests from the JSON file."""
try:
@@ -71,12 +73,14 @@ class TeamTryoutsBot(commands.Bot):
data = json.load(f)
# Convert string keys back to int
self.pending_requests = {int(k): v for k, v in data.items()}
logger.info(f"Loaded {len(self.pending_requests)} pending requests from {PENDING_FILE}")
logger.info(
f"Loaded {len(self.pending_requests)} pending requests from {PENDING_FILE}"
)
else:
logger.info("No pending requests file found, starting fresh.")
except Exception as e:
logger.error(f"Error loading pending requests: {e}")
def _save_pending(self):
"""Save pending requests to the JSON file."""
try:
@@ -84,12 +88,12 @@ class TeamTryoutsBot(commands.Bot):
json.dump(self.pending_requests, f, indent=2)
except Exception as e:
logger.error(f"Error saving pending requests: {e}")
async def setup_hook(self):
"""Called when the bot is ready."""
self._load_pending()
logger.info(f'TeamTryoutsBot logged in as {self.user}')
async def on_ready(self):
"""Log when the bot is ready and start background tasks."""
try:
@@ -97,13 +101,13 @@ class TeamTryoutsBot(commands.Bot):
logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user} | Guilds: {guilds}')
except Exception:
logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user}')
# Start the queue processing task
self.loop.create_task(self.process_queue())
# Start the daily reminder scheduler
self.loop.create_task(self.start_scheduler())
async def start_scheduler(self):
"""Start the APScheduler for daily reminders."""
try:
@@ -111,13 +115,13 @@ class TeamTryoutsBot(commands.Bot):
self.send_daily_reminders,
trigger=CronTrigger(hour=18, minute=0, timezone=self.timezone),
id='daily_reminders',
replace_existing=True
replace_existing=True,
)
self.scheduler.start()
logger.info('Daily reminder scheduler started (18:00 EDT)')
except Exception as e:
logger.error(f'Error starting scheduler: {e}')
async def process_queue(self):
"""Process messages from the queue (runs continuously)."""
while True:
@@ -127,7 +131,7 @@ class TeamTryoutsBot(commands.Bot):
except Empty:
await asyncio.sleep(0.5)
continue
if item.get('type') == 'one_on_one_request':
await self._send_one_on_one_dm(**item['data'])
elif item.get('type') == 'schedule_addition':
@@ -138,91 +142,116 @@ class TeamTryoutsBot(commands.Bot):
await self._send_schedule_notification(**item['data'])
elif item.get('type') == 'one_on_one_response':
await self._send_one_on_one_response_dm(**item['data'])
except Exception as e:
logger.error(f"Error processing queue: {e}\n{traceback.format_exc()}")
await asyncio.sleep(0.1)
def is_dm_channel(self, channel) -> bool:
"""Check if a channel is a DM channel."""
return hasattr(channel, 'recipient') or hasattr(channel, 'recipients')
async def on_raw_reaction_add(self, payload):
"""Handle when a reaction is added to a message (works even after bot restart)."""
# Ignore bot's own reactions
if payload.user_id == self.user.id:
return
# Check if this is a pending request we're tracking
if payload.message_id not in self.pending_requests:
return
# Fetch the channel and check if it's a DM
try:
channel = await self.fetch_channel(payload.channel_id)
except Exception:
return
if not self.is_dm_channel(channel):
return
# Fetch the user who reacted
try:
user = await self.fetch_user(payload.user_id)
except Exception:
return
if user is None:
return
request_info = self.pending_requests[payload.message_id]
emoji_str = str(payload.emoji)
handler_type = request_info.get('type')
request_id = request_info.get('id')
if emoji_str == CHECK_EMOJI:
if handler_type == 'one_on_one':
if self.flask_app:
with self.flask_app.app_context():
await self.handle_one_on_one_approve(user, payload.message_id, request_id, channel)
await self.handle_one_on_one_approve(
user, payload.message_id, request_id, channel
)
else:
await self.handle_one_on_one_approve(user, payload.message_id, request_id, channel)
await self.handle_one_on_one_approve(
user, payload.message_id, request_id, channel
)
elif handler_type == 'schedule_addition':
if self.flask_app:
with self.flask_app.app_context():
await self.handle_attendance_confirm(user, payload.message_id, request_id, channel)
await self.handle_attendance_confirm(
user, payload.message_id, request_id, channel
)
else:
await self.handle_attendance_confirm(user, payload.message_id, request_id, channel)
await self.handle_attendance_confirm(
user, payload.message_id, request_id, channel
)
elif emoji_str == CROSS_EMOJI:
if handler_type == 'one_on_one':
if self.flask_app:
with self.flask_app.app_context():
await self.handle_one_on_one_reject(user, payload.message_id, request_id, channel)
await self.handle_one_on_one_reject(
user, payload.message_id, request_id, channel
)
else:
await self.handle_one_on_one_reject(user, payload.message_id, request_id, channel)
await self.handle_one_on_one_reject(
user, payload.message_id, request_id, channel
)
elif handler_type == 'schedule_addition':
if self.flask_app:
with self.flask_app.app_context():
await self.handle_attendance_decline(user, payload.message_id, request_id, channel)
await self.handle_attendance_decline(
user, payload.message_id, request_id, channel
)
else:
await self.handle_attendance_decline(user, payload.message_id, request_id, channel)
async def _send_one_on_one_dm(self, coach_name: str, coach_discord_id: str, player_name: str,
team_name: str, date_str: str, start_time: str, end_time: str,
points: str, request_id: int) -> int:
await self.handle_attendance_decline(
user, payload.message_id, request_id, channel
)
async def _send_one_on_one_dm(
self,
coach_name: str,
coach_discord_id: str,
player_name: str,
team_name: str,
date_str: str,
start_time: str,
end_time: str,
points: str,
request_id: int,
) -> int:
"""Send a One on One request DM to a coach with reactions."""
try:
user_id = int(coach_discord_id)
except (ValueError, TypeError):
logger.warning(f"Invalid coach_discord_id '{coach_discord_id}'")
return None
try:
user = await self.fetch_user(user_id)
if not user:
return None
message = (
"📅 **One on One Request**\n\n"
f"**Player:** {player_name}\n"
@@ -234,27 +263,33 @@ class TeamTryoutsBot(commands.Bot):
f"{CHECK_EMOJI} - Confirm the meeting\n"
f"{CROSS_EMOJI} - Decline (you can add a reason by replying before clicking)"
)
msg = await user.send(message)
await msg.add_reaction(CHECK_EMOJI)
await msg.add_reaction(CROSS_EMOJI)
# Track this pending request
self.pending_requests[msg.id] = {'type': 'one_on_one', 'id': request_id}
self._save_pending()
logger.info(f"Sent One on One DM with reactions, message_id={msg.id}")
return msg.id
except Exception as e:
logger.error(f"Error sending One on One DM: {e}")
return None
async def _send_schedule_notification(self, user_id: int, event_type: str,
event_title: str, event_date: str,
event_time: str, reference_id: int) -> int:
async def _send_schedule_notification(
self,
user_id: int,
event_type: str,
event_title: str,
event_date: str,
event_time: str,
reference_id: int,
) -> int:
"""Send a schedule addition notification to a player.
Args:
user_id: Database primary key of the User (NOT Discord user ID).
event_type: 'match' or 'tryout'.
@@ -266,23 +301,24 @@ class TeamTryoutsBot(commands.Bot):
try:
# Look up the DB user to get their Discord user ID
from app.models import User as DBUser
db_user = DBUser.query.get(user_id)
if not db_user:
logger.warning(f"DB user {user_id} not found for schedule notification")
return None
if not db_user.discord_user_id:
logger.warning(f"User {db_user.username} has no Discord user ID, cannot send DM")
return None
discord_uid = int(db_user.discord_user_id)
user = await self.fetch_user(discord_uid)
if not user:
logger.warning(f"Could not fetch Discord user {discord_uid}")
return None
event_name = "Match" if event_type == 'match' else "Tryout"
message = (
f"📅 **{event_name} Scheduled**\n\n"
f"You have been added to the following {event_type}:\n"
@@ -293,52 +329,58 @@ class TeamTryoutsBot(commands.Bot):
f"{CHECK_EMOJI} - Confirm attendance\n"
f"{CROSS_EMOJI} - Decline"
)
msg = await user.send(message)
await msg.add_reaction(CHECK_EMOJI)
await msg.add_reaction(CROSS_EMOJI)
# Track this pending request
self.pending_requests[msg.id] = {'type': 'schedule_addition', 'id': reference_id, 'event_type': event_type}
self.pending_requests[msg.id] = {
'type': 'schedule_addition',
'id': reference_id,
'event_type': event_type,
}
self._save_pending()
logger.info(f"Sent {event_type} schedule notification to {db_user.username}, message_id={msg.id}")
logger.info(
f"Sent {event_type} schedule notification to {db_user.username}, message_id={msg.id}"
)
return msg.id
except Exception as e:
logger.error(f"Error sending schedule notification: {e}")
return None
async def handle_one_on_one_approve(self, coach, message_id, request_id, channel):
"""Handle coach approving a One on One request."""
try:
from app.models import OneOnOneRequest
from app.extensions import db
request = OneOnOneRequest.query.get(request_id)
if not request:
return
if request.coach.discord_user_id != str(coach.id):
await channel.send("⚠️ You are not the intended recipient.")
return
# Re-attach to current session (object may be detached across app contexts)
request = db.session.merge(request)
# Capture data before commit
player_full_name = request.player.full_name if request.player else 'Unknown'
player_discord_id = request.player.discord_user_id if request.player else None
coach_obj = request.coach
request.status = 'approved'
request.responded_at = datetime.utcnow()
db.session.commit()
await channel.send(
f"✅ You have **approved** the One on One session with {player_full_name}."
)
# Notify player via Discord
if player_discord_id:
await self.notify_player_about_one_on_one_direct(
@@ -346,56 +388,62 @@ class TeamTryoutsBot(commands.Bot):
player_full_name=player_full_name,
coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
request=request,
approved=True
approved=True,
)
del self.pending_requests[message_id]
self._save_pending()
except Exception as e:
logger.error(f"Error handling approval: {e}\n{traceback.format_exc()}")
async def handle_one_on_one_reject(self, coach, message_id, request_id, channel):
"""Handle coach rejecting a One on One request."""
try:
from app.models import OneOnOneRequest
from app.extensions import db
request = OneOnOneRequest.query.get(request_id)
if not request:
return
if request.coach.discord_user_id != str(coach.id):
await channel.send("⚠️ You are not the intended recipient.")
return
# Re-attach to current session (object may be detached across app contexts)
request = db.session.merge(request)
player_full_name = request.player.full_name if request.player else 'Unknown'
player_discord_id = request.player.discord_user_id if request.player else None
coach_obj = request.coach
refusal_note = None
try:
async for reply in channel.history(limit=20):
if reply.author.id == coach.id and reply.reference and reply.reference.message_id == message_id:
if (
reply.author.id == coach.id
and reply.reference
and reply.reference.message_id == message_id
):
refusal_note = reply.content
break
except Exception as e:
logger.warning(f"Could not check for reply message: {e}")
request.status = 'rejected'
request.responded_at = datetime.utcnow()
if refusal_note:
request.coach_rejection_message = refusal_note
db.session.commit()
rejection_msg = f"❌ You have **rejected** the One on One session with {player_full_name}."
rejection_msg = (
f"❌ You have **rejected** the One on One session with {player_full_name}."
)
if refusal_note:
rejection_msg += f"\n**Reason:** {refusal_note}"
else:
rejection_msg += "\n\n️ The player has been notified that you are not available."
await channel.send(rejection_msg)
if player_discord_id:
await self.notify_player_about_one_on_one_direct(
@@ -404,23 +452,23 @@ class TeamTryoutsBot(commands.Bot):
coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
request=request,
approved=False,
refusal_note=refusal_note
refusal_note=refusal_note,
)
del self.pending_requests[message_id]
self._save_pending()
except Exception as e:
logger.error(f"Error handling rejection: {e}\n{traceback.format_exc()}")
async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
"""Handle player confirming attendance for a match/tryout."""
try:
from app.models import MatchParticipant, TryoutRegistration
from app.extensions import db
request_info = self.pending_requests[message_id]
event_type = request_info.get('event_type')
if event_type == 'match':
participant = MatchParticipant.query.get(reference_id)
if participant:
@@ -431,25 +479,25 @@ class TeamTryoutsBot(commands.Bot):
if registration:
registration = db.session.merge(registration)
registration.attendance_confirmed = True
db.session.commit()
await channel.send("✅ Your attendance has been confirmed!")
del self.pending_requests[message_id]
self._save_pending()
except Exception as e:
logger.error(f"Error handling attendance confirmation: {e}\n{traceback.format_exc()}")
async def handle_attendance_decline(self, player, message_id, reference_id, channel):
"""Handle player declining attendance for a match/tryout."""
try:
from app.models import MatchParticipant, TryoutRegistration
from app.extensions import db
request_info = self.pending_requests[message_id]
event_type = request_info.get('event_type')
if event_type == 'match':
participant = MatchParticipant.query.get(reference_id)
if participant:
@@ -460,24 +508,30 @@ class TeamTryoutsBot(commands.Bot):
if registration:
registration = db.session.merge(registration)
registration.status = 'no_show'
db.session.commit()
await channel.send("❌ Your attendance has been declined.")
del self.pending_requests[message_id]
self._save_pending()
except Exception as e:
logger.error(f"Error handling attendance decline: {e}\n{traceback.format_exc()}")
async def notify_player_about_one_on_one_direct(self, player_discord_id, player_full_name,
coach_full_name, request,
approved=True, refusal_note=None):
async def notify_player_about_one_on_one_direct(
self,
player_discord_id,
player_full_name,
coach_full_name,
request,
approved=True,
refusal_note=None,
):
"""Send confirmation to player about One on One response using pre-fetched data.
This method avoids session expiration issues by using data captured before
the database commit.
Args:
player_discord_id: The player's Discord user ID string.
player_full_name: The player's full name.
@@ -490,12 +544,12 @@ class TeamTryoutsBot(commands.Bot):
if not player_discord_id:
logger.warning(f"Player has no Discord user ID for request {request.id}")
return
player_user = await self.fetch_user(int(player_discord_id))
if not player_user:
logger.warning(f"Could not fetch Discord user {player_discord_id}")
return
if approved:
message = (
"🎉 **One on One Session Confirmed!**\n\n"
@@ -519,13 +573,15 @@ class TeamTryoutsBot(commands.Bot):
f"Your coach **{coach_full_name}** is not available.\n\n"
"Please try selecting a different time slot."
)
await player_user.send(message)
logger.info(f"Sent One on One notification to player {player_full_name} (request {request.id})")
logger.info(
f"Sent One on One notification to player {player_full_name} (request {request.id})"
)
except Exception as e:
logger.error(f"Error in direct One on One notification: {e}")
async def send_daily_reminders(self):
"""Send daily reminders at 18:00 EDT for events in 24-48 hours."""
try:
@@ -540,12 +596,18 @@ class TeamTryoutsBot(commands.Bot):
async def _send_daily_reminders_impl(self):
"""Internal implementation of daily reminders with proper app context."""
try:
from app.models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest
from app.models import (
Match,
Tryout,
MatchParticipant,
TryoutRegistration,
OneOnOneRequest,
)
from sqlalchemy.orm import joinedload
now = datetime.now(self.timezone)
tomorrow = now.date() + timedelta(days=1)
# Find matches for tomorrow
matches = Match.query.filter(Match.date == tomorrow).all()
for match in matches:
@@ -553,7 +615,7 @@ class TeamTryoutsBot(commands.Bot):
for participant in participants:
if participant.player.discord_user_id:
await self.send_match_reminder(participant.player, match)
# Find tryouts for tomorrow
tryouts = Tryout.query.filter(Tryout.date == tomorrow).all()
for tryout in tryouts:
@@ -561,22 +623,22 @@ class TeamTryoutsBot(commands.Bot):
for reg in registrations:
if reg.player.discord_user_id:
await self.send_tryout_reminder(reg.player, tryout)
# Find One on One sessions for tomorrow (only approved ones)
one_on_ones = OneOnOneRequest.query.options(
joinedload(OneOnOneRequest.player),
joinedload(OneOnOneRequest.coach)
).filter(
OneOnOneRequest.date == tomorrow,
OneOnOneRequest.status == 'approved'
).all()
one_on_ones = (
OneOnOneRequest.query.options(
joinedload(OneOnOneRequest.player), joinedload(OneOnOneRequest.coach)
)
.filter(OneOnOneRequest.date == tomorrow, OneOnOneRequest.status == 'approved')
.all()
)
for session in one_on_ones:
if session.player and session.player.discord_user_id:
await self.send_one_on_one_reminder(session.player, session)
except Exception as e:
logger.error(f"Error sending daily reminders: {e}")
async def send_match_reminder(self, player, match):
"""Send match reminder to player."""
try:
@@ -593,7 +655,7 @@ class TeamTryoutsBot(commands.Bot):
await player_user.send(message)
except Exception as e:
logger.error(f"Error sending match reminder: {e}")
async def send_tryout_reminder(self, player, tryout):
"""Send tryout reminder to player."""
try:
@@ -608,25 +670,33 @@ class TeamTryoutsBot(commands.Bot):
await player_user.send(message)
except Exception as e:
logger.error(f"Error sending tryout reminder: {e}")
async def _send_one_on_one_response_dm(self, player_discord_id: str, player_full_name: str,
coach_full_name: str, date_str: str, start_time: str,
end_time: str, points: str, approved: bool,
refusal_note: str = None) -> bool:
async def _send_one_on_one_response_dm(
self,
player_discord_id: str,
player_full_name: str,
coach_full_name: str,
date_str: str,
start_time: str,
end_time: str,
points: str,
approved: bool,
refusal_note: str = None,
) -> bool:
"""Send a DM to a player notifying them of their One on One request response.
Called from the message queue when a coach accepts/rejects via the web app.
"""
try:
if not player_discord_id:
logger.warning("Cannot send response DM: no player_discord_id")
return False
player_user = await self.fetch_user(int(player_discord_id))
if not player_user:
logger.warning(f"Could not fetch Discord user {player_discord_id}")
return False
if approved:
message = (
"🎉 **One on One Session Confirmed!**\n\n"
@@ -650,11 +720,13 @@ class TeamTryoutsBot(commands.Bot):
f"Your coach **{coach_full_name}** is not available.\n\n"
"Please try selecting a different time slot."
)
await player_user.send(message)
logger.info(f"Sent One on One response DM to player {player_full_name} (approved={approved})")
logger.info(
f"Sent One on One response DM to player {player_full_name} (approved={approved})"
)
return True
except Exception as e:
logger.error(f"Error sending One on One response DM: {e}")
return False
@@ -691,78 +763,105 @@ def get_bot(flask_app=None):
return bot_instance
def send_one_on_one_dm(coach_name: str, coach_discord_id: str, player_name: str,
team_name: str, date_str: str, start_time: str, end_time: str,
points: str, request_id: int) -> bool:
def send_one_on_one_dm(
coach_name: str,
coach_discord_id: str,
player_name: str,
team_name: str,
date_str: str,
start_time: str,
end_time: str,
points: str,
request_id: int,
) -> bool:
"""Queue a One on One request DM to be sent by the bot."""
bot = get_bot()
try:
bot.message_queue.put({
'type': 'one_on_one_request',
'data': {
'coach_name': coach_name,
'coach_discord_id': coach_discord_id,
'player_name': player_name,
'team_name': team_name,
'date_str': date_str,
'start_time': start_time,
'end_time': end_time,
'points': points,
'request_id': request_id
bot.message_queue.put(
{
'type': 'one_on_one_request',
'data': {
'coach_name': coach_name,
'coach_discord_id': coach_discord_id,
'player_name': player_name,
'team_name': team_name,
'date_str': date_str,
'start_time': start_time,
'end_time': end_time,
'points': points,
'request_id': request_id,
},
}
})
)
return True
except Exception as e:
logger.error(f"Error queuing One on One DM: {e}")
return False
def send_schedule_notification(user_id: int, event_type: str, event_title: str,
event_date: str, event_time: str, reference_id: int) -> bool:
def send_schedule_notification(
user_id: int,
event_type: str,
event_title: str,
event_date: str,
event_time: str,
reference_id: int,
) -> bool:
"""Queue a schedule addition notification to be sent by the bot."""
bot = get_bot()
try:
bot.message_queue.put({
'type': 'schedule_addition',
'data': {
'user_id': user_id,
'event_type': event_type,
'event_title': event_title,
'event_date': event_date,
'event_time': event_time,
'reference_id': reference_id
bot.message_queue.put(
{
'type': 'schedule_addition',
'data': {
'user_id': user_id,
'event_type': event_type,
'event_title': event_title,
'event_date': event_date,
'event_time': event_time,
'reference_id': reference_id,
},
}
})
)
return True
except Exception as e:
logger.error(f"Error queuing schedule notification: {e}")
return False
def send_one_on_one_response(player_discord_id: str, player_full_name: str,
coach_full_name: str, date_str: str, start_time: str,
end_time: str, points: str, approved: bool,
refusal_note: str = None) -> bool:
def send_one_on_one_response(
player_discord_id: str,
player_full_name: str,
coach_full_name: str,
date_str: str,
start_time: str,
end_time: str,
points: str,
approved: bool,
refusal_note: str = None,
) -> bool:
"""Queue a One on One response DM to be sent to the player by the bot.
Called from Flask routes when a coach accepts/rejects via the web app.
"""
bot = get_bot()
try:
bot.message_queue.put({
'type': 'one_on_one_response',
'data': {
'player_discord_id': player_discord_id,
'player_full_name': player_full_name,
'coach_full_name': coach_full_name,
'date_str': date_str,
'start_time': start_time,
'end_time': end_time,
'points': points,
'approved': approved,
'refusal_note': refusal_note,
bot.message_queue.put(
{
'type': 'one_on_one_response',
'data': {
'player_discord_id': player_discord_id,
'player_full_name': player_full_name,
'coach_full_name': coach_full_name,
'date_str': date_str,
'start_time': start_time,
'end_time': end_time,
'points': points,
'approved': approved,
'refusal_note': refusal_note,
},
}
})
)
return True
except Exception as e:
logger.error(f"Error queuing One on One response DM: {e}")
@@ -772,17 +871,18 @@ def send_one_on_one_response(player_discord_id: str, player_full_name: str,
def start_bot(flask_app=None):
"""Start the Discord bot in the background."""
global bot_thread
bot = get_bot(flask_app=flask_app)
if DISCORD_BOT_TOKEN and bot_thread is None:
def run_bot():
try:
bot.run(DISCORD_BOT_TOKEN)
except Exception as e:
logger.error(f"Bot error: {e}")
bot_thread = threading.Thread(target=run_bot, daemon=True)
bot_thread.start()
logger.info("TeamTryoutsBot started in background thread")
elif not DISCORD_BOT_TOKEN:
logger.warning("DISCORD_BOT_TOKEN not set, bot not started")
logger.warning("DISCORD_BOT_TOKEN not set, bot not started")
+6 -9
View File
@@ -18,19 +18,16 @@ csrf = CSRFProtect()
babel = Babel()
# Rate limiter for brute-force protection
limiter = Limiter(
key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"]
)
limiter = Limiter(key_func=get_remote_address, default_limits=["200 per day", "50 per hour"])
def hash_password(password):
"""
Hash a plain text password using werkzeug's security functions.
Args:
password (str): The plain text password to hash.
Returns:
str: The hashed password string.
"""
@@ -40,12 +37,12 @@ def hash_password(password):
def check_password(password_hash, password):
"""
Verify a password against its hash.
Args:
password_hash (str): The stored password hash.
password (str): The plain text password to verify.
Returns:
bool: True if the password matches the hash, False otherwise.
"""
return check_password_hash(password_hash, password)
return check_password_hash(password_hash, password)
+21 -12
View File
@@ -17,15 +17,25 @@ import re
class SensitiveDataFilter(logging.Filter):
"""Logging filter that redacts sensitive information from log messages.
Filters out: passwords, API keys, session tokens, and other secrets
that might accidentally be logged.
"""
# Patterns to redact
SENSITIVE_PATTERNS = [
(re.compile(r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;)]+', re.IGNORECASE), '[REDACTED]'),
(re.compile(r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*"[^"]*"', re.IGNORECASE), lambda m: m.group(0).split('=')[0] + '="[REDACTED]"'),
(
re.compile(
r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;)]+', re.IGNORECASE
),
'[REDACTED]',
),
(
re.compile(
r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*"[^"]*"', re.IGNORECASE
),
lambda m: m.group(0).split('=')[0] + '="[REDACTED]"',
),
(re.compile(r'Authorization[:\s]+[^\s]+', re.IGNORECASE), 'Authorization: [REDACTED]'),
(re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'),
]
@@ -61,14 +71,14 @@ class SensitiveDataFilter(logging.Filter):
def configure_logging(app):
"""Configure structured logging for the Flask application.
Sets up three rotating file handlers:
- errors.log: ERROR and CRITICAL level messages
- auth.log: Authentication-related events (INFO and above)
- app.log: All application logs (DEBUG and above, configurable)
Also configures console output for development.
Args:
app: The Flask application instance to configure logging for.
"""
@@ -88,8 +98,7 @@ def configure_logging(app):
# Formatter with timestamp, level, module, and message
formatter = logging.Formatter(
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s', datefmt='%Y-%m-%d %H:%M:%S'
)
# -------------------------------------------------------------------------
@@ -98,7 +107,7 @@ def configure_logging(app):
error_handler = RotatingFileHandler(
os.path.join(log_dir, 'errors.log'),
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=10
backupCount=10,
)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(formatter)
@@ -111,7 +120,7 @@ def configure_logging(app):
auth_handler = RotatingFileHandler(
os.path.join(log_dir, 'auth.log'),
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5
backupCount=5,
)
auth_handler.setLevel(logging.INFO)
auth_handler.setFormatter(formatter)
@@ -129,7 +138,7 @@ def configure_logging(app):
app_handler = RotatingFileHandler(
os.path.join(log_dir, 'app.log'),
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=10
backupCount=10,
)
app_handler.setLevel(log_level)
app_handler.setFormatter(formatter)
@@ -208,4 +217,4 @@ def log_auth_event(event, **fields):
parts.append('path=%s' % request.path)
parts.extend('%s=%s' % (key, value) for key, value in fields.items())
get_auth_logger().info(' '.join(parts))
get_auth_logger().info(' '.join(parts))
+1 -1
View File
@@ -92,4 +92,4 @@ from app.models.user_gamertag import UserGamertag
from app.models.contract import Contract
from app.models.team_note import TeamNote
from app.models.personal_note import PersonalNote
from app.models.one_on_one_request import OneOnOneRequest
from app.models.one_on_one_request import OneOnOneRequest
+30 -15
View File
@@ -3,23 +3,38 @@
from app.extensions import db
org_team_coaches = db.Table('org_team_coaches',
db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'),
primary_key=True),
db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
primary_key=True),
org_team_coaches = db.Table(
'org_team_coaches',
db.Column(
'org_team_id',
db.Integer,
db.ForeignKey('org_teams.id', ondelete='CASCADE'),
primary_key=True,
),
db.Column(
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
),
)
org_team_managers = db.Table('org_team_managers',
db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'),
primary_key=True),
db.Column('manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
primary_key=True),
org_team_managers = db.Table(
'org_team_managers',
db.Column(
'org_team_id',
db.Integer,
db.ForeignKey('org_teams.id', ondelete='CASCADE'),
primary_key=True,
),
db.Column(
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
),
)
tryout_coaches = db.Table('tryout_coaches',
db.Column('tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'),
primary_key=True),
db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
primary_key=True),
tryout_coaches = db.Table(
'tryout_coaches',
db.Column(
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
),
db.Column(
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
),
)
+2 -6
View File
@@ -52,11 +52,7 @@ PLATFORM_CODES = {
'Epic': 'epic',
}
PLATFORM_DEFAULTS = {
'Apex Legends': 'pc',
'Rainbow Six Siege': 'ubi',
'Rocket League': 'epic'
}
PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket League': 'epic'}
TRN_URLS = {
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
@@ -67,4 +63,4 @@ TRN_URLS = {
'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}',
'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}',
'Super Smash Bros.': 'https://tracker.gg/smash/profile/{username}',
}
}
+1 -1
View File
@@ -4,4 +4,4 @@ from app.models.availability.base import BaseAvailability
from app.models.availability.player_disponibility import PlayerDisponibility
from app.models.availability.coach_availability import CoachAvailability
__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability']
__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability']
+3 -1
View File
@@ -1,14 +1,16 @@
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
from app.extensions import db
from datetime import datetime
class BaseAvailability(db.Model):
"""Shared schema for player disponibilities and coach availabilities."""
__abstract__ = True
day_of_week = db.Column(db.Integer, nullable=False)
start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -1,12 +1,14 @@
"""Coach availability in 30-minute time blocks for One on One sessions."""
from app.extensions import db
from app.models.availability.base import BaseAvailability
class CoachAvailability(BaseAvailability):
"""Coach availability in 30-minute blocks for One on One sessions."""
__tablename__ = 'coach_availabilities'
id = db.Column(db.Integer, primary_key=True)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
coach = db.relationship('User', backref='coach_availabilities')
coach = db.relationship('User', backref='coach_availabilities')
@@ -1,12 +1,14 @@
"""Player availability in 30-minute time blocks."""
from app.extensions import db
from app.models.availability.base import BaseAvailability
class PlayerDisponibility(BaseAvailability):
"""Player availability in 30-minute blocks."""
__tablename__ = 'player_disponibilities'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
player = db.relationship('User', backref='disponibilities')
player = db.relationship('User', backref='disponibilities')
+3 -1
View File
@@ -1,10 +1,12 @@
"""Contract documents for players to sign."""
from app.extensions import db
from datetime import datetime
class Contract(db.Model):
"""Contract documents for players to sign."""
__tablename__ = 'contracts'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
@@ -62,4 +64,4 @@ class Contract(db.Model):
return False
def can_upload_signed(self, user):
return user.id == self.player_id
return user.id == self.player_id
+3 -1
View File
@@ -1,10 +1,12 @@
"""Player evaluation record."""
from app.extensions import db
from datetime import datetime
class Evaluation(db.Model):
"""Player evaluation record."""
__tablename__ = 'evaluations'
id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
@@ -27,4 +29,4 @@ class Evaluation(db.Model):
__table_args__ = (
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
)
)
+2 -1
View File
@@ -1,6 +1,7 @@
"""Match models — BaseMatch and its concrete subclasses."""
from app.models.match_model.base import BaseMatch
from app.models.match_model.match import Match
from app.models.match_model.team_match import TeamMatch
__all__ = ['BaseMatch', 'Match', 'TeamMatch']
__all__ = ['BaseMatch', 'Match', 'TeamMatch']
+3 -1
View File
@@ -1,10 +1,12 @@
"""Abstract base class for match models (Match + TeamMatch)."""
from app.extensions import db
from datetime import datetime
class BaseMatch(db.Model):
"""Shared schema for tryout-scoped matches and regular-season team matches."""
__abstract__ = True
title = db.Column(db.String(200), nullable=False)
@@ -15,4 +17,4 @@ class BaseMatch(db.Model):
location = db.Column(db.String(200), nullable=True)
status = db.Column(db.String(20), default='scheduled')
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
+5 -3
View File
@@ -1,10 +1,12 @@
"""Match / scrimmage within a tryout."""
from app.extensions import db
from app.models.match_model.base import BaseMatch
class Match(BaseMatch):
"""Match / scrimmage within a tryout."""
__tablename__ = 'matches'
id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
@@ -21,8 +23,8 @@ class Match(BaseMatch):
# deleting any match that had participants raised IntegrityError.
# TeamMatch.participants already declared this; Match did not.
participants = db.relationship(
'MatchParticipant', backref='match', lazy='dynamic',
cascade='all, delete-orphan')
'MatchParticipant', backref='match', lazy='dynamic', cascade='all, delete-orphan'
)
def get_participating_players(self):
return [p.player_id for p in self.participants.all()]
return [p.player_id for p in self.participants.all()]
+5 -3
View File
@@ -1,10 +1,12 @@
"""Regular-season match for an organisation team (not tied to a tryout)."""
from app.extensions import db
from app.models.match_model.base import BaseMatch
class TeamMatch(BaseMatch):
"""Regular-season match for an organisation team (not tied to a tryout)."""
__tablename__ = 'team_matches'
id = db.Column(db.Integer, primary_key=True)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
@@ -13,10 +15,10 @@ class TeamMatch(BaseMatch):
org_team = db.relationship('OrgTeam', backref='team_matches')
creator = db.relationship('User', backref='created_team_matches')
participants = db.relationship(
'TeamMatchParticipant', backref='team_match', lazy='dynamic',
cascade='all, delete-orphan')
'TeamMatchParticipant', backref='team_match', lazy='dynamic', cascade='all, delete-orphan'
)
def get_confirmed_count(self):
all_p = self.participants.all()
confirmed = sum(1 for p in all_p if p.is_confirmed)
return confirmed, len(all_p)
return confirmed, len(all_p)
+3 -1
View File
@@ -1,10 +1,12 @@
"""Request from player to coach for a One on One session."""
from app.extensions import db
from datetime import datetime
class OneOnOneRequest(db.Model):
"""Request from player to coach for a One on One session."""
__tablename__ = 'one_on_one_requests'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
@@ -22,4 +24,4 @@ class OneOnOneRequest(db.Model):
player = db.relationship('User', foreign_keys=[player_id], backref='one_on_one_requests')
coach = db.relationship('User', foreign_keys=[coach_id])
team = db.relationship('OrgTeam', foreign_keys=[org_team_id])
team = db.relationship('OrgTeam', foreign_keys=[org_team_id])
+2 -1
View File
@@ -1,5 +1,6 @@
"""Organisation team models."""
from app.models.org_team.org_team import OrgTeam
from app.models.org_team.team_player import TeamPlayer
__all__ = ['OrgTeam', 'TeamPlayer']
__all__ = ['OrgTeam', 'TeamPlayer']
+24 -10
View File
@@ -1,4 +1,5 @@
"""Persistent organisation team (e.g. Varsity, JV)."""
from app.extensions import db
from app.models._associations import org_team_coaches, org_team_managers
from datetime import datetime
@@ -6,6 +7,7 @@ from datetime import datetime
class OrgTeam(db.Model):
"""Persistent organisation team (e.g. Varsity, JV)."""
__tablename__ = 'org_teams'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
@@ -17,20 +19,30 @@ class OrgTeam(db.Model):
creator = db.relationship('User', foreign_keys=[created_by])
coaches = db.relationship(
'User', secondary=org_team_coaches, lazy='dynamic',
backref=db.backref('coached_org_teams', lazy='dynamic'))
'User',
secondary=org_team_coaches,
lazy='dynamic',
backref=db.backref('coached_org_teams', lazy='dynamic'),
)
managers = db.relationship(
'User', secondary=org_team_managers, lazy='dynamic',
backref=db.backref('managed_org_teams', lazy='dynamic'))
'User',
secondary=org_team_managers,
lazy='dynamic',
backref=db.backref('managed_org_teams', lazy='dynamic'),
)
coach = db.relationship(
'User', foreign_keys=[coach_id],
'User',
foreign_keys=[coach_id],
backref=db.backref('coached_org_team_legacy', uselist=False),
viewonly=True)
viewonly=True,
)
manager = db.relationship(
'User', foreign_keys=[manager_id],
'User',
foreign_keys=[manager_id],
backref=db.backref('managed_org_team_legacy', uselist=False),
viewonly=True)
viewonly=True,
)
def get_coaches(self):
coach_list = self.coaches.all()
@@ -49,5 +61,7 @@ class OrgTeam(db.Model):
return [tp.player for tp in self.team_players]
def get_players_with_status(self):
return [{'player': tp.player, 'status': tp.status,
'position': tp.position} for tp in self.team_players]
return [
{'player': tp.player, 'status': tp.status, 'position': tp.position}
for tp in self.team_players
]
+3 -1
View File
@@ -1,10 +1,12 @@
"""Many-to-many junction: player to org-team."""
from app.extensions import db
from datetime import datetime
class TeamPlayer(db.Model):
"""Many-to-many: player to org-team."""
__tablename__ = 'team_players'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
@@ -18,4 +20,4 @@ class TeamPlayer(db.Model):
__table_args__ = (
db.UniqueConstraint('player_id', 'org_team_id', name='unique_player_org_team'),
)
)
+2 -1
View File
@@ -1,6 +1,7 @@
"""Participant models — BaseParticipant and its concrete subclasses."""
from app.models.participant.base import BaseParticipant
from app.models.participant.match_participant import MatchParticipant
from app.models.participant.team_match_participant import TeamMatchParticipant
__all__ = ['BaseParticipant', 'MatchParticipant', 'TeamMatchParticipant']
__all__ = ['BaseParticipant', 'MatchParticipant', 'TeamMatchParticipant']
+3 -1
View File
@@ -1,11 +1,13 @@
"""Abstract base class for match participant models."""
from app.extensions import db
from datetime import datetime
class BaseParticipant(db.Model):
"""Shared schema for match participants."""
__abstract__ = True
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
+3 -1
View File
@@ -1,10 +1,12 @@
"""Participant in a tryout-scoped match."""
from app.extensions import db
from app.models.participant.base import BaseParticipant
class MatchParticipant(BaseParticipant):
"""Participant in a tryout-scoped match."""
__tablename__ = 'match_participants'
id = db.Column(db.Integer, primary_key=True)
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False)
@@ -12,4 +14,4 @@ class MatchParticipant(BaseParticipant):
position = db.Column(db.String(50), nullable=True)
attendance_confirmed = db.Column(db.Boolean, default=False)
player = db.relationship('User')
player = db.relationship('User')
@@ -1,13 +1,15 @@
"""Participant in a regular-season team match."""
from app.extensions import db
from app.models.participant.base import BaseParticipant
class TeamMatchParticipant(BaseParticipant):
"""Participant in a regular-season team match."""
__tablename__ = 'team_match_participants'
id = db.Column(db.Integer, primary_key=True)
team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False)
is_confirmed = db.Column(db.Boolean, default=False)
player = db.relationship('User')
player = db.relationship('User')
+3 -1
View File
@@ -1,10 +1,12 @@
"""Personal notes from coach to individual player."""
from app.extensions import db
from datetime import datetime
class PersonalNote(db.Model):
"""Personal notes from coach to individual player."""
__tablename__ = 'personal_notes'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
@@ -21,4 +23,4 @@ class PersonalNote(db.Model):
coach = db.relationship('User', foreign_keys=[coach_id])
match = db.relationship('Match', foreign_keys=[match_id])
team = db.relationship('Team', foreign_keys=[team_id])
tryout = db.relationship('Tryout', foreign_keys=[tryout_id])
tryout = db.relationship('Tryout', foreign_keys=[tryout_id])
+2 -1
View File
@@ -1,5 +1,6 @@
"""Tryout-specific temporary team models."""
from app.models.team.team import Team
from app.models.team.team_member import TeamMember
__all__ = ['Team', 'TeamMember']
__all__ = ['Team', 'TeamMember']
+3 -1
View File
@@ -1,10 +1,12 @@
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
from app.extensions import db
from datetime import datetime
class Team(db.Model):
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
__tablename__ = 'teams'
id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
@@ -13,4 +15,4 @@ class Team(db.Model):
created_at = db.Column(db.DateTime, default=datetime.utcnow)
creator = db.relationship('User', backref='created_teams')
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
+3 -1
View File
@@ -1,10 +1,12 @@
"""Link between a player and a tryout-specific team."""
from app.extensions import db
from datetime import datetime
class TeamMember(db.Model):
"""Link between a player and a tryout-specific team."""
__tablename__ = 'team_members'
id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
@@ -12,4 +14,4 @@ class TeamMember(db.Model):
position = db.Column(db.String(50), nullable=True)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
player = db.relationship('User', overlaps="player_ref,team_assignments")
player = db.relationship('User', overlaps="player_ref,team_assignments")
+3 -1
View File
@@ -1,10 +1,12 @@
"""Team improvement notes from coach."""
from app.extensions import db
from datetime import datetime
class TeamNote(db.Model):
"""Team improvement notes from coach."""
__tablename__ = 'team_notes'
id = db.Column(db.Integer, primary_key=True)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
@@ -14,4 +16,4 @@ class TeamNote(db.Model):
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
team = db.relationship('OrgTeam', backref='team_notes')
coach = db.relationship('User', foreign_keys=[coach_id])
coach = db.relationship('User', foreign_keys=[coach_id])
+2 -1
View File
@@ -1,5 +1,6 @@
"""Tryout models."""
from app.models.tryout.tryout import Tryout
from app.models.tryout.tryout_registration import TryoutRegistration
__all__ = ['Tryout', 'TryoutRegistration']
__all__ = ['Tryout', 'TryoutRegistration']
+9 -2
View File
@@ -1,4 +1,5 @@
"""Tryout event for player evaluations and team formation."""
from app.extensions import db
from app.models._associations import tryout_coaches
from datetime import datetime
@@ -6,6 +7,7 @@ from datetime import datetime
class Tryout(db.Model):
"""Tryout event for player evaluations and team formation."""
__tablename__ = 'tryouts'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
@@ -19,7 +21,9 @@ class Tryout(db.Model):
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
coach_id = db.Column(
db.Integer, db.ForeignKey('users.id'), nullable=True
) # deprecated, kept for migration
created_at = db.Column(db.DateTime, default=datetime.utcnow)
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
@@ -29,13 +33,16 @@ class Tryout(db.Model):
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
target_org_team = db.relationship('OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id])
target_org_team = db.relationship(
'OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id]
)
@property
def is_ended(self):
"""Tryout is considered ended after its end_date passes.
Falls back to date if end_date is not set."""
from datetime import date as date_type
today = date_type.today()
if self.end_date is not None:
return self.end_date < today
+3 -1
View File
@@ -1,14 +1,16 @@
"""Registration linking a player to a tryout."""
from app.extensions import db
from datetime import datetime
class TryoutRegistration(db.Model):
"""Registration linking a player to a tryout."""
__tablename__ = 'tryout_registrations'
id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
registered_at = db.Column(db.DateTime, default=datetime.utcnow)
status = db.Column(db.String(20), default='registered')
notes = db.Column(db.Text, nullable=True)
notes = db.Column(db.Text, nullable=True)
+3 -3
View File
@@ -1,4 +1,5 @@
"""Store gamertag per game for each user."""
from app.extensions import db
from app.models._constants import TRN_URLS, PLATFORM_CODES, PLATFORM_DEFAULTS
from urllib.parse import quote
@@ -6,6 +7,7 @@ from urllib.parse import quote
class UserGamertag(db.Model):
"""Store gamertag per game for each user."""
__tablename__ = 'user_gamertags'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
@@ -15,9 +17,7 @@ class UserGamertag(db.Model):
user = db.relationship('User', backref='gamertags')
__table_args__ = (
db.UniqueConstraint('user_id', 'game', name='unique_user_game'),
)
__table_args__ = (db.UniqueConstraint('user_id', 'game', name='unique_user_game'),)
def get_trn_url(self):
if self.game not in TRN_URLS:
+1
View File
@@ -1,4 +1,5 @@
"""User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout)."""
from app.models.user_model.user import User
from app.models.user_model.admin import Admin
from app.models.user_model.manager import Manager
+3
View File
@@ -1,9 +1,11 @@
"""Admin / President — full access to everything."""
from app.models.user_model.user import User
class Admin(User):
"""President / super-admin — full access to everything."""
__mapper_args__ = {'polymorphic_identity': 'admin'}
def can_evaluate(self):
@@ -29,4 +31,5 @@ class Admin(User):
def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout
return Tryout.query.order_by(Tryout.date).all()
+4
View File
@@ -1,4 +1,5 @@
"""Coach — evaluates, schedules matches, manages their own org teams."""
from app.models.user_model.user import User
@@ -13,6 +14,7 @@ class Coach(User):
and ``get_visible_tryouts`` ignored it entirely. A coach attached only
by that column therefore saw an empty calendar (ARCH-002).
"""
__mapper_args__ = {'polymorphic_identity': 'coach'}
def can_evaluate(self):
@@ -26,6 +28,7 @@ class Coach(User):
def can_manage_this_tryout(self, tryout):
from app.permissions import coach_manages_tryout
return coach_manages_tryout(self, tryout)
def can_manage_this_org_team(self, org_team):
@@ -35,4 +38,5 @@ class Coach(User):
def get_visible_tryouts(self):
from app.permissions import coach_tryouts
return coach_tryouts(self)
+8 -3
View File
@@ -1,9 +1,11 @@
"""Manager — manages own tryouts, all org teams, all contracts."""
from app.models.user_model.user import User
class Manager(User):
"""Manager — manages own tryouts, all org teams, all contracts."""
__mapper_args__ = {'polymorphic_identity': 'manager'}
def can_evaluate(self):
@@ -27,6 +29,9 @@ class Manager(User):
def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout
from sqlalchemy import or_
return Tryout.query.filter(
or_(Tryout.created_by == self.id, Tryout.manager_id == self.id)
).order_by(Tryout.date).all()
return (
Tryout.query.filter(or_(Tryout.created_by == self.id, Tryout.manager_id == self.id))
.order_by(Tryout.date)
.all()
)
+23 -9
View File
@@ -1,9 +1,11 @@
"""Player — registers for tryouts, manages their own profile."""
from app.models.user_model.user import User
class Player(User):
"""Player — registers for tryouts, manages their own profile."""
__mapper_args__ = {'polymorphic_identity': 'player'}
def get_visible_tryouts(self):
@@ -13,18 +15,30 @@ class Player(User):
# tryouts they registered for
player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()]
tryouts = Tryout.query.filter(
Tryout.id.in_(player_tryout_ids)
).order_by(Tryout.date).all() if player_tryout_ids else []
tryouts = (
Tryout.query.filter(Tryout.id.in_(player_tryout_ids)).order_by(Tryout.date).all()
if player_tryout_ids
else []
)
# plus tryouts where they participate in a match
player_matches = Match.query.join(MatchParticipant).filter(
MatchParticipant.player_id == self.id,
).all()
player_matches = (
Match.query.join(MatchParticipant)
.filter(
MatchParticipant.player_id == self.id,
)
.all()
)
extra_ids = set(m.tryout_id for m in player_matches)
extra = Tryout.query.filter(
Tryout.id.in_(extra_ids),
).order_by(Tryout.date).all() if extra_ids else []
extra = (
Tryout.query.filter(
Tryout.id.in_(extra_ids),
)
.order_by(Tryout.date)
.all()
if extra_ids
else []
)
all_ids = {t.id for t in tryouts}
return tryouts + [t for t in extra if t.id not in all_ids]
+3
View File
@@ -1,9 +1,11 @@
"""Scout — view-only access to tryouts and evaluations."""
from app.models.user_model.user import User
class Scout(User):
"""Scout — view-only access to tryouts and evaluations."""
__mapper_args__ = {'polymorphic_identity': 'scout'}
def can_evaluate(self):
@@ -11,4 +13,5 @@ class Scout(User):
def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout
return Tryout.query.order_by(Tryout.date).all()
+13 -11
View File
@@ -1,4 +1,5 @@
"""Base User model — shared fields and polymorphic configuration."""
from app.extensions import db
from flask_login import UserMixin
from datetime import datetime
@@ -10,6 +11,7 @@ class User(UserMixin, db.Model):
Do not instantiate this class directly; use Admin, Manager, Coach, Player,
or Scout so that `polymorphic_identity` is set correctly.
"""
__tablename__ = 'users'
# --- columns -----------------------------------------------------------
@@ -27,7 +29,7 @@ class User(UserMixin, db.Model):
locked_until = db.Column(db.DateTime, nullable=True)
# E-Sports fields
games = db.Column(db.Text, nullable=True) # comma-separated (only meaningful for Player)
games = db.Column(db.Text, nullable=True) # comma-separated (only meaningful for Player)
discord_username = db.Column(db.String(128), nullable=True)
discord_user_id = db.Column(db.String(64), nullable=True)
league_os_profile = db.Column(db.String(256), nullable=True)
@@ -40,16 +42,15 @@ class User(UserMixin, db.Model):
# --- relationships (defined once on the base) --------------------------
evaluations_given = db.relationship(
'Evaluation', foreign_keys='Evaluation.evaluator_id',
backref='evaluator', lazy='dynamic')
'Evaluation', foreign_keys='Evaluation.evaluator_id', backref='evaluator', lazy='dynamic'
)
evaluations_received = db.relationship(
'Evaluation', foreign_keys='Evaluation.player_id',
backref='player', lazy='dynamic')
tryout_registrations = db.relationship(
'TryoutRegistration', backref='player', lazy='dynamic')
'Evaluation', foreign_keys='Evaluation.player_id', backref='player', lazy='dynamic'
)
tryout_registrations = db.relationship('TryoutRegistration', backref='player', lazy='dynamic')
team_assignments = db.relationship(
'TeamMember', foreign_keys='TeamMember.player_id',
backref='player_ref', lazy='dynamic')
'TeamMember', foreign_keys='TeamMember.player_id', backref='player_ref', lazy='dynamic'
)
# --- Flask-Login integration -------------------------------------------
@property
@@ -71,8 +72,9 @@ class User(UserMixin, db.Model):
def get_gamertags(self):
"""Return gamertags as a dict keyed by game."""
return {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform}
for gt in self.gamertags}
return {
gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in self.gamertags
}
def get_org_teams(self):
"""Return all OrgTeams this player belongs to."""
+29 -14
View File
@@ -33,6 +33,7 @@ from app.extensions import db
# Team attachment
# ---------------------------------------------------------------------------
def coach_org_teams(coach):
"""Organisation teams a coach is attached to, ordered by name.
@@ -47,12 +48,16 @@ def coach_org_teams(coach):
"""
from app.models import OrgTeam
return OrgTeam.query.filter(
db.or_(
OrgTeam.coaches.any(id=coach.id),
OrgTeam.coach_id == coach.id,
return (
OrgTeam.query.filter(
db.or_(
OrgTeam.coaches.any(id=coach.id),
OrgTeam.coach_id == coach.id,
)
)
).order_by(OrgTeam.name).all()
.order_by(OrgTeam.name)
.all()
)
def coach_org_team_ids(coach):
@@ -81,12 +86,16 @@ def manager_org_teams(manager):
"""
from app.models import OrgTeam
return OrgTeam.query.filter(
db.or_(
OrgTeam.managers.any(id=manager.id),
OrgTeam.manager_id == manager.id,
return (
OrgTeam.query.filter(
db.or_(
OrgTeam.managers.any(id=manager.id),
OrgTeam.manager_id == manager.id,
)
)
).order_by(OrgTeam.name).all()
.order_by(OrgTeam.name)
.all()
)
def attached_org_teams(user):
@@ -154,6 +163,7 @@ def can_manage_org_team(user, org_team):
# Reach over players
# ---------------------------------------------------------------------------
def org_team_player_ids(team_ids):
"""IDs of the players placed on any of these teams.
@@ -227,10 +237,14 @@ def coach_can_access_player(coach, player_id):
if registered:
return True
plays_a_match = MatchParticipant.query.join(Match).filter(
MatchParticipant.player_id == player_id,
Match.tryout_id.in_(tryout_ids),
).first()
plays_a_match = (
MatchParticipant.query.join(Match)
.filter(
MatchParticipant.player_id == player_id,
Match.tryout_id.in_(tryout_ids),
)
.first()
)
return plays_a_match is not None
@@ -262,6 +276,7 @@ def can_manage_player_contract(user, player_id):
# Tryouts
# ---------------------------------------------------------------------------
def coach_tryouts(coach, team_ids=None):
"""Tryouts a coach manages, ordered by date.
+32 -20
View File
@@ -78,8 +78,7 @@ def is_safe_url(url):
parsed = urlparse(url)
if parsed.netloc:
return (parsed.netloc == request.host
and parsed.scheme in ('', 'http', 'https'))
return parsed.netloc == request.host and parsed.scheme in ('', 'http', 'https')
# Relative targets must be rooted. 'dashboard' or 'javascript:...' are
# not paths on this site.
return url.startswith('/')
@@ -112,7 +111,7 @@ def cooloff_minutes(failed_attempts):
int: Minutes.
"""
steps = max(failed_attempts // MAX_LOGIN_ATTEMPTS - 1, 0)
return min(LOCKOUT_DURATION_MINUTES * (2 ** steps), MAX_LOCKOUT_MINUTES)
return min(LOCKOUT_DURATION_MINUTES * (2**steps), MAX_LOCKOUT_MINUTES)
def generate_captcha():
@@ -124,6 +123,7 @@ def generate_captcha():
dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys.
"""
import random
a = random.randint(1, 10)
b = random.randint(1, 10)
captcha_id = str(uuid.uuid4())
@@ -199,8 +199,7 @@ def login():
if user and credentials_ok:
if not user.is_active_account:
log_auth_event('login.rejected.deactivated',
username=username, user_id=user.id)
log_auth_event('login.rejected.deactivated', username=username, user_id=user.id)
flash(_('This account has been deactivated.'), 'danger')
return render_template('pages/login.html')
@@ -219,9 +218,7 @@ def login():
# back into French — the preference lives in the session, and
# clearing it discards a decision the user just made.
_preserved = {
key: session[key]
for key in ('csrf_token', LOCALE_SESSION_KEY)
if key in session
key: session[key] for key in ('csrf_token', LOCALE_SESSION_KEY) if key in session
}
session.clear()
session.update(_preserved)
@@ -232,8 +229,7 @@ def login():
session.permanent = True
login_user(user)
log_auth_event('login.success',
username=user.username, user_id=user.id, role=user.role)
log_auth_event('login.success', username=user.username, user_id=user.id, role=user.role)
# Validate redirect URL to prevent open redirect vulnerability
next_page = request.args.get('next')
@@ -248,20 +244,33 @@ def login():
# who asked (SEC-017).
if user:
user.failed_login_attempts += 1
log_auth_event('login.failure', username=username, user_id=user.id,
attempts=user.failed_login_attempts)
log_auth_event(
'login.failure',
username=username,
user_id=user.id,
attempts=user.failed_login_attempts,
)
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
minutes = cooloff_minutes(user.failed_login_attempts)
user.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
log_auth_event('account.throttled', username=username,
user_id=user.id, minutes=minutes,
attempts=user.failed_login_attempts)
log_auth_event(
'account.throttled',
username=username,
user_id=user.id,
minutes=minutes,
attempts=user.failed_login_attempts,
)
db.session.commit()
else:
log_auth_event('login.failure.unknown_user', username=username)
flash(_('Login unsuccessful. Please check your username and '
'password, or ask a president for help.'), 'danger')
flash(
_(
'Login unsuccessful. Please check your username and '
'password, or ask a president for help.'
),
'danger',
)
return render_template('pages/login.html')
@@ -375,6 +384,7 @@ def register():
# Create UserGamertag records for each selected game
from app.models import UserGamertag
for game in selected_games:
field_name = f'gamertag_{game}'
gamertag_value = request.form.get(field_name, '').strip()
@@ -458,8 +468,10 @@ def discord_callback():
if not expected_state or not secrets.compare_digest(expected_state, received_state):
flash(
_('Discord authorization could not be verified. '
'Please start the connection again from this page.'),
_(
'Discord authorization could not be verified. '
'Please start the connection again from this page.'
),
'danger',
)
return redirect(url_for('auth.register'))
@@ -585,4 +597,4 @@ def logout():
if _locale:
session[LOCALE_SESSION_KEY] = _locale
flash(_('You have been logged out.'), 'info')
return redirect(url_for('auth.login'))
return redirect(url_for('auth.login'))
+96 -48
View File
@@ -8,8 +8,12 @@ from flask_login import login_required, current_user
from flask_babel import gettext as _
from app.extensions import db
from app.models import (
Admin, Player,
User, Tryout, Evaluation, TryoutRegistration,
Admin,
Player,
User,
Tryout,
Evaluation,
TryoutRegistration,
GAME_POSITIONS,
)
from sqlalchemy import func
@@ -74,22 +78,29 @@ def list_evaluations():
sort_expr = sort_expr.desc()
if isinstance(user, Admin):
evaluations = Evaluation.query \
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \
.order_by(sort_expr).all()
avg_scores = db.session.query(
Evaluation.player_id,
func.count(Evaluation.id).label('eval_count'),
func.avg(Evaluation.overall_score).label('avg_score'),
).group_by(Evaluation.player_id).all()
evaluations = (
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.order_by(sort_expr)
.all()
)
avg_scores = (
db.session.query(
Evaluation.player_id,
func.count(Evaluation.id).label('eval_count'),
func.avg(Evaluation.overall_score).label('avg_score'),
)
.group_by(Evaluation.player_id)
.all()
)
player_scores = {}
for row in avg_scores:
p = User.query.get(row.player_id)
if p:
player_scores[p.id] = {
'player': p, 'count': row.eval_count,
'player': p,
'count': row.eval_count,
'avg': round(row.avg_score, 1) if row.avg_score else 0,
}
else:
@@ -97,17 +108,23 @@ def list_evaluations():
# can_evaluate() is true for the four remaining roles. The former
# `else` branch listed evaluations *received* — a player's view,
# unreachable from this point (ARCH-007).
evaluations = Evaluation.query \
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \
.filter(Evaluation.evaluator_id == user.id) \
.order_by(sort_expr).all()
evaluations = (
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.filter(Evaluation.evaluator_id == user.id)
.order_by(sort_expr)
.all()
)
player_scores = {}
return render_template('pages/evaluations.html',
evaluations=evaluations, player_scores=player_scores,
sort_column=sort_column, sort_order=sort_order)
return render_template(
'pages/evaluations.html',
evaluations=evaluations,
player_scores=player_scores,
sort_column=sort_column,
sort_order=sort_order,
)
@evaluations_bp.route('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST'])
@@ -123,9 +140,13 @@ def evaluate_player(tryout_id, player_id):
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts'))
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id,
).first() is not None
is_registered = (
TryoutRegistration.query.filter_by(
tryout_id=tryout_id,
player_id=player_id,
).first()
is not None
)
if not is_registered:
flash(_('Player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -136,7 +157,9 @@ def evaluate_player(tryout_id, player_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing_eval = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=player_id, evaluator_id=current_user.id,
tryout_id=tryout_id,
player_id=player_id,
evaluator_id=current_user.id,
).first()
if request.method == 'POST':
@@ -152,9 +175,21 @@ def evaluate_player(tryout_id, player_id):
comments = request.form.get('comments')
position = request.form.get('position_recommendation')
scores = [s for s in [mecanics, cohesion, communication, gamesense,
versatility, discipline, analysis, sport_ethics, mental]
if s is not None]
scores = [
s
for s in [
mecanics,
cohesion,
communication,
gamesense,
versatility,
discipline,
analysis,
sport_ethics,
mental,
]
if s is not None
]
overall = sum(scores) / len(scores) if scores else None
if existing_eval:
@@ -173,14 +208,21 @@ def evaluate_player(tryout_id, player_id):
flash(_('Evaluation updated!'), 'success')
else:
evaluation = Evaluation(
tryout_id=tryout_id, player_id=player_id,
tryout_id=tryout_id,
player_id=player_id,
evaluator_id=current_user.id,
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, overall_score=overall,
comments=comments, position_recommendation=position,
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,
overall_score=overall,
comments=comments,
position_recommendation=position,
)
db.session.add(evaluation)
flash(_('Evaluation submitted successfully!'), 'success')
@@ -191,16 +233,21 @@ def evaluate_player(tryout_id, player_id):
evaluators = None
if isinstance(current_user, Admin):
all_evaluations = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=player_id,
tryout_id=tryout_id,
player_id=player_id,
).all()
evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e}
for e in all_evaluations]
evaluators = [
{'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations
]
return render_template('pages/evaluate_player.html',
tryout=tryout, player=player,
existing_eval=existing_eval,
evaluators=evaluators,
game_positions=GAME_POSITIONS)
return render_template(
'pages/evaluate_player.html',
tryout=tryout,
player=player,
existing_eval=existing_eval,
evaluators=evaluators,
game_positions=GAME_POSITIONS,
)
@evaluations_bp.route('/<int:tryout_id>/players')
@@ -222,9 +269,10 @@ def players_to_evaluate(tryout_id):
p = User.query.get(reg.player_id)
if p and isinstance(p, Player):
existing = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
tryout_id=tryout_id,
player_id=p.id,
evaluator_id=current_user.id,
).first()
players.append({'player': p, 'evaluated': existing is not None,
'registration': reg})
players.append({'player': p, 'evaluated': existing is not None, 'registration': reg})
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
+103 -43
View File
@@ -8,9 +8,18 @@ from flask_login import login_required, current_user
from flask_babel import gettext as _
from app.extensions import db
from app.models import (
Admin, Manager, Coach, Player, Scout,
User, Tryout, Evaluation, TryoutRegistration, TeamMember,
Match, MatchParticipant,
Admin,
Manager,
Coach,
Player,
Scout,
User,
Tryout,
Evaluation,
TryoutRegistration,
TeamMember,
Match,
MatchParticipant,
)
from app.permissions import coach_tryout_ids
from sqlalchemy import func
@@ -46,8 +55,9 @@ def set_language(locale):
target = request.referrer
if target and is_safe_url(target):
return redirect(target)
return redirect(url_for('main.dashboard') if current_user.is_authenticated
else url_for('auth.login'))
return redirect(
url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login')
)
@main_bp.route('/dashboard')
@@ -70,47 +80,82 @@ def dashboard():
stats['recent_users'] = User.query.order_by(User.created_at.desc()).limit(10).all()
stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
today = date.today()
stats['upcoming_matches'] = Match.query.filter(
Match.status == 'scheduled', Match.date >= today,
).order_by(Match.date, Match.start_time).limit(5).all()
stats['upcoming_matches'] = (
Match.query.filter(
Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.limit(5)
.all()
)
elif isinstance(user, Manager):
stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count()
stats['active_tryouts'] = Tryout.query.filter_by(
created_by=user.id, status='in_progress').count()
created_by=user.id, status='in_progress'
).count()
stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
stats['my_tryouts'] = Tryout.query.filter_by(
created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
stats['my_tryouts'] = (
Tryout.query.filter_by(created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
)
today = date.today()
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
stats['upcoming_matches'] = Match.query.filter(
Match.tryout_id.in_(manager_tryout_ids),
Match.status == 'scheduled', Match.date >= today,
).order_by(Match.date, Match.start_time).limit(5).all() if manager_tryout_ids else []
stats['upcoming_matches'] = (
Match.query.filter(
Match.tryout_id.in_(manager_tryout_ids),
Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.limit(5)
.all()
if manager_tryout_ids
else []
)
elif isinstance(user, Coach):
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
registrations = TryoutRegistration.query.filter(
TryoutRegistration.status.in_(['registered', 'attended'])).all()
TryoutRegistration.status.in_(['registered', 'attended'])
).all()
registered_player_ids = [r.player_id for r in registrations]
evaluated_player_ids = [e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()]
evaluated_player_ids = [
e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()
]
stats['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids))
stats['my_recent_evaluations'] = Evaluation.query.filter_by(
evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all()
stats['my_recent_evaluations'] = (
Evaluation.query.filter_by(evaluator_id=user.id)
.order_by(Evaluation.created_at.desc())
.limit(10)
.all()
)
today = date.today()
# Was: the first team matching the legacy coach_id column, and only
# the tryouts targeting it. A coach attached by the many-to-many
# relationship, or coaching a second team, saw no upcoming match.
tryout_ids = coach_tryout_ids(user)
stats['upcoming_matches'] = Match.query.filter(
Match.tryout_id.in_(tryout_ids),
Match.status == 'scheduled', Match.date >= today,
).order_by(Match.date, Match.start_time).limit(5).all() if tryout_ids else []
stats['upcoming_matches'] = (
Match.query.filter(
Match.tryout_id.in_(tryout_ids),
Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.limit(5)
.all()
if tryout_ids
else []
)
elif isinstance(user, Player):
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
stats['my_registrations'] = TryoutRegistration.query.filter_by(
player_id=user.id).order_by(TryoutRegistration.registered_at.desc()).limit(5).all()
stats['my_registrations'] = (
TryoutRegistration.query.filter_by(player_id=user.id)
.order_by(TryoutRegistration.registered_at.desc())
.limit(5)
.all()
)
today = date.today()
next_matches = []
@@ -121,10 +166,15 @@ def dashboard():
player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all()
player_team_ids = [tm.team_id for tm in player_team_memberships]
upcoming_matches = Match.query.filter(
Match.tryout_id.in_(registered_tryout_ids),
Match.status == 'scheduled', Match.date >= today,
).order_by(Match.date, Match.start_time).all()
upcoming_matches = (
Match.query.filter(
Match.tryout_id.in_(registered_tryout_ids),
Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.all()
)
for match in upcoming_matches:
is_participant = False
@@ -132,36 +182,46 @@ def dashboard():
if match.match_type == 'team_vs_team':
if match.team1_id in player_team_ids:
is_participant = True
team = next((tm for tm in player_team_memberships
if tm.team_id == match.team1_id), None)
team = next(
(tm for tm in player_team_memberships if tm.team_id == match.team1_id), None
)
elif match.team2_id in player_team_ids:
is_participant = True
team = next((tm for tm in player_team_memberships
if tm.team_id == match.team2_id), None)
team = next(
(tm for tm in player_team_memberships if tm.team_id == match.team2_id), None
)
else:
if match.id in player_match_ids:
is_participant = True
if is_participant:
next_matches.append({
'tryout': match.tryout, 'match': match,
'team': team.team if team else None,
})
next_matches.append(
{
'tryout': match.tryout,
'match': match,
'team': team.team if team else None,
}
)
stats['next_matches'] = next_matches
elif isinstance(user, Scout):
stats['total_players'] = User.query.filter_by(role='player').count()
stats['total_evaluations'] = Evaluation.query.count()
stats['avg_scores'] = db.session.query(
Evaluation.player_id,
func.avg(Evaluation.overall_score).label('avg_score'),
).group_by(Evaluation.player_id).order_by(
func.avg(Evaluation.overall_score).desc()).limit(5).all()
stats['avg_scores'] = (
db.session.query(
Evaluation.player_id,
func.avg(Evaluation.overall_score).label('avg_score'),
)
.group_by(Evaluation.player_id)
.order_by(func.avg(Evaluation.overall_score).desc())
.limit(5)
.all()
)
stats['top_players'] = []
for row in stats['avg_scores']:
p = User.query.get(row.player_id)
if p:
stats['top_players'].append((p, round(row.avg_score, 1)))
return render_template('pages/dashboard.html', user=user, stats=stats)
return render_template('pages/dashboard.html', user=user, stats=stats)
+243 -114
View File
@@ -8,10 +8,21 @@ from flask_login import login_required, current_user
from flask_babel import gettext as _
from app.extensions import db
from app.models import (
Admin, Manager, Coach, Player, Scout,
User, Tryout, Match, MatchParticipant, Team, TeamMember,
TryoutRegistration, PlayerDisponibility,
OneOnOneRequest, PersonalNote,
Admin,
Manager,
Coach,
Player,
Scout,
User,
Tryout,
Match,
MatchParticipant,
Team,
TeamMember,
TryoutRegistration,
PlayerDisponibility,
OneOnOneRequest,
PersonalNote,
)
from datetime import datetime, timedelta
from app.discord_bot import send_schedule_notification
@@ -73,56 +84,65 @@ def api_events():
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
user_participant = MatchParticipant.query.filter_by(
match_id=match.id, player_id=current_user.id,
match_id=match.id,
player_id=current_user.id,
).first()
events.append({
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match', 'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status, 'description': match.description or '',
'match_type': match.match_type,
'tryout_id': tryout.id, 'match_id': match.id,
'start_time': start_time_str, 'end_time': end_time_str,
'participants': participants_str,
'user_participant_id': user_participant.id if user_participant else None,
'user_attendance_confirmed': user_participant.attendance_confirmed if user_participant else False,
},
})
events.append(
{
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match',
'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status,
'description': match.description or '',
'match_type': match.match_type,
'tryout_id': tryout.id,
'match_id': match.id,
'start_time': start_time_str,
'end_time': end_time_str,
'participants': participants_str,
'user_participant_id': user_participant.id if user_participant else None,
'user_attendance_confirmed': user_participant.attendance_confirmed
if user_participant
else False,
},
}
)
# Add approved One on One sessions for the current user (player or coach)
if isinstance(current_user, Player):
one_on_ones = OneOnOneRequest.query.filter_by(
player_id=current_user.id,
status='approved'
player_id=current_user.id, status='approved'
).all()
elif isinstance(current_user, Coach):
one_on_ones = OneOnOneRequest.query.filter_by(
coach_id=current_user.id,
status='approved'
coach_id=current_user.id, status='approved'
).all()
else:
one_on_ones = []
for ooo in one_on_ones:
events.append({
'id': f'one_on_one_{ooo.id}',
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'date': ooo.date.strftime('%Y-%m-%d'),
'type': 'one_on_one',
'color': '#8b5cf6',
'extendedProps': {
'location': 'Discord / Voice Chat',
'status': 'approved',
'description': ooo.points or 'One on One session',
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
},
})
events.append(
{
'id': f'one_on_one_{ooo.id}',
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'date': ooo.date.strftime('%Y-%m-%d'),
'type': 'one_on_one',
'color': '#8b5cf6',
'extendedProps': {
'location': 'Discord / Voice Chat',
'status': 'approved',
'description': ooo.points or 'One on One session',
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
},
}
)
return jsonify(events)
@@ -137,13 +157,21 @@ def api_events_for_tryout(tryout_id):
is_registered = False
player_in_match = False
if isinstance(current_user, Player):
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id,
).first() is not None
player_matches = Match.query.join(MatchParticipant).filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
).all()
is_registered = (
TryoutRegistration.query.filter_by(
tryout_id=tryout_id,
player_id=current_user.id,
).first()
is not None
)
player_matches = (
Match.query.join(MatchParticipant)
.filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
)
.all()
)
player_in_match = len(player_matches) > 0
if not can_view and not is_registered and not player_in_match:
@@ -152,7 +180,9 @@ def api_events_for_tryout(tryout_id):
events = []
for match in tryout.matches:
match_color = '#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
match_color = (
'#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
)
participants_str = ''
if match.match_type == 'team_vs_team':
teams = []
@@ -162,8 +192,16 @@ def api_events_for_tryout(tryout_id):
teams.append(match.team2.name)
participants_str = f"{' vs '.join(teams)}"
elif match.match_type == 'player_vs_player':
team1_players = [p.player.username for p in match.participants.filter_by(team_side=1).all() if p.player]
team2_players = [p.player.username for p in match.participants.filter_by(team_side=2).all() if p.player]
team1_players = [
p.player.username
for p in match.participants.filter_by(team_side=1).all()
if p.player
]
team2_players = [
p.player.username
for p in match.participants.filter_by(team_side=2).all()
if p.player
]
if team1_players and team2_players:
participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}"
else:
@@ -175,19 +213,25 @@ def api_events_for_tryout(tryout_id):
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
events.append({
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match', 'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status, 'match_type': match.match_type,
'tryout_id': tryout.id, 'match_id': match.id,
'participants': participants_str,
'start_time': start_time_str, 'end_time': end_time_str,
},
})
events.append(
{
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match',
'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status,
'match_type': match.match_type,
'tryout_id': tryout.id,
'match_id': match.id,
'participants': participants_str,
'start_time': start_time_str,
'end_time': end_time_str,
},
}
)
return jsonify(events)
@@ -207,7 +251,9 @@ def create_match(tryout_id):
teams = Team.query.filter_by(tryout_id=tryout_id).all()
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
all_players = [User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)]
all_players = [
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', '')
@@ -222,15 +268,25 @@ def create_match(tryout_id):
if not start_time_str:
flash(_('Start time is required. Please select a time slot.'), 'danger')
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
all_players=all_players, prefill_date=prefill_date)
return render_template(
'pages/match_form.html',
tryout=tryout,
teams=teams,
all_players=all_players,
prefill_date=prefill_date,
)
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date
except (ValueError, TypeError):
flash(_('Invalid date format.'), 'danger')
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
all_players=all_players, prefill_date=prefill_date)
return render_template(
'pages/match_form.html',
tryout=tryout,
teams=teams,
all_players=all_players,
prefill_date=prefill_date,
)
start_time = None
end_time = None
@@ -244,12 +300,20 @@ def create_match(tryout_id):
end_time = end_dt.time()
except ValueError:
flash(_('Invalid time format.'), 'danger')
return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players)
return render_template(
'pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players
)
match = Match(
tryout_id=tryout_id, title=title, description=description,
date=date_obj, start_time=start_time, end_time=end_time,
location=location, match_type=match_type, created_by=current_user.id,
tryout_id=tryout_id,
title=title,
description=description,
date=date_obj,
start_time=start_time,
end_time=end_time,
location=location,
match_type=match_type,
created_by=current_user.id,
)
db.session.add(match)
db.session.flush()
@@ -264,14 +328,18 @@ def create_match(tryout_id):
match.team2_id = int(team2_id) if team2_id else None
if match.team1_id:
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1)
participant = MatchParticipant(
match_id=match.id, player_id=m.player_id, team_side=1
)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
if match.team2_id:
for m in TeamMember.query.filter_by(team_id=match.team2_id).all():
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=2)
participant = MatchParticipant(
match_id=match.id, player_id=m.player_id, team_side=2
)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
@@ -279,8 +347,12 @@ def create_match(tryout_id):
elif match_type == 'player_vs_player':
team1_player_ids = request.form.get('team1_player_ids', '')
team2_player_ids = request.form.get('team2_player_ids', '')
team1_ids = [int(p) for p in team1_player_ids.split(',') if p] if team1_player_ids else []
team2_ids = [int(p) for p in team2_player_ids.split(',') if p] if team2_player_ids else []
team1_ids = (
[int(p) for p in team1_player_ids.split(',') if p] if team1_player_ids else []
)
team2_ids = (
[int(p) for p in team2_player_ids.split(',') if p] if team2_player_ids else []
)
for pid in team1_ids:
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1)
db.session.add(participant)
@@ -305,20 +377,34 @@ def create_match(tryout_id):
# Discord notifications
event_date_str = date_obj.strftime('%Y-%m-%d')
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
event_time_str = (
f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}"
if start_time and end_time
else 'TBD'
)
for i, player_id in enumerate(notified_player_ids):
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
reference_id = (
notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
)
send_schedule_notification(
user_id=player_id, event_type='match', event_title=match.title,
event_date=event_date_str, event_time=event_time_str,
user_id=player_id,
event_type='match',
event_title=match.title,
event_date=event_date_str,
event_time=event_time_str,
reference_id=reference_id,
)
flash(_('Match scheduled successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
all_players=all_players, prefill_date=prefill_date)
return render_template(
'pages/match_form.html',
tryout=tryout,
teams=teams,
all_players=all_players,
prefill_date=prefill_date,
)
@matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
@@ -357,15 +443,25 @@ def edit_match(match_id):
match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash(_('Invalid date format.'), 'danger')
return render_template('pages/match_form.html', match=match, tryout=tryout,
teams=teams, all_players=all_players,
current_player_ids=current_player_ids)
return render_template(
'pages/match_form.html',
match=match,
tryout=tryout,
teams=teams,
all_players=all_players,
current_player_ids=current_player_ids,
)
if not start_time_str:
flash(_('Start time is required.'), 'danger')
return render_template('pages/match_form.html', match=match, tryout=tryout,
teams=teams, all_players=all_players,
current_player_ids=current_player_ids)
return render_template(
'pages/match_form.html',
match=match,
tryout=tryout,
teams=teams,
all_players=all_players,
current_player_ids=current_player_ids,
)
try:
match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
@@ -397,23 +493,37 @@ def edit_match(match_id):
match.team2_id = new_team2_id
if match.team1_id:
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1)
participant = MatchParticipant(
match_id=match.id, player_id=m.player_id, team_side=1
)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
if match.team2_id:
for m in TeamMember.query.filter_by(team_id=match.team2_id).all():
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=2)
participant = MatchParticipant(
match_id=match.id, player_id=m.player_id, team_side=2
)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
else:
if match.team1_id:
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team1_id).all()])
notified_player_ids.extend(
[
m.player_id
for m in TeamMember.query.filter_by(team_id=match.team1_id).all()
]
)
if match.team2_id:
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team2_id).all()])
notified_player_ids.extend(
[
m.player_id
for m in TeamMember.query.filter_by(team_id=match.team2_id).all()
]
)
elif match.match_type == 'player_vs_player':
MatchParticipant.query.filter_by(match_id=match.id).delete()
team1_str = request.form.get('team1_player_ids', '')
@@ -446,15 +556,22 @@ def edit_match(match_id):
# Discord notifications
end_time_val = match.end_time or (match.start_time if match.start_time else None)
if match.start_time and end_time_val:
event_time_str = f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}"
event_time_str = (
f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}"
)
else:
event_time_str = 'TBD'
event_date_str = match.date.strftime('%Y-%m-%d')
for i, player_id in enumerate(notified_player_ids):
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
reference_id = (
notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
)
send_schedule_notification(
user_id=player_id, event_type='match', event_title=match.title,
event_date=event_date_str, event_time=event_time_str,
user_id=player_id,
event_type='match',
event_title=match.title,
event_date=event_date_str,
event_time=event_time_str,
reference_id=reference_id,
)
@@ -469,12 +586,17 @@ def edit_match(match_id):
'team_side': p.team_side,
}
return render_template('pages/match_form.html', match=match, tryout=tryout,
teams=teams, all_players=all_players,
current_player_ids=current_player_ids,
team1_player_ids=team1_player_ids,
team2_player_ids=team2_player_ids,
participants_map=participants_map)
return render_template(
'pages/match_form.html',
match=match,
tryout=tryout,
teams=teams,
all_players=all_players,
current_player_ids=current_player_ids,
team1_player_ids=team1_player_ids,
team2_player_ids=team2_player_ids,
participants_map=participants_map,
)
@matches_bp.route('/api/manageable-tryouts')
@@ -488,11 +610,14 @@ def api_manageable_tryouts():
manageable = []
for t in tryouts:
if current_user.can_manage_this_tryout(t):
manageable.append({
'id': t.id, 'title': t.title,
'date': t.date.strftime('%Y-%m-%d'),
'end_date': t.end_date.strftime('%Y-%m-%d') if t.end_date else None,
})
manageable.append(
{
'id': t.id,
'title': t.title,
'date': t.date.strftime('%Y-%m-%d'),
'end_date': t.end_date.strftime('%Y-%m-%d') if t.end_date else None,
}
)
return jsonify(manageable)
@@ -514,7 +639,8 @@ def delete_match(match_id):
# Only the context link is dropped. Participants go through the
# relationship's delete-orphan cascade.
PersonalNote.query.filter_by(match_id=match_id).update(
{'match_id': None}, synchronize_session=False)
{'match_id': None}, synchronize_session=False
)
db.session.delete(match)
db.session.commit()
@@ -536,7 +662,8 @@ def get_players_available_at_time(date_str, time_str):
available_players = []
for player in players:
disponibilities = PlayerDisponibility.query.filter_by(
player_id=player.id, day_of_week=day_of_week,
player_id=player.id,
day_of_week=day_of_week,
).all()
for disp in disponibilities:
disp_start = disp.start_time.hour * 60 + disp.start_time.minute
@@ -575,8 +702,10 @@ def toggle_presence(match_id, participant_id):
participant.attendance_confirmed = not participant.attendance_confirmed
db.session.commit()
return jsonify({
'participant_id': participant.id,
'attendance_confirmed': participant.attendance_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown',
})
return jsonify(
{
'participant_id': participant.id,
'attendance_confirmed': participant.attendance_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown',
}
)
+95 -43
View File
@@ -8,8 +8,14 @@ from flask_login import login_required, current_user
from flask_babel import gettext as _
from app.extensions import db
from app.models import (
Admin, Manager, Coach, Player,
OrgTeam, TeamMatch, TeamMatchParticipant, TeamPlayer,
Admin,
Manager,
Coach,
Player,
OrgTeam,
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
)
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
from datetime import datetime, timedelta
@@ -41,9 +47,13 @@ def list_matches():
elif isinstance(current_user, (Coach, Player)):
teams = visible_org_teams(current_user)
team_ids = [t.id for t in teams]
matches_query = TeamMatch.query.filter(
TeamMatch.org_team_id.in_(team_ids),
) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
matches_query = (
TeamMatch.query.filter(
TeamMatch.org_team_id.in_(team_ids),
)
if team_ids
else TeamMatch.query.filter(TeamMatch.id == -1)
)
else:
teams = []
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
@@ -58,18 +68,25 @@ def list_matches():
confirmed, total = tm.get_confirmed_count()
participants = []
for p in tm.participants.all():
participants.append({
'id': p.id, 'player': p.player,
'is_confirmed': p.is_confirmed,
})
match_data.append({
'match': tm, 'participants': participants,
'confirmed_count': confirmed, 'total_count': total,
})
participants.append(
{
'id': p.id,
'player': p.player,
'is_confirmed': p.is_confirmed,
}
)
match_data.append(
{
'match': tm,
'participants': participants,
'confirmed_count': confirmed,
'total_count': total,
}
)
return render_template('pages/team_matches.html',
teams=teams, match_data=match_data,
now=datetime.utcnow())
return render_template(
'pages/team_matches.html', teams=teams, match_data=match_data, now=datetime.utcnow()
)
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
@@ -87,6 +104,7 @@ def create_match(team_id):
default_title = 'Practice' if is_practice else f'Team Match — {team.name}'
if is_practice and request.method == 'GET':
class TryoutProxy:
def __init__(self, team_obj):
self.id = 0
@@ -98,10 +116,16 @@ def create_match(team_id):
proxy_tryout = TryoutProxy(team)
all_players = [tp.player for tp in team_players if tp.player]
return render_template('pages/match_form.html',
tryout=proxy_tryout, teams=[], all_players=all_players,
prefill_date=prefill_date, is_practice=True,
team_id=team_id, team=team)
return render_template(
'pages/match_form.html',
tryout=proxy_tryout,
teams=[],
all_players=all_players,
prefill_date=prefill_date,
is_practice=True,
team_id=team_id,
team=team,
)
if request.method == 'POST':
title = request.form.get('title', default_title)
@@ -114,16 +138,24 @@ def create_match(team_id):
if not date_str:
flash(_('Date is required.'), 'danger')
return render_template('pages/team_match_form.html', team=team,
team_players=team_players, prefill_date=prefill_date)
return render_template(
'pages/team_match_form.html',
team=team,
team_players=team_players,
prefill_date=prefill_date,
)
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash(_('Invalid date format.'), 'danger')
return render_template('pages/team_match_form.html', team=team,
team_players=team_players, prefill_date=prefill_date,
is_practice=is_practice)
return render_template(
'pages/team_match_form.html',
team=team,
team_players=team_players,
prefill_date=prefill_date,
is_practice=is_practice,
)
start_time = None
end_time = None
@@ -138,16 +170,24 @@ def create_match(team_id):
end_time = end_dt.time()
except ValueError:
flash(_('Invalid time format.'), 'danger')
return render_template('pages/team_match_form.html', team=team,
team_players=team_players, prefill_date=prefill_date,
is_practice=is_practice)
return render_template(
'pages/team_match_form.html',
team=team,
team_players=team_players,
prefill_date=prefill_date,
is_practice=is_practice,
)
team_match = TeamMatch(
org_team_id=team_id, title=title,
org_team_id=team_id,
title=title,
description=description or None,
opponent=opponent or None,
date=date_obj, start_time=start_time, end_time=end_time,
location=location or None, created_by=current_user.id,
date=date_obj,
start_time=start_time,
end_time=end_time,
location=location or None,
created_by=current_user.id,
)
db.session.add(team_match)
db.session.flush()
@@ -155,7 +195,8 @@ def create_match(team_id):
notified_participant_ids = []
for tp in team_players:
participant = TeamMatchParticipant(
team_match_id=team_match.id, player_id=tp.player_id,
team_match_id=team_match.id,
player_id=tp.player_id,
)
db.session.add(participant)
db.session.flush()
@@ -165,14 +206,22 @@ def create_match(team_id):
# Discord notifications
event_date_str = date_obj.strftime('%Y-%m-%d')
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
event_time_str = (
f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}"
if start_time and end_time
else 'TBD'
)
for i, tp in enumerate(team_players):
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else team_match.id
reference_id = (
notified_participant_ids[i] if i < len(notified_participant_ids) else team_match.id
)
send_schedule_notification(
user_id=tp.player_id, event_type='match',
user_id=tp.player_id,
event_type='match',
event_title=team_match.title,
event_date=event_date_str, event_time=event_time_str,
event_date=event_date_str,
event_time=event_time_str,
reference_id=reference_id,
)
@@ -229,8 +278,9 @@ def edit_match(match_id):
flash(_('Match updated successfully!'), 'success')
return redirect(url_for('team_matches.list_matches'))
return render_template('pages/team_match_form.html',
match=team_match, team=team, team_players=[])
return render_template(
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
)
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
@@ -282,8 +332,10 @@ def toggle_presence(match_id, participant_id):
participant.is_confirmed = not participant.is_confirmed
db.session.commit()
return jsonify({
'participant_id': participant.id,
'is_confirmed': participant.is_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown',
})
return jsonify(
{
'participant_id': participant.id,
'is_confirmed': participant.is_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown',
}
)
+108 -40
View File
@@ -8,9 +8,19 @@ from flask_login import login_required, current_user
from flask_babel import gettext as _
from app.extensions import db
from app.models import (
Admin, Manager, Coach, Player,
OrgTeam, User, PersonalNote, TeamNote, Tryout, TeamPlayer,
TeamMatch, Contract, OneOnOneRequest,
Admin,
Manager,
Coach,
Player,
OrgTeam,
User,
PersonalNote,
TeamNote,
Tryout,
TeamPlayer,
TeamMatch,
Contract,
OneOnOneRequest,
)
from app.permissions import visible_org_teams
from datetime import datetime
@@ -33,11 +43,21 @@ def list_teams():
teams = visible_org_teams(current_user)
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
coaches = (
User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
)
managers = (
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
)
all_players = User.query.filter_by(role='player').order_by(User.username).all()
return render_template('pages/teams.html', teams=teams, coaches=coaches,
managers=managers, all_players=all_players, can_manage=can_manage)
return render_template(
'pages/teams.html',
teams=teams,
coaches=coaches,
managers=managers,
all_players=all_players,
can_manage=can_manage,
)
@teams_bp.route('/my-teams')
@@ -55,29 +75,40 @@ def my_teams():
team_data = []
for org_team in player_teams:
matches = TeamMatch.query.filter(
TeamMatch.org_team_id == org_team.id,
TeamMatch.status == 'scheduled',
).order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc()).all()
matches = (
TeamMatch.query.filter(
TeamMatch.org_team_id == org_team.id,
TeamMatch.status == 'scheduled',
)
.order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc())
.all()
)
matches_data = []
for tm in matches:
confirmed, total = tm.get_confirmed_count()
participant = TeamMatchParticipant.query.filter_by(
team_match_id=tm.id, player_id=current_user.id,
team_match_id=tm.id,
player_id=current_user.id,
).first()
matches_data.append({
'match': tm,
'participant_id': participant.id if participant else None,
'is_confirmed': participant.is_confirmed if participant else False,
'confirmed_count': confirmed, 'total_count': total,
})
matches_data.append(
{
'match': tm,
'participant_id': participant.id if participant else None,
'is_confirmed': participant.is_confirmed if participant else False,
'confirmed_count': confirmed,
'total_count': total,
}
)
team_data.append({
'team': org_team, 'matches': matches_data,
'coaches': org_team.get_coaches(),
'managers': org_team.get_managers(),
})
team_data.append(
{
'team': org_team,
'matches': matches_data,
'coaches': org_team.get_coaches(),
'managers': org_team.get_managers(),
}
)
return render_template('pages/my_teams.html', team_data=team_data, now=now)
@@ -215,11 +246,12 @@ def delete_team(team_id):
# Entities that survive it.
Tryout.query.filter_by(target_org_team_id=team_id).update(
{'target_org_team_id': None}, synchronize_session=False)
Contract.query.filter_by(team_id=team_id).update(
{'team_id': None}, synchronize_session=False)
{'target_org_team_id': None}, synchronize_session=False
)
Contract.query.filter_by(team_id=team_id).update({'team_id': None}, synchronize_session=False)
OneOnOneRequest.query.filter_by(org_team_id=team_id).update(
{'org_team_id': None}, synchronize_session=False)
{'org_team_id': None}, synchronize_session=False
)
db.session.delete(team)
db.session.commit()
@@ -247,14 +279,24 @@ def add_coach(team_id):
return redirect(url_for('teams.list_teams'))
if team.coaches.filter_by(id=coach.id).first():
flash(_('%(username)s is already a coach of %(name)s.', username=coach.username, name=team.name), 'info')
flash(
_(
'%(username)s is already a coach of %(name)s.',
username=coach.username,
name=team.name,
),
'info',
)
return redirect(url_for('teams.list_teams'))
team.coaches.append(coach)
if not team.coach_id:
team.coach_id = coach.id
db.session.commit()
flash(_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name), 'success')
flash(
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
'success',
)
return redirect(url_for('teams.list_teams'))
@@ -278,14 +320,24 @@ def add_manager(team_id):
return redirect(url_for('teams.list_teams'))
if team.managers.filter_by(id=manager.id).first():
flash(_('%(username)s is already a manager of %(name)s.', username=manager.username, name=team.name), 'info')
flash(
_(
'%(username)s is already a manager of %(name)s.',
username=manager.username,
name=team.name,
),
'info',
)
return redirect(url_for('teams.list_teams'))
team.managers.append(manager)
if not team.manager_id:
team.manager_id = manager.id
db.session.commit()
flash(_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name), 'success')
flash(
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
'success',
)
return redirect(url_for('teams.list_teams'))
@@ -361,7 +413,10 @@ def add_player(team_id):
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
if existing:
flash(_('%(username)s is already on %(name)s.', username=player.username, name=team.name), 'info')
flash(
_('%(username)s is already on %(name)s.', username=player.username, name=team.name),
'info',
)
return redirect(url_for('teams.list_teams'))
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
@@ -383,12 +438,18 @@ def remove_player(team_id, player_id):
player = User.query.get_or_404(player_id)
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
flash(_('%(username)s is not on %(name)s.', username=player.username, name=team.name), 'danger')
flash(
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
'danger',
)
return redirect(url_for('teams.list_teams'))
db.session.delete(tp)
db.session.commit()
flash(_('%(username)s removed from %(name)s.', username=player.username, name=team.name), 'success')
flash(
_('%(username)s removed from %(name)s.', username=player.username, name=team.name),
'success',
)
return redirect(url_for('teams.list_teams'))
@@ -406,10 +467,14 @@ def toggle_player_status(team_id, player_id):
tp.status = 'substitute' if tp.status == 'starter' else 'starter'
db.session.commit()
return jsonify({
'success': True, 'player_id': player_id,
'new_status': tp.status, 'player_name': tp.player.username,
})
return jsonify(
{
'success': True,
'player_id': player_id,
'new_status': tp.status,
'player_name': tp.player.username,
}
)
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
@@ -446,7 +511,10 @@ def add_player_note(team_id, player_id):
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
flash(_('%(username)s is not on %(name)s.', username=player.username, name=team.name), 'danger')
flash(
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
'danger',
)
return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip()
@@ -455,4 +523,4 @@ def add_player_note(team_id, player_id):
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'))
+230 -92
View File
@@ -9,10 +9,23 @@ from flask_login import login_required, current_user
from flask_babel import gettext as _
from app.extensions import db
from app.models import (
Admin, Manager, Coach, Player, Scout,
User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember,
OrgTeam, Match, MatchParticipant, PersonalNote,
ESPORT_GAMES, GAME_POSITIONS,
Admin,
Manager,
Coach,
Player,
Scout,
User,
Tryout,
TryoutRegistration,
Evaluation,
Team,
TeamMember,
OrgTeam,
Match,
MatchParticipant,
PersonalNote,
ESPORT_GAMES,
GAME_POSITIONS,
)
from datetime import datetime
@@ -44,8 +57,12 @@ def create_tryout():
return redirect(url_for('tryouts.list_tryouts'))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
managers = (
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
)
coaches = (
User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
)
if request.method == 'POST':
title = request.form.get('title')
@@ -63,8 +80,14 @@ def create_tryout():
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash(_('Invalid start date format.'), 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
return render_template(
'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
end_date_obj = None
if end_date_str:
@@ -72,19 +95,35 @@ def create_tryout():
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
if end_date_obj < date_obj:
flash(_('End date cannot be before start date.'), 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
return render_template(
'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
except (ValueError, TypeError):
flash(_('Invalid end date format.'), 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
return render_template(
'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
tryout = Tryout(
title=title, description=description, game=game, date=date_obj,
title=title,
description=description,
game=game,
date=date_obj,
end_date=end_date_obj,
location=location,
max_players=int(max_players) if max_players else None,
created_by=current_user.id, status='upcoming',
created_by=current_user.id,
status='upcoming',
target_org_team_id=int(target_org_team_id) if target_org_team_id else None,
manager_id=int(manager_id) if manager_id else None,
)
@@ -100,8 +139,14 @@ def create_tryout():
flash(_('Tryout created successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
return render_template(
'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
@tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST'])
@@ -119,8 +164,12 @@ def edit_tryout(tryout_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.full_name).all()
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all()
managers = (
User.query.filter_by(role='manager', is_active_account=True).order_by(User.full_name).all()
)
coaches = (
User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all()
)
if request.method == 'POST':
title = request.form.get('title')
@@ -138,8 +187,14 @@ def edit_tryout(tryout_id):
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash(_('Invalid start date format.'), 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
return render_template(
'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
end_date_obj = None
if end_date_str:
@@ -147,12 +202,24 @@ def edit_tryout(tryout_id):
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
if end_date_obj < date_obj:
flash(_('End date cannot be before start date.'), 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
return render_template(
'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
except (ValueError, TypeError):
flash(_('Invalid end date format.'), 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
return render_template(
'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
tryout.title = title
tryout.description = description
@@ -175,8 +242,14 @@ def edit_tryout(tryout_id):
flash(_('Tryout updated successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
return render_template(
'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
@tryouts_bp.route('/<int:tryout_id>')
@@ -193,12 +266,21 @@ def view_tryout(tryout_id):
elif isinstance(current_user, Coach):
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player):
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id).first() is not None
player_in_match = MatchParticipant.query.join(Match).filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
).first() is not None
is_registered = (
TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id
).first()
is not None
)
player_in_match = (
MatchParticipant.query.join(Match)
.filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
)
.first()
is not None
)
can_view = is_registered or player_in_match
elif isinstance(current_user, Scout):
can_view = True
@@ -215,39 +297,55 @@ def view_tryout(tryout_id):
if current_user.can_evaluate():
for p in registered_players:
existing = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
tryout_id=tryout_id,
player_id=p.id,
evaluator_id=current_user.id,
).first()
player_eval_status[p.id] = existing is not None
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id,
).first() is not None
is_registered = (
TryoutRegistration.query.filter_by(
tryout_id=tryout_id,
player_id=current_user.id,
).first()
is not None
)
teams = Team.query.filter_by(tryout_id=tryout_id).all()
team_data = []
for team in teams:
members = TeamMember.query.filter_by(team_id=team.id).all()
team_data.append({
'team': team,
'members': [{'player': User.query.get(m.player_id), 'position': m.position}
for m in members],
})
team_data.append(
{
'team': team,
'members': [
{'player': User.query.get(m.player_id), 'position': m.position} for m in members
],
}
)
can_edit = current_user.can_manage_this_tryout(tryout)
can_view_calendar = can_edit
if isinstance(current_user, Player):
player_in_match = MatchParticipant.query.join(Match).filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
).first() is not None
player_in_match = (
MatchParticipant.query.join(Match)
.filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
)
.first()
is not None
)
can_view_calendar = is_registered or player_in_match
all_players = None
if can_edit:
all_players = User.query.filter_by(role='player').order_by(User.username).all()
matches = Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
matches = (
Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
)
match_data = []
for match in matches:
all_participants = list(match.participants.all())
@@ -257,47 +355,79 @@ def view_tryout(tryout_id):
player_presence = []
for p in all_participants:
if p.player:
player_presence.append({
'participant_id': p.id, 'player_id': p.player_id,
'player_name': p.player.username,
'attendance_confirmed': p.attendance_confirmed,
})
player_presence.append(
{
'participant_id': p.id,
'player_id': p.player_id,
'player_name': p.player.username,
'attendance_confirmed': p.attendance_confirmed,
}
)
if match.match_type == 'team_vs_team':
participants = {
'team1': match.team1.name if match.team1 else 'TBD',
'team2': match.team2.name if match.team2 else 'TBD',
'team1_players': [{'name': m.player.username, 'position': m.position}
for m in match.team1.members.all()] if match.team1 else [],
'team2_players': [{'name': m.player.username, 'position': m.position}
for m in match.team2.members.all()] if match.team2 else [],
'team1_players': [
{'name': m.player.username, 'position': m.position}
for m in match.team1.members.all()
]
if match.team1
else [],
'team2_players': [
{'name': m.player.username, 'position': m.position}
for m in match.team2.members.all()
]
if match.team2
else [],
}
elif match.match_type == 'player_vs_player':
team1_players = [{'name': p.player.username, 'position': p.position}
for p in match.participants.filter_by(team_side=1).all() if p.player]
team2_players = [{'name': p.player.username, 'position': p.position}
for p in match.participants.filter_by(team_side=2).all() if p.player]
team1_players = [
{'name': p.player.username, 'position': p.position}
for p in match.participants.filter_by(team_side=1).all()
if p.player
]
team2_players = [
{'name': p.player.username, 'position': p.position}
for p in match.participants.filter_by(team_side=2).all()
if p.player
]
participants = {
'team1': 'Team 1', 'team2': 'Team 2',
'team1_players': team1_players, 'team2_players': team2_players,
'team1': 'Team 1',
'team2': 'Team 2',
'team1_players': team1_players,
'team2_players': team2_players,
}
else:
participants = [p.player.username for p in match.participants.all()]
match_data.append({
'match': match, 'participants': participants,
'confirmed_count': confirmed_count, 'total_count': total_count,
'player_presence': player_presence,
})
match_data.append(
{
'match': match,
'participants': participants,
'confirmed_count': confirmed_count,
'total_count': total_count,
'player_presence': player_presence,
}
)
return render_template('pages/view_tryout.html',
tryout=tryout, registered_players=registered_players,
evaluations=evaluations, player_eval_status=player_eval_status,
is_registered=is_registered, registrations=registrations,
team_data=team_data, can_edit=can_edit,
can_view_calendar=can_view_calendar, all_players=all_players,
matches=matches, match_data=match_data,
game_positions=GAME_POSITIONS, now=datetime.utcnow())
return render_template(
'pages/view_tryout.html',
tryout=tryout,
registered_players=registered_players,
evaluations=evaluations,
player_eval_status=player_eval_status,
is_registered=is_registered,
registrations=registrations,
team_data=team_data,
can_edit=can_edit,
can_view_calendar=can_view_calendar,
all_players=all_players,
matches=matches,
match_data=match_data,
game_positions=GAME_POSITIONS,
now=datetime.utcnow(),
)
@tryouts_bp.route('/<int:tryout_id>/register', methods=['POST'])
@@ -314,7 +444,8 @@ def register_for_tryout(tryout_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id).first()
tryout_id=tryout_id, player_id=current_user.id
).first()
if existing:
flash(_('You are already registered for this tryout.'), 'info')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -358,7 +489,8 @@ def update_registration_status(tryout_id, player_id):
return redirect(url_for('tryouts.list_tryouts'))
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id).first_or_404()
tryout_id=tryout_id, player_id=player_id
).first_or_404()
new_status = request.form.get('status')
if new_status in ['registered', 'attended', 'no_show']:
registration.status = new_status
@@ -385,10 +517,12 @@ def register_player(tryout_id):
flash(_('Can only register players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player.id).first()
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first()
if existing:
flash(_('%(username)s is already registered for this tryout.', username=player.username), 'info')
flash(
_('%(username)s is already registered for this tryout.', username=player.username),
'info',
)
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players:
@@ -416,7 +550,8 @@ def remove_player(tryout_id, player_id):
player = User.query.get_or_404(player_id)
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id).first()
tryout_id=tryout_id, player_id=player_id
).first()
if registration:
db.session.delete(registration)
@@ -479,8 +614,10 @@ def add_to_team(tryout_id, team_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# Only players registered for this tryout may be placed on its teams.
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id).first() is not None
is_registered = (
TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first()
is not None
)
if not is_registered:
flash(_('That player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -513,24 +650,25 @@ def delete_tryout(tryout_id):
# about a player, not tryout data. Only their context links are cleared.
# Missing this step made the deletion fail on the foreign keys below.
PersonalNote.query.filter_by(tryout_id=tryout_id).update(
{'tryout_id': None}, synchronize_session=False)
{'tryout_id': None}, synchronize_session=False
)
if match_ids:
PersonalNote.query.filter(PersonalNote.match_id.in_(match_ids)).update(
{'match_id': None}, synchronize_session=False)
{'match_id': None}, synchronize_session=False
)
if team_ids:
PersonalNote.query.filter(PersonalNote.team_id.in_(team_ids)).update(
{'team_id': None}, synchronize_session=False)
{'team_id': None}, synchronize_session=False
)
if match_ids:
MatchParticipant.query.filter(
MatchParticipant.match_id.in_(match_ids)
).delete(synchronize_session=False)
MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids)).delete(
synchronize_session=False
)
Match.query.filter(Match.id.in_(match_ids)).delete(synchronize_session=False)
if team_ids:
TeamMember.query.filter(
TeamMember.team_id.in_(team_ids)
).delete(synchronize_session=False)
TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).delete(synchronize_session=False)
Team.query.filter(Team.id.in_(team_ids)).delete(synchronize_session=False)
TryoutRegistration.query.filter_by(tryout_id=tryout_id).delete()
@@ -539,4 +677,4 @@ def delete_tryout(tryout_id):
db.session.delete(tryout)
db.session.commit()
flash(_('Tryout deleted successfully.'), 'success')
return redirect(url_for('tryouts.list_tryouts'))
return redirect(url_for('tryouts.list_tryouts'))
+478 -207
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
# supporting scripts package
# supporting scripts package
+20 -10
View File
@@ -53,6 +53,7 @@ class BackupError(Exception):
# Connection handling
# ---------------------------------------------------------------------------
def parse_database_url(url):
"""Split a SQLAlchemy/PostgreSQL URL into pg_dump connection settings.
@@ -110,14 +111,19 @@ def build_dump_command(conn, output_path):
"""
return [
PG_DUMP,
'--host', conn['host'],
'--port', conn['port'],
'--username', conn['user'],
'--dbname', conn['dbname'],
'--host',
conn['host'],
'--port',
conn['port'],
'--username',
conn['user'],
'--dbname',
conn['dbname'],
'--format=custom',
'--no-owner',
'--no-privileges',
'--file', output_path,
'--file',
output_path,
]
@@ -133,6 +139,7 @@ def dump_environment(conn):
# Backup steps
# ---------------------------------------------------------------------------
def create_backup_dir():
"""Create the backup directory if it doesn't exist."""
os.makedirs(BACKUP_DIR, exist_ok=True)
@@ -201,7 +208,9 @@ def verify_backup(backup_path):
try:
result = subprocess.run(
[PG_RESTORE, '--list', backup_path],
capture_output=True, text=True, timeout=300,
capture_output=True,
text=True,
timeout=300,
)
except FileNotFoundError:
print(f'[WARNING] {PG_RESTORE} not found: archive left unverified.')
@@ -214,8 +223,7 @@ def verify_backup(backup_path):
print(f'[ERROR] Archive is not readable: {result.stderr.strip()}')
return False
table_count = sum(1 for line in result.stdout.splitlines()
if ' TABLE DATA ' in line)
table_count = sum(1 for line in result.stdout.splitlines() if ' TABLE DATA ' in line)
if table_count == 0:
print('[ERROR] Archive contains no table data.')
return False
@@ -282,6 +290,7 @@ def cleanup_old_backups():
# Entry point
# ---------------------------------------------------------------------------
def main(argv=None):
"""Run the full backup process.
@@ -290,8 +299,9 @@ def main(argv=None):
previous version returned 0 even when it had backed up nothing.
"""
parser = argparse.ArgumentParser(description='Team Tryouts backup')
parser.add_argument('--verify-only', metavar='ARCHIVE',
help='Verify an existing archive and exit')
parser.add_argument(
'--verify-only', metavar='ARCHIVE', help='Verify an existing archive and exit'
)
args = parser.parse_args(argv)
if args.verify_only:
+20 -8
View File
@@ -37,14 +37,26 @@ def generate_self_signed_cert():
print('[INFO] Generating self-signed certificate for localhost...')
try:
subprocess.run([
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
'-keyout', KEY_FILE,
'-out', CERT_FILE,
'-days', '365',
'-nodes',
'-subj', '/CN=localhost'
], check=True, capture_output=True)
subprocess.run(
[
'openssl',
'req',
'-x509',
'-newkey',
'rsa:2048',
'-keyout',
KEY_FILE,
'-out',
CERT_FILE,
'-days',
'365',
'-nodes',
'-subj',
'/CN=localhost',
],
check=True,
capture_output=True,
)
print('[OK] Certificate generated: certs/localhost.pem')
except FileNotFoundError:
print('[ERROR] OpenSSL not found. Install OpenSSL or use:')
+70 -64
View File
@@ -23,18 +23,18 @@ from datetime import datetime
def check_environment():
"""Check required environment variables are set.
Returns:
bool: True if all critical variables are set.
"""
print('=' * 60)
print('1. ENVIRONMENT VARIABLES CHECK')
print('=' * 60)
critical_vars = ['SECRET_KEY']
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS']
all_ok = True
for var in critical_vars:
value = os.getenv(var)
if value:
@@ -47,37 +47,37 @@ def check_environment():
else:
print(f'[FAIL] {var} is not set!')
all_ok = False
for var in recommended_vars:
value = os.getenv(var)
if value:
print(f'[OK] {var} is set')
else:
print(f'[INFO] {var} is not set (using default)')
# Check FLASK_DEBUG
debug = os.getenv('FLASK_DEBUG', 'false').lower()
if debug == 'true':
print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.')
else:
print('[OK] FLASK_DEBUG is disabled')
return all_ok
def check_https_headers(url):
"""Check HTTP security headers from a running application.
Args:
url: The base URL of the application to check.
Returns:
bool: True if all critical headers are present.
"""
print('\n' + '=' * 60)
print('2. HTTP SECURITY HEADERS CHECK')
print('=' * 60)
required_headers = {
'Strict-Transport-Security': 'HSTS enabled',
'X-Content-Type-Options': 'Prevents MIME sniffing',
@@ -87,31 +87,31 @@ def check_https_headers(url):
'Permissions-Policy': 'Permissions control',
'Cross-Origin-Opener-Policy': 'Cross-origin isolation',
}
all_ok = True
try:
# Create a context that doesn't verify SSL (for local testing)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, method='HEAD')
try:
with urllib.request.urlopen(req, context=ctx, timeout=10) as response:
headers = response.headers
status = response.status
print(f'[INFO] Response status: {status}')
for header, description in required_headers.items():
if header in headers:
print(f'[OK] {header}: {description}')
else:
print(f'[FAIL] {header} is missing: {description}')
all_ok = False
# Check cookie attributes if any set-cookie headers exist
if 'Set-Cookie' in headers:
cookie = headers['Set-Cookie']
@@ -120,21 +120,23 @@ def check_https_headers(url):
else:
print('[WARN] Cookies missing Secure flag')
all_ok = False
if 'HttpOnly' in cookie:
print('[OK] Cookies have HttpOnly flag')
else:
print('[WARN] Cookies missing HttpOnly flag')
all_ok = False
if 'SameSite' in cookie:
print(f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}')
print(
f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}'
)
else:
print('[WARN] Cookies missing SameSite attribute')
all_ok = False
else:
print('[INFO] No Set-Cookie headers in response')
except urllib.error.HTTPError as e:
print(f'[INFO] Got HTTP {e.code} (may need authentication)')
# Still check headers even on error responses
@@ -144,33 +146,33 @@ def check_https_headers(url):
else:
print(f'[FAIL] {header} is missing: {description}')
all_ok = False
except urllib.error.URLError as e:
print(f'[SKIP] Cannot connect to {url}: {e.reason}')
print('[SKIP] Run with --url <application_url> to check headers')
return True # Not a failure, just can't check
return all_ok
def check_dependencies():
"""Run pip-audit to check for known vulnerabilities.
Returns:
bool: True if no critical vulnerabilities found.
"""
print('\n' + '=' * 60)
print('3. DEPENDENCY VULNERABILITY SCAN')
print('=' * 60)
try:
result = subprocess.run(
[sys.executable, '-m', 'pip_audit', '--format', 'json'],
capture_output=True,
text=True,
timeout=60
timeout=60,
)
if result.returncode == 0:
print('[OK] No known vulnerabilities found')
return True
@@ -182,15 +184,10 @@ def check_dependencies():
# clean. Treating the array itself as the vulnerability list
# reported all ~45 installed packages as vulnerable on every
# run, which is why this check was pure noise.
affected = [
dep for dep in data.get('dependencies', [])
if dep.get('vulns')
]
affected = [dep for dep in data.get('dependencies', []) if dep.get('vulns')]
if affected:
for dep in affected:
ids = ', '.join(
v.get('id', '?') for v in dep.get('vulns', [])
)
ids = ', '.join(v.get('id', '?') for v in dep.get('vulns', []))
print(f'[FAIL] {dep["name"]}=={dep["version"]}: {ids}')
return False
else:
@@ -212,23 +209,23 @@ def check_dependencies():
def check_file_permissions():
"""Check for common security issues in the project structure.
Returns:
bool: True if no critical issues found.
"""
print('\n' + '=' * 60)
print('4. PROJECT FILES CHECK')
print('=' * 60)
all_ok = True
# Check .gitignore exists and contains important patterns
gitignore_path = os.path.join(os.getcwd(), '.gitignore')
if os.path.exists(gitignore_path):
required_patterns = ['.env', 'instance/', '*.db', '*.log']
with open(gitignore_path, 'r') as f:
content = f.read()
for pattern in required_patterns:
if pattern in content:
print(f'[OK] .gitignore contains: {pattern}')
@@ -238,14 +235,14 @@ def check_file_permissions():
else:
print('[FAIL] .gitignore file not found!')
all_ok = False
# Check for .env in working directory (should NOT be committed)
env_path = os.path.join(os.getcwd(), '.env')
if os.path.exists(env_path):
print('[INFO] .env file exists (ensure it is NOT committed)')
else:
print('[WARN] No .env file found')
# Check for leftover .pyc or __pycache__
pycache_count = 0
for root, dirs, files in os.walk(os.getcwd()):
@@ -258,22 +255,22 @@ def check_file_permissions():
print('[OK] No __pycache__ or .pyc files found')
else:
print(f'[INFO] Found {pycache_count} cache files/dirs (should be in .gitignore)')
return all_ok
def check_flask_config():
"""Check Flask application configuration for security.
Returns:
bool: True if configuration looks secure.
"""
print('\n' + '=' * 60)
print('5. FLASK CONFIGURATION CHECK')
print('=' * 60)
all_ok = True
try:
# The script lives two levels below the project root; without this the
# import fails and the whole check was silently skipped.
@@ -282,12 +279,15 @@ def check_flask_config():
sys.path.insert(0, root)
from app.app import create_app
# Inspect configuration only: no schema creation, no Discord bot.
app = create_app({
'SQLALCHEMY_DATABASE_URI': os.getenv('DATABASE_URL') or 'sqlite:///:memory:',
'AUTO_CREATE_TABLES': False,
'ENABLE_DISCORD_BOT': False,
})
app = create_app(
{
'SQLALCHEMY_DATABASE_URI': os.getenv('DATABASE_URL') or 'sqlite:///:memory:',
'AUTO_CREATE_TABLES': False,
'ENABLE_DISCORD_BOT': False,
}
)
# Check session cookie settings
cookie_checks = [
@@ -295,7 +295,7 @@ def check_flask_config():
('SESSION_COOKIE_HTTPONLY', True, 'HttpOnly cookies'),
('PERMANENT_SESSION_LIFETIME', 3600, 'Session timeout'),
]
for config_key, expected, description in cookie_checks:
value = app.config.get(config_key)
if config_key == 'PERMANENT_SESSION_LIFETIME':
@@ -309,7 +309,7 @@ def check_flask_config():
else:
print(f'[FAIL] {description}: {value}')
all_ok = False
# Check MAX_CONTENT_LENGTH
max_content = app.config.get('MAX_CONTENT_LENGTH')
if max_content:
@@ -318,7 +318,7 @@ def check_flask_config():
else:
print('[WARN] MAX_CONTENT_LENGTH not set (unlimited uploads)')
all_ok = False
# Check CSRF
csrf_enabled = app.config.get('WTF_CSRF_ENABLED')
if csrf_enabled:
@@ -326,14 +326,14 @@ def check_flask_config():
else:
print('[FAIL] CSRF protection: disabled')
all_ok = False
# Check if app is in DEBUG mode
if app.debug:
print('[FAIL] DEBUG mode is enabled!')
all_ok = False
else:
print('[OK] DEBUG mode: disabled')
except Exception as e:
# Returning all_ok (still True) here meant that failing to load the
# application at all was counted as a passing check — the most
@@ -346,17 +346,23 @@ def check_flask_config():
def main():
"""Run all security checks and produce a summary report.
Returns:
int: 0 if all checks pass, 1 if any fail.
"""
import argparse
parser = argparse.ArgumentParser(description='Security validation scanner')
parser.add_argument('--url', default='http://localhost:5000',
help='Application URL to check headers (default: http://localhost:5000)')
parser.add_argument('--skip-http', action='store_true',
help='Skip the live HTTP header check (no server running, e.g. in CI)')
parser.add_argument(
'--url',
default='http://localhost:5000',
help='Application URL to check headers (default: http://localhost:5000)',
)
parser.add_argument(
'--skip-http',
action='store_true',
help='Skip the live HTTP header check (no server running, e.g. in CI)',
)
args = parser.parse_args()
# Plain ASCII: the box-drawing characters this banner used crashed the
@@ -381,18 +387,18 @@ def main():
results = []
for check in checks:
results.append(check())
print('\n' + '=' * 60)
print('SUMMARY')
print('=' * 60)
passed = sum(1 for r in results if r)
failed = sum(1 for r in results if not r)
total = len(results)
print(f'Passed: {passed}/{total}')
print(f'Failed: {failed}/{total}')
if failed == 0:
print('\n[OK] All security checks passed!')
return 0
@@ -402,4 +408,4 @@ def main():
if __name__ == '__main__':
sys.exit(main())
sys.exit(main())
+63 -67
View File
@@ -12,7 +12,15 @@ Usage:
import re
from flask_babel import lazy_gettext as _l
from marshmallow import Schema, fields, validate, ValidationError, pre_load, validates_schema, EXCLUDE
from marshmallow import (
Schema,
fields,
validate,
ValidationError,
pre_load,
validates_schema,
EXCLUDE,
)
from app.models import USER_TYPES
@@ -20,58 +28,55 @@ from app.models import USER_TYPES
# Custom Validators
# =============================================================================
PASSWORD_POLICY = re.compile(
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$'
)
PASSWORD_POLICY = re.compile(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$')
"""Password policy: minimum 8 chars, 1 uppercase, 1 lowercase, 1 digit."""
def validate_password(value):
"""Validate password meets strength requirements.
Requires: minimum 8 characters, at least one uppercase letter,
one lowercase letter, and one digit.
Args:
value: The password string to validate.
Raises:
ValidationError: If password does not meet requirements.
"""
if not PASSWORD_POLICY.match(value):
raise ValidationError(_l(
'Password must be at least 8 characters with uppercase, '
'lowercase, and a number.'
))
raise ValidationError(
_l('Password must be at least 8 characters with uppercase, lowercase, and a number.')
)
def validate_username(value):
"""Validate username format.
Usernames must be 3-30 characters and contain only alphanumeric
characters, underscores, and hyphens.
Args:
value: The username string to validate.
Raises:
ValidationError: If username does not meet requirements.
"""
if not re.match(r'^[a-zA-Z0-9_-]{3,30}$', value):
raise ValidationError(_l(
'Username must be 3-30 characters (letters, numbers, underscore, hyphen).'
))
raise ValidationError(
_l('Username must be 3-30 characters (letters, numbers, underscore, hyphen).')
)
def validate_discord_username(value):
"""Validate Discord username format if provided.
Accepts empty strings (optional field). Validates that the username
matches common Discord username patterns.
Args:
value: The Discord username to validate.
Raises:
ValidationError: If the format is invalid.
"""
@@ -83,12 +88,12 @@ def validate_discord_username(value):
def validate_discord_user_id(value):
"""Validate Discord user ID (snowflake) if provided.
Discord user IDs are 17-20 digit numbers.
Args:
value: The Discord user ID to validate.
Raises:
ValidationError: If the format is invalid.
"""
@@ -100,12 +105,12 @@ def validate_discord_user_id(value):
def validate_phone(value):
"""Validate optional phone number format.
Accepts empty strings. Validates common phone formats.
Args:
value: The phone number to validate.
Raises:
ValidationError: If the format is invalid.
"""
@@ -120,21 +125,23 @@ def validate_phone(value):
# Validation Schemas
# =============================================================================
class StripMixin(Schema):
"""Mixin that automatically strips whitespace from all string fields
and ignores unknown fields (e.g., csrf_token from Flask-WTF)."""
class Meta:
"""Marshmallow Meta options."""
unknown = EXCLUDE # Ignore csrf_token and other unknown fields
@pre_load
def strip_strings(self, data, **kwargs):
"""Strip whitespace from all string values in the input data.
Args:
data: The input dictionary.
Returns:
dict: Data with stripped strings.
"""
@@ -145,11 +152,12 @@ class StripMixin(Schema):
class LoginSchema(StripMixin):
"""Validate login form input.
Fields:
username: 3-30 chars, required.
password: Non-empty, required.
"""
username = fields.String(
required=True,
validate=validate.Length(min=1, max=80, error=_l('Username is required.')),
@@ -162,7 +170,7 @@ class LoginSchema(StripMixin):
class RegisterSchema(StripMixin):
"""Validate player registration form input.
Fields:
username: 3-30 chars alphanumeric, required.
email: Valid email, required.
@@ -174,6 +182,7 @@ class RegisterSchema(StripMixin):
discord_username: Optional, valid format.
league_os_profile: Optional URL.
"""
username = fields.String(
required=True,
validate=[
@@ -223,10 +232,10 @@ class RegisterSchema(StripMixin):
@validates_schema
def validate_password_match(self, data, **kwargs):
"""Ensure confirm_password matches password.
Args:
data: The validated data dictionary.
Raises:
ValidationError: If passwords do not match.
"""
@@ -236,7 +245,7 @@ class RegisterSchema(StripMixin):
class CreateUserSchema(StripMixin):
"""Validate president-created user form input.
Fields:
username: 3-30 chars alphanumeric, required.
email: Valid email, required.
@@ -245,6 +254,7 @@ class CreateUserSchema(StripMixin):
role: Must be valid role, required.
phone: Optional, valid phone format.
"""
username = fields.String(
required=True,
validate=[
@@ -267,10 +277,7 @@ class CreateUserSchema(StripMixin):
)
role = fields.String(
required=True,
validate=validate.OneOf(
USER_TYPES,
error=_l('Invalid role selected.')
),
validate=validate.OneOf(USER_TYPES, error=_l('Invalid role selected.')),
)
phone = fields.String(
validate=validate_phone,
@@ -281,7 +288,7 @@ class CreateUserSchema(StripMixin):
class EditUserSchema(StripMixin):
"""Validate president-edited user form input.
Fields:
full_name: 1-100 chars, required.
email: Valid email, required.
@@ -294,6 +301,7 @@ class EditUserSchema(StripMixin):
league_os_profile: Optional.
games: Optional list.
"""
full_name = fields.String(
required=True,
validate=validate.Length(min=1, max=100, error=_l('Full name is required.')),
@@ -304,10 +312,7 @@ class EditUserSchema(StripMixin):
)
role = fields.String(
required=True,
validate=validate.OneOf(
USER_TYPES,
error=_l('Invalid role selected.')
),
validate=validate.OneOf(USER_TYPES, error=_l('Invalid role selected.')),
)
is_active_account = fields.Boolean(load_default=True)
phone = fields.String(
@@ -341,7 +346,7 @@ class EditUserSchema(StripMixin):
class EditProfileSchema(StripMixin):
"""Validate self-edit profile form input.
Fields:
username: 3-30 chars, required.
full_name: 1-100 chars, required.
@@ -353,6 +358,7 @@ class EditProfileSchema(StripMixin):
league_os_profile: Optional.
games: Optional list.
"""
username = fields.String(
required=True,
validate=[
@@ -399,11 +405,12 @@ class EditProfileSchema(StripMixin):
class UploadContractSchema(StripMixin):
"""Validate contract upload form input.
Fields:
player_id: Integer, required.
notes: Optional text.
"""
player_id = fields.Integer(
required=True,
validate=validate.Range(min=1, error=_l('Player must be selected.')),
@@ -417,33 +424,27 @@ class UploadContractSchema(StripMixin):
class OneOnOneRequestSchema(StripMixin):
"""Validate One on One session request form input.
Fields:
date: Date string (YYYY-MM-DD), required.
start_time: Time string (HH:MM), required.
end_time: Time string (HH:MM), required.
points: Optional text.
"""
date = fields.String(
required=True,
validate=validate.Regexp(
r'^\d{4}-\d{2}-\d{2}$',
error=_l('Date must be in YYYY-MM-DD format.')
r'^\d{4}-\d{2}-\d{2}$', error=_l('Date must be in YYYY-MM-DD format.')
),
)
start_time = fields.String(
required=True,
validate=validate.Regexp(
r'^\d{2}:\d{2}$',
error=_l('Start time must be in HH:MM format.')
),
validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('Start time must be in HH:MM format.')),
)
end_time = fields.String(
required=True,
validate=validate.Regexp(
r'^\d{2}:\d{2}$',
error=_l('End time must be in HH:MM format.')
),
validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('End time must be in HH:MM format.')),
)
points = fields.String(
validate=validate.Length(max=2000, error=_l('Points must be 2000 characters or less.')),
@@ -454,22 +455,17 @@ class OneOnOneRequestSchema(StripMixin):
class DisponibilityAddSchema(StripMixin):
"""Validate disponibility block addition.
Fields:
day_of_week: Integer 0-6, required.
start_time: Time string (HH:MM), required.
"""
day_of_week = fields.Integer(
required=True,
validate=validate.Range(
min=0, max=6,
error=_l('Day must be 0 (Monday) to 6 (Sunday).')
),
validate=validate.Range(min=0, max=6, error=_l('Day must be 0 (Monday) to 6 (Sunday).')),
)
start_time = fields.String(
required=True,
validate=validate.Regexp(
r'^\d{2}:\d{2}$',
error=_l('Start time must be in HH:MM format.')
),
)
validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('Start time must be in HH:MM format.')),
)