From 7cec18c1392e2c7bb1fff5d8eb0795ed37ed0647 Mon Sep 17 00:00:00 2001 From: GGThed Date: Sat, 8 Aug 2026 15:53:10 -0400 Subject: [PATCH] style: formater le depot avec ruff format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/app.py | 116 +-- app/discord_bot.py | 462 +++++++----- app/extensions.py | 15 +- app/logging_config.py | 33 +- app/models/__init__.py | 2 +- app/models/_associations.py | 45 +- app/models/_constants.py | 8 +- app/models/availability/__init__.py | 2 +- app/models/availability/base.py | 4 +- app/models/availability/coach_availability.py | 4 +- .../availability/player_disponibility.py | 4 +- app/models/contract.py | 4 +- app/models/evaluation.py | 4 +- app/models/match_model/__init__.py | 3 +- app/models/match_model/base.py | 4 +- app/models/match_model/match.py | 8 +- app/models/match_model/team_match.py | 8 +- app/models/one_on_one_request.py | 4 +- app/models/org_team/__init__.py | 3 +- app/models/org_team/org_team.py | 34 +- app/models/org_team/team_player.py | 4 +- app/models/participant/__init__.py | 3 +- app/models/participant/base.py | 4 +- app/models/participant/match_participant.py | 4 +- .../participant/team_match_participant.py | 4 +- app/models/personal_note.py | 4 +- app/models/team/__init__.py | 3 +- app/models/team/team.py | 4 +- app/models/team/team_member.py | 4 +- app/models/team_note.py | 4 +- app/models/tryout/__init__.py | 3 +- app/models/tryout/tryout.py | 11 +- app/models/tryout/tryout_registration.py | 4 +- app/models/user_gamertag.py | 6 +- app/models/user_model/__init__.py | 1 + app/models/user_model/admin.py | 3 + app/models/user_model/coach.py | 4 + app/models/user_model/manager.py | 11 +- app/models/user_model/player.py | 32 +- app/models/user_model/scout.py | 3 + app/models/user_model/user.py | 24 +- app/permissions.py | 43 +- app/routes/auth.py | 52 +- app/routes/evaluations.py | 144 ++-- app/routes/main.py | 146 ++-- app/routes/matches.py | 357 ++++++--- app/routes/team_matches.py | 138 ++-- app/routes/teams.py | 148 +++- app/routes/tryouts.py | 322 +++++--- app/routes/users.py | 685 ++++++++++++------ app/supporting_scripts/__init__.py | 2 +- app/supporting_scripts/backup.py | 30 +- app/supporting_scripts/run_https.py | 28 +- app/supporting_scripts/security_scan.py | 134 ++-- app/validators.py | 130 ++-- clear_db.py | 26 +- tests/conftest.py | 1 + tests/test_audit_logging.py | 82 ++- tests/test_auth_session.py | 41 +- tests/test_authorization.py | 169 +++-- tests/test_backup.py | 6 +- tests/test_calendar_api.py | 40 +- tests/test_contract_uploads.py | 26 +- tests/test_csp.py | 7 +- tests/test_database_url.py | 28 +- tests/test_deletions.py | 105 ++- tests/test_discord_oauth.py | 34 +- tests/test_i18n.py | 110 +-- tests/test_permissions.py | 145 ++-- tests/test_role_change.py | 8 +- tests/test_security_headers.py | 4 +- wsgi.py | 9 +- 72 files changed, 2658 insertions(+), 1449 deletions(-) diff --git a/app/app.py b/app/app.py index 46b0a1a..c37103d 100644 --- a/app/app.py +++ b/app/app.py @@ -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) diff --git a/app/discord_bot.py b/app/discord_bot.py index dc1a80f..a2823bc 100644 --- a/app/discord_bot.py +++ b/app/discord_bot.py @@ -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") \ No newline at end of file + logger.warning("DISCORD_BOT_TOKEN not set, bot not started") diff --git a/app/extensions.py b/app/extensions.py index e49214c..d262e4b 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -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) \ No newline at end of file + return check_password_hash(password_hash, password) diff --git a/app/logging_config.py b/app/logging_config.py index e460bed..35004e9 100644 --- a/app/logging_config.py +++ b/app/logging_config.py @@ -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)) \ No newline at end of file + get_auth_logger().info(' '.join(parts)) diff --git a/app/models/__init__.py b/app/models/__init__.py index e82c7e9..e285720 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 \ No newline at end of file +from app.models.one_on_one_request import OneOnOneRequest diff --git a/app/models/_associations.py b/app/models/_associations.py index 53b2775..207fa54 100644 --- a/app/models/_associations.py +++ b/app/models/_associations.py @@ -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 + ), ) diff --git a/app/models/_constants.py b/app/models/_constants.py index 180c14a..a0ce394 100644 --- a/app/models/_constants.py +++ b/app/models/_constants.py @@ -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}', -} \ No newline at end of file +} diff --git a/app/models/availability/__init__.py b/app/models/availability/__init__.py index c4c2f7a..b60623d 100644 --- a/app/models/availability/__init__.py +++ b/app/models/availability/__init__.py @@ -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'] \ No newline at end of file +__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability'] diff --git a/app/models/availability/base.py b/app/models/availability/base.py index fb1dd06..6fc6c4e 100644 --- a/app/models/availability/base.py +++ b/app/models/availability/base.py @@ -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) \ No newline at end of file + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/app/models/availability/coach_availability.py b/app/models/availability/coach_availability.py index deafc35..53bbdfd 100644 --- a/app/models/availability/coach_availability.py +++ b/app/models/availability/coach_availability.py @@ -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') \ No newline at end of file + coach = db.relationship('User', backref='coach_availabilities') diff --git a/app/models/availability/player_disponibility.py b/app/models/availability/player_disponibility.py index 7d51dad..b3b3d2c 100644 --- a/app/models/availability/player_disponibility.py +++ b/app/models/availability/player_disponibility.py @@ -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') \ No newline at end of file + player = db.relationship('User', backref='disponibilities') diff --git a/app/models/contract.py b/app/models/contract.py index cda8fb6..e1a97ca 100644 --- a/app/models/contract.py +++ b/app/models/contract.py @@ -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 \ No newline at end of file + return user.id == self.player_id diff --git a/app/models/evaluation.py b/app/models/evaluation.py index 8f5ec5b..8127d6e 100644 --- a/app/models/evaluation.py +++ b/app/models/evaluation.py @@ -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'), - ) \ No newline at end of file + ) diff --git a/app/models/match_model/__init__.py b/app/models/match_model/__init__.py index 1608108..6d05ddc 100644 --- a/app/models/match_model/__init__.py +++ b/app/models/match_model/__init__.py @@ -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'] \ No newline at end of file +__all__ = ['BaseMatch', 'Match', 'TeamMatch'] diff --git a/app/models/match_model/base.py b/app/models/match_model/base.py index 48ad8df..ca63991 100644 --- a/app/models/match_model/base.py +++ b/app/models/match_model/base.py @@ -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) \ No newline at end of file + created_at = db.Column(db.DateTime, default=datetime.utcnow) diff --git a/app/models/match_model/match.py b/app/models/match_model/match.py index 7d63eb3..df2740c 100644 --- a/app/models/match_model/match.py +++ b/app/models/match_model/match.py @@ -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()] \ No newline at end of file + return [p.player_id for p in self.participants.all()] diff --git a/app/models/match_model/team_match.py b/app/models/match_model/team_match.py index cc36947..5862348 100644 --- a/app/models/match_model/team_match.py +++ b/app/models/match_model/team_match.py @@ -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) \ No newline at end of file + return confirmed, len(all_p) diff --git a/app/models/one_on_one_request.py b/app/models/one_on_one_request.py index 1d264ae..2a726ca 100644 --- a/app/models/one_on_one_request.py +++ b/app/models/one_on_one_request.py @@ -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]) \ No newline at end of file + team = db.relationship('OrgTeam', foreign_keys=[org_team_id]) diff --git a/app/models/org_team/__init__.py b/app/models/org_team/__init__.py index a5f2da9..83614f1 100644 --- a/app/models/org_team/__init__.py +++ b/app/models/org_team/__init__.py @@ -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'] \ No newline at end of file +__all__ = ['OrgTeam', 'TeamPlayer'] diff --git a/app/models/org_team/org_team.py b/app/models/org_team/org_team.py index 8cb7a15..86e9007 100644 --- a/app/models/org_team/org_team.py +++ b/app/models/org_team/org_team.py @@ -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] \ No newline at end of file + return [ + {'player': tp.player, 'status': tp.status, 'position': tp.position} + for tp in self.team_players + ] diff --git a/app/models/org_team/team_player.py b/app/models/org_team/team_player.py index 0b4a720..c36fe7b 100644 --- a/app/models/org_team/team_player.py +++ b/app/models/org_team/team_player.py @@ -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'), - ) \ No newline at end of file + ) diff --git a/app/models/participant/__init__.py b/app/models/participant/__init__.py index a282cb7..4219682 100644 --- a/app/models/participant/__init__.py +++ b/app/models/participant/__init__.py @@ -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'] \ No newline at end of file +__all__ = ['BaseParticipant', 'MatchParticipant', 'TeamMatchParticipant'] diff --git a/app/models/participant/base.py b/app/models/participant/base.py index ff1dddc..6d5d99c 100644 --- a/app/models/participant/base.py +++ b/app/models/participant/base.py @@ -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) \ No newline at end of file + added_at = db.Column(db.DateTime, default=datetime.utcnow) diff --git a/app/models/participant/match_participant.py b/app/models/participant/match_participant.py index 6209e78..ce73b68 100644 --- a/app/models/participant/match_participant.py +++ b/app/models/participant/match_participant.py @@ -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') \ No newline at end of file + player = db.relationship('User') diff --git a/app/models/participant/team_match_participant.py b/app/models/participant/team_match_participant.py index a57c61c..bc29c5a 100644 --- a/app/models/participant/team_match_participant.py +++ b/app/models/participant/team_match_participant.py @@ -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') \ No newline at end of file + player = db.relationship('User') diff --git a/app/models/personal_note.py b/app/models/personal_note.py index c12318c..b8c3bc8 100644 --- a/app/models/personal_note.py +++ b/app/models/personal_note.py @@ -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]) \ No newline at end of file + tryout = db.relationship('Tryout', foreign_keys=[tryout_id]) diff --git a/app/models/team/__init__.py b/app/models/team/__init__.py index 1808aa2..a77b5b7 100644 --- a/app/models/team/__init__.py +++ b/app/models/team/__init__.py @@ -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'] \ No newline at end of file +__all__ = ['Team', 'TeamMember'] diff --git a/app/models/team/team.py b/app/models/team/team.py index c692eba..d243fdc 100644 --- a/app/models/team/team.py +++ b/app/models/team/team.py @@ -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') \ No newline at end of file + members = db.relationship('TeamMember', backref='team', lazy='dynamic') diff --git a/app/models/team/team_member.py b/app/models/team/team_member.py index e84b231..8861298 100644 --- a/app/models/team/team_member.py +++ b/app/models/team/team_member.py @@ -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") \ No newline at end of file + player = db.relationship('User', overlaps="player_ref,team_assignments") diff --git a/app/models/team_note.py b/app/models/team_note.py index 2de61f5..46ebbfb 100644 --- a/app/models/team_note.py +++ b/app/models/team_note.py @@ -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]) \ No newline at end of file + coach = db.relationship('User', foreign_keys=[coach_id]) diff --git a/app/models/tryout/__init__.py b/app/models/tryout/__init__.py index 5765899..986fe95 100644 --- a/app/models/tryout/__init__.py +++ b/app/models/tryout/__init__.py @@ -1,5 +1,6 @@ """Tryout models.""" + from app.models.tryout.tryout import Tryout from app.models.tryout.tryout_registration import TryoutRegistration -__all__ = ['Tryout', 'TryoutRegistration'] \ No newline at end of file +__all__ = ['Tryout', 'TryoutRegistration'] diff --git a/app/models/tryout/tryout.py b/app/models/tryout/tryout.py index fed9ec5..3d34a49 100644 --- a/app/models/tryout/tryout.py +++ b/app/models/tryout/tryout.py @@ -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 diff --git a/app/models/tryout/tryout_registration.py b/app/models/tryout/tryout_registration.py index 8016ad7..162f7d8 100644 --- a/app/models/tryout/tryout_registration.py +++ b/app/models/tryout/tryout_registration.py @@ -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) \ No newline at end of file + notes = db.Column(db.Text, nullable=True) diff --git a/app/models/user_gamertag.py b/app/models/user_gamertag.py index ed8b729..b6f194b 100644 --- a/app/models/user_gamertag.py +++ b/app/models/user_gamertag.py @@ -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: diff --git a/app/models/user_model/__init__.py b/app/models/user_model/__init__.py index 05166d5..2a81dbb 100644 --- a/app/models/user_model/__init__.py +++ b/app/models/user_model/__init__.py @@ -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 diff --git a/app/models/user_model/admin.py b/app/models/user_model/admin.py index 3d0d1e2..6505b60 100644 --- a/app/models/user_model/admin.py +++ b/app/models/user_model/admin.py @@ -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() diff --git a/app/models/user_model/coach.py b/app/models/user_model/coach.py index 693d8a0..be7c55f 100644 --- a/app/models/user_model/coach.py +++ b/app/models/user_model/coach.py @@ -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) diff --git a/app/models/user_model/manager.py b/app/models/user_model/manager.py index 7c71b57..10a7b94 100644 --- a/app/models/user_model/manager.py +++ b/app/models/user_model/manager.py @@ -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() + ) diff --git a/app/models/user_model/player.py b/app/models/user_model/player.py index f53a2ee..74c0067 100644 --- a/app/models/user_model/player.py +++ b/app/models/user_model/player.py @@ -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] diff --git a/app/models/user_model/scout.py b/app/models/user_model/scout.py index 5757160..4c0ba13 100644 --- a/app/models/user_model/scout.py +++ b/app/models/user_model/scout.py @@ -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() diff --git a/app/models/user_model/user.py b/app/models/user_model/user.py index 0de7282..3a27544 100644 --- a/app/models/user_model/user.py +++ b/app/models/user_model/user.py @@ -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.""" diff --git a/app/permissions.py b/app/permissions.py index 5f30c59..00bfe7f 100644 --- a/app/permissions.py +++ b/app/permissions.py @@ -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. diff --git a/app/routes/auth.py b/app/routes/auth.py index b9c972e..e6b790a 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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')) \ No newline at end of file + return redirect(url_for('auth.login')) diff --git a/app/routes/evaluations.py b/app/routes/evaluations.py index 134123f..ba696c8 100644 --- a/app/routes/evaluations.py +++ b/app/routes/evaluations.py @@ -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('//', 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('//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) \ No newline at end of file + return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players) diff --git a/app/routes/main.py b/app/routes/main.py index 727b563..7109451 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -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) \ No newline at end of file + return render_template('pages/dashboard.html', user=user, stats=stats) diff --git a/app/routes/matches.py b/app/routes/matches.py index 63a2a2e..6944843 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -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('//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', - }) \ No newline at end of file + return jsonify( + { + 'participant_id': participant.id, + 'attendance_confirmed': participant.attendance_confirmed, + 'player_name': participant.player.username if participant.player else 'Unknown', + } + ) diff --git a/app/routes/team_matches.py b/app/routes/team_matches.py index 1032295..0ef562e 100644 --- a/app/routes/team_matches.py +++ b/app/routes/team_matches.py @@ -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('//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('//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', - }) \ No newline at end of file + return jsonify( + { + 'participant_id': participant.id, + 'is_confirmed': participant.is_confirmed, + 'player_name': participant.player.username if participant.player else 'Unknown', + } + ) diff --git a/app/routes/teams.py b/app/routes/teams.py index 81ae8c2..d038437 100644 --- a/app/routes/teams.py +++ b/app/routes/teams.py @@ -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('//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')) \ No newline at end of file + return redirect(url_for('teams.list_teams')) diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index f80823f..2c545a7 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -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('//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('/') @@ -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('//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')) \ No newline at end of file + return redirect(url_for('tryouts.list_tryouts')) diff --git a/app/routes/users.py b/app/routes/users.py index 5f36f46..5b7fd4f 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -10,24 +10,46 @@ from flask_login import login_required, current_user from flask_babel import gettext as _ from app.extensions import db, hash_password from app.models import ( - Admin, Manager, Coach, Player, Scout, - User, USER_TYPES, ESPORT_GAMES, - PlayerDisponibility, UserGamertag, GAME_PLATFORMS, - Contract, OrgTeam, CoachAvailability, - TeamNote, PersonalNote, OneOnOneRequest, - Evaluation, Match, Team, TeamMember, - MatchParticipant, Tryout, TryoutRegistration, TeamPlayer, + Admin, + Manager, + Coach, + Player, + Scout, + User, + USER_TYPES, + ESPORT_GAMES, + PlayerDisponibility, + UserGamertag, + GAME_PLATFORMS, + Contract, + OrgTeam, + CoachAvailability, + TeamNote, + PersonalNote, + OneOnOneRequest, + Evaluation, + Match, + Team, + TeamMember, + MatchParticipant, + Tryout, + TryoutRegistration, + TeamPlayer, ) from werkzeug.utils import secure_filename from datetime import datetime, timedelta from marshmallow import ValidationError from app.validators import ( - CreateUserSchema, EditUserSchema, EditProfileSchema, + CreateUserSchema, + EditUserSchema, + EditProfileSchema, UploadContractSchema, ) from app.logging_config import log_auth_event from app.permissions import ( - can_manage_player_contract, coach_can_access_player, coach_org_teams, + can_manage_player_contract, + coach_can_access_player, + coach_org_teams, coach_player_ids, ) import requests @@ -76,6 +98,7 @@ def pdf_upload_error(file, allowed_extensions): # Form helpers shared by the schema-validated routes # --------------------------------------------------------------------------- + def _flash_validation_errors(err): """Surface marshmallow errors the same way auth.py already does.""" for field, messages in err.messages.items(): @@ -103,16 +126,20 @@ def _form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('pas payload.pop(name, None) return payload + # --------------------------------------------------------------------------- # Gamertag helper (shared) # --------------------------------------------------------------------------- + def update_user_gamertags(user, selected_games): """Update gamertags for a user based on form input.""" existing_gamertags = {gt.game: gt for gt in user.gamertags} for game in selected_games: gamertag = request.form.get(f'gamertag_{game}', '').strip() - platform = request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None + platform = ( + request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None + ) existing = existing_gamertags.get(game) if gamertag: if existing: @@ -145,6 +172,7 @@ _USER_CLASS_MAP = { # ROUTES # =========================================================================== + @users_bp.route('') @login_required def list_users(): @@ -171,16 +199,20 @@ def edit_user(user_id): actor_name, actor_id = current_user.username, current_user.id def _rerender(): - return render_template('pages/edit_user.html', user=user, roles=USER_TYPES, - esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, - user_gamertags={gt.game: {'gamertag': gt.gamertag, - 'platform': gt.platform} - for gt in user.gamertags}) + return render_template( + 'pages/edit_user.html', + user=user, + roles=USER_TYPES, + esport_games=ESPORT_GAMES, + game_platforms=GAME_PLATFORMS, + user_gamertags={ + gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} + for gt in user.gamertags + }, + ) try: - validated = EditUserSchema().load( - _form_payload(checkboxes=('is_active_account',)) - ) + validated = EditUserSchema().load(_form_payload(checkboxes=('is_active_account',))) except ValidationError as err: _flash_validation_errors(err) return _rerender() @@ -209,8 +241,9 @@ def edit_user(user_id): # Two ways to lock everyone out of administration, neither of # which any interface can undo afterwards. if user.id == actor_id: - flash(_('You cannot change your own role. Ask another ' - 'president to do it.'), 'danger') + flash( + _('You cannot change your own role. Ask another president to do it.'), 'danger' + ) return _rerender() if user.role == 'admin': @@ -220,8 +253,13 @@ def edit_user(user_id): User.id != user.id, ).count() if remaining_admins == 0: - flash(_('This is the last active president. Promote ' - 'another account before changing this one.'), 'danger') + flash( + _( + 'This is the last active president. Promote ' + 'another account before changing this one.' + ), + 'danger', + ) return _rerender() if role_changed: @@ -271,25 +309,45 @@ def edit_user(user_id): # Logged after the commit, not before: the audit trail should record # what happened, and until this point nothing had. if role_changed: - log_auth_event('account.role_changed', - actor=actor_name, actor_id=actor_id, - target=user.username, target_id=user.id, - previous_role=previous_role, new_role=role) + log_auth_event( + 'account.role_changed', + actor=actor_name, + actor_id=actor_id, + target=user.username, + target_id=user.id, + previous_role=previous_role, + new_role=role, + ) if password: - log_auth_event('account.password_reset_by_admin', - actor=actor_name, actor_id=actor_id, - target=user.username, target_id=user.id) - log_auth_event('account.updated', - actor=actor_name, actor_id=actor_id, - target=user.username, target_id=user.id, active=is_active) + log_auth_event( + 'account.password_reset_by_admin', + actor=actor_name, + actor_id=actor_id, + target=user.username, + target_id=user.id, + ) + log_auth_event( + 'account.updated', + actor=actor_name, + actor_id=actor_id, + target=user.username, + target_id=user.id, + active=is_active, + ) flash(_('User %(username)s updated successfully!', username=user.username), 'success') return redirect(url_for('users.list_users')) - user_gamertags = {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} - for gt in user.gamertags} - return render_template('pages/edit_user.html', user=user, roles=USER_TYPES, - esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, - user_gamertags=user_gamertags) + user_gamertags = { + gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in user.gamertags + } + return render_template( + 'pages/edit_user.html', + user=user, + roles=USER_TYPES, + esport_games=ESPORT_GAMES, + game_platforms=GAME_PLATFORMS, + user_gamertags=user_gamertags, + ) @users_bp.route('//delete', methods=['POST']) @@ -335,10 +393,18 @@ def delete_user(user_id): deleted_username, deleted_role = user.username, user.role db.session.delete(user) db.session.commit() - log_auth_event('account.deleted', - actor=current_user.username, actor_id=current_user.id, - target=deleted_username, target_id=user_id, role=deleted_role) - flash(_('User %(deleted_username)s has been removed.', deleted_username=deleted_username), 'success') + log_auth_event( + 'account.deleted', + actor=current_user.username, + actor_id=current_user.id, + target=deleted_username, + target_id=user_id, + role=deleted_role, + ) + flash( + _('User %(deleted_username)s has been removed.', deleted_username=deleted_username), + 'success', + ) return redirect(url_for('users.list_users')) @@ -377,16 +443,26 @@ def create_user(): hashed_password = hash_password(password) user_cls = _USER_CLASS_MAP.get(role, Player) user = user_cls( - username=username, password_hash=hashed_password, - role=role, full_name=full_name, - email=email, phone=phone, + username=username, + password_hash=hashed_password, + role=role, + full_name=full_name, + email=email, + phone=phone, ) db.session.add(user) db.session.commit() - log_auth_event('account.created_by_admin', - actor=current_user.username, actor_id=current_user.id, - target=user.username, target_id=user.id, role=role) - flash(_('User %(full_name)s created as %(role)s!', full_name=full_name, role=role), 'success') + log_auth_event( + 'account.created_by_admin', + actor=current_user.username, + actor_id=current_user.id, + target=user.username, + target_id=user.id, + role=role, + ) + flash( + _('User %(full_name)s created as %(role)s!', full_name=full_name, role=role), 'success' + ) return redirect(url_for('users.list_users')) return render_template('pages/create_user.html', roles=USER_TYPES) @@ -406,9 +482,13 @@ def profile(): """View the current user's profile.""" contracts = None if isinstance(current_user, Player): - contracts = Contract.query.filter_by( - player_id=current_user.id, - ).order_by(Contract.uploaded_at.desc()).all() + contracts = ( + Contract.query.filter_by( + player_id=current_user.id, + ) + .order_by(Contract.uploaded_at.desc()) + .all() + ) existing_availability = None if isinstance(current_user, Coach): @@ -416,8 +496,12 @@ def profile(): coach_id=current_user.id, ).all() - return render_template('pages/profile.html', user=current_user, contracts=contracts, - existing_availability=existing_availability) + return render_template( + 'pages/profile.html', + user=current_user, + contracts=contracts, + existing_availability=existing_availability, + ) @users_bp.route('/profile/edit', methods=['GET', 'POST']) @@ -429,9 +513,13 @@ def edit_profile(): validated = EditProfileSchema().load(_form_payload()) except ValidationError as err: _flash_validation_errors(err) - return render_template('pages/edit_profile.html', user=current_user, - esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, - user_gamertags=current_user.get_gamertags()) + return render_template( + 'pages/edit_profile.html', + user=current_user, + esport_games=ESPORT_GAMES, + game_platforms=GAME_PLATFORMS, + user_gamertags=current_user.get_gamertags(), + ) username = validated['username'] full_name = validated['full_name'] @@ -444,15 +532,23 @@ def edit_profile(): if username != current_user.username and User.query.filter_by(username=username).first(): flash(_('Username already taken.'), 'danger') - return render_template('pages/edit_profile.html', user=current_user, - esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, - user_gamertags=current_user.get_gamertags()) + return render_template( + 'pages/edit_profile.html', + user=current_user, + esport_games=ESPORT_GAMES, + game_platforms=GAME_PLATFORMS, + user_gamertags=current_user.get_gamertags(), + ) if email != current_user.email and User.query.filter_by(email=email).first(): flash(_('Email already in use.'), 'danger') - return render_template('pages/edit_profile.html', user=current_user, - esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, - user_gamertags=current_user.get_gamertags()) + return render_template( + 'pages/edit_profile.html', + user=current_user, + esport_games=ESPORT_GAMES, + game_platforms=GAME_PLATFORMS, + user_gamertags=current_user.get_gamertags(), + ) current_user.username = username current_user.full_name = full_name @@ -470,16 +566,21 @@ def edit_profile(): password = validated.get('password') if password: current_user.password_hash = hash_password(password) - log_auth_event('account.password_changed', - username=current_user.username, user_id=current_user.id) + log_auth_event( + 'account.password_changed', username=current_user.username, user_id=current_user.id + ) db.session.commit() flash(_('Profile updated successfully!'), 'success') return redirect(url_for('users.profile')) - return render_template('pages/edit_profile.html', user=current_user, - esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, - user_gamertags=current_user.get_gamertags()) + return render_template( + 'pages/edit_profile.html', + user=current_user, + esport_games=ESPORT_GAMES, + game_platforms=GAME_PLATFORMS, + user_gamertags=current_user.get_gamertags(), + ) # --------------------------------------------------------------------------- @@ -500,7 +601,9 @@ def get_disponibilities(): if not current_user.can_manage_teams() and not current_user.can_schedule_matches(): return jsonify({'error': 'Unauthorized'}), 403 - players = User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all() + players = ( + User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all() + ) result = {} for player in players: disponibilities = list(player.disponibilities) @@ -508,7 +611,8 @@ def get_disponibilities(): 'username': player.username, 'disponibilities': [ { - 'id': d.id, 'day_of_week': d.day_of_week, + 'id': d.id, + 'day_of_week': d.day_of_week, 'day_name': DAY_NAMES[d.day_of_week], 'start_time': d.start_time.strftime('%H:%M'), 'end_time': d.end_time.strftime('%H:%M'), @@ -529,12 +633,15 @@ def get_my_disponibilities(): day = d.day_of_week if day not in result: result[day] = [] - result[day].append({ - 'id': d.id, 'day_of_week': d.day_of_week, - 'day_name': DAY_NAMES[d.day_of_week], - 'start_time': d.start_time.strftime('%H:%M'), - 'end_time': d.end_time.strftime('%H:%M'), - }) + result[day].append( + { + 'id': d.id, + 'day_of_week': d.day_of_week, + 'day_name': DAY_NAMES[d.day_of_week], + 'start_time': d.start_time.strftime('%H:%M'), + 'end_time': d.end_time.strftime('%H:%M'), + } + ) return jsonify(result) @@ -553,17 +660,22 @@ def add_disponibility(): end_time = add_30_minutes(start_time) disponibility = PlayerDisponibility( - player_id=current_user.id, day_of_week=day_of_week, - start_time=start_time, end_time=end_time, + player_id=current_user.id, + day_of_week=day_of_week, + start_time=start_time, + end_time=end_time, ) db.session.add(disponibility) db.session.commit() - return jsonify({ - 'id': disponibility.id, 'day_of_week': disponibility.day_of_week, - 'day_name': DAY_NAMES[disponibility.day_of_week], - 'start_time': disponibility.start_time.strftime('%H:%M'), - 'end_time': disponibility.end_time.strftime('%H:%M'), - }) + return jsonify( + { + 'id': disponibility.id, + 'day_of_week': disponibility.day_of_week, + 'day_name': DAY_NAMES[disponibility.day_of_week], + 'start_time': disponibility.start_time.strftime('%H:%M'), + 'end_time': disponibility.end_time.strftime('%H:%M'), + } + ) @users_bp.route('/disponibilities/add_bulk', methods=['POST']) @@ -585,20 +697,27 @@ def add_disponibilities_bulk(): end_time = add_30_minutes(start_time) existing = PlayerDisponibility.query.filter_by( - player_id=current_user.id, day_of_week=day_of_week, start_time=start_time, + player_id=current_user.id, + day_of_week=day_of_week, + start_time=start_time, ).first() if not existing: disponibility = PlayerDisponibility( - player_id=current_user.id, day_of_week=day_of_week, - start_time=start_time, end_time=end_time, + player_id=current_user.id, + day_of_week=day_of_week, + start_time=start_time, + end_time=end_time, ) db.session.add(disponibility) db.session.flush() - created.append({ - 'id': disponibility.id, 'day_of_week': disponibility.day_of_week, - 'day_name': DAY_NAMES[disponibility.day_of_week], - 'start_time': disponibility.start_time.strftime('%H:%M'), - }) + created.append( + { + 'id': disponibility.id, + 'day_of_week': disponibility.day_of_week, + 'day_name': DAY_NAMES[disponibility.day_of_week], + 'start_time': disponibility.start_time.strftime('%H:%M'), + } + ) db.session.commit() return jsonify({'success': True, 'created': created}) @@ -628,6 +747,7 @@ def delete_disponibility(disponibility_id): # Contracts # --------------------------------------------------------------------------- + def _manageable_players(): """Players the current user may attach a contract to. @@ -638,8 +758,11 @@ def _manageable_players(): """ if isinstance(current_user, Coach): player_ids = coach_player_ids(current_user) - return User.query.filter(User.id.in_(player_ids)).order_by( - User.username).all() if player_ids else [] + return ( + User.query.filter(User.id.in_(player_ids)).order_by(User.username).all() + if player_ids + else [] + ) return User.query.filter_by(role='player').order_by(User.username).all() @@ -651,20 +774,31 @@ def list_contracts(): players = None if isinstance(current_user, Player): - contracts = Contract.query.filter_by( - player_id=current_user.id, - ).order_by(Contract.uploaded_at.desc()).all() + contracts = ( + Contract.query.filter_by( + player_id=current_user.id, + ) + .order_by(Contract.uploaded_at.desc()) + .all() + ) elif isinstance(current_user, (Admin, Manager, Coach)): players = _manageable_players() if players: player_ids = [p.id for p in players] - contracts = Contract.query.filter( - Contract.player_id.in_(player_ids), - ).order_by(Contract.uploaded_at.desc()).all() + contracts = ( + Contract.query.filter( + Contract.player_id.in_(player_ids), + ) + .order_by(Contract.uploaded_at.desc()) + .all() + ) - return render_template('pages/contracts.html', contracts=contracts, players=players - if isinstance(current_user, (Admin, Manager, Coach)) else None) + return render_template( + 'pages/contracts.html', + contracts=contracts, + players=players if isinstance(current_user, (Admin, Manager, Coach)) else None, + ) @users_bp.route('/contracts/upload', methods=['GET', 'POST']) @@ -721,7 +855,8 @@ def upload_contract(): file.save(file_path) contract = Contract( - player_id=player_id, team_id=team.id if team else None, + player_id=player_id, + team_id=team.id if team else None, uploaded_by_id=current_user.id, original_filename=original_filename, stored_filename=stored_filename, @@ -730,7 +865,10 @@ def upload_contract(): ) db.session.add(contract) db.session.commit() - flash(_('Contract uploaded successfully for %(username)s!', username=player.username), 'success') + flash( + _('Contract uploaded successfully for %(username)s!', username=player.username), + 'success', + ) return redirect(url_for('users.list_contracts')) return render_template('pages/upload_contract.html', players=players) @@ -755,7 +893,9 @@ def upload_signed_contract(contract_id): file.save(contract.file_path.replace(contract.stored_filename, signed_filename)) contract.signed_filename = signed_filename - contract.signed_file_path = contract.file_path.replace(contract.stored_filename, signed_filename) + contract.signed_file_path = contract.file_path.replace( + contract.stored_filename, signed_filename + ) contract.status = 'signed' contract.signed_at = datetime.utcnow() db.session.commit() @@ -771,7 +911,9 @@ def download_contract(contract_id): if not contract.can_view(current_user): flash(_('You do not have permission to download this contract.'), 'danger') return redirect(url_for('users.list_contracts')) - return send_file(contract.file_path, as_attachment=True, download_name=contract.original_filename) + return send_file( + contract.file_path, as_attachment=True, download_name=contract.original_filename + ) @users_bp.route('/contracts//download_signed') @@ -785,7 +927,9 @@ def download_signed_contract(contract_id): if not contract.signed_file_path: flash(_('No signed contract available.'), 'danger') return redirect(url_for('users.list_contracts')) - return send_file(contract.signed_file_path, as_attachment=True, download_name=contract.signed_filename) + return send_file( + contract.signed_file_path, as_attachment=True, download_name=contract.signed_filename + ) # --------------------------------------------------------------------------- @@ -795,20 +939,37 @@ def download_signed_contract(contract_id): DISCORD_WEBHOOK_URL = os.environ.get('DISCORD_WEBHOOK_URL', '') -def send_discord_notification(player_name, points, date_str, start_time_str, end_time_str, - team_name, coach_name, coach_discord, coach_discord_id, request_id=None): +def send_discord_notification( + player_name, + points, + date_str, + start_time_str, + end_time_str, + team_name, + coach_name, + coach_discord, + coach_discord_id, + request_id=None, +): """Send a Discord notification for a One on One request.""" import logging + logger = logging.getLogger(__name__) if coach_discord_id: try: from app.discord_bot import send_one_on_one_dm + send_one_on_one_dm( - 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_str, - end_time=end_time_str, points=points, request_id=request_id, + 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_str, + end_time=end_time_str, + points=points, + request_id=request_id, ) except Exception as e: logger.warning(f"Failed to send Discord DM: {e}") @@ -816,29 +977,49 @@ def send_discord_notification(player_name, points, date_str, start_time_str, end if DISCORD_WEBHOOK_URL: try: from app.discord_bot import send_one_on_one_dm + if DISCORD_WEBHOOK_URL.isdigit() and not coach_discord_id: send_one_on_one_dm( - coach_name=coach_name, coach_discord_id=DISCORD_WEBHOOK_URL, - player_name=player_name, team_name=team_name, - date_str=date_str, start_time=start_time_str, - end_time=end_time_str, points=points, + coach_name=coach_name, + coach_discord_id=DISCORD_WEBHOOK_URL, + player_name=player_name, + team_name=team_name, + date_str=date_str, + start_time=start_time_str, + end_time=end_time_str, + points=points, ) elif not DISCORD_WEBHOOK_URL.isdigit(): embed = { - "embeds": [{ - "title": "One on One Request", "color": 3447003, - "fields": [ - {"name": "Player", "value": player_name, "inline": True}, - {"name": "Team", "value": team_name or "Unknown Team", "inline": True}, - {"name": "Date", "value": date_str, "inline": True}, - {"name": "Time", "value": f"{start_time_str} - {end_time_str}", "inline": True}, - {"name": "Discussion Points", "value": points or "No specific points provided", "inline": False}, - ], - "footer": { - "text": f"Coach: {coach_name}" - + (f" (Discord: {coach_discord})" if coach_discord else ""), - }, - }], + "embeds": [ + { + "title": "One on One Request", + "color": 3447003, + "fields": [ + {"name": "Player", "value": player_name, "inline": True}, + { + "name": "Team", + "value": team_name or "Unknown Team", + "inline": True, + }, + {"name": "Date", "value": date_str, "inline": True}, + { + "name": "Time", + "value": f"{start_time_str} - {end_time_str}", + "inline": True, + }, + { + "name": "Discussion Points", + "value": points or "No specific points provided", + "inline": False, + }, + ], + "footer": { + "text": f"Coach: {coach_name}" + + (f" (Discord: {coach_discord})" if coach_discord else ""), + }, + } + ], } requests.post(DISCORD_WEBHOOK_URL, json=embed, timeout=5) except Exception as e: @@ -867,9 +1048,17 @@ def one_on_one(): team_notes = [] if org_team: - team_notes = TeamNote.query.filter_by(org_team_id=org_team.id).order_by(TeamNote.created_at.desc()).all() + team_notes = ( + TeamNote.query.filter_by(org_team_id=org_team.id) + .order_by(TeamNote.created_at.desc()) + .all() + ) - personal_notes = PersonalNote.query.filter_by(player_id=current_user.id).order_by(PersonalNote.created_at.desc()).all() + personal_notes = ( + PersonalNote.query.filter_by(player_id=current_user.id) + .order_by(PersonalNote.created_at.desc()) + .all() + ) coach_availability = [] if coach: @@ -916,9 +1105,12 @@ def one_on_one(): return redirect(url_for('users.one_on_one')) request_obj = OneOnOneRequest( - player_id=current_user.id, coach_id=coach.id, + player_id=current_user.id, + coach_id=coach.id, org_team_id=org_team.id if org_team else None, - date=date_obj, start_time=start_time, end_time=end_time, + date=date_obj, + start_time=start_time, + end_time=end_time, points=points if points else None, ) db.session.add(request_obj) @@ -926,8 +1118,10 @@ def one_on_one(): send_discord_notification( player_name=current_user.full_name, - points=points, date_str=date_str, - start_time_str=start_time_str, end_time_str=end_time_str, + points=points, + date_str=date_str, + start_time_str=start_time_str, + end_time_str=end_time_str, team_name=org_team.name if org_team else 'Unknown Team', coach_name=coach.full_name, coach_discord=coach.discord_username or '', @@ -940,29 +1134,38 @@ def one_on_one(): # Build list of upcoming dates that have coach availability from datetime import date as date_cls, timedelta as td + today = date_cls.today() available_days = {av['day_of_week'] for av in coach_availability} dates = [] for i in range(14): # Next 14 days d = today + td(days=i) if d.weekday() in available_days: - dates.append({ - 'value': d.strftime('%Y-%m-%d'), - 'day_of_week': d.weekday(), - 'display': d.strftime('%B %d, %Y (%A)'), - }) + dates.append( + { + 'value': d.strftime('%Y-%m-%d'), + 'day_of_week': d.weekday(), + 'display': d.strftime('%B %d, %Y (%A)'), + } + ) # Player's own One on One request history - my_requests = OneOnOneRequest.query.filter_by( - player_id=current_user.id - ).order_by(OneOnOneRequest.created_at.desc()).all() + my_requests = ( + OneOnOneRequest.query.filter_by(player_id=current_user.id) + .order_by(OneOnOneRequest.created_at.desc()) + .all() + ) - return render_template('pages/one_on_one.html', - org_team=org_team, coach=coach, - team_notes=team_notes, personal_notes=personal_notes, - coach_availability=coach_availability, - dates=dates, - my_requests=my_requests) + return render_template( + 'pages/one_on_one.html', + org_team=org_team, + coach=coach, + team_notes=team_notes, + personal_notes=personal_notes, + coach_availability=coach_availability, + dates=dates, + my_requests=my_requests, + ) @users_bp.route('/one-on-one//accept', methods=['POST']) @@ -991,18 +1194,27 @@ def accept_one_on_one(request_id): # Notify player via Discord (same message as if approved through Discord reactions) if player and player.discord_user_id: from app.discord_bot import send_one_on_one_response + send_one_on_one_response( player_discord_id=player.discord_user_id, player_full_name=player.full_name, coach_full_name=current_user.full_name, date_str=request_obj.date.strftime('%A, %B %d, %Y'), - start_time=request_obj.start_time.strftime('%I:%M %p') if request_obj.start_time else 'TBD', + start_time=request_obj.start_time.strftime('%I:%M %p') + if request_obj.start_time + else 'TBD', end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD', points=request_obj.points or 'No specific points provided', approved=True, ) - flash(_('One on One request from %(player)s has been approved!', player=player.username if player else 'Unknown'), 'success') + flash( + _( + 'One on One request from %(player)s has been approved!', + player=player.username if player else 'Unknown', + ), + 'success', + ) return redirect(url_for('users.notes_dashboard')) @@ -1036,19 +1248,28 @@ def reject_one_on_one(request_id): # Notify player via Discord (same message as if rejected through Discord reactions) if player and player.discord_user_id: from app.discord_bot import send_one_on_one_response + send_one_on_one_response( player_discord_id=player.discord_user_id, player_full_name=player.full_name, coach_full_name=current_user.full_name, date_str=request_obj.date.strftime('%A, %B %d, %Y'), - start_time=request_obj.start_time.strftime('%I:%M %p') if request_obj.start_time else 'TBD', + start_time=request_obj.start_time.strftime('%I:%M %p') + if request_obj.start_time + else 'TBD', end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD', points=request_obj.points or 'No specific points provided', approved=False, refusal_note=rejection_reason or None, ) - flash(_('One on One request from %(player)s has been rejected.', player=player.username if player else 'Unknown'), 'info') + flash( + _( + 'One on One request from %(player)s has been rejected.', + player=player.username if player else 'Unknown', + ), + 'info', + ) return redirect(url_for('users.notes_dashboard')) @@ -1056,6 +1277,7 @@ def reject_one_on_one(request_id): # My Notes (Player) # --------------------------------------------------------------------------- + @users_bp.route('/my-notes') @login_required def my_notes(): @@ -1067,26 +1289,37 @@ def my_notes(): org_teams = current_user.get_org_teams() org_team = org_teams[0] if org_teams else None - personal_notes = PersonalNote.query.filter_by( - player_id=current_user.id, - ).order_by(PersonalNote.created_at.desc()).all() + personal_notes = ( + PersonalNote.query.filter_by( + player_id=current_user.id, + ) + .order_by(PersonalNote.created_at.desc()) + .all() + ) team_notes = [] if org_team: - team_notes = TeamNote.query.filter_by( - org_team_id=org_team.id, - ).order_by(TeamNote.created_at.desc()).all() + team_notes = ( + TeamNote.query.filter_by( + org_team_id=org_team.id, + ) + .order_by(TeamNote.created_at.desc()) + .all() + ) - return render_template('pages/player_personal_notes.html', - org_team=org_team, - personal_notes=personal_notes, - team_notes=team_notes) + return render_template( + 'pages/player_personal_notes.html', + org_team=org_team, + personal_notes=personal_notes, + team_notes=team_notes, + ) # --------------------------------------------------------------------------- # Coach Availability # --------------------------------------------------------------------------- + @users_bp.route('/coach-availability', methods=['GET', 'POST']) @login_required def manage_coach_availability(): @@ -1110,7 +1343,9 @@ def manage_coach_availability(): continue try: start_time = datetime.strptime(start_time_str, '%H:%M').time() - end_time = (datetime.combine(datetime.today(), start_time) + timedelta(minutes=30)).time() + end_time = ( + datetime.combine(datetime.today(), start_time) + timedelta(minutes=30) + ).time() except (ValueError, TypeError): continue @@ -1129,8 +1364,9 @@ def manage_coach_availability(): coach_id=current_user.id, ).all() - return render_template('pages/coach_availability.html', - existing_availability=existing_availability) + return render_template( + 'pages/coach_availability.html', existing_availability=existing_availability + ) @users_bp.route('/coach-availability/clear', methods=['POST']) @@ -1149,6 +1385,7 @@ def clear_coach_availability(): # Notes Dashboard (Coach) # --------------------------------------------------------------------------- + @users_bp.route('/notes-dashboard') @login_required def notes_dashboard(): @@ -1165,54 +1402,80 @@ def notes_dashboard(): org_team = org_teams[0] if org_teams else None player_ids = coach_player_ids(current_user) - players = User.query.filter(User.id.in_(player_ids)).order_by( - User.username).all() if player_ids else [] + players = ( + User.query.filter(User.id.in_(player_ids)).order_by(User.username).all() + if player_ids + else [] + ) team_notes = [] latest_team_note = None if org_team: - team_notes = TeamNote.query.filter_by( - org_team_id=org_team.id, - ).order_by(TeamNote.created_at.desc()).all() + team_notes = ( + TeamNote.query.filter_by( + org_team_id=org_team.id, + ) + .order_by(TeamNote.created_at.desc()) + .all() + ) latest_team_note = team_notes[0] if team_notes else None # A coach's own notes belong to them whether or not they hold a team; # this list was gated on org_team and came back empty without one. - personal_notes = PersonalNote.query.filter_by( - coach_id=current_user.id, - ).order_by(PersonalNote.created_at.desc()).all() + personal_notes = ( + PersonalNote.query.filter_by( + coach_id=current_user.id, + ) + .order_by(PersonalNote.created_at.desc()) + .all() + ) one_on_one_requests = [] if player_ids: - one_on_one_requests = OneOnOneRequest.query.filter( - OneOnOneRequest.player_id.in_(player_ids) - ).order_by(OneOnOneRequest.created_at.desc()).all() + one_on_one_requests = ( + OneOnOneRequest.query.filter(OneOnOneRequest.player_id.in_(player_ids)) + .order_by(OneOnOneRequest.created_at.desc()) + .all() + ) # For context selectors in the form - matches = Match.query.filter( - db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'), - ).order_by(Match.date.desc()).limit(20).all() - tryouts = Tryout.query.filter_by( - created_by=current_user.id, - ).order_by(Tryout.date.desc()).limit(20).all() + matches = ( + Match.query.filter( + db.or_(Match.created_by == current_user.id, Match.status == 'scheduled'), + ) + .order_by(Match.date.desc()) + .limit(20) + .all() + ) + tryouts = ( + Tryout.query.filter_by( + created_by=current_user.id, + ) + .order_by(Tryout.date.desc()) + .limit(20) + .all() + ) teams = OrgTeam.query.order_by(OrgTeam.name).all() - return render_template('pages/notes.html', - org_team=org_team, - players=players, - team_notes=team_notes, - latest_team_note=latest_team_note, - personal_notes=personal_notes, - one_on_one_requests=one_on_one_requests, - matches=matches, - tryouts=tryouts, - teams=teams) + return render_template( + 'pages/notes.html', + org_team=org_team, + players=players, + team_notes=team_notes, + latest_team_note=latest_team_note, + personal_notes=personal_notes, + one_on_one_requests=one_on_one_requests, + matches=matches, + tryouts=tryouts, + teams=teams, + ) # --------------------------------------------------------------------------- # Manage Team Notes (POST) # --------------------------------------------------------------------------- + @users_bp.route('/team-notes/manage', methods=['POST']) @login_required def manage_team_notes(): @@ -1246,6 +1509,7 @@ def manage_team_notes(): # Manage Personal Notes (POST, simple form) # --------------------------------------------------------------------------- + @users_bp.route('/personal-notes/manage', methods=['POST']) @login_required def manage_personal_notes(): @@ -1285,6 +1549,7 @@ def manage_personal_notes(): # Add Personal Note (POST, full form with context) # --------------------------------------------------------------------------- + @users_bp.route('/personal-notes/add', methods=['POST']) @login_required def add_personal_note(): @@ -1330,6 +1595,7 @@ def add_personal_note(): # Add Note from Tryout context (GET + POST) # --------------------------------------------------------------------------- + @users_bp.route('/personal-notes/tryout/', methods=['GET', 'POST']) @login_required def add_note_from_tryout(tryout_id): @@ -1368,18 +1634,21 @@ def add_note_from_tryout(tryout_id): flash(_('Note added successfully.'), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - return render_template('pages/add_note.html', - context_type='tryout', - tryout=tryout, - players=players, - preselected_player_id=preselected_player_id, - team_notes=[]) + return render_template( + 'pages/add_note.html', + context_type='tryout', + tryout=tryout, + players=players, + preselected_player_id=preselected_player_id, + team_notes=[], + ) # --------------------------------------------------------------------------- # Add Note from Match context (GET + POST) # --------------------------------------------------------------------------- + @users_bp.route('/personal-notes/match/', methods=['GET', 'POST']) @login_required def add_note_from_match(match_id): @@ -1419,10 +1688,12 @@ def add_note_from_match(match_id): flash(_('Note added successfully.'), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=match_obj.tryout_id)) - return render_template('pages/add_note.html', - context_type='match', - tryout=match_obj, - match=match_obj, - players=players, - preselected_player_id=preselected_player_id, - team_notes=[]) + return render_template( + 'pages/add_note.html', + context_type='match', + tryout=match_obj, + match=match_obj, + players=players, + preselected_player_id=preselected_player_id, + team_notes=[], + ) diff --git a/app/supporting_scripts/__init__.py b/app/supporting_scripts/__init__.py index a5bf2f2..d677b40 100644 --- a/app/supporting_scripts/__init__.py +++ b/app/supporting_scripts/__init__.py @@ -1 +1 @@ -# supporting scripts package \ No newline at end of file +# supporting scripts package diff --git a/app/supporting_scripts/backup.py b/app/supporting_scripts/backup.py index 0acfd66..bf89988 100644 --- a/app/supporting_scripts/backup.py +++ b/app/supporting_scripts/backup.py @@ -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: diff --git a/app/supporting_scripts/run_https.py b/app/supporting_scripts/run_https.py index e402b8a..788ca91 100644 --- a/app/supporting_scripts/run_https.py +++ b/app/supporting_scripts/run_https.py @@ -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:') diff --git a/app/supporting_scripts/security_scan.py b/app/supporting_scripts/security_scan.py index 722ed37..b817b14 100644 --- a/app/supporting_scripts/security_scan.py +++ b/app/supporting_scripts/security_scan.py @@ -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 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()) \ No newline at end of file + sys.exit(main()) diff --git a/app/validators.py b/app/validators.py index cfa22c7..a22c886 100644 --- a/app/validators.py +++ b/app/validators.py @@ -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.') - ), - ) \ No newline at end of file + validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('Start time must be in HH:MM format.')), + ) diff --git a/clear_db.py b/clear_db.py index 39b76d4..4d074b0 100644 --- a/clear_db.py +++ b/clear_db.py @@ -15,13 +15,22 @@ def seed_database(): print("Deleting existing data...") tables = [ 'one_on_one_requests', - 'player_disponibilities', 'coach_availabilities', - 'match_participants', 'team_match_participants', - 'matches', 'team_matches', - 'team_players', 'team_members', 'teams', - 'evaluations', 'tryout_registrations', 'tryouts', - 'org_team_coaches', 'org_team_managers', - 'team_notes', 'personal_notes', + 'player_disponibilities', + 'coach_availabilities', + 'match_participants', + 'team_match_participants', + 'matches', + 'team_matches', + 'team_players', + 'team_members', + 'teams', + 'evaluations', + 'tryout_registrations', + 'tryouts', + 'org_team_coaches', + 'org_team_managers', + 'team_notes', + 'personal_notes', 'user_gamertags', 'org_teams', 'users', @@ -51,6 +60,7 @@ def seed_database(): if __name__ == '__main__': from app.app import create_app + app = create_app() with app.app_context(): - seed_database() \ No newline at end of file + seed_database() diff --git a/tests/conftest.py b/tests/conftest.py index 12c9d44..0f0ec73 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -183,6 +183,7 @@ def as_role(app, client, make_user, login): user_id = make_user(role, **kwargs) with app.app_context(): from app.models import User + username = _db.session.get(User, user_id).username response = login(username) assert response.status_code in (301, 302), ( diff --git a/tests/test_audit_logging.py b/tests/test_audit_logging.py index 1bd9f11..0823c04 100644 --- a/tests/test_audit_logging.py +++ b/tests/test_audit_logging.py @@ -106,10 +106,17 @@ class TestAdministrativeEvents: def test_account_creation_is_recorded(self, client, as_role, auth_log): as_role('admin') - client.post('/users/create', data={ - 'username': 'newcoach', 'email': 'newcoach@example.test', - 'password': 'Password123', 'full_name': 'New Coach', 'role': 'coach', - }, follow_redirects=True) + client.post( + '/users/create', + data={ + 'username': 'newcoach', + 'email': 'newcoach@example.test', + 'password': 'Password123', + 'full_name': 'New Coach', + 'role': 'coach', + }, + follow_redirects=True, + ) assert _has_event(auth_log, 'account.created_by_admin') @@ -117,10 +124,16 @@ class TestAdministrativeEvents: target_id = make_user('player') as_role('admin') - client.post(f'/users/{target_id}/edit', data={ - 'full_name': 'Promoted', 'email': 'promoted@example.test', - 'role': 'coach', 'is_active_account': 'on', - }, follow_redirects=True) + client.post( + f'/users/{target_id}/edit', + data={ + 'full_name': 'Promoted', + 'email': 'promoted@example.test', + 'role': 'coach', + 'is_active_account': 'on', + }, + follow_redirects=True, + ) message = next(m for m in _events(auth_log) if 'event=account.role_changed' in m) assert 'previous_role=player' in message @@ -135,17 +148,21 @@ class TestAdministrativeEvents: assert _has_event(auth_log, 'account.deleted') - def test_a_self_service_password_change_is_recorded( - self, app, client, as_role, auth_log - ): + def test_a_self_service_password_change_is_recorded(self, app, client, as_role, auth_log): user_id = as_role('player') with app.app_context(): username = db.session.get(User, user_id).username - client.post('/users/profile/edit', data={ - 'username': username, 'full_name': 'Same Name', - 'email': 'same@example.test', 'password': 'BrandNew123', - }, follow_redirects=True) + client.post( + '/users/profile/edit', + data={ + 'username': username, + 'full_name': 'Same Name', + 'email': 'same@example.test', + 'password': 'BrandNew123', + }, + follow_redirects=True, + ) assert _has_event(auth_log, 'account.password_changed') @@ -162,8 +179,13 @@ class TestRedaction: from app.logging_config import SensitiveDataFilter record = logging.LogRecord( - 'test', logging.ERROR, 'x.py', 1, - 'Database failure: %s', ('password=hunter2 host=db.internal',), None, + 'test', + logging.ERROR, + 'x.py', + 1, + 'Database failure: %s', + ('password=hunter2 host=db.internal',), + None, ) SensitiveDataFilter().filter(record) @@ -175,8 +197,13 @@ class TestRedaction: from app.logging_config import SensitiveDataFilter record = logging.LogRecord( - 'test', logging.INFO, 'x.py', 1, - 'Calling Discord with %s', ('Bearer abcdef123456',), None, + 'test', + logging.INFO, + 'x.py', + 1, + 'Calling Discord with %s', + ('Bearer abcdef123456',), + None, ) SensitiveDataFilter().filter(record) @@ -186,8 +213,13 @@ class TestRedaction: from app.logging_config import SensitiveDataFilter record = logging.LogRecord( - 'test', logging.INFO, 'x.py', 1, - 'event=login.success username=%s', ('alice',), None, + 'test', + logging.INFO, + 'x.py', + 1, + 'event=login.success username=%s', + ('alice',), + None, ) SensitiveDataFilter().filter(record) @@ -197,6 +229,12 @@ class TestRedaction: from app.logging_config import SensitiveDataFilter record = logging.LogRecord( - 'test', logging.INFO, 'x.py', 1, 'broken %d', ('not-a-number',), None, + 'test', + logging.INFO, + 'x.py', + 1, + 'broken %d', + ('not-a-number',), + None, ) assert SensitiveDataFilter().filter(record) is True diff --git a/tests/test_auth_session.py b/tests/test_auth_session.py index 4ad6e00..7307861 100644 --- a/tests/test_auth_session.py +++ b/tests/test_auth_session.py @@ -121,20 +121,26 @@ class TestLogout: with app_with_csrf.app_context(): from app.extensions import hash_password from app.models import Player - user = Player(username='navtest', password_hash=hash_password('Password123'), - role='player', full_name='Nav Test', email='nav@example.test') + + user = Player( + username='navtest', + password_hash=hash_password('Password123'), + role='player', + full_name='Nav Test', + email='nav@example.test', + ) db.session.add(user) db.session.commit() page = client.get('/auth/login').get_data(as_text=True) token = re.search(r'name="csrf_token" value="([^"]+)"', page).group(1) - client.post('/auth/login', data={'username': 'navtest', - 'password': 'Password123', - 'csrf_token': token}) + client.post( + '/auth/login', + data={'username': 'navtest', 'password': 'Password123', 'csrf_token': token}, + ) body = client.get('/users/profile').get_data(as_text=True) - form = re.search( - r'
', body, re.S) + form = re.search(r'', body, re.S) assert form is not None, 'no logout form in the navigation' assert 'name="csrf_token"' in form.group(0) @@ -150,9 +156,7 @@ class TestLoginRejection: response = client.get('/users/profile', follow_redirects=False) assert response.status_code in (301, 302) - def test_login_failure_message_does_not_reveal_account_existence( - self, client, make_user, app - ): + def test_login_failure_message_does_not_reveal_account_existence(self, client, make_user, app): """Compares the rendered flash messages rather than looking for a known substring: the site is served in French by default, so an English marker would silently match nothing on both sides and make @@ -171,9 +175,7 @@ class TestLoginRejection: assert failure_message(username) == failure_message('no-such-account') - def test_the_message_stays_the_same_past_the_attempt_threshold( - self, client, make_user, app - ): + def test_the_message_stays_the_same_past_the_attempt_threshold(self, client, make_user, app): """The tally used to be counted out loud — "3 attempt(s) remaining" — which is the same disclosure, spread over five requests.""" user_id = make_user('player') @@ -200,13 +202,13 @@ class TestFailedAttemptThrottle: @staticmethod def _exhaust(client, username, times=6): for _ in range(times): - client.post('/auth/login', - data={'username': username, 'password': 'WrongPassword1'}, - follow_redirects=True) + client.post( + '/auth/login', + data={'username': username, 'password': 'WrongPassword1'}, + follow_redirects=True, + ) - def test_the_owner_still_gets_in_after_the_threshold( - self, app, client, make_user, login - ): + def test_the_owner_still_gets_in_after_the_threshold(self, app, client, make_user, login): user_id = make_user('player') with app.app_context(): username = db.session.get(User, user_id).username @@ -294,6 +296,7 @@ class TestRedirectValidation: with app.test_request_context('/auth/login'): from flask import request + assert is_safe_url(f'http://{request.host}/dashboard') def test_the_login_redirect_refuses_to_leave_the_site(self, client, make_user, app): diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 1df8eb9..1e16df4 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -61,9 +61,7 @@ class TestVerticalAccess: def test_only_admin_reaches_user_management(self, client, as_role, role, route): as_role(role) response = client.get(route, follow_redirects=False) - assert _redirected(response), ( - f'{role} reached {route}, which is meant to be admin-only' - ) + assert _redirected(response), f'{role} reached {route}, which is meant to be admin-only' def test_admin_reaches_user_management(self, client, as_role): as_role('admin') @@ -106,8 +104,10 @@ class TestHorizontalAccess: owner_id = make_user('player') with app.app_context(): slot = PlayerDisponibility( - player_id=owner_id, day_of_week=1, - start_time=time(10, 0), end_time=time(10, 30), + player_id=owner_id, + day_of_week=1, + start_time=time(10, 0), + end_time=time(10, 30), ) db.session.add(slot) db.session.commit() @@ -134,8 +134,11 @@ class TestNestedResourceOwnership: with app.app_context(): tryout = Tryout( - title=title, game='Valorant', date=date(2030, 1, 1), - created_by=owner_id, status='upcoming', + title=title, + game='Valorant', + date=date(2030, 1, 1), + created_by=owner_id, + status='upcoming', ) db.session.add(tryout) db.session.flush() @@ -144,9 +147,7 @@ class TestNestedResourceOwnership: db.session.commit() return tryout.id, team.id - def test_cannot_add_a_player_to_a_team_of_another_tryout( - self, app, client, as_role, make_user - ): + def test_cannot_add_a_player_to_a_team_of_another_tryout(self, app, client, as_role, make_user): from app.models import TeamMember, TryoutRegistration other_admin = make_user('admin') @@ -157,8 +158,7 @@ class TestNestedResourceOwnership: player_id = make_user('player') with app.app_context(): - db.session.add(TryoutRegistration( - tryout_id=own_tryout_id, player_id=player_id)) + db.session.add(TryoutRegistration(tryout_id=own_tryout_id, player_id=player_id)) db.session.commit() response = client.post( @@ -167,15 +167,11 @@ class TestNestedResourceOwnership: follow_redirects=False, ) - assert response.status_code == 404, ( - 'a team belonging to another tryout was accepted' - ) + assert response.status_code == 404, 'a team belonging to another tryout was accepted' with app.app_context(): assert TeamMember.query.filter_by(team_id=foreign_team_id).count() == 0 - def test_cannot_add_a_player_who_is_not_registered( - self, app, client, as_role, make_user - ): + def test_cannot_add_a_player_who_is_not_registered(self, app, client, as_role, make_user): from app.models import TeamMember manager_id = as_role('manager') @@ -191,9 +187,7 @@ class TestNestedResourceOwnership: with app.app_context(): assert TeamMember.query.filter_by(team_id=team_id).count() == 0 - def test_a_registered_player_can_still_be_added( - self, app, client, as_role, make_user - ): + def test_a_registered_player_can_still_be_added(self, app, client, as_role, make_user): """Guard against over-correcting: the normal path must keep working.""" from app.models import TeamMember, TryoutRegistration @@ -243,11 +237,15 @@ class TestInputValidation: user_id = as_role('player') payload = '' - client.post('/users/profile/edit', data={ - 'username': payload, - 'full_name': 'Legit Name', - 'email': 'legit@example.test', - }, follow_redirects=True) + client.post( + '/users/profile/edit', + data={ + 'username': payload, + 'full_name': 'Legit Name', + 'email': 'legit@example.test', + }, + follow_redirects=True, + ) with app.app_context(): assert db.session.get(User, user_id).username != payload @@ -257,12 +255,16 @@ class TestInputValidation: with app.app_context(): before = db.session.get(User, user_id).password_hash - client.post('/users/profile/edit', data={ - 'username': _username(app, user_id), - 'full_name': 'Legit Name', - 'email': 'legit@example.test', - 'password': 'a', - }, follow_redirects=True) + client.post( + '/users/profile/edit', + data={ + 'username': _username(app, user_id), + 'full_name': 'Legit Name', + 'email': 'legit@example.test', + 'password': 'a', + }, + follow_redirects=True, + ) with app.app_context(): assert db.session.get(User, user_id).password_hash == before, ( @@ -272,13 +274,17 @@ class TestInputValidation: def test_create_user_enforces_the_password_policy(self, app, client, as_role): as_role('admin') - client.post('/users/create', data={ - 'username': 'weakling', - 'email': 'weak@example.test', - 'password': 'a', - 'full_name': 'Weak Account', - 'role': 'admin', - }, follow_redirects=True) + client.post( + '/users/create', + data={ + 'username': 'weakling', + 'email': 'weak@example.test', + 'password': 'a', + 'full_name': 'Weak Account', + 'role': 'admin', + }, + follow_redirects=True, + ) with app.app_context(): created = User.query.filter_by(username='weakling').first() @@ -292,11 +298,15 @@ class TestInputValidation: with app.app_context(): taken = db.session.get(User, other_id).email - response = client.post(f'/users/{target_id}/edit', data={ - 'full_name': 'Target', - 'email': taken, - 'role': 'player', - }, follow_redirects=False) + response = client.post( + f'/users/{target_id}/edit', + data={ + 'full_name': 'Target', + 'email': taken, + 'role': 'player', + }, + follow_redirects=False, + ) assert response.status_code < 500, 'duplicate email produced a server error' @@ -305,18 +315,21 @@ class TestAdminSafety: def test_the_last_admin_cannot_demote_itself(self, app, client, as_role): admin_id = as_role('admin') - client.post(f'/users/{admin_id}/edit', data={ - 'full_name': 'Admin', - 'email': 'admin-self@example.test', - 'role': 'player', - }, follow_redirects=True) + client.post( + f'/users/{admin_id}/edit', + data={ + 'full_name': 'Admin', + 'email': 'admin-self@example.test', + 'role': 'player', + }, + follow_redirects=True, + ) with app.app_context(): assert db.session.get(User, admin_id).role == 'admin', ( 'the only administrator demoted itself; no interface can undo this' ) - def test_an_admin_cannot_change_its_own_role_even_with_others_present( self, app, client, as_role, make_user ): @@ -324,9 +337,15 @@ class TestAdminSafety: make_user('admin') admin_id = as_role('admin') - client.post(f'/users/{admin_id}/edit', data={ - 'full_name': 'Admin', 'email': 'self@example.test', 'role': 'player', - }, follow_redirects=True) + client.post( + f'/users/{admin_id}/edit', + data={ + 'full_name': 'Admin', + 'email': 'self@example.test', + 'role': 'player', + }, + follow_redirects=True, + ) with app.app_context(): assert db.session.get(User, admin_id).role == 'admin' @@ -336,10 +355,16 @@ class TestAdminSafety: other_id = make_user('admin') as_role('admin') - client.post(f'/users/{other_id}/edit', data={ - 'full_name': 'Other', 'email': 'other-demoted@example.test', - 'role': 'coach', 'is_active_account': 'on', - }, follow_redirects=True) + client.post( + f'/users/{other_id}/edit', + data={ + 'full_name': 'Other', + 'email': 'other-demoted@example.test', + 'role': 'coach', + 'is_active_account': 'on', + }, + follow_redirects=True, + ) with app.app_context(): assert db.session.get(User, other_id).role == 'coach' @@ -350,11 +375,16 @@ class TestCsrf: """CSRFProtect is global. This pins that down so a future @csrf.exempt cannot slip in unnoticed.""" client = app_with_csrf.test_client() - response = client.post('/auth/login', data={ - 'username': 'someone', 'password': 'Password123', - }) + response = client.post( + '/auth/login', + data={ + 'username': 'someone', + 'password': 'Password123', + }, + ) assert response.status_code == 400 + class TestCorsPolicy: """SEC-WEB-003 — with no origins configured, flask-cors defaulted to '*' and, credentials being allowed, echoed back the caller's Origin.""" @@ -367,12 +397,19 @@ class TestCorsPolicy: def test_configured_origins_are_still_honoured(self, app_with_csrf): from app.app import create_app - application = create_app({ - 'SECRET_KEY': 'test', 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', - 'TESTING': True, 'FORCE_HTTPS': False, 'ENABLE_DISCORD_BOT': False, - 'AUTO_CREATE_TABLES': False, 'CORS_ALLOWED_ORIGINS': 'https://trusted.test', - }) + application = create_app( + { + 'SECRET_KEY': 'test', + 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', + 'TESTING': True, + 'FORCE_HTTPS': False, + 'ENABLE_DISCORD_BOT': False, + 'AUTO_CREATE_TABLES': False, + 'CORS_ALLOWED_ORIGINS': 'https://trusted.test', + } + ) response = application.test_client().get( - '/auth/login', headers={'Origin': 'https://trusted.test'}) + '/auth/login', headers={'Origin': 'https://trusted.test'} + ) assert response.headers.get('Access-Control-Allow-Origin') == 'https://trusted.test' diff --git a/tests/test_backup.py b/tests/test_backup.py index 3965411..65d004b 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -37,8 +37,7 @@ class TestUrlParsing: def test_the_sqlalchemy_dialect_suffix_is_accepted(self): """SQLAlchemy writes postgresql+psycopg://, which pg_dump rejects.""" - conn = parse_database_url( - 'postgresql+psycopg://u:p@localhost/tryouts') + conn = parse_database_url('postgresql+psycopg://u:p@localhost/tryouts') assert conn['dbname'] == 'tryouts' def test_the_default_port_is_applied(self): @@ -119,5 +118,4 @@ class TestExitCodes: assert backup_module.main([]) == 1 def test_verifying_a_missing_archive_fails(self, tmp_path): - assert backup_module.main( - ['--verify-only', str(tmp_path / 'nope.dump')]) == 1 + assert backup_module.main(['--verify-only', str(tmp_path / 'nope.dump')]) == 1 diff --git a/tests/test_calendar_api.py b/tests/test_calendar_api.py index 51427f6..f796d3d 100644 --- a/tests/test_calendar_api.py +++ b/tests/test_calendar_api.py @@ -25,7 +25,10 @@ from app.extensions import db def match_factory(app): """Create a tryout with one player_scrim match and one participant.""" from app.models import ( - Match, MatchParticipant, Tryout, TryoutRegistration, + Match, + MatchParticipant, + Tryout, + TryoutRegistration, ) def _make(owner_id, player_id, *, description=None, username=None): @@ -39,24 +42,30 @@ def match_factory(app): player.username = username tryout = Tryout( - title='Spring tryout', game='Valorant', date=date(2030, 5, 1), - created_by=owner_id, status='upcoming', + title='Spring tryout', + game='Valorant', + date=date(2030, 5, 1), + created_by=owner_id, + status='upcoming', ) db.session.add(tryout) db.session.flush() - db.session.add(TryoutRegistration( - tryout_id=tryout.id, player_id=player_id)) + db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id)) match = Match( - tryout_id=tryout.id, title='Scrim A', description=description, - date=date(2030, 5, 2), start_time=time(18, 0), end_time=time(19, 0), - match_type='player_scrim', created_by=owner_id, + tryout_id=tryout.id, + title='Scrim A', + description=description, + date=date(2030, 5, 2), + start_time=time(18, 0), + end_time=time(19, 0), + match_type='player_scrim', + created_by=owner_id, ) db.session.add(match) db.session.flush() - db.session.add(MatchParticipant( - match_id=match.id, player_id=player_id)) + db.session.add(MatchParticipant(match_id=match.id, player_id=player_id)) db.session.commit() return tryout.id, match.id @@ -68,9 +77,7 @@ def _match_event(payload): class TestCalendarEventPayload: - def test_description_is_returned_verbatim( - self, app, client, as_role, make_user, match_factory - ): + def test_description_is_returned_verbatim(self, app, client, as_role, make_user, match_factory): player_id = make_user('player') admin_id = as_role('admin') match_factory(admin_id, player_id, description='Bring your own peripherals') @@ -92,9 +99,7 @@ class TestCalendarEventPayload: assert '
' not in props['description'] assert props['participants'] not in props['description'] - def test_an_empty_description_stays_empty( - self, app, client, as_role, make_user, match_factory - ): + def test_an_empty_description_stays_empty(self, app, client, as_role, make_user, match_factory): player_id = make_user('player') admin_id = as_role('admin') match_factory(admin_id, player_id, description=None) @@ -132,8 +137,7 @@ class TestLegacyHostileData: ): player_id = make_user('player') admin_id = as_role('admin') - match_factory(admin_id, player_id, - description='Normal text', username=self.PAYLOAD) + match_factory(admin_id, player_id, description='Normal text', username=self.PAYLOAD) props = _match_event(client.get('/matches/api/events').get_json())['extendedProps'] diff --git a/tests/test_contract_uploads.py b/tests/test_contract_uploads.py index fde96f5..a7d6ac8 100644 --- a/tests/test_contract_uploads.py +++ b/tests/test_contract_uploads.py @@ -32,8 +32,10 @@ def contract_for(app, tmp_path): path.write_bytes(PDF_BYTES) with app.app_context(): contract = Contract( - player_id=player_id, uploaded_by_id=uploader_id, - original_filename='contract.pdf', stored_filename=stored, + player_id=player_id, + uploaded_by_id=uploader_id, + original_filename='contract.pdf', + stored_filename=stored, file_path=str(path), ) db.session.add(contract) @@ -54,7 +56,8 @@ class TestSignedUpload: client.post( f'/users/contracts/{contract_id}/upload_signed', data={'signed_file': (io.BytesIO(NOT_PDF_BYTES), 'signed.pdf')}, - content_type='multipart/form-data', follow_redirects=True, + content_type='multipart/form-data', + follow_redirects=True, ) with app.app_context(): @@ -63,9 +66,7 @@ class TestSignedUpload: assert contract.signed_file_path is None assert not os.path.exists(directory / 'signed_deadbeef.pdf') - def test_a_foreign_extension_is_refused( - self, app, client, as_role, make_user, contract_for - ): + def test_a_foreign_extension_is_refused(self, app, client, as_role, make_user, contract_for): player_id = as_role('player') admin_id = make_user('admin') contract_id, _directory = contract_for(player_id, admin_id) @@ -73,15 +74,14 @@ class TestSignedUpload: client.post( f'/users/contracts/{contract_id}/upload_signed', data={'signed_file': (io.BytesIO(b''), 'shell.php')}, - content_type='multipart/form-data', follow_redirects=True, + content_type='multipart/form-data', + follow_redirects=True, ) with app.app_context(): assert db.session.get(Contract, contract_id).status != 'signed' - def test_a_real_pdf_still_goes_through( - self, app, client, as_role, make_user, contract_for - ): + def test_a_real_pdf_still_goes_through(self, app, client, as_role, make_user, contract_for): """Guard against over-correcting: signing a contract is the point.""" player_id = as_role('player') admin_id = make_user('admin') @@ -90,7 +90,8 @@ class TestSignedUpload: client.post( f'/users/contracts/{contract_id}/upload_signed', data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')}, - content_type='multipart/form-data', follow_redirects=True, + content_type='multipart/form-data', + follow_redirects=True, ) with app.app_context(): @@ -110,7 +111,8 @@ class TestSignedUpload: client.post( f'/users/contracts/{contract_id}/upload_signed', data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')}, - content_type='multipart/form-data', follow_redirects=True, + content_type='multipart/form-data', + follow_redirects=True, ) with app.app_context(): diff --git a/tests/test_csp.py b/tests/test_csp.py index f590662..3002992 100644 --- a/tests/test_csp.py +++ b/tests/test_csp.py @@ -21,7 +21,8 @@ from app.app import build_csp TEMPLATE_ROOT = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - 'app', 'templates', + 'app', + 'templates', ) #: Attributes a nonce can never authorise. @@ -126,9 +127,7 @@ class TestInlineHandlerRatchet: continue offenders.append(f'{relative}: {tag}') - assert not offenders, ( - 'inline