style: formater le depot avec ruff format

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
GGThed
2026-08-08 15:53:10 -04:00
co-authored by Claude Opus 5
parent 2f40290f00
commit 7cec18c139
72 changed files with 2658 additions and 1449 deletions
+64 -52
View File
@@ -100,19 +100,21 @@ def build_csp(*, allow_inline_script, nonce=None):
else: else:
script_src = f"'self' 'nonce-{nonce}' https://cdn.jsdelivr.net" script_src = f"'self' 'nonce-{nonce}' https://cdn.jsdelivr.net"
return '; '.join([ return '; '.join(
"default-src 'self'", [
f'script-src {script_src}', "default-src 'self'",
# style-src is a separate migration: inline style="" attributes are f'script-src {script_src}',
# spread across the templates and are not an XSS vector on their own. # style-src is a separate migration: inline style="" attributes are
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net", # spread across the templates and are not an XSS vector on their own.
"font-src 'self' https://cdnjs.cloudflare.com", "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
"img-src 'self' data: https://cdn.discordapp.com", "font-src 'self' https://cdnjs.cloudflare.com",
"connect-src 'self'", "img-src 'self' data: https://cdn.discordapp.com",
"frame-ancestors 'none'", "connect-src 'self'",
"base-uri 'self'", "frame-ancestors 'none'",
"form-action 'self'", "base-uri 'self'",
]) "form-action 'self'",
]
)
def create_app(config=None): 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 # Side effects of create_app(), both on by default so that production and
# development behave exactly as before. Tests turn them off. # development behave exactly as before. Tests turn them off.
app.config['AUTO_CREATE_TABLES'] = ( app.config['AUTO_CREATE_TABLES'] = os.getenv('AUTO_CREATE_TABLES', 'true').lower() == 'true'
os.getenv('AUTO_CREATE_TABLES', 'true').lower() == 'true' app.config['ENABLE_DISCORD_BOT'] = os.getenv('ENABLE_DISCORD_BOT', 'true').lower() == 'true'
)
app.config['ENABLE_DISCORD_BOT'] = (
os.getenv('ENABLE_DISCORD_BOT', 'true').lower() == 'true'
)
# --- caller overrides win --------------------------------------------- # --- caller overrides win ---------------------------------------------
if config: if config:
@@ -198,7 +196,9 @@ def create_app(config=None):
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
# Secure session cookie settings # 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_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout
@@ -245,12 +245,14 @@ def create_app(config=None):
@app.context_processor @app.context_processor
def inject_csp_nonce(): def inject_csp_nonce():
return {'csp_nonce': '' if app.config['CSP_ALLOW_INLINE_SCRIPT'] return {
else g.get('csp_nonce', '')} 'csp_nonce': '' if app.config['CSP_ALLOW_INLINE_SCRIPT'] else g.get('csp_nonce', '')
}
@app.context_processor @app.context_processor
def inject_locales(): def inject_locales():
from flask_babel import get_locale from flask_babel import get_locale
return { return {
'current_locale': str(get_locale() or i18n.DEFAULT_LOCALE), 'current_locale': str(get_locale() or i18n.DEFAULT_LOCALE),
'supported_locales': i18n.SUPPORTED_LOCALES, 'supported_locales': i18n.SUPPORTED_LOCALES,
@@ -259,6 +261,7 @@ def create_app(config=None):
# Configure structured logging # Configure structured logging
from app.logging_config import configure_logging from app.logging_config import configure_logging
configure_logging(app) configure_logging(app)
from app.routes.auth import auth_bp from app.routes.auth import auth_bp
@@ -303,8 +306,7 @@ def create_app(config=None):
response.headers['X-Frame-Options'] = 'DENY' response.headers['X-Frame-Options'] = 'DENY'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response.headers['Permissions-Policy'] = ( response.headers['Permissions-Policy'] = (
'camera=(), microphone=(), geolocation=(), ' 'camera=(), microphone=(), geolocation=(), interest-cohort=(), payment=(), usb=()'
'interest-cohort=(), payment=(), usb=()'
) )
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin' response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
response.headers['Content-Security-Policy'] = build_csp( response.headers['Content-Security-Policy'] = build_csp(
@@ -383,9 +385,11 @@ def create_app(config=None):
Returns: Returns:
Response: Rendered error page or JSON for API requests. Response: Rendered error page or JSON for API requests.
""" """
if request.path.startswith('/users/disponibilities') or \ if (
request.path.startswith('/users/coach-availability') or \ request.path.startswith('/users/disponibilities')
request.path.startswith('/users/api/'): or request.path.startswith('/users/coach-availability')
or request.path.startswith('/users/api/')
):
return jsonify({'error': 'Bad request', 'message': str(error)}), 400 return jsonify({'error': 'Bad request', 'message': str(error)}), 400
return render_template('errors/400.html', error=error), 400 return render_template('errors/400.html', error=error), 400
@@ -399,10 +403,12 @@ def create_app(config=None):
Returns: Returns:
Response: Redirect to login for pages, JSON for API. Response: Redirect to login for pages, JSON for API.
""" """
if request.path.startswith('/users/disponibilities') or \ if request.path.startswith('/users/disponibilities') or request.path.startswith(
request.path.startswith('/users/api/'): '/users/api/'
):
return jsonify({'error': 'Unauthorized'}), 401 return jsonify({'error': 'Unauthorized'}), 401
from flask import flash as _flash from flask import flash as _flash
_flash('Please log in to access this page.', 'warning') _flash('Please log in to access this page.', 'warning')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@@ -416,8 +422,9 @@ def create_app(config=None):
Returns: Returns:
Response: Rendered error page or JSON for API requests. Response: Rendered error page or JSON for API requests.
""" """
if request.path.startswith('/users/disponibilities') or \ if request.path.startswith('/users/disponibilities') or request.path.startswith(
request.path.startswith('/users/api/'): '/users/api/'
):
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403 return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
return render_template('errors/403.html', error=error), 403 return render_template('errors/403.html', error=error), 403
@@ -431,8 +438,9 @@ def create_app(config=None):
Returns: Returns:
Response: Rendered error page or JSON for API requests. Response: Rendered error page or JSON for API requests.
""" """
if request.path.startswith('/users/disponibilities') or \ if request.path.startswith('/users/disponibilities') or request.path.startswith(
request.path.startswith('/users/api/'): '/users/api/'
):
return jsonify({'error': 'Not found'}), 404 return jsonify({'error': 'Not found'}), 404
return render_template('errors/404.html', error=error), 404 return render_template('errors/404.html', error=error), 404
@@ -446,12 +454,12 @@ def create_app(config=None):
Returns: Returns:
Response: JSON error for API or rendered page. Response: JSON error for API or rendered page.
""" """
if request.path.startswith('/users/disponibilities') or \ if request.path.startswith('/users/disponibilities') or request.path.startswith(
request.path.startswith('/users/api/'): '/users/api/'
return jsonify({ ):
'error': 'Too many requests', return jsonify(
'message': 'Please try again later.' {'error': 'Too many requests', 'message': 'Please try again later.'}
}), 429 ), 429
return render_template('errors/429.html', error=error), 429 return render_template('errors/429.html', error=error), 429
@app.errorhandler(500) @app.errorhandler(500)
@@ -472,12 +480,15 @@ def create_app(config=None):
# Roll back any failed database session # Roll back any failed database session
db.session.rollback() db.session.rollback()
if request.path.startswith('/users/disponibilities') or \ if request.path.startswith('/users/disponibilities') or request.path.startswith(
request.path.startswith('/users/api/'): '/users/api/'
return jsonify({ ):
'error': 'Internal server error', return jsonify(
'message': 'An unexpected error occurred. Please try again later.' {
}), 500 'error': 'Internal server error',
'message': 'An unexpected error occurred. Please try again later.',
}
), 500
return render_template('errors/500.html'), 500 return render_template('errors/500.html'), 500
@app.errorhandler(HTTPException) @app.errorhandler(HTTPException)
@@ -490,13 +501,12 @@ def create_app(config=None):
Returns: Returns:
Response: JSON error for API, re-raises for others. Response: JSON error for API, re-raises for others.
""" """
if request.path.startswith('/users/disponibilities') or \ if request.path.startswith('/users/disponibilities') or request.path.startswith(
request.path.startswith('/users/api/'): '/users/api/'
return jsonify({ ):
'error': error.name, return jsonify(
'message': error.description, {'error': error.name, 'message': error.description, 'code': error.code}
'code': error.code ), error.code
}), error.code
return error return error
# ========================================================================= # =========================================================================
@@ -504,6 +514,7 @@ def create_app(config=None):
# ========================================================================= # =========================================================================
with app.app_context(): with app.app_context():
import app.models as models # noqa: F401 — registers all models with SQLAlchemy import app.models as models # noqa: F401 — registers all models with SQLAlchemy
# NOTE: create_all() only ever creates missing tables. It never adds a # 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 # column to an existing one, so a model change is silently absent from
# any database that already has the table. Replacing this with Alembic # 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']: if app.config['ENABLE_DISCORD_BOT']:
try: try:
from app.discord_bot import start_bot from app.discord_bot import start_bot
start_bot(flask_app=app) start_bot(flask_app=app)
except Exception as e: except Exception as e:
app.logger.warning('Could not start Discord bot: %s', e) app.logger.warning('Could not start Discord bot: %s', e)
+281 -181
View File
@@ -28,19 +28,21 @@ DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# File for persisting pending requests across bot restarts # 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 # Emoji constants
CHECK_EMOJI = '' # Green checkmark CHECK_EMOJI = '' # Green checkmark
CROSS_EMOJI = '' # Red X CROSS_EMOJI = '' # Red X
class TeamTryoutsBot(commands.Bot): class TeamTryoutsBot(commands.Bot):
"""Unified Discord bot for Team Tryouts notifications. """Unified Discord bot for Team Tryouts notifications.
Handles One on One requests, schedule additions, and daily reminders. Handles One on One requests, schedule additions, and daily reminders.
""" """
def __init__(self, flask_app=None): def __init__(self, flask_app=None):
# Only what the bot actually reads. `members` — the privileged # Only what the bot actually reads. `members` — the privileged
# GUILD_MEMBERS intent — was requested and never used: nothing here # 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.message_queue = Queue() # Thread-safe queue for messages from Flask
self.scheduler = AsyncIOScheduler() self.scheduler = AsyncIOScheduler()
self.timezone = ZoneInfo('America/Toronto') # EDT timezone self.timezone = ZoneInfo('America/Toronto') # EDT timezone
def _load_pending(self): def _load_pending(self):
"""Load pending requests from the JSON file.""" """Load pending requests from the JSON file."""
try: try:
@@ -71,12 +73,14 @@ class TeamTryoutsBot(commands.Bot):
data = json.load(f) data = json.load(f)
# Convert string keys back to int # Convert string keys back to int
self.pending_requests = {int(k): v for k, v in data.items()} 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: else:
logger.info("No pending requests file found, starting fresh.") logger.info("No pending requests file found, starting fresh.")
except Exception as e: except Exception as e:
logger.error(f"Error loading pending requests: {e}") logger.error(f"Error loading pending requests: {e}")
def _save_pending(self): def _save_pending(self):
"""Save pending requests to the JSON file.""" """Save pending requests to the JSON file."""
try: try:
@@ -84,12 +88,12 @@ class TeamTryoutsBot(commands.Bot):
json.dump(self.pending_requests, f, indent=2) json.dump(self.pending_requests, f, indent=2)
except Exception as e: except Exception as e:
logger.error(f"Error saving pending requests: {e}") logger.error(f"Error saving pending requests: {e}")
async def setup_hook(self): async def setup_hook(self):
"""Called when the bot is ready.""" """Called when the bot is ready."""
self._load_pending() self._load_pending()
logger.info(f'TeamTryoutsBot logged in as {self.user}') logger.info(f'TeamTryoutsBot logged in as {self.user}')
async def on_ready(self): async def on_ready(self):
"""Log when the bot is ready and start background tasks.""" """Log when the bot is ready and start background tasks."""
try: try:
@@ -97,13 +101,13 @@ class TeamTryoutsBot(commands.Bot):
logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user} | Guilds: {guilds}') logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user} | Guilds: {guilds}')
except Exception: except Exception:
logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user}') logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user}')
# Start the queue processing task # Start the queue processing task
self.loop.create_task(self.process_queue()) self.loop.create_task(self.process_queue())
# Start the daily reminder scheduler # Start the daily reminder scheduler
self.loop.create_task(self.start_scheduler()) self.loop.create_task(self.start_scheduler())
async def start_scheduler(self): async def start_scheduler(self):
"""Start the APScheduler for daily reminders.""" """Start the APScheduler for daily reminders."""
try: try:
@@ -111,13 +115,13 @@ class TeamTryoutsBot(commands.Bot):
self.send_daily_reminders, self.send_daily_reminders,
trigger=CronTrigger(hour=18, minute=0, timezone=self.timezone), trigger=CronTrigger(hour=18, minute=0, timezone=self.timezone),
id='daily_reminders', id='daily_reminders',
replace_existing=True replace_existing=True,
) )
self.scheduler.start() self.scheduler.start()
logger.info('Daily reminder scheduler started (18:00 EDT)') logger.info('Daily reminder scheduler started (18:00 EDT)')
except Exception as e: except Exception as e:
logger.error(f'Error starting scheduler: {e}') logger.error(f'Error starting scheduler: {e}')
async def process_queue(self): async def process_queue(self):
"""Process messages from the queue (runs continuously).""" """Process messages from the queue (runs continuously)."""
while True: while True:
@@ -127,7 +131,7 @@ class TeamTryoutsBot(commands.Bot):
except Empty: except Empty:
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
continue continue
if item.get('type') == 'one_on_one_request': if item.get('type') == 'one_on_one_request':
await self._send_one_on_one_dm(**item['data']) await self._send_one_on_one_dm(**item['data'])
elif item.get('type') == 'schedule_addition': elif item.get('type') == 'schedule_addition':
@@ -138,91 +142,116 @@ class TeamTryoutsBot(commands.Bot):
await self._send_schedule_notification(**item['data']) await self._send_schedule_notification(**item['data'])
elif item.get('type') == 'one_on_one_response': elif item.get('type') == 'one_on_one_response':
await self._send_one_on_one_response_dm(**item['data']) await self._send_one_on_one_response_dm(**item['data'])
except Exception as e: except Exception as e:
logger.error(f"Error processing queue: {e}\n{traceback.format_exc()}") logger.error(f"Error processing queue: {e}\n{traceback.format_exc()}")
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
def is_dm_channel(self, channel) -> bool: def is_dm_channel(self, channel) -> bool:
"""Check if a channel is a DM channel.""" """Check if a channel is a DM channel."""
return hasattr(channel, 'recipient') or hasattr(channel, 'recipients') return hasattr(channel, 'recipient') or hasattr(channel, 'recipients')
async def on_raw_reaction_add(self, payload): async def on_raw_reaction_add(self, payload):
"""Handle when a reaction is added to a message (works even after bot restart).""" """Handle when a reaction is added to a message (works even after bot restart)."""
# Ignore bot's own reactions # Ignore bot's own reactions
if payload.user_id == self.user.id: if payload.user_id == self.user.id:
return return
# Check if this is a pending request we're tracking # Check if this is a pending request we're tracking
if payload.message_id not in self.pending_requests: if payload.message_id not in self.pending_requests:
return return
# Fetch the channel and check if it's a DM # Fetch the channel and check if it's a DM
try: try:
channel = await self.fetch_channel(payload.channel_id) channel = await self.fetch_channel(payload.channel_id)
except Exception: except Exception:
return return
if not self.is_dm_channel(channel): if not self.is_dm_channel(channel):
return return
# Fetch the user who reacted # Fetch the user who reacted
try: try:
user = await self.fetch_user(payload.user_id) user = await self.fetch_user(payload.user_id)
except Exception: except Exception:
return return
if user is None: if user is None:
return return
request_info = self.pending_requests[payload.message_id] request_info = self.pending_requests[payload.message_id]
emoji_str = str(payload.emoji) emoji_str = str(payload.emoji)
handler_type = request_info.get('type') handler_type = request_info.get('type')
request_id = request_info.get('id') request_id = request_info.get('id')
if emoji_str == CHECK_EMOJI: if emoji_str == CHECK_EMOJI:
if handler_type == 'one_on_one': if handler_type == 'one_on_one':
if self.flask_app: if self.flask_app:
with self.flask_app.app_context(): 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: 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': elif handler_type == 'schedule_addition':
if self.flask_app: if self.flask_app:
with self.flask_app.app_context(): 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: 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: elif emoji_str == CROSS_EMOJI:
if handler_type == 'one_on_one': if handler_type == 'one_on_one':
if self.flask_app: if self.flask_app:
with self.flask_app.app_context(): 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: 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': elif handler_type == 'schedule_addition':
if self.flask_app: if self.flask_app:
with self.flask_app.app_context(): 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: else:
await self.handle_attendance_decline(user, payload.message_id, request_id, channel) 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: 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.""" """Send a One on One request DM to a coach with reactions."""
try: try:
user_id = int(coach_discord_id) user_id = int(coach_discord_id)
except (ValueError, TypeError): except (ValueError, TypeError):
logger.warning(f"Invalid coach_discord_id '{coach_discord_id}'") logger.warning(f"Invalid coach_discord_id '{coach_discord_id}'")
return None return None
try: try:
user = await self.fetch_user(user_id) user = await self.fetch_user(user_id)
if not user: if not user:
return None return None
message = ( message = (
"📅 **One on One Request**\n\n" "📅 **One on One Request**\n\n"
f"**Player:** {player_name}\n" f"**Player:** {player_name}\n"
@@ -234,27 +263,33 @@ class TeamTryoutsBot(commands.Bot):
f"{CHECK_EMOJI} - Confirm the meeting\n" f"{CHECK_EMOJI} - Confirm the meeting\n"
f"{CROSS_EMOJI} - Decline (you can add a reason by replying before clicking)" f"{CROSS_EMOJI} - Decline (you can add a reason by replying before clicking)"
) )
msg = await user.send(message) msg = await user.send(message)
await msg.add_reaction(CHECK_EMOJI) await msg.add_reaction(CHECK_EMOJI)
await msg.add_reaction(CROSS_EMOJI) await msg.add_reaction(CROSS_EMOJI)
# Track this pending request # Track this pending request
self.pending_requests[msg.id] = {'type': 'one_on_one', 'id': request_id} self.pending_requests[msg.id] = {'type': 'one_on_one', 'id': request_id}
self._save_pending() self._save_pending()
logger.info(f"Sent One on One DM with reactions, message_id={msg.id}") logger.info(f"Sent One on One DM with reactions, message_id={msg.id}")
return msg.id return msg.id
except Exception as e: except Exception as e:
logger.error(f"Error sending One on One DM: {e}") logger.error(f"Error sending One on One DM: {e}")
return None return None
async def _send_schedule_notification(self, user_id: int, event_type: str, async def _send_schedule_notification(
event_title: str, event_date: str, self,
event_time: str, reference_id: int) -> int: 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. """Send a schedule addition notification to a player.
Args: Args:
user_id: Database primary key of the User (NOT Discord user ID). user_id: Database primary key of the User (NOT Discord user ID).
event_type: 'match' or 'tryout'. event_type: 'match' or 'tryout'.
@@ -266,23 +301,24 @@ class TeamTryoutsBot(commands.Bot):
try: try:
# Look up the DB user to get their Discord user ID # Look up the DB user to get their Discord user ID
from app.models import User as DBUser from app.models import User as DBUser
db_user = DBUser.query.get(user_id) db_user = DBUser.query.get(user_id)
if not db_user: if not db_user:
logger.warning(f"DB user {user_id} not found for schedule notification") logger.warning(f"DB user {user_id} not found for schedule notification")
return None return None
if not db_user.discord_user_id: if not db_user.discord_user_id:
logger.warning(f"User {db_user.username} has no Discord user ID, cannot send DM") logger.warning(f"User {db_user.username} has no Discord user ID, cannot send DM")
return None return None
discord_uid = int(db_user.discord_user_id) discord_uid = int(db_user.discord_user_id)
user = await self.fetch_user(discord_uid) user = await self.fetch_user(discord_uid)
if not user: if not user:
logger.warning(f"Could not fetch Discord user {discord_uid}") logger.warning(f"Could not fetch Discord user {discord_uid}")
return None return None
event_name = "Match" if event_type == 'match' else "Tryout" event_name = "Match" if event_type == 'match' else "Tryout"
message = ( message = (
f"📅 **{event_name} Scheduled**\n\n" f"📅 **{event_name} Scheduled**\n\n"
f"You have been added to the following {event_type}:\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"{CHECK_EMOJI} - Confirm attendance\n"
f"{CROSS_EMOJI} - Decline" f"{CROSS_EMOJI} - Decline"
) )
msg = await user.send(message) msg = await user.send(message)
await msg.add_reaction(CHECK_EMOJI) await msg.add_reaction(CHECK_EMOJI)
await msg.add_reaction(CROSS_EMOJI) await msg.add_reaction(CROSS_EMOJI)
# Track this pending request # 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() 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 return msg.id
except Exception as e: except Exception as e:
logger.error(f"Error sending schedule notification: {e}") logger.error(f"Error sending schedule notification: {e}")
return None return None
async def handle_one_on_one_approve(self, coach, message_id, request_id, channel): async def handle_one_on_one_approve(self, coach, message_id, request_id, channel):
"""Handle coach approving a One on One request.""" """Handle coach approving a One on One request."""
try: try:
from app.models import OneOnOneRequest from app.models import OneOnOneRequest
from app.extensions import db from app.extensions import db
request = OneOnOneRequest.query.get(request_id) request = OneOnOneRequest.query.get(request_id)
if not request: if not request:
return return
if request.coach.discord_user_id != str(coach.id): if request.coach.discord_user_id != str(coach.id):
await channel.send("⚠️ You are not the intended recipient.") await channel.send("⚠️ You are not the intended recipient.")
return return
# Re-attach to current session (object may be detached across app contexts) # Re-attach to current session (object may be detached across app contexts)
request = db.session.merge(request) request = db.session.merge(request)
# Capture data before commit # Capture data before commit
player_full_name = request.player.full_name if request.player else 'Unknown' 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 player_discord_id = request.player.discord_user_id if request.player else None
coach_obj = request.coach coach_obj = request.coach
request.status = 'approved' request.status = 'approved'
request.responded_at = datetime.utcnow() request.responded_at = datetime.utcnow()
db.session.commit() db.session.commit()
await channel.send( await channel.send(
f"✅ You have **approved** the One on One session with {player_full_name}." f"✅ You have **approved** the One on One session with {player_full_name}."
) )
# Notify player via Discord # Notify player via Discord
if player_discord_id: if player_discord_id:
await self.notify_player_about_one_on_one_direct( await self.notify_player_about_one_on_one_direct(
@@ -346,56 +388,62 @@ class TeamTryoutsBot(commands.Bot):
player_full_name=player_full_name, player_full_name=player_full_name,
coach_full_name=coach_obj.full_name if coach_obj else 'Coach', coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
request=request, request=request,
approved=True approved=True,
) )
del self.pending_requests[message_id] del self.pending_requests[message_id]
self._save_pending() self._save_pending()
except Exception as e: except Exception as e:
logger.error(f"Error handling approval: {e}\n{traceback.format_exc()}") 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): async def handle_one_on_one_reject(self, coach, message_id, request_id, channel):
"""Handle coach rejecting a One on One request.""" """Handle coach rejecting a One on One request."""
try: try:
from app.models import OneOnOneRequest from app.models import OneOnOneRequest
from app.extensions import db from app.extensions import db
request = OneOnOneRequest.query.get(request_id) request = OneOnOneRequest.query.get(request_id)
if not request: if not request:
return return
if request.coach.discord_user_id != str(coach.id): if request.coach.discord_user_id != str(coach.id):
await channel.send("⚠️ You are not the intended recipient.") await channel.send("⚠️ You are not the intended recipient.")
return return
# Re-attach to current session (object may be detached across app contexts) # Re-attach to current session (object may be detached across app contexts)
request = db.session.merge(request) request = db.session.merge(request)
player_full_name = request.player.full_name if request.player else 'Unknown' 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 player_discord_id = request.player.discord_user_id if request.player else None
coach_obj = request.coach coach_obj = request.coach
refusal_note = None refusal_note = None
try: try:
async for reply in channel.history(limit=20): 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 refusal_note = reply.content
break break
except Exception as e: except Exception as e:
logger.warning(f"Could not check for reply message: {e}") logger.warning(f"Could not check for reply message: {e}")
request.status = 'rejected' request.status = 'rejected'
request.responded_at = datetime.utcnow() request.responded_at = datetime.utcnow()
if refusal_note: if refusal_note:
request.coach_rejection_message = refusal_note request.coach_rejection_message = refusal_note
db.session.commit() 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: if refusal_note:
rejection_msg += f"\n**Reason:** {refusal_note}" rejection_msg += f"\n**Reason:** {refusal_note}"
else: else:
rejection_msg += "\n\n️ The player has been notified that you are not available." rejection_msg += "\n\n️ The player has been notified that you are not available."
await channel.send(rejection_msg) await channel.send(rejection_msg)
if player_discord_id: if player_discord_id:
await self.notify_player_about_one_on_one_direct( 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', coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
request=request, request=request,
approved=False, approved=False,
refusal_note=refusal_note refusal_note=refusal_note,
) )
del self.pending_requests[message_id] del self.pending_requests[message_id]
self._save_pending() self._save_pending()
except Exception as e: except Exception as e:
logger.error(f"Error handling rejection: {e}\n{traceback.format_exc()}") logger.error(f"Error handling rejection: {e}\n{traceback.format_exc()}")
async def handle_attendance_confirm(self, player, message_id, reference_id, channel): async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
"""Handle player confirming attendance for a match/tryout.""" """Handle player confirming attendance for a match/tryout."""
try: try:
from app.models import MatchParticipant, TryoutRegistration from app.models import MatchParticipant, TryoutRegistration
from app.extensions import db from app.extensions import db
request_info = self.pending_requests[message_id] request_info = self.pending_requests[message_id]
event_type = request_info.get('event_type') event_type = request_info.get('event_type')
if event_type == 'match': if event_type == 'match':
participant = MatchParticipant.query.get(reference_id) participant = MatchParticipant.query.get(reference_id)
if participant: if participant:
@@ -431,25 +479,25 @@ class TeamTryoutsBot(commands.Bot):
if registration: if registration:
registration = db.session.merge(registration) registration = db.session.merge(registration)
registration.attendance_confirmed = True registration.attendance_confirmed = True
db.session.commit() db.session.commit()
await channel.send("✅ Your attendance has been confirmed!") await channel.send("✅ Your attendance has been confirmed!")
del self.pending_requests[message_id] del self.pending_requests[message_id]
self._save_pending() self._save_pending()
except Exception as e: except Exception as e:
logger.error(f"Error handling attendance confirmation: {e}\n{traceback.format_exc()}") logger.error(f"Error handling attendance confirmation: {e}\n{traceback.format_exc()}")
async def handle_attendance_decline(self, player, message_id, reference_id, channel): async def handle_attendance_decline(self, player, message_id, reference_id, channel):
"""Handle player declining attendance for a match/tryout.""" """Handle player declining attendance for a match/tryout."""
try: try:
from app.models import MatchParticipant, TryoutRegistration from app.models import MatchParticipant, TryoutRegistration
from app.extensions import db from app.extensions import db
request_info = self.pending_requests[message_id] request_info = self.pending_requests[message_id]
event_type = request_info.get('event_type') event_type = request_info.get('event_type')
if event_type == 'match': if event_type == 'match':
participant = MatchParticipant.query.get(reference_id) participant = MatchParticipant.query.get(reference_id)
if participant: if participant:
@@ -460,24 +508,30 @@ class TeamTryoutsBot(commands.Bot):
if registration: if registration:
registration = db.session.merge(registration) registration = db.session.merge(registration)
registration.status = 'no_show' registration.status = 'no_show'
db.session.commit() db.session.commit()
await channel.send("❌ Your attendance has been declined.") await channel.send("❌ Your attendance has been declined.")
del self.pending_requests[message_id] del self.pending_requests[message_id]
self._save_pending() self._save_pending()
except Exception as e: except Exception as e:
logger.error(f"Error handling attendance decline: {e}\n{traceback.format_exc()}") 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, async def notify_player_about_one_on_one_direct(
coach_full_name, request, self,
approved=True, refusal_note=None): 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. """Send confirmation to player about One on One response using pre-fetched data.
This method avoids session expiration issues by using data captured before This method avoids session expiration issues by using data captured before
the database commit. the database commit.
Args: Args:
player_discord_id: The player's Discord user ID string. player_discord_id: The player's Discord user ID string.
player_full_name: The player's full name. player_full_name: The player's full name.
@@ -490,12 +544,12 @@ class TeamTryoutsBot(commands.Bot):
if not player_discord_id: if not player_discord_id:
logger.warning(f"Player has no Discord user ID for request {request.id}") logger.warning(f"Player has no Discord user ID for request {request.id}")
return return
player_user = await self.fetch_user(int(player_discord_id)) player_user = await self.fetch_user(int(player_discord_id))
if not player_user: if not player_user:
logger.warning(f"Could not fetch Discord user {player_discord_id}") logger.warning(f"Could not fetch Discord user {player_discord_id}")
return return
if approved: if approved:
message = ( message = (
"🎉 **One on One Session Confirmed!**\n\n" "🎉 **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" f"Your coach **{coach_full_name}** is not available.\n\n"
"Please try selecting a different time slot." "Please try selecting a different time slot."
) )
await player_user.send(message) 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: except Exception as e:
logger.error(f"Error in direct One on One notification: {e}") logger.error(f"Error in direct One on One notification: {e}")
async def send_daily_reminders(self): async def send_daily_reminders(self):
"""Send daily reminders at 18:00 EDT for events in 24-48 hours.""" """Send daily reminders at 18:00 EDT for events in 24-48 hours."""
try: try:
@@ -540,12 +596,18 @@ class TeamTryoutsBot(commands.Bot):
async def _send_daily_reminders_impl(self): async def _send_daily_reminders_impl(self):
"""Internal implementation of daily reminders with proper app context.""" """Internal implementation of daily reminders with proper app context."""
try: try:
from app.models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest from app.models import (
Match,
Tryout,
MatchParticipant,
TryoutRegistration,
OneOnOneRequest,
)
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
now = datetime.now(self.timezone) now = datetime.now(self.timezone)
tomorrow = now.date() + timedelta(days=1) tomorrow = now.date() + timedelta(days=1)
# Find matches for tomorrow # Find matches for tomorrow
matches = Match.query.filter(Match.date == tomorrow).all() matches = Match.query.filter(Match.date == tomorrow).all()
for match in matches: for match in matches:
@@ -553,7 +615,7 @@ class TeamTryoutsBot(commands.Bot):
for participant in participants: for participant in participants:
if participant.player.discord_user_id: if participant.player.discord_user_id:
await self.send_match_reminder(participant.player, match) await self.send_match_reminder(participant.player, match)
# Find tryouts for tomorrow # Find tryouts for tomorrow
tryouts = Tryout.query.filter(Tryout.date == tomorrow).all() tryouts = Tryout.query.filter(Tryout.date == tomorrow).all()
for tryout in tryouts: for tryout in tryouts:
@@ -561,22 +623,22 @@ class TeamTryoutsBot(commands.Bot):
for reg in registrations: for reg in registrations:
if reg.player.discord_user_id: if reg.player.discord_user_id:
await self.send_tryout_reminder(reg.player, tryout) await self.send_tryout_reminder(reg.player, tryout)
# Find One on One sessions for tomorrow (only approved ones) # Find One on One sessions for tomorrow (only approved ones)
one_on_ones = OneOnOneRequest.query.options( one_on_ones = (
joinedload(OneOnOneRequest.player), OneOnOneRequest.query.options(
joinedload(OneOnOneRequest.coach) joinedload(OneOnOneRequest.player), joinedload(OneOnOneRequest.coach)
).filter( )
OneOnOneRequest.date == tomorrow, .filter(OneOnOneRequest.date == tomorrow, OneOnOneRequest.status == 'approved')
OneOnOneRequest.status == 'approved' .all()
).all() )
for session in one_on_ones: for session in one_on_ones:
if session.player and session.player.discord_user_id: if session.player and session.player.discord_user_id:
await self.send_one_on_one_reminder(session.player, session) await self.send_one_on_one_reminder(session.player, session)
except Exception as e: except Exception as e:
logger.error(f"Error sending daily reminders: {e}") logger.error(f"Error sending daily reminders: {e}")
async def send_match_reminder(self, player, match): async def send_match_reminder(self, player, match):
"""Send match reminder to player.""" """Send match reminder to player."""
try: try:
@@ -593,7 +655,7 @@ class TeamTryoutsBot(commands.Bot):
await player_user.send(message) await player_user.send(message)
except Exception as e: except Exception as e:
logger.error(f"Error sending match reminder: {e}") logger.error(f"Error sending match reminder: {e}")
async def send_tryout_reminder(self, player, tryout): async def send_tryout_reminder(self, player, tryout):
"""Send tryout reminder to player.""" """Send tryout reminder to player."""
try: try:
@@ -608,25 +670,33 @@ class TeamTryoutsBot(commands.Bot):
await player_user.send(message) await player_user.send(message)
except Exception as e: except Exception as e:
logger.error(f"Error sending tryout reminder: {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, async def _send_one_on_one_response_dm(
coach_full_name: str, date_str: str, start_time: str, self,
end_time: str, points: str, approved: bool, player_discord_id: str,
refusal_note: str = None) -> bool: 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. """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. Called from the message queue when a coach accepts/rejects via the web app.
""" """
try: try:
if not player_discord_id: if not player_discord_id:
logger.warning("Cannot send response DM: no player_discord_id") logger.warning("Cannot send response DM: no player_discord_id")
return False return False
player_user = await self.fetch_user(int(player_discord_id)) player_user = await self.fetch_user(int(player_discord_id))
if not player_user: if not player_user:
logger.warning(f"Could not fetch Discord user {player_discord_id}") logger.warning(f"Could not fetch Discord user {player_discord_id}")
return False return False
if approved: if approved:
message = ( message = (
"🎉 **One on One Session Confirmed!**\n\n" "🎉 **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" f"Your coach **{coach_full_name}** is not available.\n\n"
"Please try selecting a different time slot." "Please try selecting a different time slot."
) )
await player_user.send(message) 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 return True
except Exception as e: except Exception as e:
logger.error(f"Error sending One on One response DM: {e}") logger.error(f"Error sending One on One response DM: {e}")
return False return False
@@ -691,78 +763,105 @@ def get_bot(flask_app=None):
return bot_instance return bot_instance
def send_one_on_one_dm(coach_name: str, coach_discord_id: str, player_name: str, def send_one_on_one_dm(
team_name: str, date_str: str, start_time: str, end_time: str, coach_name: str,
points: str, request_id: int) -> bool: 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.""" """Queue a One on One request DM to be sent by the bot."""
bot = get_bot() bot = get_bot()
try: try:
bot.message_queue.put({ bot.message_queue.put(
'type': 'one_on_one_request', {
'data': { 'type': 'one_on_one_request',
'coach_name': coach_name, 'data': {
'coach_discord_id': coach_discord_id, 'coach_name': coach_name,
'player_name': player_name, 'coach_discord_id': coach_discord_id,
'team_name': team_name, 'player_name': player_name,
'date_str': date_str, 'team_name': team_name,
'start_time': start_time, 'date_str': date_str,
'end_time': end_time, 'start_time': start_time,
'points': points, 'end_time': end_time,
'request_id': request_id 'points': points,
'request_id': request_id,
},
} }
}) )
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error queuing One on One DM: {e}") logger.error(f"Error queuing One on One DM: {e}")
return False return False
def send_schedule_notification(user_id: int, event_type: str, event_title: str, def send_schedule_notification(
event_date: str, event_time: str, reference_id: int) -> bool: 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.""" """Queue a schedule addition notification to be sent by the bot."""
bot = get_bot() bot = get_bot()
try: try:
bot.message_queue.put({ bot.message_queue.put(
'type': 'schedule_addition', {
'data': { 'type': 'schedule_addition',
'user_id': user_id, 'data': {
'event_type': event_type, 'user_id': user_id,
'event_title': event_title, 'event_type': event_type,
'event_date': event_date, 'event_title': event_title,
'event_time': event_time, 'event_date': event_date,
'reference_id': reference_id 'event_time': event_time,
'reference_id': reference_id,
},
} }
}) )
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error queuing schedule notification: {e}") logger.error(f"Error queuing schedule notification: {e}")
return False return False
def send_one_on_one_response(player_discord_id: str, player_full_name: str, def send_one_on_one_response(
coach_full_name: str, date_str: str, start_time: str, player_discord_id: str,
end_time: str, points: str, approved: bool, player_full_name: str,
refusal_note: str = None) -> bool: 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. """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. Called from Flask routes when a coach accepts/rejects via the web app.
""" """
bot = get_bot() bot = get_bot()
try: try:
bot.message_queue.put({ bot.message_queue.put(
'type': 'one_on_one_response', {
'data': { 'type': 'one_on_one_response',
'player_discord_id': player_discord_id, 'data': {
'player_full_name': player_full_name, 'player_discord_id': player_discord_id,
'coach_full_name': coach_full_name, 'player_full_name': player_full_name,
'date_str': date_str, 'coach_full_name': coach_full_name,
'start_time': start_time, 'date_str': date_str,
'end_time': end_time, 'start_time': start_time,
'points': points, 'end_time': end_time,
'approved': approved, 'points': points,
'refusal_note': refusal_note, 'approved': approved,
'refusal_note': refusal_note,
},
} }
}) )
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error queuing One on One response DM: {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): def start_bot(flask_app=None):
"""Start the Discord bot in the background.""" """Start the Discord bot in the background."""
global bot_thread global bot_thread
bot = get_bot(flask_app=flask_app) bot = get_bot(flask_app=flask_app)
if DISCORD_BOT_TOKEN and bot_thread is None: if DISCORD_BOT_TOKEN and bot_thread is None:
def run_bot(): def run_bot():
try: try:
bot.run(DISCORD_BOT_TOKEN) bot.run(DISCORD_BOT_TOKEN)
except Exception as e: except Exception as e:
logger.error(f"Bot error: {e}") logger.error(f"Bot error: {e}")
bot_thread = threading.Thread(target=run_bot, daemon=True) bot_thread = threading.Thread(target=run_bot, daemon=True)
bot_thread.start() bot_thread.start()
logger.info("TeamTryoutsBot started in background thread") logger.info("TeamTryoutsBot started in background thread")
elif not DISCORD_BOT_TOKEN: elif not DISCORD_BOT_TOKEN:
logger.warning("DISCORD_BOT_TOKEN not set, bot not started") logger.warning("DISCORD_BOT_TOKEN not set, bot not started")
+6 -9
View File
@@ -18,19 +18,16 @@ csrf = CSRFProtect()
babel = Babel() babel = Babel()
# Rate limiter for brute-force protection # Rate limiter for brute-force protection
limiter = Limiter( limiter = Limiter(key_func=get_remote_address, default_limits=["200 per day", "50 per hour"])
key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"]
)
def hash_password(password): def hash_password(password):
""" """
Hash a plain text password using werkzeug's security functions. Hash a plain text password using werkzeug's security functions.
Args: Args:
password (str): The plain text password to hash. password (str): The plain text password to hash.
Returns: Returns:
str: The hashed password string. str: The hashed password string.
""" """
@@ -40,12 +37,12 @@ def hash_password(password):
def check_password(password_hash, password): def check_password(password_hash, password):
""" """
Verify a password against its hash. Verify a password against its hash.
Args: Args:
password_hash (str): The stored password hash. password_hash (str): The stored password hash.
password (str): The plain text password to verify. password (str): The plain text password to verify.
Returns: Returns:
bool: True if the password matches the hash, False otherwise. bool: True if the password matches the hash, False otherwise.
""" """
return check_password_hash(password_hash, password) return check_password_hash(password_hash, password)
+21 -12
View File
@@ -17,15 +17,25 @@ import re
class SensitiveDataFilter(logging.Filter): class SensitiveDataFilter(logging.Filter):
"""Logging filter that redacts sensitive information from log messages. """Logging filter that redacts sensitive information from log messages.
Filters out: passwords, API keys, session tokens, and other secrets Filters out: passwords, API keys, session tokens, and other secrets
that might accidentally be logged. that might accidentally be logged.
""" """
# Patterns to redact # Patterns to redact
SENSITIVE_PATTERNS = [ 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'Authorization[:\s]+[^\s]+', re.IGNORECASE), 'Authorization: [REDACTED]'),
(re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'), (re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'),
] ]
@@ -61,14 +71,14 @@ class SensitiveDataFilter(logging.Filter):
def configure_logging(app): def configure_logging(app):
"""Configure structured logging for the Flask application. """Configure structured logging for the Flask application.
Sets up three rotating file handlers: Sets up three rotating file handlers:
- errors.log: ERROR and CRITICAL level messages - errors.log: ERROR and CRITICAL level messages
- auth.log: Authentication-related events (INFO and above) - auth.log: Authentication-related events (INFO and above)
- app.log: All application logs (DEBUG and above, configurable) - app.log: All application logs (DEBUG and above, configurable)
Also configures console output for development. Also configures console output for development.
Args: Args:
app: The Flask application instance to configure logging for. 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 with timestamp, level, module, and message
formatter = logging.Formatter( formatter = logging.Formatter(
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s', '[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s', datefmt='%Y-%m-%d %H:%M:%S'
datefmt='%Y-%m-%d %H:%M:%S'
) )
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -98,7 +107,7 @@ def configure_logging(app):
error_handler = RotatingFileHandler( error_handler = RotatingFileHandler(
os.path.join(log_dir, 'errors.log'), os.path.join(log_dir, 'errors.log'),
maxBytes=10 * 1024 * 1024, # 10 MB maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=10 backupCount=10,
) )
error_handler.setLevel(logging.ERROR) error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(formatter) error_handler.setFormatter(formatter)
@@ -111,7 +120,7 @@ def configure_logging(app):
auth_handler = RotatingFileHandler( auth_handler = RotatingFileHandler(
os.path.join(log_dir, 'auth.log'), os.path.join(log_dir, 'auth.log'),
maxBytes=10 * 1024 * 1024, # 10 MB maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5 backupCount=5,
) )
auth_handler.setLevel(logging.INFO) auth_handler.setLevel(logging.INFO)
auth_handler.setFormatter(formatter) auth_handler.setFormatter(formatter)
@@ -129,7 +138,7 @@ def configure_logging(app):
app_handler = RotatingFileHandler( app_handler = RotatingFileHandler(
os.path.join(log_dir, 'app.log'), os.path.join(log_dir, 'app.log'),
maxBytes=10 * 1024 * 1024, # 10 MB maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=10 backupCount=10,
) )
app_handler.setLevel(log_level) app_handler.setLevel(log_level)
app_handler.setFormatter(formatter) app_handler.setFormatter(formatter)
@@ -208,4 +217,4 @@ def log_auth_event(event, **fields):
parts.append('path=%s' % request.path) parts.append('path=%s' % request.path)
parts.extend('%s=%s' % (key, value) for key, value in fields.items()) parts.extend('%s=%s' % (key, value) for key, value in fields.items())
get_auth_logger().info(' '.join(parts)) get_auth_logger().info(' '.join(parts))
+1 -1
View File
@@ -92,4 +92,4 @@ from app.models.user_gamertag import UserGamertag
from app.models.contract import Contract from app.models.contract import Contract
from app.models.team_note import TeamNote from app.models.team_note import TeamNote
from app.models.personal_note import PersonalNote from app.models.personal_note import PersonalNote
from app.models.one_on_one_request import OneOnOneRequest from app.models.one_on_one_request import OneOnOneRequest
+30 -15
View File
@@ -3,23 +3,38 @@
from app.extensions import db from app.extensions import db
org_team_coaches = db.Table('org_team_coaches', org_team_coaches = db.Table(
db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'), 'org_team_coaches',
primary_key=True), db.Column(
db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), 'org_team_id',
primary_key=True), 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', org_team_managers = db.Table(
db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'), 'org_team_managers',
primary_key=True), db.Column(
db.Column('manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), 'org_team_id',
primary_key=True), 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', tryout_coaches = db.Table(
db.Column('tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), 'tryout_coaches',
primary_key=True), db.Column(
db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), 'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
primary_key=True), ),
db.Column(
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
),
) )
+2 -6
View File
@@ -52,11 +52,7 @@ PLATFORM_CODES = {
'Epic': 'epic', 'Epic': 'epic',
} }
PLATFORM_DEFAULTS = { PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket League': 'epic'}
'Apex Legends': 'pc',
'Rainbow Six Siege': 'ubi',
'Rocket League': 'epic'
}
TRN_URLS = { TRN_URLS = {
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}', '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}', 'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}',
'Rocket League': 'https://rocketleague.tracker.network/rocket-league/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}', 'Super Smash Bros.': 'https://tracker.gg/smash/profile/{username}',
} }
+1 -1
View File
@@ -4,4 +4,4 @@ from app.models.availability.base import BaseAvailability
from app.models.availability.player_disponibility import PlayerDisponibility from app.models.availability.player_disponibility import PlayerDisponibility
from app.models.availability.coach_availability import CoachAvailability from app.models.availability.coach_availability import CoachAvailability
__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability'] __all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability']
+3 -1
View File
@@ -1,14 +1,16 @@
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability).""" """Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
from app.extensions import db from app.extensions import db
from datetime import datetime from datetime import datetime
class BaseAvailability(db.Model): class BaseAvailability(db.Model):
"""Shared schema for player disponibilities and coach availabilities.""" """Shared schema for player disponibilities and coach availabilities."""
__abstract__ = True __abstract__ = True
day_of_week = db.Column(db.Integer, nullable=False) day_of_week = db.Column(db.Integer, nullable=False)
start_time = db.Column(db.Time, nullable=False) start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False) end_time = db.Column(db.Time, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -1,12 +1,14 @@
"""Coach availability in 30-minute time blocks for One on One sessions.""" """Coach availability in 30-minute time blocks for One on One sessions."""
from app.extensions import db from app.extensions import db
from app.models.availability.base import BaseAvailability from app.models.availability.base import BaseAvailability
class CoachAvailability(BaseAvailability): class CoachAvailability(BaseAvailability):
"""Coach availability in 30-minute blocks for One on One sessions.""" """Coach availability in 30-minute blocks for One on One sessions."""
__tablename__ = 'coach_availabilities' __tablename__ = 'coach_availabilities'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
coach = db.relationship('User', backref='coach_availabilities') coach = db.relationship('User', backref='coach_availabilities')
@@ -1,12 +1,14 @@
"""Player availability in 30-minute time blocks.""" """Player availability in 30-minute time blocks."""
from app.extensions import db from app.extensions import db
from app.models.availability.base import BaseAvailability from app.models.availability.base import BaseAvailability
class PlayerDisponibility(BaseAvailability): class PlayerDisponibility(BaseAvailability):
"""Player availability in 30-minute blocks.""" """Player availability in 30-minute blocks."""
__tablename__ = 'player_disponibilities' __tablename__ = 'player_disponibilities'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
player = db.relationship('User', backref='disponibilities') player = db.relationship('User', backref='disponibilities')
+3 -1
View File
@@ -1,10 +1,12 @@
"""Contract documents for players to sign.""" """Contract documents for players to sign."""
from app.extensions import db from app.extensions import db
from datetime import datetime from datetime import datetime
class Contract(db.Model): class Contract(db.Model):
"""Contract documents for players to sign.""" """Contract documents for players to sign."""
__tablename__ = 'contracts' __tablename__ = 'contracts'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
@@ -62,4 +64,4 @@ class Contract(db.Model):
return False return False
def can_upload_signed(self, user): def can_upload_signed(self, user):
return user.id == self.player_id return user.id == self.player_id
+3 -1
View File
@@ -1,10 +1,12 @@
"""Player evaluation record.""" """Player evaluation record."""
from app.extensions import db from app.extensions import db
from datetime import datetime from datetime import datetime
class Evaluation(db.Model): class Evaluation(db.Model):
"""Player evaluation record.""" """Player evaluation record."""
__tablename__ = 'evaluations' __tablename__ = 'evaluations'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
@@ -27,4 +29,4 @@ class Evaluation(db.Model):
__table_args__ = ( __table_args__ = (
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'), db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
) )
+2 -1
View File
@@ -1,6 +1,7 @@
"""Match models — BaseMatch and its concrete subclasses.""" """Match models — BaseMatch and its concrete subclasses."""
from app.models.match_model.base import BaseMatch from app.models.match_model.base import BaseMatch
from app.models.match_model.match import Match from app.models.match_model.match import Match
from app.models.match_model.team_match import TeamMatch from app.models.match_model.team_match import TeamMatch
__all__ = ['BaseMatch', 'Match', 'TeamMatch'] __all__ = ['BaseMatch', 'Match', 'TeamMatch']
+3 -1
View File
@@ -1,10 +1,12 @@
"""Abstract base class for match models (Match + TeamMatch).""" """Abstract base class for match models (Match + TeamMatch)."""
from app.extensions import db from app.extensions import db
from datetime import datetime from datetime import datetime
class BaseMatch(db.Model): class BaseMatch(db.Model):
"""Shared schema for tryout-scoped matches and regular-season team matches.""" """Shared schema for tryout-scoped matches and regular-season team matches."""
__abstract__ = True __abstract__ = True
title = db.Column(db.String(200), nullable=False) title = db.Column(db.String(200), nullable=False)
@@ -15,4 +17,4 @@ class BaseMatch(db.Model):
location = db.Column(db.String(200), nullable=True) location = db.Column(db.String(200), nullable=True)
status = db.Column(db.String(20), default='scheduled') status = db.Column(db.String(20), default='scheduled')
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
+5 -3
View File
@@ -1,10 +1,12 @@
"""Match / scrimmage within a tryout.""" """Match / scrimmage within a tryout."""
from app.extensions import db from app.extensions import db
from app.models.match_model.base import BaseMatch from app.models.match_model.base import BaseMatch
class Match(BaseMatch): class Match(BaseMatch):
"""Match / scrimmage within a tryout.""" """Match / scrimmage within a tryout."""
__tablename__ = 'matches' __tablename__ = 'matches'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
@@ -21,8 +23,8 @@ class Match(BaseMatch):
# deleting any match that had participants raised IntegrityError. # deleting any match that had participants raised IntegrityError.
# TeamMatch.participants already declared this; Match did not. # TeamMatch.participants already declared this; Match did not.
participants = db.relationship( participants = db.relationship(
'MatchParticipant', backref='match', lazy='dynamic', 'MatchParticipant', backref='match', lazy='dynamic', cascade='all, delete-orphan'
cascade='all, delete-orphan') )
def get_participating_players(self): def get_participating_players(self):
return [p.player_id for p in self.participants.all()] return [p.player_id for p in self.participants.all()]
+5 -3
View File
@@ -1,10 +1,12 @@
"""Regular-season match for an organisation team (not tied to a tryout).""" """Regular-season match for an organisation team (not tied to a tryout)."""
from app.extensions import db from app.extensions import db
from app.models.match_model.base import BaseMatch from app.models.match_model.base import BaseMatch
class TeamMatch(BaseMatch): class TeamMatch(BaseMatch):
"""Regular-season match for an organisation team (not tied to a tryout).""" """Regular-season match for an organisation team (not tied to a tryout)."""
__tablename__ = 'team_matches' __tablename__ = 'team_matches'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
@@ -13,10 +15,10 @@ class TeamMatch(BaseMatch):
org_team = db.relationship('OrgTeam', backref='team_matches') org_team = db.relationship('OrgTeam', backref='team_matches')
creator = db.relationship('User', backref='created_team_matches') creator = db.relationship('User', backref='created_team_matches')
participants = db.relationship( participants = db.relationship(
'TeamMatchParticipant', backref='team_match', lazy='dynamic', 'TeamMatchParticipant', backref='team_match', lazy='dynamic', cascade='all, delete-orphan'
cascade='all, delete-orphan') )
def get_confirmed_count(self): def get_confirmed_count(self):
all_p = self.participants.all() all_p = self.participants.all()
confirmed = sum(1 for p in all_p if p.is_confirmed) confirmed = sum(1 for p in all_p if p.is_confirmed)
return confirmed, len(all_p) return confirmed, len(all_p)
+3 -1
View File
@@ -1,10 +1,12 @@
"""Request from player to coach for a One on One session.""" """Request from player to coach for a One on One session."""
from app.extensions import db from app.extensions import db
from datetime import datetime from datetime import datetime
class OneOnOneRequest(db.Model): class OneOnOneRequest(db.Model):
"""Request from player to coach for a One on One session.""" """Request from player to coach for a One on One session."""
__tablename__ = 'one_on_one_requests' __tablename__ = 'one_on_one_requests'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
@@ -22,4 +24,4 @@ class OneOnOneRequest(db.Model):
player = db.relationship('User', foreign_keys=[player_id], backref='one_on_one_requests') player = db.relationship('User', foreign_keys=[player_id], backref='one_on_one_requests')
coach = db.relationship('User', foreign_keys=[coach_id]) coach = db.relationship('User', foreign_keys=[coach_id])
team = db.relationship('OrgTeam', foreign_keys=[org_team_id]) team = db.relationship('OrgTeam', foreign_keys=[org_team_id])
+2 -1
View File
@@ -1,5 +1,6 @@
"""Organisation team models.""" """Organisation team models."""
from app.models.org_team.org_team import OrgTeam from app.models.org_team.org_team import OrgTeam
from app.models.org_team.team_player import TeamPlayer from app.models.org_team.team_player import TeamPlayer
__all__ = ['OrgTeam', 'TeamPlayer'] __all__ = ['OrgTeam', 'TeamPlayer']
+24 -10
View File
@@ -1,4 +1,5 @@
"""Persistent organisation team (e.g. Varsity, JV).""" """Persistent organisation team (e.g. Varsity, JV)."""
from app.extensions import db from app.extensions import db
from app.models._associations import org_team_coaches, org_team_managers from app.models._associations import org_team_coaches, org_team_managers
from datetime import datetime from datetime import datetime
@@ -6,6 +7,7 @@ from datetime import datetime
class OrgTeam(db.Model): class OrgTeam(db.Model):
"""Persistent organisation team (e.g. Varsity, JV).""" """Persistent organisation team (e.g. Varsity, JV)."""
__tablename__ = 'org_teams' __tablename__ = 'org_teams'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True) name = db.Column(db.String(100), nullable=False, unique=True)
@@ -17,20 +19,30 @@ class OrgTeam(db.Model):
creator = db.relationship('User', foreign_keys=[created_by]) creator = db.relationship('User', foreign_keys=[created_by])
coaches = db.relationship( coaches = db.relationship(
'User', secondary=org_team_coaches, lazy='dynamic', 'User',
backref=db.backref('coached_org_teams', lazy='dynamic')) secondary=org_team_coaches,
lazy='dynamic',
backref=db.backref('coached_org_teams', lazy='dynamic'),
)
managers = db.relationship( managers = db.relationship(
'User', secondary=org_team_managers, lazy='dynamic', 'User',
backref=db.backref('managed_org_teams', lazy='dynamic')) secondary=org_team_managers,
lazy='dynamic',
backref=db.backref('managed_org_teams', lazy='dynamic'),
)
coach = db.relationship( coach = db.relationship(
'User', foreign_keys=[coach_id], 'User',
foreign_keys=[coach_id],
backref=db.backref('coached_org_team_legacy', uselist=False), backref=db.backref('coached_org_team_legacy', uselist=False),
viewonly=True) viewonly=True,
)
manager = db.relationship( manager = db.relationship(
'User', foreign_keys=[manager_id], 'User',
foreign_keys=[manager_id],
backref=db.backref('managed_org_team_legacy', uselist=False), backref=db.backref('managed_org_team_legacy', uselist=False),
viewonly=True) viewonly=True,
)
def get_coaches(self): def get_coaches(self):
coach_list = self.coaches.all() coach_list = self.coaches.all()
@@ -49,5 +61,7 @@ class OrgTeam(db.Model):
return [tp.player for tp in self.team_players] return [tp.player for tp in self.team_players]
def get_players_with_status(self): def get_players_with_status(self):
return [{'player': tp.player, 'status': tp.status, return [
'position': tp.position} for tp in self.team_players] {'player': tp.player, 'status': tp.status, 'position': tp.position}
for tp in self.team_players
]
+3 -1
View File
@@ -1,10 +1,12 @@
"""Many-to-many junction: player to org-team.""" """Many-to-many junction: player to org-team."""
from app.extensions import db from app.extensions import db
from datetime import datetime from datetime import datetime
class TeamPlayer(db.Model): class TeamPlayer(db.Model):
"""Many-to-many: player to org-team.""" """Many-to-many: player to org-team."""
__tablename__ = 'team_players' __tablename__ = 'team_players'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
@@ -18,4 +20,4 @@ class TeamPlayer(db.Model):
__table_args__ = ( __table_args__ = (
db.UniqueConstraint('player_id', 'org_team_id', name='unique_player_org_team'), db.UniqueConstraint('player_id', 'org_team_id', name='unique_player_org_team'),
) )
+2 -1
View File
@@ -1,6 +1,7 @@
"""Participant models — BaseParticipant and its concrete subclasses.""" """Participant models — BaseParticipant and its concrete subclasses."""
from app.models.participant.base import BaseParticipant from app.models.participant.base import BaseParticipant
from app.models.participant.match_participant import MatchParticipant from app.models.participant.match_participant import MatchParticipant
from app.models.participant.team_match_participant import TeamMatchParticipant from app.models.participant.team_match_participant import TeamMatchParticipant
__all__ = ['BaseParticipant', 'MatchParticipant', 'TeamMatchParticipant'] __all__ = ['BaseParticipant', 'MatchParticipant', 'TeamMatchParticipant']
+3 -1
View File
@@ -1,11 +1,13 @@
"""Abstract base class for match participant models.""" """Abstract base class for match participant models."""
from app.extensions import db from app.extensions import db
from datetime import datetime from datetime import datetime
class BaseParticipant(db.Model): class BaseParticipant(db.Model):
"""Shared schema for match participants.""" """Shared schema for match participants."""
__abstract__ = True __abstract__ = True
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
added_at = db.Column(db.DateTime, default=datetime.utcnow) added_at = db.Column(db.DateTime, default=datetime.utcnow)
+3 -1
View File
@@ -1,10 +1,12 @@
"""Participant in a tryout-scoped match.""" """Participant in a tryout-scoped match."""
from app.extensions import db from app.extensions import db
from app.models.participant.base import BaseParticipant from app.models.participant.base import BaseParticipant
class MatchParticipant(BaseParticipant): class MatchParticipant(BaseParticipant):
"""Participant in a tryout-scoped match.""" """Participant in a tryout-scoped match."""
__tablename__ = 'match_participants' __tablename__ = 'match_participants'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False) 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) position = db.Column(db.String(50), nullable=True)
attendance_confirmed = db.Column(db.Boolean, default=False) attendance_confirmed = db.Column(db.Boolean, default=False)
player = db.relationship('User') player = db.relationship('User')
@@ -1,13 +1,15 @@
"""Participant in a regular-season team match.""" """Participant in a regular-season team match."""
from app.extensions import db from app.extensions import db
from app.models.participant.base import BaseParticipant from app.models.participant.base import BaseParticipant
class TeamMatchParticipant(BaseParticipant): class TeamMatchParticipant(BaseParticipant):
"""Participant in a regular-season team match.""" """Participant in a regular-season team match."""
__tablename__ = 'team_match_participants' __tablename__ = 'team_match_participants'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False) team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False)
is_confirmed = db.Column(db.Boolean, default=False) is_confirmed = db.Column(db.Boolean, default=False)
player = db.relationship('User') player = db.relationship('User')
+3 -1
View File
@@ -1,10 +1,12 @@
"""Personal notes from coach to individual player.""" """Personal notes from coach to individual player."""
from app.extensions import db from app.extensions import db
from datetime import datetime from datetime import datetime
class PersonalNote(db.Model): class PersonalNote(db.Model):
"""Personal notes from coach to individual player.""" """Personal notes from coach to individual player."""
__tablename__ = 'personal_notes' __tablename__ = 'personal_notes'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
@@ -21,4 +23,4 @@ class PersonalNote(db.Model):
coach = db.relationship('User', foreign_keys=[coach_id]) coach = db.relationship('User', foreign_keys=[coach_id])
match = db.relationship('Match', foreign_keys=[match_id]) match = db.relationship('Match', foreign_keys=[match_id])
team = db.relationship('Team', foreign_keys=[team_id]) team = db.relationship('Team', foreign_keys=[team_id])
tryout = db.relationship('Tryout', foreign_keys=[tryout_id]) tryout = db.relationship('Tryout', foreign_keys=[tryout_id])
+2 -1
View File
@@ -1,5 +1,6 @@
"""Tryout-specific temporary team models.""" """Tryout-specific temporary team models."""
from app.models.team.team import Team from app.models.team.team import Team
from app.models.team.team_member import TeamMember from app.models.team.team_member import TeamMember
__all__ = ['Team', 'TeamMember'] __all__ = ['Team', 'TeamMember']
+3 -1
View File
@@ -1,10 +1,12 @@
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" """Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
from app.extensions import db from app.extensions import db
from datetime import datetime from datetime import datetime
class Team(db.Model): class Team(db.Model):
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" """Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
__tablename__ = 'teams' __tablename__ = 'teams'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
@@ -13,4 +15,4 @@ class Team(db.Model):
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
creator = db.relationship('User', backref='created_teams') creator = db.relationship('User', backref='created_teams')
members = db.relationship('TeamMember', backref='team', lazy='dynamic') members = db.relationship('TeamMember', backref='team', lazy='dynamic')
+3 -1
View File
@@ -1,10 +1,12 @@
"""Link between a player and a tryout-specific team.""" """Link between a player and a tryout-specific team."""
from app.extensions import db from app.extensions import db
from datetime import datetime from datetime import datetime
class TeamMember(db.Model): class TeamMember(db.Model):
"""Link between a player and a tryout-specific team.""" """Link between a player and a tryout-specific team."""
__tablename__ = 'team_members' __tablename__ = 'team_members'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False) 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) position = db.Column(db.String(50), nullable=True)
added_at = db.Column(db.DateTime, default=datetime.utcnow) added_at = db.Column(db.DateTime, default=datetime.utcnow)
player = db.relationship('User', overlaps="player_ref,team_assignments") player = db.relationship('User', overlaps="player_ref,team_assignments")
+3 -1
View File
@@ -1,10 +1,12 @@
"""Team improvement notes from coach.""" """Team improvement notes from coach."""
from app.extensions import db from app.extensions import db
from datetime import datetime from datetime import datetime
class TeamNote(db.Model): class TeamNote(db.Model):
"""Team improvement notes from coach.""" """Team improvement notes from coach."""
__tablename__ = 'team_notes' __tablename__ = 'team_notes'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
@@ -14,4 +16,4 @@ class TeamNote(db.Model):
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
team = db.relationship('OrgTeam', backref='team_notes') team = db.relationship('OrgTeam', backref='team_notes')
coach = db.relationship('User', foreign_keys=[coach_id]) coach = db.relationship('User', foreign_keys=[coach_id])
+2 -1
View File
@@ -1,5 +1,6 @@
"""Tryout models.""" """Tryout models."""
from app.models.tryout.tryout import Tryout from app.models.tryout.tryout import Tryout
from app.models.tryout.tryout_registration import TryoutRegistration from app.models.tryout.tryout_registration import TryoutRegistration
__all__ = ['Tryout', 'TryoutRegistration'] __all__ = ['Tryout', 'TryoutRegistration']
+9 -2
View File
@@ -1,4 +1,5 @@
"""Tryout event for player evaluations and team formation.""" """Tryout event for player evaluations and team formation."""
from app.extensions import db from app.extensions import db
from app.models._associations import tryout_coaches from app.models._associations import tryout_coaches
from datetime import datetime from datetime import datetime
@@ -6,6 +7,7 @@ from datetime import datetime
class Tryout(db.Model): class Tryout(db.Model):
"""Tryout event for player evaluations and team formation.""" """Tryout event for player evaluations and team formation."""
__tablename__ = 'tryouts' __tablename__ = 'tryouts'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False) 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) 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) 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) 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) created_at = db.Column(db.DateTime, default=datetime.utcnow)
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts') 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') registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic') evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
teams = db.relationship('Team', 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 @property
def is_ended(self): def is_ended(self):
"""Tryout is considered ended after its end_date passes. """Tryout is considered ended after its end_date passes.
Falls back to date if end_date is not set.""" Falls back to date if end_date is not set."""
from datetime import date as date_type from datetime import date as date_type
today = date_type.today() today = date_type.today()
if self.end_date is not None: if self.end_date is not None:
return self.end_date < today return self.end_date < today
+3 -1
View File
@@ -1,14 +1,16 @@
"""Registration linking a player to a tryout.""" """Registration linking a player to a tryout."""
from app.extensions import db from app.extensions import db
from datetime import datetime from datetime import datetime
class TryoutRegistration(db.Model): class TryoutRegistration(db.Model):
"""Registration linking a player to a tryout.""" """Registration linking a player to a tryout."""
__tablename__ = 'tryout_registrations' __tablename__ = 'tryout_registrations'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
registered_at = db.Column(db.DateTime, default=datetime.utcnow) registered_at = db.Column(db.DateTime, default=datetime.utcnow)
status = db.Column(db.String(20), default='registered') status = db.Column(db.String(20), default='registered')
notes = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True)
+3 -3
View File
@@ -1,4 +1,5 @@
"""Store gamertag per game for each user.""" """Store gamertag per game for each user."""
from app.extensions import db from app.extensions import db
from app.models._constants import TRN_URLS, PLATFORM_CODES, PLATFORM_DEFAULTS from app.models._constants import TRN_URLS, PLATFORM_CODES, PLATFORM_DEFAULTS
from urllib.parse import quote from urllib.parse import quote
@@ -6,6 +7,7 @@ from urllib.parse import quote
class UserGamertag(db.Model): class UserGamertag(db.Model):
"""Store gamertag per game for each user.""" """Store gamertag per game for each user."""
__tablename__ = 'user_gamertags' __tablename__ = 'user_gamertags'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) 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') user = db.relationship('User', backref='gamertags')
__table_args__ = ( __table_args__ = (db.UniqueConstraint('user_id', 'game', name='unique_user_game'),)
db.UniqueConstraint('user_id', 'game', name='unique_user_game'),
)
def get_trn_url(self): def get_trn_url(self):
if self.game not in TRN_URLS: if self.game not in TRN_URLS:
+1
View File
@@ -1,4 +1,5 @@
"""User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout).""" """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.user import User
from app.models.user_model.admin import Admin from app.models.user_model.admin import Admin
from app.models.user_model.manager import Manager from app.models.user_model.manager import Manager
+3
View File
@@ -1,9 +1,11 @@
"""Admin / President — full access to everything.""" """Admin / President — full access to everything."""
from app.models.user_model.user import User from app.models.user_model.user import User
class Admin(User): class Admin(User):
"""President / super-admin — full access to everything.""" """President / super-admin — full access to everything."""
__mapper_args__ = {'polymorphic_identity': 'admin'} __mapper_args__ = {'polymorphic_identity': 'admin'}
def can_evaluate(self): def can_evaluate(self):
@@ -29,4 +31,5 @@ class Admin(User):
def get_visible_tryouts(self): def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout from app.models.tryout.tryout import Tryout
return Tryout.query.order_by(Tryout.date).all() return Tryout.query.order_by(Tryout.date).all()
+4
View File
@@ -1,4 +1,5 @@
"""Coach — evaluates, schedules matches, manages their own org teams.""" """Coach — evaluates, schedules matches, manages their own org teams."""
from app.models.user_model.user import User 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 and ``get_visible_tryouts`` ignored it entirely. A coach attached only
by that column therefore saw an empty calendar (ARCH-002). by that column therefore saw an empty calendar (ARCH-002).
""" """
__mapper_args__ = {'polymorphic_identity': 'coach'} __mapper_args__ = {'polymorphic_identity': 'coach'}
def can_evaluate(self): def can_evaluate(self):
@@ -26,6 +28,7 @@ class Coach(User):
def can_manage_this_tryout(self, tryout): def can_manage_this_tryout(self, tryout):
from app.permissions import coach_manages_tryout from app.permissions import coach_manages_tryout
return coach_manages_tryout(self, tryout) return coach_manages_tryout(self, tryout)
def can_manage_this_org_team(self, org_team): def can_manage_this_org_team(self, org_team):
@@ -35,4 +38,5 @@ class Coach(User):
def get_visible_tryouts(self): def get_visible_tryouts(self):
from app.permissions import coach_tryouts from app.permissions import coach_tryouts
return coach_tryouts(self) return coach_tryouts(self)
+8 -3
View File
@@ -1,9 +1,11 @@
"""Manager — manages own tryouts, all org teams, all contracts.""" """Manager — manages own tryouts, all org teams, all contracts."""
from app.models.user_model.user import User from app.models.user_model.user import User
class Manager(User): class Manager(User):
"""Manager — manages own tryouts, all org teams, all contracts.""" """Manager — manages own tryouts, all org teams, all contracts."""
__mapper_args__ = {'polymorphic_identity': 'manager'} __mapper_args__ = {'polymorphic_identity': 'manager'}
def can_evaluate(self): def can_evaluate(self):
@@ -27,6 +29,9 @@ class Manager(User):
def get_visible_tryouts(self): def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout from app.models.tryout.tryout import Tryout
from sqlalchemy import or_ from sqlalchemy import or_
return Tryout.query.filter(
or_(Tryout.created_by == self.id, Tryout.manager_id == self.id) return (
).order_by(Tryout.date).all() Tryout.query.filter(or_(Tryout.created_by == self.id, Tryout.manager_id == self.id))
.order_by(Tryout.date)
.all()
)
+23 -9
View File
@@ -1,9 +1,11 @@
"""Player — registers for tryouts, manages their own profile.""" """Player — registers for tryouts, manages their own profile."""
from app.models.user_model.user import User from app.models.user_model.user import User
class Player(User): class Player(User):
"""Player — registers for tryouts, manages their own profile.""" """Player — registers for tryouts, manages their own profile."""
__mapper_args__ = {'polymorphic_identity': 'player'} __mapper_args__ = {'polymorphic_identity': 'player'}
def get_visible_tryouts(self): def get_visible_tryouts(self):
@@ -13,18 +15,30 @@ class Player(User):
# tryouts they registered for # tryouts they registered for
player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()] player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()]
tryouts = Tryout.query.filter( tryouts = (
Tryout.id.in_(player_tryout_ids) Tryout.query.filter(Tryout.id.in_(player_tryout_ids)).order_by(Tryout.date).all()
).order_by(Tryout.date).all() if player_tryout_ids else [] if player_tryout_ids
else []
)
# plus tryouts where they participate in a match # plus tryouts where they participate in a match
player_matches = Match.query.join(MatchParticipant).filter( player_matches = (
MatchParticipant.player_id == self.id, Match.query.join(MatchParticipant)
).all() .filter(
MatchParticipant.player_id == self.id,
)
.all()
)
extra_ids = set(m.tryout_id for m in player_matches) extra_ids = set(m.tryout_id for m in player_matches)
extra = Tryout.query.filter( extra = (
Tryout.id.in_(extra_ids), Tryout.query.filter(
).order_by(Tryout.date).all() if extra_ids else [] Tryout.id.in_(extra_ids),
)
.order_by(Tryout.date)
.all()
if extra_ids
else []
)
all_ids = {t.id for t in tryouts} all_ids = {t.id for t in tryouts}
return tryouts + [t for t in extra if t.id not in all_ids] return tryouts + [t for t in extra if t.id not in all_ids]
+3
View File
@@ -1,9 +1,11 @@
"""Scout — view-only access to tryouts and evaluations.""" """Scout — view-only access to tryouts and evaluations."""
from app.models.user_model.user import User from app.models.user_model.user import User
class Scout(User): class Scout(User):
"""Scout — view-only access to tryouts and evaluations.""" """Scout — view-only access to tryouts and evaluations."""
__mapper_args__ = {'polymorphic_identity': 'scout'} __mapper_args__ = {'polymorphic_identity': 'scout'}
def can_evaluate(self): def can_evaluate(self):
@@ -11,4 +13,5 @@ class Scout(User):
def get_visible_tryouts(self): def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout from app.models.tryout.tryout import Tryout
return Tryout.query.order_by(Tryout.date).all() return Tryout.query.order_by(Tryout.date).all()
+13 -11
View File
@@ -1,4 +1,5 @@
"""Base User model — shared fields and polymorphic configuration.""" """Base User model — shared fields and polymorphic configuration."""
from app.extensions import db from app.extensions import db
from flask_login import UserMixin from flask_login import UserMixin
from datetime import datetime from datetime import datetime
@@ -10,6 +11,7 @@ class User(UserMixin, db.Model):
Do not instantiate this class directly; use Admin, Manager, Coach, Player, Do not instantiate this class directly; use Admin, Manager, Coach, Player,
or Scout so that `polymorphic_identity` is set correctly. or Scout so that `polymorphic_identity` is set correctly.
""" """
__tablename__ = 'users' __tablename__ = 'users'
# --- columns ----------------------------------------------------------- # --- columns -----------------------------------------------------------
@@ -27,7 +29,7 @@ class User(UserMixin, db.Model):
locked_until = db.Column(db.DateTime, nullable=True) locked_until = db.Column(db.DateTime, nullable=True)
# E-Sports fields # 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_username = db.Column(db.String(128), nullable=True)
discord_user_id = db.Column(db.String(64), nullable=True) discord_user_id = db.Column(db.String(64), nullable=True)
league_os_profile = db.Column(db.String(256), 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) -------------------------- # --- relationships (defined once on the base) --------------------------
evaluations_given = db.relationship( evaluations_given = db.relationship(
'Evaluation', foreign_keys='Evaluation.evaluator_id', 'Evaluation', foreign_keys='Evaluation.evaluator_id', backref='evaluator', lazy='dynamic'
backref='evaluator', lazy='dynamic') )
evaluations_received = db.relationship( evaluations_received = db.relationship(
'Evaluation', foreign_keys='Evaluation.player_id', 'Evaluation', foreign_keys='Evaluation.player_id', backref='player', lazy='dynamic'
backref='player', lazy='dynamic') )
tryout_registrations = db.relationship( tryout_registrations = db.relationship('TryoutRegistration', backref='player', lazy='dynamic')
'TryoutRegistration', backref='player', lazy='dynamic')
team_assignments = db.relationship( team_assignments = db.relationship(
'TeamMember', foreign_keys='TeamMember.player_id', 'TeamMember', foreign_keys='TeamMember.player_id', backref='player_ref', lazy='dynamic'
backref='player_ref', lazy='dynamic') )
# --- Flask-Login integration ------------------------------------------- # --- Flask-Login integration -------------------------------------------
@property @property
@@ -71,8 +72,9 @@ class User(UserMixin, db.Model):
def get_gamertags(self): def get_gamertags(self):
"""Return gamertags as a dict keyed by game.""" """Return gamertags as a dict keyed by game."""
return {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} return {
for gt in self.gamertags} gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in self.gamertags
}
def get_org_teams(self): def get_org_teams(self):
"""Return all OrgTeams this player belongs to.""" """Return all OrgTeams this player belongs to."""
+29 -14
View File
@@ -33,6 +33,7 @@ from app.extensions import db
# Team attachment # Team attachment
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def coach_org_teams(coach): def coach_org_teams(coach):
"""Organisation teams a coach is attached to, ordered by name. """Organisation teams a coach is attached to, ordered by name.
@@ -47,12 +48,16 @@ def coach_org_teams(coach):
""" """
from app.models import OrgTeam from app.models import OrgTeam
return OrgTeam.query.filter( return (
db.or_( OrgTeam.query.filter(
OrgTeam.coaches.any(id=coach.id), db.or_(
OrgTeam.coach_id == coach.id, 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): def coach_org_team_ids(coach):
@@ -81,12 +86,16 @@ def manager_org_teams(manager):
""" """
from app.models import OrgTeam from app.models import OrgTeam
return OrgTeam.query.filter( return (
db.or_( OrgTeam.query.filter(
OrgTeam.managers.any(id=manager.id), db.or_(
OrgTeam.manager_id == manager.id, 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): def attached_org_teams(user):
@@ -154,6 +163,7 @@ def can_manage_org_team(user, org_team):
# Reach over players # Reach over players
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def org_team_player_ids(team_ids): def org_team_player_ids(team_ids):
"""IDs of the players placed on any of these teams. """IDs of the players placed on any of these teams.
@@ -227,10 +237,14 @@ def coach_can_access_player(coach, player_id):
if registered: if registered:
return True return True
plays_a_match = MatchParticipant.query.join(Match).filter( plays_a_match = (
MatchParticipant.player_id == player_id, MatchParticipant.query.join(Match)
Match.tryout_id.in_(tryout_ids), .filter(
).first() MatchParticipant.player_id == player_id,
Match.tryout_id.in_(tryout_ids),
)
.first()
)
return plays_a_match is not None return plays_a_match is not None
@@ -262,6 +276,7 @@ def can_manage_player_contract(user, player_id):
# Tryouts # Tryouts
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def coach_tryouts(coach, team_ids=None): def coach_tryouts(coach, team_ids=None):
"""Tryouts a coach manages, ordered by date. """Tryouts a coach manages, ordered by date.
+32 -20
View File
@@ -78,8 +78,7 @@ def is_safe_url(url):
parsed = urlparse(url) parsed = urlparse(url)
if parsed.netloc: if parsed.netloc:
return (parsed.netloc == request.host return parsed.netloc == request.host and parsed.scheme in ('', 'http', 'https')
and parsed.scheme in ('', 'http', 'https'))
# Relative targets must be rooted. 'dashboard' or 'javascript:...' are # Relative targets must be rooted. 'dashboard' or 'javascript:...' are
# not paths on this site. # not paths on this site.
return url.startswith('/') return url.startswith('/')
@@ -112,7 +111,7 @@ def cooloff_minutes(failed_attempts):
int: Minutes. int: Minutes.
""" """
steps = max(failed_attempts // MAX_LOGIN_ATTEMPTS - 1, 0) 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(): def generate_captcha():
@@ -124,6 +123,7 @@ def generate_captcha():
dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys. dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys.
""" """
import random import random
a = random.randint(1, 10) a = random.randint(1, 10)
b = random.randint(1, 10) b = random.randint(1, 10)
captcha_id = str(uuid.uuid4()) captcha_id = str(uuid.uuid4())
@@ -199,8 +199,7 @@ def login():
if user and credentials_ok: if user and credentials_ok:
if not user.is_active_account: if not user.is_active_account:
log_auth_event('login.rejected.deactivated', log_auth_event('login.rejected.deactivated', username=username, user_id=user.id)
username=username, user_id=user.id)
flash(_('This account has been deactivated.'), 'danger') flash(_('This account has been deactivated.'), 'danger')
return render_template('pages/login.html') return render_template('pages/login.html')
@@ -219,9 +218,7 @@ def login():
# back into French — the preference lives in the session, and # back into French — the preference lives in the session, and
# clearing it discards a decision the user just made. # clearing it discards a decision the user just made.
_preserved = { _preserved = {
key: session[key] key: session[key] for key in ('csrf_token', LOCALE_SESSION_KEY) if key in session
for key in ('csrf_token', LOCALE_SESSION_KEY)
if key in session
} }
session.clear() session.clear()
session.update(_preserved) session.update(_preserved)
@@ -232,8 +229,7 @@ def login():
session.permanent = True session.permanent = True
login_user(user) login_user(user)
log_auth_event('login.success', log_auth_event('login.success', username=user.username, user_id=user.id, role=user.role)
username=user.username, user_id=user.id, role=user.role)
# Validate redirect URL to prevent open redirect vulnerability # Validate redirect URL to prevent open redirect vulnerability
next_page = request.args.get('next') next_page = request.args.get('next')
@@ -248,20 +244,33 @@ def login():
# who asked (SEC-017). # who asked (SEC-017).
if user: if user:
user.failed_login_attempts += 1 user.failed_login_attempts += 1
log_auth_event('login.failure', username=username, user_id=user.id, log_auth_event(
attempts=user.failed_login_attempts) 'login.failure',
username=username,
user_id=user.id,
attempts=user.failed_login_attempts,
)
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS: if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
minutes = cooloff_minutes(user.failed_login_attempts) minutes = cooloff_minutes(user.failed_login_attempts)
user.locked_until = datetime.utcnow() + timedelta(minutes=minutes) user.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
log_auth_event('account.throttled', username=username, log_auth_event(
user_id=user.id, minutes=minutes, 'account.throttled',
attempts=user.failed_login_attempts) username=username,
user_id=user.id,
minutes=minutes,
attempts=user.failed_login_attempts,
)
db.session.commit() db.session.commit()
else: else:
log_auth_event('login.failure.unknown_user', username=username) log_auth_event('login.failure.unknown_user', username=username)
flash(_('Login unsuccessful. Please check your username and ' flash(
'password, or ask a president for help.'), 'danger') _(
'Login unsuccessful. Please check your username and '
'password, or ask a president for help.'
),
'danger',
)
return render_template('pages/login.html') return render_template('pages/login.html')
@@ -375,6 +384,7 @@ def register():
# Create UserGamertag records for each selected game # Create UserGamertag records for each selected game
from app.models import UserGamertag from app.models import UserGamertag
for game in selected_games: for game in selected_games:
field_name = f'gamertag_{game}' field_name = f'gamertag_{game}'
gamertag_value = request.form.get(field_name, '').strip() 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): if not expected_state or not secrets.compare_digest(expected_state, received_state):
flash( 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', 'danger',
) )
return redirect(url_for('auth.register')) return redirect(url_for('auth.register'))
@@ -585,4 +597,4 @@ def logout():
if _locale: if _locale:
session[LOCALE_SESSION_KEY] = _locale session[LOCALE_SESSION_KEY] = _locale
flash(_('You have been logged out.'), 'info') flash(_('You have been logged out.'), 'info')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
+96 -48
View File
@@ -8,8 +8,12 @@ from flask_login import login_required, current_user
from flask_babel import gettext as _ from flask_babel import gettext as _
from app.extensions import db from app.extensions import db
from app.models import ( from app.models import (
Admin, Player, Admin,
User, Tryout, Evaluation, TryoutRegistration, Player,
User,
Tryout,
Evaluation,
TryoutRegistration,
GAME_POSITIONS, GAME_POSITIONS,
) )
from sqlalchemy import func from sqlalchemy import func
@@ -74,22 +78,29 @@ def list_evaluations():
sort_expr = sort_expr.desc() sort_expr = sort_expr.desc()
if isinstance(user, Admin): if isinstance(user, Admin):
evaluations = Evaluation.query \ evaluations = (
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \ Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \ .outerjoin(player_alias, Evaluation.player_id == player_alias.id)
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \ .outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.order_by(sort_expr).all() .order_by(sort_expr)
avg_scores = db.session.query( .all()
Evaluation.player_id, )
func.count(Evaluation.id).label('eval_count'), avg_scores = (
func.avg(Evaluation.overall_score).label('avg_score'), db.session.query(
).group_by(Evaluation.player_id).all() 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 = {} player_scores = {}
for row in avg_scores: for row in avg_scores:
p = User.query.get(row.player_id) p = User.query.get(row.player_id)
if p: if p:
player_scores[p.id] = { 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, 'avg': round(row.avg_score, 1) if row.avg_score else 0,
} }
else: else:
@@ -97,17 +108,23 @@ def list_evaluations():
# can_evaluate() is true for the four remaining roles. The former # can_evaluate() is true for the four remaining roles. The former
# `else` branch listed evaluations *received* — a player's view, # `else` branch listed evaluations *received* — a player's view,
# unreachable from this point (ARCH-007). # unreachable from this point (ARCH-007).
evaluations = Evaluation.query \ evaluations = (
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \ Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \ .outerjoin(player_alias, Evaluation.player_id == player_alias.id)
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \ .outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
.filter(Evaluation.evaluator_id == user.id) \ .filter(Evaluation.evaluator_id == user.id)
.order_by(sort_expr).all() .order_by(sort_expr)
.all()
)
player_scores = {} player_scores = {}
return render_template('pages/evaluations.html', return render_template(
evaluations=evaluations, player_scores=player_scores, 'pages/evaluations.html',
sort_column=sort_column, sort_order=sort_order) evaluations=evaluations,
player_scores=player_scores,
sort_column=sort_column,
sort_order=sort_order,
)
@evaluations_bp.route('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST']) @evaluations_bp.route('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST'])
@@ -123,9 +140,13 @@ def evaluate_player(tryout_id, player_id):
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger') flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
is_registered = TryoutRegistration.query.filter_by( is_registered = (
tryout_id=tryout_id, player_id=player_id, TryoutRegistration.query.filter_by(
).first() is not None tryout_id=tryout_id,
player_id=player_id,
).first()
is not None
)
if not is_registered: if not is_registered:
flash(_('Player is not registered for this tryout.'), 'danger') flash(_('Player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -136,7 +157,9 @@ def evaluate_player(tryout_id, player_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing_eval = Evaluation.query.filter_by( 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() ).first()
if request.method == 'POST': if request.method == 'POST':
@@ -152,9 +175,21 @@ def evaluate_player(tryout_id, player_id):
comments = request.form.get('comments') comments = request.form.get('comments')
position = request.form.get('position_recommendation') position = request.form.get('position_recommendation')
scores = [s for s in [mecanics, cohesion, communication, gamesense, scores = [
versatility, discipline, analysis, sport_ethics, mental] s
if s is not None] 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 overall = sum(scores) / len(scores) if scores else None
if existing_eval: if existing_eval:
@@ -173,14 +208,21 @@ def evaluate_player(tryout_id, player_id):
flash(_('Evaluation updated!'), 'success') flash(_('Evaluation updated!'), 'success')
else: else:
evaluation = Evaluation( evaluation = Evaluation(
tryout_id=tryout_id, player_id=player_id, tryout_id=tryout_id,
player_id=player_id,
evaluator_id=current_user.id, evaluator_id=current_user.id,
mecanics_score=mecanics, cohesion_score=cohesion, mecanics_score=mecanics,
communication_score=communication, gamesense_score=gamesense, cohesion_score=cohesion,
versatility_score=versatility, discipline_score=discipline, communication_score=communication,
analysis_score=analysis, sport_ethics_score=sport_ethics, gamesense_score=gamesense,
mental_score=mental, overall_score=overall, versatility_score=versatility,
comments=comments, position_recommendation=position, 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) db.session.add(evaluation)
flash(_('Evaluation submitted successfully!'), 'success') flash(_('Evaluation submitted successfully!'), 'success')
@@ -191,16 +233,21 @@ def evaluate_player(tryout_id, player_id):
evaluators = None evaluators = None
if isinstance(current_user, Admin): if isinstance(current_user, Admin):
all_evaluations = Evaluation.query.filter_by( all_evaluations = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=player_id, tryout_id=tryout_id,
player_id=player_id,
).all() ).all()
evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e} evaluators = [
for e in all_evaluations] {'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations
]
return render_template('pages/evaluate_player.html', return render_template(
tryout=tryout, player=player, 'pages/evaluate_player.html',
existing_eval=existing_eval, tryout=tryout,
evaluators=evaluators, player=player,
game_positions=GAME_POSITIONS) existing_eval=existing_eval,
evaluators=evaluators,
game_positions=GAME_POSITIONS,
)
@evaluations_bp.route('/<int:tryout_id>/players') @evaluations_bp.route('/<int:tryout_id>/players')
@@ -222,9 +269,10 @@ def players_to_evaluate(tryout_id):
p = User.query.get(reg.player_id) p = User.query.get(reg.player_id)
if p and isinstance(p, Player): if p and isinstance(p, Player):
existing = Evaluation.query.filter_by( 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() ).first()
players.append({'player': p, 'evaluated': existing is not None, players.append({'player': p, 'evaluated': existing is not None, 'registration': reg})
'registration': reg})
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players) return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
+103 -43
View File
@@ -8,9 +8,18 @@ from flask_login import login_required, current_user
from flask_babel import gettext as _ from flask_babel import gettext as _
from app.extensions import db from app.extensions import db
from app.models import ( from app.models import (
Admin, Manager, Coach, Player, Scout, Admin,
User, Tryout, Evaluation, TryoutRegistration, TeamMember, Manager,
Match, MatchParticipant, Coach,
Player,
Scout,
User,
Tryout,
Evaluation,
TryoutRegistration,
TeamMember,
Match,
MatchParticipant,
) )
from app.permissions import coach_tryout_ids from app.permissions import coach_tryout_ids
from sqlalchemy import func from sqlalchemy import func
@@ -46,8 +55,9 @@ def set_language(locale):
target = request.referrer target = request.referrer
if target and is_safe_url(target): if target and is_safe_url(target):
return redirect(target) return redirect(target)
return redirect(url_for('main.dashboard') if current_user.is_authenticated return redirect(
else url_for('auth.login')) url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login')
)
@main_bp.route('/dashboard') @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_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() stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
today = date.today() today = date.today()
stats['upcoming_matches'] = Match.query.filter( stats['upcoming_matches'] = (
Match.status == 'scheduled', Match.date >= today, Match.query.filter(
).order_by(Match.date, Match.start_time).limit(5).all() Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.limit(5)
.all()
)
elif isinstance(user, Manager): elif isinstance(user, Manager):
stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count() stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count()
stats['active_tryouts'] = Tryout.query.filter_by( 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['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
stats['my_tryouts'] = Tryout.query.filter_by( stats['my_tryouts'] = (
created_by=user.id).order_by(Tryout.date.desc()).limit(5).all() Tryout.query.filter_by(created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
)
today = date.today() today = date.today()
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()] manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
stats['upcoming_matches'] = Match.query.filter( stats['upcoming_matches'] = (
Match.tryout_id.in_(manager_tryout_ids), Match.query.filter(
Match.status == 'scheduled', Match.date >= today, Match.tryout_id.in_(manager_tryout_ids),
).order_by(Match.date, Match.start_time).limit(5).all() if manager_tryout_ids else [] 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): elif isinstance(user, Coach):
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count() stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
registrations = TryoutRegistration.query.filter( 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] 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['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids))
stats['my_recent_evaluations'] = Evaluation.query.filter_by( stats['my_recent_evaluations'] = (
evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all() Evaluation.query.filter_by(evaluator_id=user.id)
.order_by(Evaluation.created_at.desc())
.limit(10)
.all()
)
today = date.today() today = date.today()
# Was: the first team matching the legacy coach_id column, and only # Was: the first team matching the legacy coach_id column, and only
# the tryouts targeting it. A coach attached by the many-to-many # the tryouts targeting it. A coach attached by the many-to-many
# relationship, or coaching a second team, saw no upcoming match. # relationship, or coaching a second team, saw no upcoming match.
tryout_ids = coach_tryout_ids(user) tryout_ids = coach_tryout_ids(user)
stats['upcoming_matches'] = Match.query.filter( stats['upcoming_matches'] = (
Match.tryout_id.in_(tryout_ids), Match.query.filter(
Match.status == 'scheduled', Match.date >= today, Match.tryout_id.in_(tryout_ids),
).order_by(Match.date, Match.start_time).limit(5).all() if tryout_ids else [] Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.limit(5)
.all()
if tryout_ids
else []
)
elif isinstance(user, Player): elif isinstance(user, Player):
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count() stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
stats['my_registrations'] = TryoutRegistration.query.filter_by( stats['my_registrations'] = (
player_id=user.id).order_by(TryoutRegistration.registered_at.desc()).limit(5).all() TryoutRegistration.query.filter_by(player_id=user.id)
.order_by(TryoutRegistration.registered_at.desc())
.limit(5)
.all()
)
today = date.today() today = date.today()
next_matches = [] next_matches = []
@@ -121,10 +166,15 @@ def dashboard():
player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all() player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all()
player_team_ids = [tm.team_id for tm in player_team_memberships] player_team_ids = [tm.team_id for tm in player_team_memberships]
upcoming_matches = Match.query.filter( upcoming_matches = (
Match.tryout_id.in_(registered_tryout_ids), Match.query.filter(
Match.status == 'scheduled', Match.date >= today, Match.tryout_id.in_(registered_tryout_ids),
).order_by(Match.date, Match.start_time).all() Match.status == 'scheduled',
Match.date >= today,
)
.order_by(Match.date, Match.start_time)
.all()
)
for match in upcoming_matches: for match in upcoming_matches:
is_participant = False is_participant = False
@@ -132,36 +182,46 @@ def dashboard():
if match.match_type == 'team_vs_team': if match.match_type == 'team_vs_team':
if match.team1_id in player_team_ids: if match.team1_id in player_team_ids:
is_participant = True is_participant = True
team = next((tm for tm in player_team_memberships team = next(
if tm.team_id == match.team1_id), None) (tm for tm in player_team_memberships if tm.team_id == match.team1_id), None
)
elif match.team2_id in player_team_ids: elif match.team2_id in player_team_ids:
is_participant = True is_participant = True
team = next((tm for tm in player_team_memberships team = next(
if tm.team_id == match.team2_id), None) (tm for tm in player_team_memberships if tm.team_id == match.team2_id), None
)
else: else:
if match.id in player_match_ids: if match.id in player_match_ids:
is_participant = True is_participant = True
if is_participant: if is_participant:
next_matches.append({ next_matches.append(
'tryout': match.tryout, 'match': match, {
'team': team.team if team else None, 'tryout': match.tryout,
}) 'match': match,
'team': team.team if team else None,
}
)
stats['next_matches'] = next_matches stats['next_matches'] = next_matches
elif isinstance(user, Scout): elif isinstance(user, Scout):
stats['total_players'] = User.query.filter_by(role='player').count() stats['total_players'] = User.query.filter_by(role='player').count()
stats['total_evaluations'] = Evaluation.query.count() stats['total_evaluations'] = Evaluation.query.count()
stats['avg_scores'] = db.session.query( stats['avg_scores'] = (
Evaluation.player_id, db.session.query(
func.avg(Evaluation.overall_score).label('avg_score'), Evaluation.player_id,
).group_by(Evaluation.player_id).order_by( func.avg(Evaluation.overall_score).label('avg_score'),
func.avg(Evaluation.overall_score).desc()).limit(5).all() )
.group_by(Evaluation.player_id)
.order_by(func.avg(Evaluation.overall_score).desc())
.limit(5)
.all()
)
stats['top_players'] = [] stats['top_players'] = []
for row in stats['avg_scores']: for row in stats['avg_scores']:
p = User.query.get(row.player_id) p = User.query.get(row.player_id)
if p: if p:
stats['top_players'].append((p, round(row.avg_score, 1))) stats['top_players'].append((p, round(row.avg_score, 1)))
return render_template('pages/dashboard.html', user=user, stats=stats) return render_template('pages/dashboard.html', user=user, stats=stats)
+243 -114
View File
@@ -8,10 +8,21 @@ from flask_login import login_required, current_user
from flask_babel import gettext as _ from flask_babel import gettext as _
from app.extensions import db from app.extensions import db
from app.models import ( from app.models import (
Admin, Manager, Coach, Player, Scout, Admin,
User, Tryout, Match, MatchParticipant, Team, TeamMember, Manager,
TryoutRegistration, PlayerDisponibility, Coach,
OneOnOneRequest, PersonalNote, Player,
Scout,
User,
Tryout,
Match,
MatchParticipant,
Team,
TeamMember,
TryoutRegistration,
PlayerDisponibility,
OneOnOneRequest,
PersonalNote,
) )
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.discord_bot import send_schedule_notification 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 end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
user_participant = MatchParticipant.query.filter_by( 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() ).first()
events.append({ events.append(
'id': f'match_{match.id}', {
'title': match.title, 'id': f'match_{match.id}',
'date': match.date.strftime('%Y-%m-%d'), 'title': match.title,
'type': 'match', 'color': match_color, 'date': match.date.strftime('%Y-%m-%d'),
'extendedProps': { 'type': 'match',
'location': match.location or tryout.location or 'TBD', 'color': match_color,
'status': match.status, 'description': match.description or '', 'extendedProps': {
'match_type': match.match_type, 'location': match.location or tryout.location or 'TBD',
'tryout_id': tryout.id, 'match_id': match.id, 'status': match.status,
'start_time': start_time_str, 'end_time': end_time_str, 'description': match.description or '',
'participants': participants_str, 'match_type': match.match_type,
'user_participant_id': user_participant.id if user_participant else None, 'tryout_id': tryout.id,
'user_attendance_confirmed': user_participant.attendance_confirmed if user_participant else False, '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) # Add approved One on One sessions for the current user (player or coach)
if isinstance(current_user, Player): if isinstance(current_user, Player):
one_on_ones = OneOnOneRequest.query.filter_by( one_on_ones = OneOnOneRequest.query.filter_by(
player_id=current_user.id, player_id=current_user.id, status='approved'
status='approved'
).all() ).all()
elif isinstance(current_user, Coach): elif isinstance(current_user, Coach):
one_on_ones = OneOnOneRequest.query.filter_by( one_on_ones = OneOnOneRequest.query.filter_by(
coach_id=current_user.id, coach_id=current_user.id, status='approved'
status='approved'
).all() ).all()
else: else:
one_on_ones = [] one_on_ones = []
for ooo in one_on_ones: for ooo in one_on_ones:
events.append({ events.append(
'id': f'one_on_one_{ooo.id}', {
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}', 'id': f'one_on_one_{ooo.id}',
'date': ooo.date.strftime('%Y-%m-%d'), 'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'type': 'one_on_one', 'date': ooo.date.strftime('%Y-%m-%d'),
'color': '#8b5cf6', 'type': 'one_on_one',
'extendedProps': { 'color': '#8b5cf6',
'location': 'Discord / Voice Chat', 'extendedProps': {
'status': 'approved', 'location': 'Discord / Voice Chat',
'description': ooo.points or 'One on One session', 'status': 'approved',
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None, 'description': ooo.points or 'One on One session',
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None, 'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}", '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) return jsonify(events)
@@ -137,13 +157,21 @@ def api_events_for_tryout(tryout_id):
is_registered = False is_registered = False
player_in_match = False player_in_match = False
if isinstance(current_user, Player): if isinstance(current_user, Player):
is_registered = TryoutRegistration.query.filter_by( is_registered = (
tryout_id=tryout_id, player_id=current_user.id, TryoutRegistration.query.filter_by(
).first() is not None tryout_id=tryout_id,
player_matches = Match.query.join(MatchParticipant).filter( player_id=current_user.id,
MatchParticipant.player_id == current_user.id, ).first()
Match.tryout_id == tryout_id, is not None
).all() )
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 player_in_match = len(player_matches) > 0
if not can_view and not is_registered and not player_in_match: 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 = [] events = []
for match in tryout.matches: 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 = '' participants_str = ''
if match.match_type == 'team_vs_team': if match.match_type == 'team_vs_team':
teams = [] teams = []
@@ -162,8 +192,16 @@ def api_events_for_tryout(tryout_id):
teams.append(match.team2.name) teams.append(match.team2.name)
participants_str = f"{' vs '.join(teams)}" participants_str = f"{' vs '.join(teams)}"
elif match.match_type == 'player_vs_player': 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] team1_players = [
team2_players = [p.player.username for p in match.participants.filter_by(team_side=2).all() if p.player] 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: if team1_players and team2_players:
participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}" participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}"
else: 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 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 end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
events.append({ events.append(
'id': f'match_{match.id}', {
'title': match.title, 'id': f'match_{match.id}',
'date': match.date.strftime('%Y-%m-%d'), 'title': match.title,
'type': 'match', 'color': match_color, 'date': match.date.strftime('%Y-%m-%d'),
'extendedProps': { 'type': 'match',
'location': match.location or tryout.location or 'TBD', 'color': match_color,
'status': match.status, 'match_type': match.match_type, 'extendedProps': {
'tryout_id': tryout.id, 'match_id': match.id, 'location': match.location or tryout.location or 'TBD',
'participants': participants_str, 'status': match.status,
'start_time': start_time_str, 'end_time': end_time_str, '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) return jsonify(events)
@@ -207,7 +251,9 @@ def create_match(tryout_id):
teams = Team.query.filter_by(tryout_id=tryout_id).all() teams = Team.query.filter_by(tryout_id=tryout_id).all()
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all() 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) all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
prefill_date = request.args.get('date', '') prefill_date = request.args.get('date', '')
@@ -222,15 +268,25 @@ def create_match(tryout_id):
if not start_time_str: if not start_time_str:
flash(_('Start time is required. Please select a time slot.'), 'danger') flash(_('Start time is required. Please select a time slot.'), 'danger')
return render_template('pages/match_form.html', tryout=tryout, teams=teams, return render_template(
all_players=all_players, prefill_date=prefill_date) 'pages/match_form.html',
tryout=tryout,
teams=teams,
all_players=all_players,
prefill_date=prefill_date,
)
try: try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date
except (ValueError, TypeError): except (ValueError, TypeError):
flash(_('Invalid date format.'), 'danger') flash(_('Invalid date format.'), 'danger')
return render_template('pages/match_form.html', tryout=tryout, teams=teams, return render_template(
all_players=all_players, prefill_date=prefill_date) 'pages/match_form.html',
tryout=tryout,
teams=teams,
all_players=all_players,
prefill_date=prefill_date,
)
start_time = None start_time = None
end_time = None end_time = None
@@ -244,12 +300,20 @@ def create_match(tryout_id):
end_time = end_dt.time() end_time = end_dt.time()
except ValueError: except ValueError:
flash(_('Invalid time format.'), 'danger') 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( match = Match(
tryout_id=tryout_id, title=title, description=description, tryout_id=tryout_id,
date=date_obj, start_time=start_time, end_time=end_time, title=title,
location=location, match_type=match_type, created_by=current_user.id, 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.add(match)
db.session.flush() db.session.flush()
@@ -264,14 +328,18 @@ def create_match(tryout_id):
match.team2_id = int(team2_id) if team2_id else None match.team2_id = int(team2_id) if team2_id else None
if match.team1_id: if match.team1_id:
for m in TeamMember.query.filter_by(team_id=match.team1_id).all(): 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.add(participant)
db.session.flush() db.session.flush()
notified_participant_ids.append(participant.id) notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id) notified_player_ids.append(m.player_id)
if match.team2_id: if match.team2_id:
for m in TeamMember.query.filter_by(team_id=match.team2_id).all(): 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.add(participant)
db.session.flush() db.session.flush()
notified_participant_ids.append(participant.id) notified_participant_ids.append(participant.id)
@@ -279,8 +347,12 @@ def create_match(tryout_id):
elif match_type == 'player_vs_player': elif match_type == 'player_vs_player':
team1_player_ids = request.form.get('team1_player_ids', '') team1_player_ids = request.form.get('team1_player_ids', '')
team2_player_ids = request.form.get('team2_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 [] team1_ids = (
team2_ids = [int(p) for p in team2_player_ids.split(',') if p] if team2_player_ids else [] [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: for pid in team1_ids:
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1) participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1)
db.session.add(participant) db.session.add(participant)
@@ -305,20 +377,34 @@ def create_match(tryout_id):
# Discord notifications # Discord notifications
event_date_str = date_obj.strftime('%Y-%m-%d') 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): 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( send_schedule_notification(
user_id=player_id, event_type='match', event_title=match.title, user_id=player_id,
event_date=event_date_str, event_time=event_time_str, event_type='match',
event_title=match.title,
event_date=event_date_str,
event_time=event_time_str,
reference_id=reference_id, reference_id=reference_id,
) )
flash(_('Match scheduled successfully!'), 'success') flash(_('Match scheduled successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
return render_template('pages/match_form.html', tryout=tryout, teams=teams, return render_template(
all_players=all_players, prefill_date=prefill_date) 'pages/match_form.html',
tryout=tryout,
teams=teams,
all_players=all_players,
prefill_date=prefill_date,
)
@matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST']) @matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
@@ -357,15 +443,25 @@ def edit_match(match_id):
match.date = datetime.strptime(date_str, '%Y-%m-%d').date() match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError): except (ValueError, TypeError):
flash(_('Invalid date format.'), 'danger') flash(_('Invalid date format.'), 'danger')
return render_template('pages/match_form.html', match=match, tryout=tryout, return render_template(
teams=teams, all_players=all_players, 'pages/match_form.html',
current_player_ids=current_player_ids) match=match,
tryout=tryout,
teams=teams,
all_players=all_players,
current_player_ids=current_player_ids,
)
if not start_time_str: if not start_time_str:
flash(_('Start time is required.'), 'danger') flash(_('Start time is required.'), 'danger')
return render_template('pages/match_form.html', match=match, tryout=tryout, return render_template(
teams=teams, all_players=all_players, 'pages/match_form.html',
current_player_ids=current_player_ids) match=match,
tryout=tryout,
teams=teams,
all_players=all_players,
current_player_ids=current_player_ids,
)
try: try:
match.start_time = datetime.strptime(start_time_str, '%H:%M').time() 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 match.team2_id = new_team2_id
if match.team1_id: if match.team1_id:
for m in TeamMember.query.filter_by(team_id=match.team1_id).all(): 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.add(participant)
db.session.flush() db.session.flush()
notified_participant_ids.append(participant.id) notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id) notified_player_ids.append(m.player_id)
if match.team2_id: if match.team2_id:
for m in TeamMember.query.filter_by(team_id=match.team2_id).all(): 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.add(participant)
db.session.flush() db.session.flush()
notified_participant_ids.append(participant.id) notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id) notified_player_ids.append(m.player_id)
else: else:
if match.team1_id: 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: 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': elif match.match_type == 'player_vs_player':
MatchParticipant.query.filter_by(match_id=match.id).delete() MatchParticipant.query.filter_by(match_id=match.id).delete()
team1_str = request.form.get('team1_player_ids', '') team1_str = request.form.get('team1_player_ids', '')
@@ -446,15 +556,22 @@ def edit_match(match_id):
# Discord notifications # Discord notifications
end_time_val = match.end_time or (match.start_time if match.start_time else None) end_time_val = match.end_time or (match.start_time if match.start_time else None)
if match.start_time and end_time_val: 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: else:
event_time_str = 'TBD' event_time_str = 'TBD'
event_date_str = match.date.strftime('%Y-%m-%d') event_date_str = match.date.strftime('%Y-%m-%d')
for i, player_id in enumerate(notified_player_ids): 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( send_schedule_notification(
user_id=player_id, event_type='match', event_title=match.title, user_id=player_id,
event_date=event_date_str, event_time=event_time_str, event_type='match',
event_title=match.title,
event_date=event_date_str,
event_time=event_time_str,
reference_id=reference_id, reference_id=reference_id,
) )
@@ -469,12 +586,17 @@ def edit_match(match_id):
'team_side': p.team_side, 'team_side': p.team_side,
} }
return render_template('pages/match_form.html', match=match, tryout=tryout, return render_template(
teams=teams, all_players=all_players, 'pages/match_form.html',
current_player_ids=current_player_ids, match=match,
team1_player_ids=team1_player_ids, tryout=tryout,
team2_player_ids=team2_player_ids, teams=teams,
participants_map=participants_map) 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') @matches_bp.route('/api/manageable-tryouts')
@@ -488,11 +610,14 @@ def api_manageable_tryouts():
manageable = [] manageable = []
for t in tryouts: for t in tryouts:
if current_user.can_manage_this_tryout(t): if current_user.can_manage_this_tryout(t):
manageable.append({ manageable.append(
'id': t.id, 'title': t.title, {
'date': t.date.strftime('%Y-%m-%d'), 'id': t.id,
'end_date': t.end_date.strftime('%Y-%m-%d') if t.end_date else None, '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) return jsonify(manageable)
@@ -514,7 +639,8 @@ def delete_match(match_id):
# Only the context link is dropped. Participants go through the # Only the context link is dropped. Participants go through the
# relationship's delete-orphan cascade. # relationship's delete-orphan cascade.
PersonalNote.query.filter_by(match_id=match_id).update( 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.delete(match)
db.session.commit() db.session.commit()
@@ -536,7 +662,8 @@ def get_players_available_at_time(date_str, time_str):
available_players = [] available_players = []
for player in players: for player in players:
disponibilities = PlayerDisponibility.query.filter_by( 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() ).all()
for disp in disponibilities: for disp in disponibilities:
disp_start = disp.start_time.hour * 60 + disp.start_time.minute 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 participant.attendance_confirmed = not participant.attendance_confirmed
db.session.commit() db.session.commit()
return jsonify({ return jsonify(
'participant_id': participant.id, {
'attendance_confirmed': participant.attendance_confirmed, 'participant_id': participant.id,
'player_name': participant.player.username if participant.player else 'Unknown', 'attendance_confirmed': participant.attendance_confirmed,
}) 'player_name': participant.player.username if participant.player else 'Unknown',
}
)
+95 -43
View File
@@ -8,8 +8,14 @@ from flask_login import login_required, current_user
from flask_babel import gettext as _ from flask_babel import gettext as _
from app.extensions import db from app.extensions import db
from app.models import ( from app.models import (
Admin, Manager, Coach, Player, Admin,
OrgTeam, TeamMatch, TeamMatchParticipant, TeamPlayer, Manager,
Coach,
Player,
OrgTeam,
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
) )
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -41,9 +47,13 @@ def list_matches():
elif isinstance(current_user, (Coach, Player)): elif isinstance(current_user, (Coach, Player)):
teams = visible_org_teams(current_user) teams = visible_org_teams(current_user)
team_ids = [t.id for t in teams] team_ids = [t.id for t in teams]
matches_query = TeamMatch.query.filter( matches_query = (
TeamMatch.org_team_id.in_(team_ids), TeamMatch.query.filter(
) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1) TeamMatch.org_team_id.in_(team_ids),
)
if team_ids
else TeamMatch.query.filter(TeamMatch.id == -1)
)
else: else:
teams = [] teams = []
matches_query = TeamMatch.query.filter(TeamMatch.id == -1) matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
@@ -58,18 +68,25 @@ def list_matches():
confirmed, total = tm.get_confirmed_count() confirmed, total = tm.get_confirmed_count()
participants = [] participants = []
for p in tm.participants.all(): for p in tm.participants.all():
participants.append({ participants.append(
'id': p.id, 'player': p.player, {
'is_confirmed': p.is_confirmed, 'id': p.id,
}) 'player': p.player,
match_data.append({ 'is_confirmed': p.is_confirmed,
'match': tm, 'participants': participants, }
'confirmed_count': confirmed, 'total_count': total, )
}) match_data.append(
{
'match': tm,
'participants': participants,
'confirmed_count': confirmed,
'total_count': total,
}
)
return render_template('pages/team_matches.html', return render_template(
teams=teams, match_data=match_data, 'pages/team_matches.html', teams=teams, match_data=match_data, now=datetime.utcnow()
now=datetime.utcnow()) )
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST']) @team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
@@ -87,6 +104,7 @@ def create_match(team_id):
default_title = 'Practice' if is_practice else f'Team Match — {team.name}' default_title = 'Practice' if is_practice else f'Team Match — {team.name}'
if is_practice and request.method == 'GET': if is_practice and request.method == 'GET':
class TryoutProxy: class TryoutProxy:
def __init__(self, team_obj): def __init__(self, team_obj):
self.id = 0 self.id = 0
@@ -98,10 +116,16 @@ def create_match(team_id):
proxy_tryout = TryoutProxy(team) proxy_tryout = TryoutProxy(team)
all_players = [tp.player for tp in team_players if tp.player] all_players = [tp.player for tp in team_players if tp.player]
return render_template('pages/match_form.html', return render_template(
tryout=proxy_tryout, teams=[], all_players=all_players, 'pages/match_form.html',
prefill_date=prefill_date, is_practice=True, tryout=proxy_tryout,
team_id=team_id, team=team) teams=[],
all_players=all_players,
prefill_date=prefill_date,
is_practice=True,
team_id=team_id,
team=team,
)
if request.method == 'POST': if request.method == 'POST':
title = request.form.get('title', default_title) title = request.form.get('title', default_title)
@@ -114,16 +138,24 @@ def create_match(team_id):
if not date_str: if not date_str:
flash(_('Date is required.'), 'danger') flash(_('Date is required.'), 'danger')
return render_template('pages/team_match_form.html', team=team, return render_template(
team_players=team_players, prefill_date=prefill_date) 'pages/team_match_form.html',
team=team,
team_players=team_players,
prefill_date=prefill_date,
)
try: try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError): except (ValueError, TypeError):
flash(_('Invalid date format.'), 'danger') flash(_('Invalid date format.'), 'danger')
return render_template('pages/team_match_form.html', team=team, return render_template(
team_players=team_players, prefill_date=prefill_date, 'pages/team_match_form.html',
is_practice=is_practice) team=team,
team_players=team_players,
prefill_date=prefill_date,
is_practice=is_practice,
)
start_time = None start_time = None
end_time = None end_time = None
@@ -138,16 +170,24 @@ def create_match(team_id):
end_time = end_dt.time() end_time = end_dt.time()
except ValueError: except ValueError:
flash(_('Invalid time format.'), 'danger') flash(_('Invalid time format.'), 'danger')
return render_template('pages/team_match_form.html', team=team, return render_template(
team_players=team_players, prefill_date=prefill_date, 'pages/team_match_form.html',
is_practice=is_practice) team=team,
team_players=team_players,
prefill_date=prefill_date,
is_practice=is_practice,
)
team_match = TeamMatch( team_match = TeamMatch(
org_team_id=team_id, title=title, org_team_id=team_id,
title=title,
description=description or None, description=description or None,
opponent=opponent or None, opponent=opponent or None,
date=date_obj, start_time=start_time, end_time=end_time, date=date_obj,
location=location or None, created_by=current_user.id, start_time=start_time,
end_time=end_time,
location=location or None,
created_by=current_user.id,
) )
db.session.add(team_match) db.session.add(team_match)
db.session.flush() db.session.flush()
@@ -155,7 +195,8 @@ def create_match(team_id):
notified_participant_ids = [] notified_participant_ids = []
for tp in team_players: for tp in team_players:
participant = TeamMatchParticipant( 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.add(participant)
db.session.flush() db.session.flush()
@@ -165,14 +206,22 @@ def create_match(team_id):
# Discord notifications # Discord notifications
event_date_str = date_obj.strftime('%Y-%m-%d') 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): 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( 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_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, reference_id=reference_id,
) )
@@ -229,8 +278,9 @@ def edit_match(match_id):
flash(_('Match updated successfully!'), 'success') flash(_('Match updated successfully!'), 'success')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
return render_template('pages/team_match_form.html', return render_template(
match=team_match, team=team, team_players=[]) 'pages/team_match_form.html', match=team_match, team=team, team_players=[]
)
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST']) @team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
@@ -282,8 +332,10 @@ def toggle_presence(match_id, participant_id):
participant.is_confirmed = not participant.is_confirmed participant.is_confirmed = not participant.is_confirmed
db.session.commit() db.session.commit()
return jsonify({ return jsonify(
'participant_id': participant.id, {
'is_confirmed': participant.is_confirmed, 'participant_id': participant.id,
'player_name': participant.player.username if participant.player else 'Unknown', 'is_confirmed': participant.is_confirmed,
}) 'player_name': participant.player.username if participant.player else 'Unknown',
}
)
+108 -40
View File
@@ -8,9 +8,19 @@ from flask_login import login_required, current_user
from flask_babel import gettext as _ from flask_babel import gettext as _
from app.extensions import db from app.extensions import db
from app.models import ( from app.models import (
Admin, Manager, Coach, Player, Admin,
OrgTeam, User, PersonalNote, TeamNote, Tryout, TeamPlayer, Manager,
TeamMatch, Contract, OneOnOneRequest, Coach,
Player,
OrgTeam,
User,
PersonalNote,
TeamNote,
Tryout,
TeamPlayer,
TeamMatch,
Contract,
OneOnOneRequest,
) )
from app.permissions import visible_org_teams from app.permissions import visible_org_teams
from datetime import datetime from datetime import datetime
@@ -33,11 +43,21 @@ def list_teams():
teams = visible_org_teams(current_user) teams = visible_org_teams(current_user)
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all() coaches = (
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all() 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() all_players = User.query.filter_by(role='player').order_by(User.username).all()
return render_template('pages/teams.html', teams=teams, coaches=coaches, return render_template(
managers=managers, all_players=all_players, can_manage=can_manage) 'pages/teams.html',
teams=teams,
coaches=coaches,
managers=managers,
all_players=all_players,
can_manage=can_manage,
)
@teams_bp.route('/my-teams') @teams_bp.route('/my-teams')
@@ -55,29 +75,40 @@ def my_teams():
team_data = [] team_data = []
for org_team in player_teams: for org_team in player_teams:
matches = TeamMatch.query.filter( matches = (
TeamMatch.org_team_id == org_team.id, TeamMatch.query.filter(
TeamMatch.status == 'scheduled', TeamMatch.org_team_id == org_team.id,
).order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc()).all() TeamMatch.status == 'scheduled',
)
.order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc())
.all()
)
matches_data = [] matches_data = []
for tm in matches: for tm in matches:
confirmed, total = tm.get_confirmed_count() confirmed, total = tm.get_confirmed_count()
participant = TeamMatchParticipant.query.filter_by( 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() ).first()
matches_data.append({ matches_data.append(
'match': tm, {
'participant_id': participant.id if participant else None, 'match': tm,
'is_confirmed': participant.is_confirmed if participant else False, 'participant_id': participant.id if participant else None,
'confirmed_count': confirmed, 'total_count': total, 'is_confirmed': participant.is_confirmed if participant else False,
}) 'confirmed_count': confirmed,
'total_count': total,
}
)
team_data.append({ team_data.append(
'team': org_team, 'matches': matches_data, {
'coaches': org_team.get_coaches(), 'team': org_team,
'managers': org_team.get_managers(), '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) 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. # Entities that survive it.
Tryout.query.filter_by(target_org_team_id=team_id).update( Tryout.query.filter_by(target_org_team_id=team_id).update(
{'target_org_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) 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( 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.delete(team)
db.session.commit() db.session.commit()
@@ -247,14 +279,24 @@ def add_coach(team_id):
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
if team.coaches.filter_by(id=coach.id).first(): 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')) return redirect(url_for('teams.list_teams'))
team.coaches.append(coach) team.coaches.append(coach)
if not team.coach_id: if not team.coach_id:
team.coach_id = coach.id team.coach_id = coach.id
db.session.commit() 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')) return redirect(url_for('teams.list_teams'))
@@ -278,14 +320,24 @@ def add_manager(team_id):
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
if team.managers.filter_by(id=manager.id).first(): 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')) return redirect(url_for('teams.list_teams'))
team.managers.append(manager) team.managers.append(manager)
if not team.manager_id: if not team.manager_id:
team.manager_id = manager.id team.manager_id = manager.id
db.session.commit() 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')) 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() existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
if existing: 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')) return redirect(url_for('teams.list_teams'))
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status) 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) player = User.query.get_or_404(player_id)
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first() tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp: if not tp:
flash(_('%(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')) return redirect(url_for('teams.list_teams'))
db.session.delete(tp) db.session.delete(tp)
db.session.commit() 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')) 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' tp.status = 'substitute' if tp.status == 'starter' else 'starter'
db.session.commit() db.session.commit()
return jsonify({ return jsonify(
'success': True, 'player_id': player_id, {
'new_status': tp.status, 'player_name': tp.player.username, 'success': True,
}) 'player_id': player_id,
'new_status': tp.status,
'player_name': tp.player.username,
}
)
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST']) @teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
@@ -446,7 +511,10 @@ def add_player_note(team_id, player_id):
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first() tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp: 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')) return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip() content = request.form.get('content', '').strip()
@@ -455,4 +523,4 @@ def add_player_note(team_id, player_id):
db.session.add(note) db.session.add(note)
db.session.commit() db.session.commit()
flash(_('Note added for %(username)s!', username=player.username), 'success') flash(_('Note added for %(username)s!', username=player.username), 'success')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
+230 -92
View File
@@ -9,10 +9,23 @@ from flask_login import login_required, current_user
from flask_babel import gettext as _ from flask_babel import gettext as _
from app.extensions import db from app.extensions import db
from app.models import ( from app.models import (
Admin, Manager, Coach, Player, Scout, Admin,
User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember, Manager,
OrgTeam, Match, MatchParticipant, PersonalNote, Coach,
ESPORT_GAMES, GAME_POSITIONS, Player,
Scout,
User,
Tryout,
TryoutRegistration,
Evaluation,
Team,
TeamMember,
OrgTeam,
Match,
MatchParticipant,
PersonalNote,
ESPORT_GAMES,
GAME_POSITIONS,
) )
from datetime import datetime from datetime import datetime
@@ -44,8 +57,12 @@ def create_tryout():
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all() 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() managers = (
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all() 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': if request.method == 'POST':
title = request.form.get('title') title = request.form.get('title')
@@ -63,8 +80,14 @@ def create_tryout():
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError): except (ValueError, TypeError):
flash(_('Invalid start date format.'), 'danger') flash(_('Invalid start date format.'), 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams, return render_template(
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) 'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
end_date_obj = None end_date_obj = None
if end_date_str: if end_date_str:
@@ -72,19 +95,35 @@ def create_tryout():
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date() end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
if end_date_obj < date_obj: if end_date_obj < date_obj:
flash(_('End date cannot be before start date.'), 'danger') flash(_('End date cannot be before start date.'), 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams, return render_template(
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) 'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
except (ValueError, TypeError): except (ValueError, TypeError):
flash(_('Invalid end date format.'), 'danger') flash(_('Invalid end date format.'), 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams, return render_template(
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) 'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
tryout = Tryout( 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, end_date=end_date_obj,
location=location, location=location,
max_players=int(max_players) if max_players else None, 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, 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, manager_id=int(manager_id) if manager_id else None,
) )
@@ -100,8 +139,14 @@ def create_tryout():
flash(_('Tryout created successfully!'), 'success') flash(_('Tryout created successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams, return render_template(
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) 'pages/tryout_form.html',
tryout=None,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
@tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST']) @tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST'])
@@ -119,8 +164,12 @@ def edit_tryout(tryout_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all() 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() managers = (
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all() 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': if request.method == 'POST':
title = request.form.get('title') title = request.form.get('title')
@@ -138,8 +187,14 @@ def edit_tryout(tryout_id):
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError): except (ValueError, TypeError):
flash(_('Invalid start date format.'), 'danger') flash(_('Invalid start date format.'), 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams, return render_template(
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) 'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
end_date_obj = None end_date_obj = None
if end_date_str: 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() end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
if end_date_obj < date_obj: if end_date_obj < date_obj:
flash(_('End date cannot be before start date.'), 'danger') flash(_('End date cannot be before start date.'), 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams, return render_template(
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) 'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
except (ValueError, TypeError): except (ValueError, TypeError):
flash(_('Invalid end date format.'), 'danger') flash(_('Invalid end date format.'), 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams, return render_template(
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) 'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
tryout.title = title tryout.title = title
tryout.description = description tryout.description = description
@@ -175,8 +242,14 @@ def edit_tryout(tryout_id):
flash(_('Tryout updated successfully!'), 'success') flash(_('Tryout updated successfully!'), 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams, return render_template(
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) 'pages/tryout_form.html',
tryout=tryout,
org_teams=org_teams,
managers=managers,
coaches=coaches,
esport_games=ESPORT_GAMES,
)
@tryouts_bp.route('/<int:tryout_id>') @tryouts_bp.route('/<int:tryout_id>')
@@ -193,12 +266,21 @@ def view_tryout(tryout_id):
elif isinstance(current_user, Coach): elif isinstance(current_user, Coach):
can_view = current_user.can_manage_this_tryout(tryout) can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player): elif isinstance(current_user, Player):
is_registered = TryoutRegistration.query.filter_by( is_registered = (
tryout_id=tryout_id, player_id=current_user.id).first() is not None TryoutRegistration.query.filter_by(
player_in_match = MatchParticipant.query.join(Match).filter( tryout_id=tryout_id, player_id=current_user.id
MatchParticipant.player_id == current_user.id, ).first()
Match.tryout_id == tryout_id, is not None
).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 can_view = is_registered or player_in_match
elif isinstance(current_user, Scout): elif isinstance(current_user, Scout):
can_view = True can_view = True
@@ -215,39 +297,55 @@ def view_tryout(tryout_id):
if current_user.can_evaluate(): if current_user.can_evaluate():
for p in registered_players: for p in registered_players:
existing = Evaluation.query.filter_by( 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() ).first()
player_eval_status[p.id] = existing is not None player_eval_status[p.id] = existing is not None
is_registered = TryoutRegistration.query.filter_by( is_registered = (
tryout_id=tryout_id, player_id=current_user.id, TryoutRegistration.query.filter_by(
).first() is not None tryout_id=tryout_id,
player_id=current_user.id,
).first()
is not None
)
teams = Team.query.filter_by(tryout_id=tryout_id).all() teams = Team.query.filter_by(tryout_id=tryout_id).all()
team_data = [] team_data = []
for team in teams: for team in teams:
members = TeamMember.query.filter_by(team_id=team.id).all() members = TeamMember.query.filter_by(team_id=team.id).all()
team_data.append({ team_data.append(
'team': team, {
'members': [{'player': User.query.get(m.player_id), 'position': m.position} 'team': team,
for m in members], '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_edit = current_user.can_manage_this_tryout(tryout)
can_view_calendar = can_edit can_view_calendar = can_edit
if isinstance(current_user, Player): if isinstance(current_user, Player):
player_in_match = MatchParticipant.query.join(Match).filter( player_in_match = (
MatchParticipant.player_id == current_user.id, MatchParticipant.query.join(Match)
Match.tryout_id == tryout_id, .filter(
).first() is not None MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
)
.first()
is not None
)
can_view_calendar = is_registered or player_in_match can_view_calendar = is_registered or player_in_match
all_players = None all_players = None
if can_edit: if can_edit:
all_players = User.query.filter_by(role='player').order_by(User.username).all() 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 = [] match_data = []
for match in matches: for match in matches:
all_participants = list(match.participants.all()) all_participants = list(match.participants.all())
@@ -257,47 +355,79 @@ def view_tryout(tryout_id):
player_presence = [] player_presence = []
for p in all_participants: for p in all_participants:
if p.player: if p.player:
player_presence.append({ player_presence.append(
'participant_id': p.id, 'player_id': p.player_id, {
'player_name': p.player.username, 'participant_id': p.id,
'attendance_confirmed': p.attendance_confirmed, 'player_id': p.player_id,
}) 'player_name': p.player.username,
'attendance_confirmed': p.attendance_confirmed,
}
)
if match.match_type == 'team_vs_team': if match.match_type == 'team_vs_team':
participants = { participants = {
'team1': match.team1.name if match.team1 else 'TBD', 'team1': match.team1.name if match.team1 else 'TBD',
'team2': match.team2.name if match.team2 else 'TBD', 'team2': match.team2.name if match.team2 else 'TBD',
'team1_players': [{'name': m.player.username, 'position': m.position} 'team1_players': [
for m in match.team1.members.all()] if match.team1 else [], {'name': m.player.username, 'position': m.position}
'team2_players': [{'name': m.player.username, 'position': m.position} for m in match.team1.members.all()
for m in match.team2.members.all()] if match.team2 else [], ]
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': elif match.match_type == 'player_vs_player':
team1_players = [{'name': p.player.username, 'position': p.position} team1_players = [
for p in match.participants.filter_by(team_side=1).all() if p.player] {'name': p.player.username, 'position': p.position}
team2_players = [{'name': p.player.username, 'position': p.position} for p in match.participants.filter_by(team_side=1).all()
for p in match.participants.filter_by(team_side=2).all() if p.player] 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 = { participants = {
'team1': 'Team 1', 'team2': 'Team 2', 'team1': 'Team 1',
'team1_players': team1_players, 'team2_players': team2_players, 'team2': 'Team 2',
'team1_players': team1_players,
'team2_players': team2_players,
} }
else: else:
participants = [p.player.username for p in match.participants.all()] participants = [p.player.username for p in match.participants.all()]
match_data.append({ match_data.append(
'match': match, 'participants': participants, {
'confirmed_count': confirmed_count, 'total_count': total_count, 'match': match,
'player_presence': player_presence, 'participants': participants,
}) 'confirmed_count': confirmed_count,
'total_count': total_count,
'player_presence': player_presence,
}
)
return render_template('pages/view_tryout.html', return render_template(
tryout=tryout, registered_players=registered_players, 'pages/view_tryout.html',
evaluations=evaluations, player_eval_status=player_eval_status, tryout=tryout,
is_registered=is_registered, registrations=registrations, registered_players=registered_players,
team_data=team_data, can_edit=can_edit, evaluations=evaluations,
can_view_calendar=can_view_calendar, all_players=all_players, player_eval_status=player_eval_status,
matches=matches, match_data=match_data, is_registered=is_registered,
game_positions=GAME_POSITIONS, now=datetime.utcnow()) registrations=registrations,
team_data=team_data,
can_edit=can_edit,
can_view_calendar=can_view_calendar,
all_players=all_players,
matches=matches,
match_data=match_data,
game_positions=GAME_POSITIONS,
now=datetime.utcnow(),
)
@tryouts_bp.route('/<int:tryout_id>/register', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/register', methods=['POST'])
@@ -314,7 +444,8 @@ def register_for_tryout(tryout_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by( 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: if existing:
flash(_('You are already registered for this tryout.'), 'info') flash(_('You are already registered for this tryout.'), 'info')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) 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')) return redirect(url_for('tryouts.list_tryouts'))
registration = TryoutRegistration.query.filter_by( 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') new_status = request.form.get('status')
if new_status in ['registered', 'attended', 'no_show']: if new_status in ['registered', 'attended', 'no_show']:
registration.status = new_status registration.status = new_status
@@ -385,10 +517,12 @@ def register_player(tryout_id):
flash(_('Can only register players.'), 'danger') flash(_('Can only register players.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by( existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first()
tryout_id=tryout_id, player_id=player.id).first()
if existing: 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players: if tryout.max_players:
@@ -416,7 +550,8 @@ def remove_player(tryout_id, player_id):
player = User.query.get_or_404(player_id) player = User.query.get_or_404(player_id)
registration = TryoutRegistration.query.filter_by( 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: if registration:
db.session.delete(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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# Only players registered for this tryout may be placed on its teams. # Only players registered for this tryout may be placed on its teams.
is_registered = TryoutRegistration.query.filter_by( is_registered = (
tryout_id=tryout_id, player_id=player_id).first() is not None TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first()
is not None
)
if not is_registered: if not is_registered:
flash(_('That player is not registered for this tryout.'), 'danger') flash(_('That player is not registered for this tryout.'), 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -513,24 +650,25 @@ def delete_tryout(tryout_id):
# about a player, not tryout data. Only their context links are cleared. # about a player, not tryout data. Only their context links are cleared.
# Missing this step made the deletion fail on the foreign keys below. # Missing this step made the deletion fail on the foreign keys below.
PersonalNote.query.filter_by(tryout_id=tryout_id).update( PersonalNote.query.filter_by(tryout_id=tryout_id).update(
{'tryout_id': None}, synchronize_session=False) {'tryout_id': None}, synchronize_session=False
)
if match_ids: if match_ids:
PersonalNote.query.filter(PersonalNote.match_id.in_(match_ids)).update( 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: if team_ids:
PersonalNote.query.filter(PersonalNote.team_id.in_(team_ids)).update( 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: if match_ids:
MatchParticipant.query.filter( MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids)).delete(
MatchParticipant.match_id.in_(match_ids) synchronize_session=False
).delete(synchronize_session=False) )
Match.query.filter(Match.id.in_(match_ids)).delete(synchronize_session=False) Match.query.filter(Match.id.in_(match_ids)).delete(synchronize_session=False)
if team_ids: if team_ids:
TeamMember.query.filter( TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).delete(synchronize_session=False)
TeamMember.team_id.in_(team_ids)
).delete(synchronize_session=False)
Team.query.filter(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() TryoutRegistration.query.filter_by(tryout_id=tryout_id).delete()
@@ -539,4 +677,4 @@ def delete_tryout(tryout_id):
db.session.delete(tryout) db.session.delete(tryout)
db.session.commit() db.session.commit()
flash(_('Tryout deleted successfully.'), 'success') flash(_('Tryout deleted successfully.'), 'success')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
+478 -207
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
# supporting scripts package # supporting scripts package
+20 -10
View File
@@ -53,6 +53,7 @@ class BackupError(Exception):
# Connection handling # Connection handling
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def parse_database_url(url): def parse_database_url(url):
"""Split a SQLAlchemy/PostgreSQL URL into pg_dump connection settings. """Split a SQLAlchemy/PostgreSQL URL into pg_dump connection settings.
@@ -110,14 +111,19 @@ def build_dump_command(conn, output_path):
""" """
return [ return [
PG_DUMP, PG_DUMP,
'--host', conn['host'], '--host',
'--port', conn['port'], conn['host'],
'--username', conn['user'], '--port',
'--dbname', conn['dbname'], conn['port'],
'--username',
conn['user'],
'--dbname',
conn['dbname'],
'--format=custom', '--format=custom',
'--no-owner', '--no-owner',
'--no-privileges', '--no-privileges',
'--file', output_path, '--file',
output_path,
] ]
@@ -133,6 +139,7 @@ def dump_environment(conn):
# Backup steps # Backup steps
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def create_backup_dir(): def create_backup_dir():
"""Create the backup directory if it doesn't exist.""" """Create the backup directory if it doesn't exist."""
os.makedirs(BACKUP_DIR, exist_ok=True) os.makedirs(BACKUP_DIR, exist_ok=True)
@@ -201,7 +208,9 @@ def verify_backup(backup_path):
try: try:
result = subprocess.run( result = subprocess.run(
[PG_RESTORE, '--list', backup_path], [PG_RESTORE, '--list', backup_path],
capture_output=True, text=True, timeout=300, capture_output=True,
text=True,
timeout=300,
) )
except FileNotFoundError: except FileNotFoundError:
print(f'[WARNING] {PG_RESTORE} not found: archive left unverified.') 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()}') print(f'[ERROR] Archive is not readable: {result.stderr.strip()}')
return False return False
table_count = sum(1 for line in result.stdout.splitlines() table_count = sum(1 for line in result.stdout.splitlines() if ' TABLE DATA ' in line)
if ' TABLE DATA ' in line)
if table_count == 0: if table_count == 0:
print('[ERROR] Archive contains no table data.') print('[ERROR] Archive contains no table data.')
return False return False
@@ -282,6 +290,7 @@ def cleanup_old_backups():
# Entry point # Entry point
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def main(argv=None): def main(argv=None):
"""Run the full backup process. """Run the full backup process.
@@ -290,8 +299,9 @@ def main(argv=None):
previous version returned 0 even when it had backed up nothing. previous version returned 0 even when it had backed up nothing.
""" """
parser = argparse.ArgumentParser(description='Team Tryouts backup') parser = argparse.ArgumentParser(description='Team Tryouts backup')
parser.add_argument('--verify-only', metavar='ARCHIVE', parser.add_argument(
help='Verify an existing archive and exit') '--verify-only', metavar='ARCHIVE', help='Verify an existing archive and exit'
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.verify_only: if args.verify_only:
+20 -8
View File
@@ -37,14 +37,26 @@ def generate_self_signed_cert():
print('[INFO] Generating self-signed certificate for localhost...') print('[INFO] Generating self-signed certificate for localhost...')
try: try:
subprocess.run([ subprocess.run(
'openssl', 'req', '-x509', '-newkey', 'rsa:2048', [
'-keyout', KEY_FILE, 'openssl',
'-out', CERT_FILE, 'req',
'-days', '365', '-x509',
'-nodes', '-newkey',
'-subj', '/CN=localhost' 'rsa:2048',
], check=True, capture_output=True) '-keyout',
KEY_FILE,
'-out',
CERT_FILE,
'-days',
'365',
'-nodes',
'-subj',
'/CN=localhost',
],
check=True,
capture_output=True,
)
print('[OK] Certificate generated: certs/localhost.pem') print('[OK] Certificate generated: certs/localhost.pem')
except FileNotFoundError: except FileNotFoundError:
print('[ERROR] OpenSSL not found. Install OpenSSL or use:') print('[ERROR] OpenSSL not found. Install OpenSSL or use:')
+70 -64
View File
@@ -23,18 +23,18 @@ from datetime import datetime
def check_environment(): def check_environment():
"""Check required environment variables are set. """Check required environment variables are set.
Returns: Returns:
bool: True if all critical variables are set. bool: True if all critical variables are set.
""" """
print('=' * 60) print('=' * 60)
print('1. ENVIRONMENT VARIABLES CHECK') print('1. ENVIRONMENT VARIABLES CHECK')
print('=' * 60) print('=' * 60)
critical_vars = ['SECRET_KEY'] critical_vars = ['SECRET_KEY']
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS'] recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS']
all_ok = True all_ok = True
for var in critical_vars: for var in critical_vars:
value = os.getenv(var) value = os.getenv(var)
if value: if value:
@@ -47,37 +47,37 @@ def check_environment():
else: else:
print(f'[FAIL] {var} is not set!') print(f'[FAIL] {var} is not set!')
all_ok = False all_ok = False
for var in recommended_vars: for var in recommended_vars:
value = os.getenv(var) value = os.getenv(var)
if value: if value:
print(f'[OK] {var} is set') print(f'[OK] {var} is set')
else: else:
print(f'[INFO] {var} is not set (using default)') print(f'[INFO] {var} is not set (using default)')
# Check FLASK_DEBUG # Check FLASK_DEBUG
debug = os.getenv('FLASK_DEBUG', 'false').lower() debug = os.getenv('FLASK_DEBUG', 'false').lower()
if debug == 'true': if debug == 'true':
print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.') print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.')
else: else:
print('[OK] FLASK_DEBUG is disabled') print('[OK] FLASK_DEBUG is disabled')
return all_ok return all_ok
def check_https_headers(url): def check_https_headers(url):
"""Check HTTP security headers from a running application. """Check HTTP security headers from a running application.
Args: Args:
url: The base URL of the application to check. url: The base URL of the application to check.
Returns: Returns:
bool: True if all critical headers are present. bool: True if all critical headers are present.
""" """
print('\n' + '=' * 60) print('\n' + '=' * 60)
print('2. HTTP SECURITY HEADERS CHECK') print('2. HTTP SECURITY HEADERS CHECK')
print('=' * 60) print('=' * 60)
required_headers = { required_headers = {
'Strict-Transport-Security': 'HSTS enabled', 'Strict-Transport-Security': 'HSTS enabled',
'X-Content-Type-Options': 'Prevents MIME sniffing', 'X-Content-Type-Options': 'Prevents MIME sniffing',
@@ -87,31 +87,31 @@ def check_https_headers(url):
'Permissions-Policy': 'Permissions control', 'Permissions-Policy': 'Permissions control',
'Cross-Origin-Opener-Policy': 'Cross-origin isolation', 'Cross-Origin-Opener-Policy': 'Cross-origin isolation',
} }
all_ok = True all_ok = True
try: try:
# Create a context that doesn't verify SSL (for local testing) # Create a context that doesn't verify SSL (for local testing)
ctx = ssl.create_default_context() ctx = ssl.create_default_context()
ctx.check_hostname = False ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, method='HEAD') req = urllib.request.Request(url, method='HEAD')
try: try:
with urllib.request.urlopen(req, context=ctx, timeout=10) as response: with urllib.request.urlopen(req, context=ctx, timeout=10) as response:
headers = response.headers headers = response.headers
status = response.status status = response.status
print(f'[INFO] Response status: {status}') print(f'[INFO] Response status: {status}')
for header, description in required_headers.items(): for header, description in required_headers.items():
if header in headers: if header in headers:
print(f'[OK] {header}: {description}') print(f'[OK] {header}: {description}')
else: else:
print(f'[FAIL] {header} is missing: {description}') print(f'[FAIL] {header} is missing: {description}')
all_ok = False all_ok = False
# Check cookie attributes if any set-cookie headers exist # Check cookie attributes if any set-cookie headers exist
if 'Set-Cookie' in headers: if 'Set-Cookie' in headers:
cookie = headers['Set-Cookie'] cookie = headers['Set-Cookie']
@@ -120,21 +120,23 @@ def check_https_headers(url):
else: else:
print('[WARN] Cookies missing Secure flag') print('[WARN] Cookies missing Secure flag')
all_ok = False all_ok = False
if 'HttpOnly' in cookie: if 'HttpOnly' in cookie:
print('[OK] Cookies have HttpOnly flag') print('[OK] Cookies have HttpOnly flag')
else: else:
print('[WARN] Cookies missing HttpOnly flag') print('[WARN] Cookies missing HttpOnly flag')
all_ok = False all_ok = False
if 'SameSite' in cookie: 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: else:
print('[WARN] Cookies missing SameSite attribute') print('[WARN] Cookies missing SameSite attribute')
all_ok = False all_ok = False
else: else:
print('[INFO] No Set-Cookie headers in response') print('[INFO] No Set-Cookie headers in response')
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
print(f'[INFO] Got HTTP {e.code} (may need authentication)') print(f'[INFO] Got HTTP {e.code} (may need authentication)')
# Still check headers even on error responses # Still check headers even on error responses
@@ -144,33 +146,33 @@ def check_https_headers(url):
else: else:
print(f'[FAIL] {header} is missing: {description}') print(f'[FAIL] {header} is missing: {description}')
all_ok = False all_ok = False
except urllib.error.URLError as e: except urllib.error.URLError as e:
print(f'[SKIP] Cannot connect to {url}: {e.reason}') print(f'[SKIP] Cannot connect to {url}: {e.reason}')
print('[SKIP] Run with --url <application_url> to check headers') print('[SKIP] Run with --url <application_url> to check headers')
return True # Not a failure, just can't check return True # Not a failure, just can't check
return all_ok return all_ok
def check_dependencies(): def check_dependencies():
"""Run pip-audit to check for known vulnerabilities. """Run pip-audit to check for known vulnerabilities.
Returns: Returns:
bool: True if no critical vulnerabilities found. bool: True if no critical vulnerabilities found.
""" """
print('\n' + '=' * 60) print('\n' + '=' * 60)
print('3. DEPENDENCY VULNERABILITY SCAN') print('3. DEPENDENCY VULNERABILITY SCAN')
print('=' * 60) print('=' * 60)
try: try:
result = subprocess.run( result = subprocess.run(
[sys.executable, '-m', 'pip_audit', '--format', 'json'], [sys.executable, '-m', 'pip_audit', '--format', 'json'],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=60 timeout=60,
) )
if result.returncode == 0: if result.returncode == 0:
print('[OK] No known vulnerabilities found') print('[OK] No known vulnerabilities found')
return True return True
@@ -182,15 +184,10 @@ def check_dependencies():
# clean. Treating the array itself as the vulnerability list # clean. Treating the array itself as the vulnerability list
# reported all ~45 installed packages as vulnerable on every # reported all ~45 installed packages as vulnerable on every
# run, which is why this check was pure noise. # run, which is why this check was pure noise.
affected = [ affected = [dep for dep in data.get('dependencies', []) if dep.get('vulns')]
dep for dep in data.get('dependencies', [])
if dep.get('vulns')
]
if affected: if affected:
for dep in affected: for dep in affected:
ids = ', '.join( ids = ', '.join(v.get('id', '?') for v in dep.get('vulns', []))
v.get('id', '?') for v in dep.get('vulns', [])
)
print(f'[FAIL] {dep["name"]}=={dep["version"]}: {ids}') print(f'[FAIL] {dep["name"]}=={dep["version"]}: {ids}')
return False return False
else: else:
@@ -212,23 +209,23 @@ def check_dependencies():
def check_file_permissions(): def check_file_permissions():
"""Check for common security issues in the project structure. """Check for common security issues in the project structure.
Returns: Returns:
bool: True if no critical issues found. bool: True if no critical issues found.
""" """
print('\n' + '=' * 60) print('\n' + '=' * 60)
print('4. PROJECT FILES CHECK') print('4. PROJECT FILES CHECK')
print('=' * 60) print('=' * 60)
all_ok = True all_ok = True
# Check .gitignore exists and contains important patterns # Check .gitignore exists and contains important patterns
gitignore_path = os.path.join(os.getcwd(), '.gitignore') gitignore_path = os.path.join(os.getcwd(), '.gitignore')
if os.path.exists(gitignore_path): if os.path.exists(gitignore_path):
required_patterns = ['.env', 'instance/', '*.db', '*.log'] required_patterns = ['.env', 'instance/', '*.db', '*.log']
with open(gitignore_path, 'r') as f: with open(gitignore_path, 'r') as f:
content = f.read() content = f.read()
for pattern in required_patterns: for pattern in required_patterns:
if pattern in content: if pattern in content:
print(f'[OK] .gitignore contains: {pattern}') print(f'[OK] .gitignore contains: {pattern}')
@@ -238,14 +235,14 @@ def check_file_permissions():
else: else:
print('[FAIL] .gitignore file not found!') print('[FAIL] .gitignore file not found!')
all_ok = False all_ok = False
# Check for .env in working directory (should NOT be committed) # Check for .env in working directory (should NOT be committed)
env_path = os.path.join(os.getcwd(), '.env') env_path = os.path.join(os.getcwd(), '.env')
if os.path.exists(env_path): if os.path.exists(env_path):
print('[INFO] .env file exists (ensure it is NOT committed)') print('[INFO] .env file exists (ensure it is NOT committed)')
else: else:
print('[WARN] No .env file found') print('[WARN] No .env file found')
# Check for leftover .pyc or __pycache__ # Check for leftover .pyc or __pycache__
pycache_count = 0 pycache_count = 0
for root, dirs, files in os.walk(os.getcwd()): 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') print('[OK] No __pycache__ or .pyc files found')
else: else:
print(f'[INFO] Found {pycache_count} cache files/dirs (should be in .gitignore)') print(f'[INFO] Found {pycache_count} cache files/dirs (should be in .gitignore)')
return all_ok return all_ok
def check_flask_config(): def check_flask_config():
"""Check Flask application configuration for security. """Check Flask application configuration for security.
Returns: Returns:
bool: True if configuration looks secure. bool: True if configuration looks secure.
""" """
print('\n' + '=' * 60) print('\n' + '=' * 60)
print('5. FLASK CONFIGURATION CHECK') print('5. FLASK CONFIGURATION CHECK')
print('=' * 60) print('=' * 60)
all_ok = True all_ok = True
try: try:
# The script lives two levels below the project root; without this the # The script lives two levels below the project root; without this the
# import fails and the whole check was silently skipped. # import fails and the whole check was silently skipped.
@@ -282,12 +279,15 @@ def check_flask_config():
sys.path.insert(0, root) sys.path.insert(0, root)
from app.app import create_app from app.app import create_app
# Inspect configuration only: no schema creation, no Discord bot. # Inspect configuration only: no schema creation, no Discord bot.
app = create_app({ app = create_app(
'SQLALCHEMY_DATABASE_URI': os.getenv('DATABASE_URL') or 'sqlite:///:memory:', {
'AUTO_CREATE_TABLES': False, 'SQLALCHEMY_DATABASE_URI': os.getenv('DATABASE_URL') or 'sqlite:///:memory:',
'ENABLE_DISCORD_BOT': False, 'AUTO_CREATE_TABLES': False,
}) 'ENABLE_DISCORD_BOT': False,
}
)
# Check session cookie settings # Check session cookie settings
cookie_checks = [ cookie_checks = [
@@ -295,7 +295,7 @@ def check_flask_config():
('SESSION_COOKIE_HTTPONLY', True, 'HttpOnly cookies'), ('SESSION_COOKIE_HTTPONLY', True, 'HttpOnly cookies'),
('PERMANENT_SESSION_LIFETIME', 3600, 'Session timeout'), ('PERMANENT_SESSION_LIFETIME', 3600, 'Session timeout'),
] ]
for config_key, expected, description in cookie_checks: for config_key, expected, description in cookie_checks:
value = app.config.get(config_key) value = app.config.get(config_key)
if config_key == 'PERMANENT_SESSION_LIFETIME': if config_key == 'PERMANENT_SESSION_LIFETIME':
@@ -309,7 +309,7 @@ def check_flask_config():
else: else:
print(f'[FAIL] {description}: {value}') print(f'[FAIL] {description}: {value}')
all_ok = False all_ok = False
# Check MAX_CONTENT_LENGTH # Check MAX_CONTENT_LENGTH
max_content = app.config.get('MAX_CONTENT_LENGTH') max_content = app.config.get('MAX_CONTENT_LENGTH')
if max_content: if max_content:
@@ -318,7 +318,7 @@ def check_flask_config():
else: else:
print('[WARN] MAX_CONTENT_LENGTH not set (unlimited uploads)') print('[WARN] MAX_CONTENT_LENGTH not set (unlimited uploads)')
all_ok = False all_ok = False
# Check CSRF # Check CSRF
csrf_enabled = app.config.get('WTF_CSRF_ENABLED') csrf_enabled = app.config.get('WTF_CSRF_ENABLED')
if csrf_enabled: if csrf_enabled:
@@ -326,14 +326,14 @@ def check_flask_config():
else: else:
print('[FAIL] CSRF protection: disabled') print('[FAIL] CSRF protection: disabled')
all_ok = False all_ok = False
# Check if app is in DEBUG mode # Check if app is in DEBUG mode
if app.debug: if app.debug:
print('[FAIL] DEBUG mode is enabled!') print('[FAIL] DEBUG mode is enabled!')
all_ok = False all_ok = False
else: else:
print('[OK] DEBUG mode: disabled') print('[OK] DEBUG mode: disabled')
except Exception as e: except Exception as e:
# Returning all_ok (still True) here meant that failing to load the # Returning all_ok (still True) here meant that failing to load the
# application at all was counted as a passing check — the most # application at all was counted as a passing check — the most
@@ -346,17 +346,23 @@ def check_flask_config():
def main(): def main():
"""Run all security checks and produce a summary report. """Run all security checks and produce a summary report.
Returns: Returns:
int: 0 if all checks pass, 1 if any fail. int: 0 if all checks pass, 1 if any fail.
""" """
import argparse import argparse
parser = argparse.ArgumentParser(description='Security validation scanner') parser = argparse.ArgumentParser(description='Security validation scanner')
parser.add_argument('--url', default='http://localhost:5000', parser.add_argument(
help='Application URL to check headers (default: http://localhost:5000)') '--url',
parser.add_argument('--skip-http', action='store_true', default='http://localhost:5000',
help='Skip the live HTTP header check (no server running, e.g. in CI)') 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() args = parser.parse_args()
# Plain ASCII: the box-drawing characters this banner used crashed the # Plain ASCII: the box-drawing characters this banner used crashed the
@@ -381,18 +387,18 @@ def main():
results = [] results = []
for check in checks: for check in checks:
results.append(check()) results.append(check())
print('\n' + '=' * 60) print('\n' + '=' * 60)
print('SUMMARY') print('SUMMARY')
print('=' * 60) print('=' * 60)
passed = sum(1 for r in results if r) passed = sum(1 for r in results if r)
failed = sum(1 for r in results if not r) failed = sum(1 for r in results if not r)
total = len(results) total = len(results)
print(f'Passed: {passed}/{total}') print(f'Passed: {passed}/{total}')
print(f'Failed: {failed}/{total}') print(f'Failed: {failed}/{total}')
if failed == 0: if failed == 0:
print('\n[OK] All security checks passed!') print('\n[OK] All security checks passed!')
return 0 return 0
@@ -402,4 +408,4 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
sys.exit(main()) sys.exit(main())
+63 -67
View File
@@ -12,7 +12,15 @@ Usage:
import re import re
from flask_babel import lazy_gettext as _l 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 from app.models import USER_TYPES
@@ -20,58 +28,55 @@ from app.models import USER_TYPES
# Custom Validators # Custom Validators
# ============================================================================= # =============================================================================
PASSWORD_POLICY = re.compile( PASSWORD_POLICY = re.compile(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$')
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$'
)
"""Password policy: minimum 8 chars, 1 uppercase, 1 lowercase, 1 digit.""" """Password policy: minimum 8 chars, 1 uppercase, 1 lowercase, 1 digit."""
def validate_password(value): def validate_password(value):
"""Validate password meets strength requirements. """Validate password meets strength requirements.
Requires: minimum 8 characters, at least one uppercase letter, Requires: minimum 8 characters, at least one uppercase letter,
one lowercase letter, and one digit. one lowercase letter, and one digit.
Args: Args:
value: The password string to validate. value: The password string to validate.
Raises: Raises:
ValidationError: If password does not meet requirements. ValidationError: If password does not meet requirements.
""" """
if not PASSWORD_POLICY.match(value): if not PASSWORD_POLICY.match(value):
raise ValidationError(_l( raise ValidationError(
'Password must be at least 8 characters with uppercase, ' _l('Password must be at least 8 characters with uppercase, lowercase, and a number.')
'lowercase, and a number.' )
))
def validate_username(value): def validate_username(value):
"""Validate username format. """Validate username format.
Usernames must be 3-30 characters and contain only alphanumeric Usernames must be 3-30 characters and contain only alphanumeric
characters, underscores, and hyphens. characters, underscores, and hyphens.
Args: Args:
value: The username string to validate. value: The username string to validate.
Raises: Raises:
ValidationError: If username does not meet requirements. ValidationError: If username does not meet requirements.
""" """
if not re.match(r'^[a-zA-Z0-9_-]{3,30}$', value): if not re.match(r'^[a-zA-Z0-9_-]{3,30}$', value):
raise ValidationError(_l( raise ValidationError(
'Username must be 3-30 characters (letters, numbers, underscore, hyphen).' _l('Username must be 3-30 characters (letters, numbers, underscore, hyphen).')
)) )
def validate_discord_username(value): def validate_discord_username(value):
"""Validate Discord username format if provided. """Validate Discord username format if provided.
Accepts empty strings (optional field). Validates that the username Accepts empty strings (optional field). Validates that the username
matches common Discord username patterns. matches common Discord username patterns.
Args: Args:
value: The Discord username to validate. value: The Discord username to validate.
Raises: Raises:
ValidationError: If the format is invalid. ValidationError: If the format is invalid.
""" """
@@ -83,12 +88,12 @@ def validate_discord_username(value):
def validate_discord_user_id(value): def validate_discord_user_id(value):
"""Validate Discord user ID (snowflake) if provided. """Validate Discord user ID (snowflake) if provided.
Discord user IDs are 17-20 digit numbers. Discord user IDs are 17-20 digit numbers.
Args: Args:
value: The Discord user ID to validate. value: The Discord user ID to validate.
Raises: Raises:
ValidationError: If the format is invalid. ValidationError: If the format is invalid.
""" """
@@ -100,12 +105,12 @@ def validate_discord_user_id(value):
def validate_phone(value): def validate_phone(value):
"""Validate optional phone number format. """Validate optional phone number format.
Accepts empty strings. Validates common phone formats. Accepts empty strings. Validates common phone formats.
Args: Args:
value: The phone number to validate. value: The phone number to validate.
Raises: Raises:
ValidationError: If the format is invalid. ValidationError: If the format is invalid.
""" """
@@ -120,21 +125,23 @@ def validate_phone(value):
# Validation Schemas # Validation Schemas
# ============================================================================= # =============================================================================
class StripMixin(Schema): class StripMixin(Schema):
"""Mixin that automatically strips whitespace from all string fields """Mixin that automatically strips whitespace from all string fields
and ignores unknown fields (e.g., csrf_token from Flask-WTF).""" and ignores unknown fields (e.g., csrf_token from Flask-WTF)."""
class Meta: class Meta:
"""Marshmallow Meta options.""" """Marshmallow Meta options."""
unknown = EXCLUDE # Ignore csrf_token and other unknown fields unknown = EXCLUDE # Ignore csrf_token and other unknown fields
@pre_load @pre_load
def strip_strings(self, data, **kwargs): def strip_strings(self, data, **kwargs):
"""Strip whitespace from all string values in the input data. """Strip whitespace from all string values in the input data.
Args: Args:
data: The input dictionary. data: The input dictionary.
Returns: Returns:
dict: Data with stripped strings. dict: Data with stripped strings.
""" """
@@ -145,11 +152,12 @@ class StripMixin(Schema):
class LoginSchema(StripMixin): class LoginSchema(StripMixin):
"""Validate login form input. """Validate login form input.
Fields: Fields:
username: 3-30 chars, required. username: 3-30 chars, required.
password: Non-empty, required. password: Non-empty, required.
""" """
username = fields.String( username = fields.String(
required=True, required=True,
validate=validate.Length(min=1, max=80, error=_l('Username is required.')), validate=validate.Length(min=1, max=80, error=_l('Username is required.')),
@@ -162,7 +170,7 @@ class LoginSchema(StripMixin):
class RegisterSchema(StripMixin): class RegisterSchema(StripMixin):
"""Validate player registration form input. """Validate player registration form input.
Fields: Fields:
username: 3-30 chars alphanumeric, required. username: 3-30 chars alphanumeric, required.
email: Valid email, required. email: Valid email, required.
@@ -174,6 +182,7 @@ class RegisterSchema(StripMixin):
discord_username: Optional, valid format. discord_username: Optional, valid format.
league_os_profile: Optional URL. league_os_profile: Optional URL.
""" """
username = fields.String( username = fields.String(
required=True, required=True,
validate=[ validate=[
@@ -223,10 +232,10 @@ class RegisterSchema(StripMixin):
@validates_schema @validates_schema
def validate_password_match(self, data, **kwargs): def validate_password_match(self, data, **kwargs):
"""Ensure confirm_password matches password. """Ensure confirm_password matches password.
Args: Args:
data: The validated data dictionary. data: The validated data dictionary.
Raises: Raises:
ValidationError: If passwords do not match. ValidationError: If passwords do not match.
""" """
@@ -236,7 +245,7 @@ class RegisterSchema(StripMixin):
class CreateUserSchema(StripMixin): class CreateUserSchema(StripMixin):
"""Validate president-created user form input. """Validate president-created user form input.
Fields: Fields:
username: 3-30 chars alphanumeric, required. username: 3-30 chars alphanumeric, required.
email: Valid email, required. email: Valid email, required.
@@ -245,6 +254,7 @@ class CreateUserSchema(StripMixin):
role: Must be valid role, required. role: Must be valid role, required.
phone: Optional, valid phone format. phone: Optional, valid phone format.
""" """
username = fields.String( username = fields.String(
required=True, required=True,
validate=[ validate=[
@@ -267,10 +277,7 @@ class CreateUserSchema(StripMixin):
) )
role = fields.String( role = fields.String(
required=True, required=True,
validate=validate.OneOf( validate=validate.OneOf(USER_TYPES, error=_l('Invalid role selected.')),
USER_TYPES,
error=_l('Invalid role selected.')
),
) )
phone = fields.String( phone = fields.String(
validate=validate_phone, validate=validate_phone,
@@ -281,7 +288,7 @@ class CreateUserSchema(StripMixin):
class EditUserSchema(StripMixin): class EditUserSchema(StripMixin):
"""Validate president-edited user form input. """Validate president-edited user form input.
Fields: Fields:
full_name: 1-100 chars, required. full_name: 1-100 chars, required.
email: Valid email, required. email: Valid email, required.
@@ -294,6 +301,7 @@ class EditUserSchema(StripMixin):
league_os_profile: Optional. league_os_profile: Optional.
games: Optional list. games: Optional list.
""" """
full_name = fields.String( full_name = fields.String(
required=True, required=True,
validate=validate.Length(min=1, max=100, error=_l('Full name is required.')), validate=validate.Length(min=1, max=100, error=_l('Full name is required.')),
@@ -304,10 +312,7 @@ class EditUserSchema(StripMixin):
) )
role = fields.String( role = fields.String(
required=True, required=True,
validate=validate.OneOf( validate=validate.OneOf(USER_TYPES, error=_l('Invalid role selected.')),
USER_TYPES,
error=_l('Invalid role selected.')
),
) )
is_active_account = fields.Boolean(load_default=True) is_active_account = fields.Boolean(load_default=True)
phone = fields.String( phone = fields.String(
@@ -341,7 +346,7 @@ class EditUserSchema(StripMixin):
class EditProfileSchema(StripMixin): class EditProfileSchema(StripMixin):
"""Validate self-edit profile form input. """Validate self-edit profile form input.
Fields: Fields:
username: 3-30 chars, required. username: 3-30 chars, required.
full_name: 1-100 chars, required. full_name: 1-100 chars, required.
@@ -353,6 +358,7 @@ class EditProfileSchema(StripMixin):
league_os_profile: Optional. league_os_profile: Optional.
games: Optional list. games: Optional list.
""" """
username = fields.String( username = fields.String(
required=True, required=True,
validate=[ validate=[
@@ -399,11 +405,12 @@ class EditProfileSchema(StripMixin):
class UploadContractSchema(StripMixin): class UploadContractSchema(StripMixin):
"""Validate contract upload form input. """Validate contract upload form input.
Fields: Fields:
player_id: Integer, required. player_id: Integer, required.
notes: Optional text. notes: Optional text.
""" """
player_id = fields.Integer( player_id = fields.Integer(
required=True, required=True,
validate=validate.Range(min=1, error=_l('Player must be selected.')), validate=validate.Range(min=1, error=_l('Player must be selected.')),
@@ -417,33 +424,27 @@ class UploadContractSchema(StripMixin):
class OneOnOneRequestSchema(StripMixin): class OneOnOneRequestSchema(StripMixin):
"""Validate One on One session request form input. """Validate One on One session request form input.
Fields: Fields:
date: Date string (YYYY-MM-DD), required. date: Date string (YYYY-MM-DD), required.
start_time: Time string (HH:MM), required. start_time: Time string (HH:MM), required.
end_time: Time string (HH:MM), required. end_time: Time string (HH:MM), required.
points: Optional text. points: Optional text.
""" """
date = fields.String( date = fields.String(
required=True, required=True,
validate=validate.Regexp( validate=validate.Regexp(
r'^\d{4}-\d{2}-\d{2}$', r'^\d{4}-\d{2}-\d{2}$', error=_l('Date must be in YYYY-MM-DD format.')
error=_l('Date must be in YYYY-MM-DD format.')
), ),
) )
start_time = fields.String( start_time = fields.String(
required=True, required=True,
validate=validate.Regexp( validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('Start time must be in HH:MM format.')),
r'^\d{2}:\d{2}$',
error=_l('Start time must be in HH:MM format.')
),
) )
end_time = fields.String( end_time = fields.String(
required=True, required=True,
validate=validate.Regexp( validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('End time must be in HH:MM format.')),
r'^\d{2}:\d{2}$',
error=_l('End time must be in HH:MM format.')
),
) )
points = fields.String( points = fields.String(
validate=validate.Length(max=2000, error=_l('Points must be 2000 characters or less.')), validate=validate.Length(max=2000, error=_l('Points must be 2000 characters or less.')),
@@ -454,22 +455,17 @@ class OneOnOneRequestSchema(StripMixin):
class DisponibilityAddSchema(StripMixin): class DisponibilityAddSchema(StripMixin):
"""Validate disponibility block addition. """Validate disponibility block addition.
Fields: Fields:
day_of_week: Integer 0-6, required. day_of_week: Integer 0-6, required.
start_time: Time string (HH:MM), required. start_time: Time string (HH:MM), required.
""" """
day_of_week = fields.Integer( day_of_week = fields.Integer(
required=True, required=True,
validate=validate.Range( validate=validate.Range(min=0, max=6, error=_l('Day must be 0 (Monday) to 6 (Sunday).')),
min=0, max=6,
error=_l('Day must be 0 (Monday) to 6 (Sunday).')
),
) )
start_time = fields.String( start_time = fields.String(
required=True, required=True,
validate=validate.Regexp( validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('Start time must be in HH:MM format.')),
r'^\d{2}:\d{2}$', )
error=_l('Start time must be in HH:MM format.')
),
)
+18 -8
View File
@@ -15,13 +15,22 @@ def seed_database():
print("Deleting existing data...") print("Deleting existing data...")
tables = [ tables = [
'one_on_one_requests', 'one_on_one_requests',
'player_disponibilities', 'coach_availabilities', 'player_disponibilities',
'match_participants', 'team_match_participants', 'coach_availabilities',
'matches', 'team_matches', 'match_participants',
'team_players', 'team_members', 'teams', 'team_match_participants',
'evaluations', 'tryout_registrations', 'tryouts', 'matches',
'org_team_coaches', 'org_team_managers', 'team_matches',
'team_notes', 'personal_notes', 'team_players',
'team_members',
'teams',
'evaluations',
'tryout_registrations',
'tryouts',
'org_team_coaches',
'org_team_managers',
'team_notes',
'personal_notes',
'user_gamertags', 'user_gamertags',
'org_teams', 'org_teams',
'users', 'users',
@@ -51,6 +60,7 @@ def seed_database():
if __name__ == '__main__': if __name__ == '__main__':
from app.app import create_app from app.app import create_app
app = create_app() app = create_app()
with app.app_context(): with app.app_context():
seed_database() seed_database()
+1
View File
@@ -183,6 +183,7 @@ def as_role(app, client, make_user, login):
user_id = make_user(role, **kwargs) user_id = make_user(role, **kwargs)
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
username = _db.session.get(User, user_id).username username = _db.session.get(User, user_id).username
response = login(username) response = login(username)
assert response.status_code in (301, 302), ( assert response.status_code in (301, 302), (
+60 -22
View File
@@ -106,10 +106,17 @@ class TestAdministrativeEvents:
def test_account_creation_is_recorded(self, client, as_role, auth_log): def test_account_creation_is_recorded(self, client, as_role, auth_log):
as_role('admin') as_role('admin')
client.post('/users/create', data={ client.post(
'username': 'newcoach', 'email': '[email protected]', '/users/create',
'password': 'Password123', 'full_name': 'New Coach', 'role': 'coach', data={
}, follow_redirects=True) 'username': 'newcoach',
'email': '[email protected]',
'password': 'Password123',
'full_name': 'New Coach',
'role': 'coach',
},
follow_redirects=True,
)
assert _has_event(auth_log, 'account.created_by_admin') assert _has_event(auth_log, 'account.created_by_admin')
@@ -117,10 +124,16 @@ class TestAdministrativeEvents:
target_id = make_user('player') target_id = make_user('player')
as_role('admin') as_role('admin')
client.post(f'/users/{target_id}/edit', data={ client.post(
'full_name': 'Promoted', 'email': '[email protected]t', f'/users/{target_id}/edit',
'role': 'coach', 'is_active_account': 'on', data={
}, follow_redirects=True) 'full_name': 'Promoted',
'email': '[email protected]',
'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) message = next(m for m in _events(auth_log) if 'event=account.role_changed' in m)
assert 'previous_role=player' in message assert 'previous_role=player' in message
@@ -135,17 +148,21 @@ class TestAdministrativeEvents:
assert _has_event(auth_log, 'account.deleted') assert _has_event(auth_log, 'account.deleted')
def test_a_self_service_password_change_is_recorded( def test_a_self_service_password_change_is_recorded(self, app, client, as_role, auth_log):
self, app, client, as_role, auth_log
):
user_id = as_role('player') user_id = as_role('player')
with app.app_context(): with app.app_context():
username = db.session.get(User, user_id).username username = db.session.get(User, user_id).username
client.post('/users/profile/edit', data={ client.post(
'username': username, 'full_name': 'Same Name', '/users/profile/edit',
'email': '[email protected]', 'password': 'BrandNew123', data={
}, follow_redirects=True) 'username': username,
'full_name': 'Same Name',
'email': '[email protected]',
'password': 'BrandNew123',
},
follow_redirects=True,
)
assert _has_event(auth_log, 'account.password_changed') assert _has_event(auth_log, 'account.password_changed')
@@ -162,8 +179,13 @@ class TestRedaction:
from app.logging_config import SensitiveDataFilter from app.logging_config import SensitiveDataFilter
record = logging.LogRecord( record = logging.LogRecord(
'test', logging.ERROR, 'x.py', 1, 'test',
'Database failure: %s', ('password=hunter2 host=db.internal',), None, logging.ERROR,
'x.py',
1,
'Database failure: %s',
('password=hunter2 host=db.internal',),
None,
) )
SensitiveDataFilter().filter(record) SensitiveDataFilter().filter(record)
@@ -175,8 +197,13 @@ class TestRedaction:
from app.logging_config import SensitiveDataFilter from app.logging_config import SensitiveDataFilter
record = logging.LogRecord( record = logging.LogRecord(
'test', logging.INFO, 'x.py', 1, 'test',
'Calling Discord with %s', ('Bearer abcdef123456',), None, logging.INFO,
'x.py',
1,
'Calling Discord with %s',
('Bearer abcdef123456',),
None,
) )
SensitiveDataFilter().filter(record) SensitiveDataFilter().filter(record)
@@ -186,8 +213,13 @@ class TestRedaction:
from app.logging_config import SensitiveDataFilter from app.logging_config import SensitiveDataFilter
record = logging.LogRecord( record = logging.LogRecord(
'test', logging.INFO, 'x.py', 1, 'test',
'event=login.success username=%s', ('alice',), None, logging.INFO,
'x.py',
1,
'event=login.success username=%s',
('alice',),
None,
) )
SensitiveDataFilter().filter(record) SensitiveDataFilter().filter(record)
@@ -197,6 +229,12 @@ class TestRedaction:
from app.logging_config import SensitiveDataFilter from app.logging_config import SensitiveDataFilter
record = logging.LogRecord( 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 assert SensitiveDataFilter().filter(record) is True
+22 -19
View File
@@ -121,20 +121,26 @@ class TestLogout:
with app_with_csrf.app_context(): with app_with_csrf.app_context():
from app.extensions import hash_password from app.extensions import hash_password
from app.models import Player from app.models import Player
user = Player(username='navtest', password_hash=hash_password('Password123'),
role='player', full_name='Nav Test', email='[email protected]') user = Player(
username='navtest',
password_hash=hash_password('Password123'),
role='player',
full_name='Nav Test',
email='[email protected]',
)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
page = client.get('/auth/login').get_data(as_text=True) page = client.get('/auth/login').get_data(as_text=True)
token = re.search(r'name="csrf_token" value="([^"]+)"', page).group(1) token = re.search(r'name="csrf_token" value="([^"]+)"', page).group(1)
client.post('/auth/login', data={'username': 'navtest', client.post(
'password': 'Password123', '/auth/login',
'csrf_token': token}) data={'username': 'navtest', 'password': 'Password123', 'csrf_token': token},
)
body = client.get('/users/profile').get_data(as_text=True) body = client.get('/users/profile').get_data(as_text=True)
form = re.search( form = re.search(r'<form method="POST" action="/auth/logout".*?</form>', body, re.S)
r'<form method="POST" action="/auth/logout".*?</form>', body, re.S)
assert form is not None, 'no logout form in the navigation' assert form is not None, 'no logout form in the navigation'
assert 'name="csrf_token"' in form.group(0) assert 'name="csrf_token"' in form.group(0)
@@ -150,9 +156,7 @@ class TestLoginRejection:
response = client.get('/users/profile', follow_redirects=False) response = client.get('/users/profile', follow_redirects=False)
assert response.status_code in (301, 302) assert response.status_code in (301, 302)
def test_login_failure_message_does_not_reveal_account_existence( def test_login_failure_message_does_not_reveal_account_existence(self, client, make_user, app):
self, client, make_user, app
):
"""Compares the rendered flash messages rather than looking for a """Compares the rendered flash messages rather than looking for a
known substring: the site is served in French by default, so an known substring: the site is served in French by default, so an
English marker would silently match nothing on both sides and make 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') assert failure_message(username) == failure_message('no-such-account')
def test_the_message_stays_the_same_past_the_attempt_threshold( def test_the_message_stays_the_same_past_the_attempt_threshold(self, client, make_user, app):
self, client, make_user, app
):
"""The tally used to be counted out loud — "3 attempt(s) remaining" """The tally used to be counted out loud — "3 attempt(s) remaining"
which is the same disclosure, spread over five requests.""" which is the same disclosure, spread over five requests."""
user_id = make_user('player') user_id = make_user('player')
@@ -200,13 +202,13 @@ class TestFailedAttemptThrottle:
@staticmethod @staticmethod
def _exhaust(client, username, times=6): def _exhaust(client, username, times=6):
for _ in range(times): for _ in range(times):
client.post('/auth/login', client.post(
data={'username': username, 'password': 'WrongPassword1'}, '/auth/login',
follow_redirects=True) data={'username': username, 'password': 'WrongPassword1'},
follow_redirects=True,
)
def test_the_owner_still_gets_in_after_the_threshold( def test_the_owner_still_gets_in_after_the_threshold(self, app, client, make_user, login):
self, app, client, make_user, login
):
user_id = make_user('player') user_id = make_user('player')
with app.app_context(): with app.app_context():
username = db.session.get(User, user_id).username username = db.session.get(User, user_id).username
@@ -294,6 +296,7 @@ class TestRedirectValidation:
with app.test_request_context('/auth/login'): with app.test_request_context('/auth/login'):
from flask import request from flask import request
assert is_safe_url(f'http://{request.host}/dashboard') assert is_safe_url(f'http://{request.host}/dashboard')
def test_the_login_redirect_refuses_to_leave_the_site(self, client, make_user, app): def test_the_login_redirect_refuses_to_leave_the_site(self, client, make_user, app):
+103 -66
View File
@@ -61,9 +61,7 @@ class TestVerticalAccess:
def test_only_admin_reaches_user_management(self, client, as_role, role, route): def test_only_admin_reaches_user_management(self, client, as_role, role, route):
as_role(role) as_role(role)
response = client.get(route, follow_redirects=False) response = client.get(route, follow_redirects=False)
assert _redirected(response), ( assert _redirected(response), f'{role} reached {route}, which is meant to be admin-only'
f'{role} reached {route}, which is meant to be admin-only'
)
def test_admin_reaches_user_management(self, client, as_role): def test_admin_reaches_user_management(self, client, as_role):
as_role('admin') as_role('admin')
@@ -106,8 +104,10 @@ class TestHorizontalAccess:
owner_id = make_user('player') owner_id = make_user('player')
with app.app_context(): with app.app_context():
slot = PlayerDisponibility( slot = PlayerDisponibility(
player_id=owner_id, day_of_week=1, player_id=owner_id,
start_time=time(10, 0), end_time=time(10, 30), day_of_week=1,
start_time=time(10, 0),
end_time=time(10, 30),
) )
db.session.add(slot) db.session.add(slot)
db.session.commit() db.session.commit()
@@ -134,8 +134,11 @@ class TestNestedResourceOwnership:
with app.app_context(): with app.app_context():
tryout = Tryout( tryout = Tryout(
title=title, game='Valorant', date=date(2030, 1, 1), title=title,
created_by=owner_id, status='upcoming', game='Valorant',
date=date(2030, 1, 1),
created_by=owner_id,
status='upcoming',
) )
db.session.add(tryout) db.session.add(tryout)
db.session.flush() db.session.flush()
@@ -144,9 +147,7 @@ class TestNestedResourceOwnership:
db.session.commit() db.session.commit()
return tryout.id, team.id return tryout.id, team.id
def test_cannot_add_a_player_to_a_team_of_another_tryout( def test_cannot_add_a_player_to_a_team_of_another_tryout(self, app, client, as_role, make_user):
self, app, client, as_role, make_user
):
from app.models import TeamMember, TryoutRegistration from app.models import TeamMember, TryoutRegistration
other_admin = make_user('admin') other_admin = make_user('admin')
@@ -157,8 +158,7 @@ class TestNestedResourceOwnership:
player_id = make_user('player') player_id = make_user('player')
with app.app_context(): with app.app_context():
db.session.add(TryoutRegistration( db.session.add(TryoutRegistration(tryout_id=own_tryout_id, player_id=player_id))
tryout_id=own_tryout_id, player_id=player_id))
db.session.commit() db.session.commit()
response = client.post( response = client.post(
@@ -167,15 +167,11 @@ class TestNestedResourceOwnership:
follow_redirects=False, follow_redirects=False,
) )
assert response.status_code == 404, ( assert response.status_code == 404, 'a team belonging to another tryout was accepted'
'a team belonging to another tryout was accepted'
)
with app.app_context(): with app.app_context():
assert TeamMember.query.filter_by(team_id=foreign_team_id).count() == 0 assert TeamMember.query.filter_by(team_id=foreign_team_id).count() == 0
def test_cannot_add_a_player_who_is_not_registered( def test_cannot_add_a_player_who_is_not_registered(self, app, client, as_role, make_user):
self, app, client, as_role, make_user
):
from app.models import TeamMember from app.models import TeamMember
manager_id = as_role('manager') manager_id = as_role('manager')
@@ -191,9 +187,7 @@ class TestNestedResourceOwnership:
with app.app_context(): with app.app_context():
assert TeamMember.query.filter_by(team_id=team_id).count() == 0 assert TeamMember.query.filter_by(team_id=team_id).count() == 0
def test_a_registered_player_can_still_be_added( def test_a_registered_player_can_still_be_added(self, app, client, as_role, make_user):
self, app, client, as_role, make_user
):
"""Guard against over-correcting: the normal path must keep working.""" """Guard against over-correcting: the normal path must keep working."""
from app.models import TeamMember, TryoutRegistration from app.models import TeamMember, TryoutRegistration
@@ -243,11 +237,15 @@ class TestInputValidation:
user_id = as_role('player') user_id = as_role('player')
payload = '<img src=x onerror=alert(1)>' payload = '<img src=x onerror=alert(1)>'
client.post('/users/profile/edit', data={ client.post(
'username': payload, '/users/profile/edit',
'full_name': 'Legit Name', data={
'email': '[email protected]', 'username': payload,
}, follow_redirects=True) 'full_name': 'Legit Name',
'email': '[email protected]',
},
follow_redirects=True,
)
with app.app_context(): with app.app_context():
assert db.session.get(User, user_id).username != payload assert db.session.get(User, user_id).username != payload
@@ -257,12 +255,16 @@ class TestInputValidation:
with app.app_context(): with app.app_context():
before = db.session.get(User, user_id).password_hash before = db.session.get(User, user_id).password_hash
client.post('/users/profile/edit', data={ client.post(
'username': _username(app, user_id), '/users/profile/edit',
'full_name': 'Legit Name', data={
'email': '[email protected]', 'username': _username(app, user_id),
'password': 'a', 'full_name': 'Legit Name',
}, follow_redirects=True) 'email': '[email protected]',
'password': 'a',
},
follow_redirects=True,
)
with app.app_context(): with app.app_context():
assert db.session.get(User, user_id).password_hash == before, ( 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): def test_create_user_enforces_the_password_policy(self, app, client, as_role):
as_role('admin') as_role('admin')
client.post('/users/create', data={ client.post(
'username': 'weakling', '/users/create',
'email': '[email protected]', data={
'password': 'a', 'username': 'weakling',
'full_name': 'Weak Account', 'email': 'weak@example.test',
'role': 'admin', 'password': 'a',
}, follow_redirects=True) 'full_name': 'Weak Account',
'role': 'admin',
},
follow_redirects=True,
)
with app.app_context(): with app.app_context():
created = User.query.filter_by(username='weakling').first() created = User.query.filter_by(username='weakling').first()
@@ -292,11 +298,15 @@ class TestInputValidation:
with app.app_context(): with app.app_context():
taken = db.session.get(User, other_id).email taken = db.session.get(User, other_id).email
response = client.post(f'/users/{target_id}/edit', data={ response = client.post(
'full_name': 'Target', f'/users/{target_id}/edit',
'email': taken, data={
'role': 'player', 'full_name': 'Target',
}, follow_redirects=False) 'email': taken,
'role': 'player',
},
follow_redirects=False,
)
assert response.status_code < 500, 'duplicate email produced a server error' 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): def test_the_last_admin_cannot_demote_itself(self, app, client, as_role):
admin_id = as_role('admin') admin_id = as_role('admin')
client.post(f'/users/{admin_id}/edit', data={ client.post(
'full_name': 'Admin', f'/users/{admin_id}/edit',
'email': '[email protected]', data={
'role': 'player', 'full_name': 'Admin',
}, follow_redirects=True) 'email': '[email protected]',
'role': 'player',
},
follow_redirects=True,
)
with app.app_context(): with app.app_context():
assert db.session.get(User, admin_id).role == 'admin', ( assert db.session.get(User, admin_id).role == 'admin', (
'the only administrator demoted itself; no interface can undo this' 'the only administrator demoted itself; no interface can undo this'
) )
def test_an_admin_cannot_change_its_own_role_even_with_others_present( def test_an_admin_cannot_change_its_own_role_even_with_others_present(
self, app, client, as_role, make_user self, app, client, as_role, make_user
): ):
@@ -324,9 +337,15 @@ class TestAdminSafety:
make_user('admin') make_user('admin')
admin_id = as_role('admin') admin_id = as_role('admin')
client.post(f'/users/{admin_id}/edit', data={ client.post(
'full_name': 'Admin', 'email': '[email protected]', 'role': 'player', f'/users/{admin_id}/edit',
}, follow_redirects=True) data={
'full_name': 'Admin',
'email': '[email protected]',
'role': 'player',
},
follow_redirects=True,
)
with app.app_context(): with app.app_context():
assert db.session.get(User, admin_id).role == 'admin' assert db.session.get(User, admin_id).role == 'admin'
@@ -336,10 +355,16 @@ class TestAdminSafety:
other_id = make_user('admin') other_id = make_user('admin')
as_role('admin') as_role('admin')
client.post(f'/users/{other_id}/edit', data={ client.post(
'full_name': 'Other', 'email': '[email protected]t', f'/users/{other_id}/edit',
'role': 'coach', 'is_active_account': 'on', data={
}, follow_redirects=True) 'full_name': 'Other',
'email': '[email protected]',
'role': 'coach',
'is_active_account': 'on',
},
follow_redirects=True,
)
with app.app_context(): with app.app_context():
assert db.session.get(User, other_id).role == 'coach' 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 """CSRFProtect is global. This pins that down so a future
@csrf.exempt cannot slip in unnoticed.""" @csrf.exempt cannot slip in unnoticed."""
client = app_with_csrf.test_client() client = app_with_csrf.test_client()
response = client.post('/auth/login', data={ response = client.post(
'username': 'someone', 'password': 'Password123', '/auth/login',
}) data={
'username': 'someone',
'password': 'Password123',
},
)
assert response.status_code == 400 assert response.status_code == 400
class TestCorsPolicy: class TestCorsPolicy:
"""SEC-WEB-003 — with no origins configured, flask-cors defaulted to '*' """SEC-WEB-003 — with no origins configured, flask-cors defaulted to '*'
and, credentials being allowed, echoed back the caller's Origin.""" 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): def test_configured_origins_are_still_honoured(self, app_with_csrf):
from app.app import create_app from app.app import create_app
application = create_app({ application = create_app(
'SECRET_KEY': 'test', 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', {
'TESTING': True, 'FORCE_HTTPS': False, 'ENABLE_DISCORD_BOT': False, 'SECRET_KEY': 'test',
'AUTO_CREATE_TABLES': False, 'CORS_ALLOWED_ORIGINS': 'https://trusted.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( 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' assert response.headers.get('Access-Control-Allow-Origin') == 'https://trusted.test'
+2 -4
View File
@@ -37,8 +37,7 @@ class TestUrlParsing:
def test_the_sqlalchemy_dialect_suffix_is_accepted(self): def test_the_sqlalchemy_dialect_suffix_is_accepted(self):
"""SQLAlchemy writes postgresql+psycopg://, which pg_dump rejects.""" """SQLAlchemy writes postgresql+psycopg://, which pg_dump rejects."""
conn = parse_database_url( conn = parse_database_url('postgresql+psycopg://u:p@localhost/tryouts')
'postgresql+psycopg://u:p@localhost/tryouts')
assert conn['dbname'] == 'tryouts' assert conn['dbname'] == 'tryouts'
def test_the_default_port_is_applied(self): def test_the_default_port_is_applied(self):
@@ -119,5 +118,4 @@ class TestExitCodes:
assert backup_module.main([]) == 1 assert backup_module.main([]) == 1
def test_verifying_a_missing_archive_fails(self, tmp_path): def test_verifying_a_missing_archive_fails(self, tmp_path):
assert backup_module.main( assert backup_module.main(['--verify-only', str(tmp_path / 'nope.dump')]) == 1
['--verify-only', str(tmp_path / 'nope.dump')]) == 1
+22 -18
View File
@@ -25,7 +25,10 @@ from app.extensions import db
def match_factory(app): def match_factory(app):
"""Create a tryout with one player_scrim match and one participant.""" """Create a tryout with one player_scrim match and one participant."""
from app.models import ( from app.models import (
Match, MatchParticipant, Tryout, TryoutRegistration, Match,
MatchParticipant,
Tryout,
TryoutRegistration,
) )
def _make(owner_id, player_id, *, description=None, username=None): def _make(owner_id, player_id, *, description=None, username=None):
@@ -39,24 +42,30 @@ def match_factory(app):
player.username = username player.username = username
tryout = Tryout( tryout = Tryout(
title='Spring tryout', game='Valorant', date=date(2030, 5, 1), title='Spring tryout',
created_by=owner_id, status='upcoming', game='Valorant',
date=date(2030, 5, 1),
created_by=owner_id,
status='upcoming',
) )
db.session.add(tryout) db.session.add(tryout)
db.session.flush() db.session.flush()
db.session.add(TryoutRegistration( db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
tryout_id=tryout.id, player_id=player_id))
match = Match( match = Match(
tryout_id=tryout.id, title='Scrim A', description=description, tryout_id=tryout.id,
date=date(2030, 5, 2), start_time=time(18, 0), end_time=time(19, 0), title='Scrim A',
match_type='player_scrim', created_by=owner_id, 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.add(match)
db.session.flush() db.session.flush()
db.session.add(MatchParticipant( db.session.add(MatchParticipant(match_id=match.id, player_id=player_id))
match_id=match.id, player_id=player_id))
db.session.commit() db.session.commit()
return tryout.id, match.id return tryout.id, match.id
@@ -68,9 +77,7 @@ def _match_event(payload):
class TestCalendarEventPayload: class TestCalendarEventPayload:
def test_description_is_returned_verbatim( def test_description_is_returned_verbatim(self, app, client, as_role, make_user, match_factory):
self, app, client, as_role, make_user, match_factory
):
player_id = make_user('player') player_id = make_user('player')
admin_id = as_role('admin') admin_id = as_role('admin')
match_factory(admin_id, player_id, description='Bring your own peripherals') match_factory(admin_id, player_id, description='Bring your own peripherals')
@@ -92,9 +99,7 @@ class TestCalendarEventPayload:
assert '<br>' not in props['description'] assert '<br>' not in props['description']
assert props['participants'] not in props['description'] assert props['participants'] not in props['description']
def test_an_empty_description_stays_empty( def test_an_empty_description_stays_empty(self, app, client, as_role, make_user, match_factory):
self, app, client, as_role, make_user, match_factory
):
player_id = make_user('player') player_id = make_user('player')
admin_id = as_role('admin') admin_id = as_role('admin')
match_factory(admin_id, player_id, description=None) match_factory(admin_id, player_id, description=None)
@@ -132,8 +137,7 @@ class TestLegacyHostileData:
): ):
player_id = make_user('player') player_id = make_user('player')
admin_id = as_role('admin') admin_id = as_role('admin')
match_factory(admin_id, player_id, match_factory(admin_id, player_id, description='Normal text', username=self.PAYLOAD)
description='Normal text', username=self.PAYLOAD)
props = _match_event(client.get('/matches/api/events').get_json())['extendedProps'] props = _match_event(client.get('/matches/api/events').get_json())['extendedProps']
+14 -12
View File
@@ -32,8 +32,10 @@ def contract_for(app, tmp_path):
path.write_bytes(PDF_BYTES) path.write_bytes(PDF_BYTES)
with app.app_context(): with app.app_context():
contract = Contract( contract = Contract(
player_id=player_id, uploaded_by_id=uploader_id, player_id=player_id,
original_filename='contract.pdf', stored_filename=stored, uploaded_by_id=uploader_id,
original_filename='contract.pdf',
stored_filename=stored,
file_path=str(path), file_path=str(path),
) )
db.session.add(contract) db.session.add(contract)
@@ -54,7 +56,8 @@ class TestSignedUpload:
client.post( client.post(
f'/users/contracts/{contract_id}/upload_signed', f'/users/contracts/{contract_id}/upload_signed',
data={'signed_file': (io.BytesIO(NOT_PDF_BYTES), 'signed.pdf')}, 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(): with app.app_context():
@@ -63,9 +66,7 @@ class TestSignedUpload:
assert contract.signed_file_path is None assert contract.signed_file_path is None
assert not os.path.exists(directory / 'signed_deadbeef.pdf') assert not os.path.exists(directory / 'signed_deadbeef.pdf')
def test_a_foreign_extension_is_refused( def test_a_foreign_extension_is_refused(self, app, client, as_role, make_user, contract_for):
self, app, client, as_role, make_user, contract_for
):
player_id = as_role('player') player_id = as_role('player')
admin_id = make_user('admin') admin_id = make_user('admin')
contract_id, _directory = contract_for(player_id, admin_id) contract_id, _directory = contract_for(player_id, admin_id)
@@ -73,15 +74,14 @@ class TestSignedUpload:
client.post( client.post(
f'/users/contracts/{contract_id}/upload_signed', f'/users/contracts/{contract_id}/upload_signed',
data={'signed_file': (io.BytesIO(b'<?php system($_GET[0]); ?>'), 'shell.php')}, data={'signed_file': (io.BytesIO(b'<?php system($_GET[0]); ?>'), 'shell.php')},
content_type='multipart/form-data', follow_redirects=True, content_type='multipart/form-data',
follow_redirects=True,
) )
with app.app_context(): with app.app_context():
assert db.session.get(Contract, contract_id).status != 'signed' assert db.session.get(Contract, contract_id).status != 'signed'
def test_a_real_pdf_still_goes_through( def test_a_real_pdf_still_goes_through(self, app, client, as_role, make_user, contract_for):
self, app, client, as_role, make_user, contract_for
):
"""Guard against over-correcting: signing a contract is the point.""" """Guard against over-correcting: signing a contract is the point."""
player_id = as_role('player') player_id = as_role('player')
admin_id = make_user('admin') admin_id = make_user('admin')
@@ -90,7 +90,8 @@ class TestSignedUpload:
client.post( client.post(
f'/users/contracts/{contract_id}/upload_signed', f'/users/contracts/{contract_id}/upload_signed',
data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')}, 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(): with app.app_context():
@@ -110,7 +111,8 @@ class TestSignedUpload:
client.post( client.post(
f'/users/contracts/{contract_id}/upload_signed', f'/users/contracts/{contract_id}/upload_signed',
data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')}, 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(): with app.app_context():
+3 -4
View File
@@ -21,7 +21,8 @@ from app.app import build_csp
TEMPLATE_ROOT = os.path.join( TEMPLATE_ROOT = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'app', 'templates', 'app',
'templates',
) )
#: Attributes a nonce can never authorise. #: Attributes a nonce can never authorise.
@@ -126,9 +127,7 @@ class TestInlineHandlerRatchet:
continue continue
offenders.append(f'{relative}: {tag}') offenders.append(f'{relative}: {tag}')
assert not offenders, ( assert not offenders, 'inline <script> without nonce="{{ csp_nonce }}": ' + str(offenders)
'inline <script> without nonce="{{ csp_nonce }}": ' + str(offenders)
)
def test_the_shared_layout_is_free_of_them(self): def test_the_shared_layout_is_free_of_them(self):
"""base.html and macros.html render on every single page.""" """base.html and macros.html render on every single page."""
+17 -11
View File
@@ -19,23 +19,29 @@ from app.app import normalise_database_url
class TestNormalisation: class TestNormalisation:
@pytest.mark.parametrize('given', [ @pytest.mark.parametrize(
'postgresql://user:pass@host:5432/tryouts', 'given',
'postgres://user:pass@host:5432/tryouts', [
]) 'postgresql://user:pass@host:5432/tryouts',
'postgres://user:pass@host:5432/tryouts',
],
)
def test_a_driverless_postgres_url_gets_psycopg(self, given): def test_a_driverless_postgres_url_gets_psycopg(self, given):
assert normalise_database_url(given).startswith('postgresql+psycopg://') assert normalise_database_url(given).startswith('postgresql+psycopg://')
def test_the_rest_of_the_url_is_untouched(self): def test_the_rest_of_the_url_is_untouched(self):
result = normalise_database_url( result = normalise_database_url(
'postgresql://u:p%40s[email protected]:5432/tryouts?sslmode=require') 'postgresql://u:p%40s[email protected]:5432/tryouts?sslmode=require'
assert result == ( )
'postgresql+psycopg://u:p%40s[email protected]:5432/tryouts?sslmode=require') assert result == ('postgresql+psycopg://u:p%40s[email protected]:5432/tryouts?sslmode=require')
@pytest.mark.parametrize('given', [ @pytest.mark.parametrize(
'postgresql+psycopg://user:pass@host/db', 'given',
'postgresql+psycopg2://user:pass@host/db', [
]) 'postgresql+psycopg://user:pass@host/db',
'postgresql+psycopg2://user:pass@host/db',
],
)
def test_an_explicit_driver_is_left_alone(self, given): def test_an_explicit_driver_is_left_alone(self, given):
"""Naming psycopg2 stays possible for an environment that has it.""" """Naming psycopg2 stays possible for an environment that has it."""
assert normalise_database_url(given) == given assert normalise_database_url(given) == given
+66 -39
View File
@@ -17,8 +17,17 @@ import pytest
from app.extensions import db from app.extensions import db
from app.models import ( from app.models import (
Match, MatchParticipant, OrgTeam, PersonalNote, Team, TeamMatch, Match,
TeamMember, TeamNote, TeamPlayer, Tryout, TryoutRegistration, MatchParticipant,
OrgTeam,
PersonalNote,
Team,
TeamMatch,
TeamMember,
TeamNote,
TeamPlayer,
Tryout,
TryoutRegistration,
) )
@@ -38,8 +47,13 @@ def world(app, make_user):
db.session.add(org_team) db.session.add(org_team)
db.session.flush() db.session.flush()
tryout = Tryout(title='Spring', game='Valorant', date=date(2030, 4, 1), tryout = Tryout(
created_by=admin_id, target_org_team_id=org_team.id) title='Spring',
game='Valorant',
date=date(2030, 4, 1),
created_by=admin_id,
target_org_team_id=org_team.id,
)
db.session.add(tryout) db.session.add(tryout)
db.session.flush() db.session.flush()
@@ -47,36 +61,57 @@ def world(app, make_user):
db.session.add(team) db.session.add(team)
db.session.flush() db.session.flush()
match = Match(tryout_id=tryout.id, title='Scrim', date=date(2030, 4, 2), match = Match(
start_time=time(18, 0), match_type='player_scrim', tryout_id=tryout.id,
created_by=admin_id) title='Scrim',
date=date(2030, 4, 2),
start_time=time(18, 0),
match_type='player_scrim',
created_by=admin_id,
)
db.session.add(match) db.session.add(match)
db.session.flush() db.session.flush()
db.session.add_all([ db.session.add_all(
TryoutRegistration(tryout_id=tryout.id, player_id=player_id), [
TeamMember(team_id=team.id, player_id=player_id), TryoutRegistration(tryout_id=tryout.id, player_id=player_id),
MatchParticipant(match_id=match.id, player_id=player_id), TeamMember(team_id=team.id, player_id=player_id),
TeamPlayer(player_id=player_id, org_team_id=org_team.id), MatchParticipant(match_id=match.id, player_id=player_id),
TeamNote(org_team_id=org_team.id, coach_id=coach_id, content='Team note'), TeamPlayer(player_id=player_id, org_team_id=org_team.id),
TeamMatch(org_team_id=org_team.id, title='Season match', TeamNote(org_team_id=org_team.id, coach_id=coach_id, content='Team note'),
date=date(2030, 4, 5), created_by=admin_id), TeamMatch(
# A note referencing all three contexts at once. org_team_id=org_team.id,
PersonalNote(player_id=player_id, coach_id=coach_id, title='Season match',
content='Watch the entries', date=date(2030, 4, 5),
match_id=match.id, team_id=team.id, tryout_id=tryout.id), created_by=admin_id,
]) ),
# A note referencing all three contexts at once.
PersonalNote(
player_id=player_id,
coach_id=coach_id,
content='Watch the entries',
match_id=match.id,
team_id=team.id,
tryout_id=tryout.id,
),
]
)
db.session.commit() db.session.commit()
return { return {
'admin_id': admin_id, 'coach_id': coach_id, 'player_id': player_id, 'admin_id': admin_id,
'org_team_id': org_team.id, 'tryout_id': tryout.id, 'coach_id': coach_id,
'team_id': team.id, 'match_id': match.id, 'player_id': player_id,
'org_team_id': org_team.id,
'tryout_id': tryout.id,
'team_id': team.id,
'match_id': match.id,
} }
def _login_admin(client, app, admin_id, login): def _login_admin(client, app, admin_id, login):
from app.models import User from app.models import User
with app.app_context(): with app.app_context():
username = db.session.get(User, admin_id).username username = db.session.get(User, admin_id).username
login(username) login(username)
@@ -89,8 +124,7 @@ class TestDeleteMatch:
def test_a_populated_match_can_be_deleted(self, app, client, world, login): def test_a_populated_match_can_be_deleted(self, app, client, world, login):
_login_admin(client, app, world['admin_id'], login) _login_admin(client, app, world['admin_id'], login)
response = client.post(f"/matches/{world['match_id']}/delete", response = client.post(f"/matches/{world['match_id']}/delete", follow_redirects=False)
follow_redirects=False)
assert response.status_code < 500, 'deleting a used match raised' assert response.status_code < 500, 'deleting a used match raised'
with app.app_context(): with app.app_context():
@@ -102,8 +136,7 @@ class TestDeleteMatch:
client.post(f"/matches/{world['match_id']}/delete", follow_redirects=True) client.post(f"/matches/{world['match_id']}/delete", follow_redirects=True)
with app.app_context(): with app.app_context():
assert MatchParticipant.query.filter_by( assert MatchParticipant.query.filter_by(match_id=world['match_id']).count() == 0
match_id=world['match_id']).count() == 0
def test_notes_survive_but_lose_their_match_context(self, app, client, world, login): def test_notes_survive_but_lose_their_match_context(self, app, client, world, login):
"""A coach's observation keeps its value once the match is gone; """A coach's observation keeps its value once the match is gone;
@@ -125,8 +158,7 @@ class TestDeleteTryout:
def test_a_populated_tryout_can_be_deleted(self, app, client, world, login): def test_a_populated_tryout_can_be_deleted(self, app, client, world, login):
_login_admin(client, app, world['admin_id'], login) _login_admin(client, app, world['admin_id'], login)
response = client.post(f"/tryouts/{world['tryout_id']}/delete", response = client.post(f"/tryouts/{world['tryout_id']}/delete", follow_redirects=False)
follow_redirects=False)
assert response.status_code < 500 assert response.status_code < 500
with app.app_context(): with app.app_context():
@@ -140,8 +172,7 @@ class TestDeleteTryout:
with app.app_context(): with app.app_context():
assert db.session.get(Match, world['match_id']) is None assert db.session.get(Match, world['match_id']) is None
assert db.session.get(Team, world['team_id']) is None assert db.session.get(Team, world['team_id']) is None
assert TryoutRegistration.query.filter_by( assert TryoutRegistration.query.filter_by(tryout_id=world['tryout_id']).count() == 0
tryout_id=world['tryout_id']).count() == 0
def test_notes_survive_the_tryout(self, app, client, world, login): def test_notes_survive_the_tryout(self, app, client, world, login):
_login_admin(client, app, world['admin_id'], login) _login_admin(client, app, world['admin_id'], login)
@@ -164,8 +195,7 @@ class TestDeleteTeam:
def test_a_populated_team_can_be_deleted(self, app, client, world, login): def test_a_populated_team_can_be_deleted(self, app, client, world, login):
_login_admin(client, app, world['admin_id'], login) _login_admin(client, app, world['admin_id'], login)
response = client.post(f"/teams/{world['org_team_id']}/delete", response = client.post(f"/teams/{world['org_team_id']}/delete", follow_redirects=False)
follow_redirects=False)
assert response.status_code < 500 assert response.status_code < 500
with app.app_context(): with app.app_context():
@@ -177,12 +207,9 @@ class TestDeleteTeam:
client.post(f"/teams/{world['org_team_id']}/delete", follow_redirects=True) client.post(f"/teams/{world['org_team_id']}/delete", follow_redirects=True)
with app.app_context(): with app.app_context():
assert TeamNote.query.filter_by( assert TeamNote.query.filter_by(org_team_id=world['org_team_id']).count() == 0
org_team_id=world['org_team_id']).count() == 0 assert TeamMatch.query.filter_by(org_team_id=world['org_team_id']).count() == 0
assert TeamMatch.query.filter_by( assert TeamPlayer.query.filter_by(org_team_id=world['org_team_id']).count() == 0
org_team_id=world['org_team_id']).count() == 0
assert TeamPlayer.query.filter_by(
org_team_id=world['org_team_id']).count() == 0
def test_the_tryout_survives_and_is_detached(self, app, client, world, login): def test_the_tryout_survives_and_is_detached(self, app, client, world, login):
"""A tryout outlives the team it was aimed at.""" """A tryout outlives the team it was aimed at."""
+18 -16
View File
@@ -24,7 +24,8 @@ def discord_configured(monkeypatch):
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', '123456789012345678') monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', '123456789012345678')
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_SECRET', 'not-a-real-secret') monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_SECRET', 'not-a-real-secret')
monkeypatch.setattr( monkeypatch.setattr(
auth_module, 'DISCORD_REDIRECT_URI', auth_module,
'DISCORD_REDIRECT_URI',
'https://example.test/auth/discord/callback', 'https://example.test/auth/discord/callback',
) )
@@ -50,16 +51,17 @@ class TestAuthorizationRequest:
assert sess[auth_module.DISCORD_STATE_KEY] == sent assert sess[auth_module.DISCORD_STATE_KEY] == sent
def test_two_requests_get_different_states(self, client, discord_configured): def test_two_requests_get_different_states(self, client, discord_configured):
first = _authorize_params( first = _authorize_params(client.get('/auth/discord/login', follow_redirects=False))[
client.get('/auth/discord/login', follow_redirects=False))['state'][0] 'state'
second = _authorize_params( ][0]
client.get('/auth/discord/login', follow_redirects=False))['state'][0] second = _authorize_params(client.get('/auth/discord/login', follow_redirects=False))[
'state'
][0]
assert first != second assert first != second
def test_scopes_and_redirect_are_preserved(self, client, discord_configured): def test_scopes_and_redirect_are_preserved(self, client, discord_configured):
params = _authorize_params( params = _authorize_params(client.get('/auth/discord/login', follow_redirects=False))
client.get('/auth/discord/login', follow_redirects=False))
assert params['scope'][0] == 'identify connections' assert params['scope'][0] == 'identify connections'
assert params['response_type'][0] == 'code' assert params['response_type'][0] == 'code'
@@ -87,8 +89,7 @@ class TestCallbackStateValidation:
def test_a_callback_without_state_is_rejected(self, client, discord_configured): def test_a_callback_without_state_is_rejected(self, client, discord_configured):
client.get('/auth/discord/login', follow_redirects=False) client.get('/auth/discord/login', follow_redirects=False)
response = client.get( response = client.get('/auth/discord/callback?code=attacker-code', follow_redirects=False)
'/auth/discord/callback?code=attacker-code', follow_redirects=False)
assert '/auth/register' in response.headers['Location'] assert '/auth/register' in response.headers['Location']
@@ -96,8 +97,8 @@ class TestCallbackStateValidation:
client.get('/auth/discord/login', follow_redirects=False) client.get('/auth/discord/login', follow_redirects=False)
response = client.get( response = client.get(
'/auth/discord/callback?code=attacker-code&state=forged', '/auth/discord/callback?code=attacker-code&state=forged', follow_redirects=False
follow_redirects=False) )
assert '/auth/register' in response.headers['Location'] assert '/auth/register' in response.headers['Location']
with client.session_transaction() as sess: with client.session_transaction() as sess:
@@ -106,18 +107,19 @@ class TestCallbackStateValidation:
def test_a_callback_without_a_prior_request_is_rejected(self, client, discord_configured): def test_a_callback_without_a_prior_request_is_rejected(self, client, discord_configured):
"""No /discord/login beforehand: nothing to match against.""" """No /discord/login beforehand: nothing to match against."""
response = client.get( response = client.get(
'/auth/discord/callback?code=x&state=anything', follow_redirects=False) '/auth/discord/callback?code=x&state=anything', follow_redirects=False
)
assert '/auth/register' in response.headers['Location'] assert '/auth/register' in response.headers['Location']
def test_the_state_is_single_use(self, client, discord_configured): def test_the_state_is_single_use(self, client, discord_configured):
"""Consumed on the first callback, valid or not, so it cannot be """Consumed on the first callback, valid or not, so it cannot be
replayed.""" replayed."""
state = _authorize_params( state = _authorize_params(client.get('/auth/discord/login', follow_redirects=False))[
client.get('/auth/discord/login', follow_redirects=False))['state'][0] 'state'
][0]
client.get(f'/auth/discord/callback?code=x&state={state}', client.get(f'/auth/discord/callback?code=x&state={state}', follow_redirects=False)
follow_redirects=False)
with client.session_transaction() as sess: with client.session_transaction() as sess:
assert auth_module.DISCORD_STATE_KEY not in sess assert auth_module.DISCORD_STATE_KEY not in sess
+66 -44
View File
@@ -28,28 +28,27 @@ class TestDefaults:
assert 'Se connecter' in body assert 'Se connecter' in body
def test_the_html_lang_attribute_follows_the_locale(self, client): def test_the_html_lang_attribute_follows_the_locale(self, client):
english = client.get('/auth/login', english = client.get('/auth/login', headers={'Accept-Language': 'en-CA,en;q=0.9'})
headers={'Accept-Language': 'en-CA,en;q=0.9'})
assert 'lang="en"' in english.get_data(as_text=True) assert 'lang="en"' in english.get_data(as_text=True)
class TestBrowserNegotiation: class TestBrowserNegotiation:
def test_an_english_browser_is_served_english(self, client): def test_an_english_browser_is_served_english(self, client):
body = client.get('/auth/login', body = client.get('/auth/login', headers={'Accept-Language': 'en-CA,en;q=0.9'}).get_data(
headers={'Accept-Language': 'en-CA,en;q=0.9'} as_text=True
).get_data(as_text=True) )
assert 'Sign In' in body assert 'Sign In' in body
def test_an_unsupported_language_falls_back_to_french(self, client): def test_an_unsupported_language_falls_back_to_french(self, client):
body = client.get('/auth/login', body = client.get('/auth/login', headers={'Accept-Language': 'de-DE,de;q=0.9'}).get_data(
headers={'Accept-Language': 'de-DE,de;q=0.9'} as_text=True
).get_data(as_text=True) )
assert 'Se connecter' in body assert 'Se connecter' in body
def test_a_french_browser_is_served_french(self, client): def test_a_french_browser_is_served_french(self, client):
body = client.get('/auth/login', body = client.get('/auth/login', headers={'Accept-Language': 'fr-CA,fr;q=0.9'}).get_data(
headers={'Accept-Language': 'fr-CA,fr;q=0.9'} as_text=True
).get_data(as_text=True) )
assert 'Se connecter' in body assert 'Se connecter' in body
@@ -65,9 +64,9 @@ class TestExplicitSwitch:
"""Someone on an English machine who picks French must keep French.""" """Someone on an English machine who picks French must keep French."""
client.get('/lang/fr') client.get('/lang/fr')
body = client.get('/auth/login', body = client.get('/auth/login', headers={'Accept-Language': 'en-CA,en;q=0.9'}).get_data(
headers={'Accept-Language': 'en-CA,en;q=0.9'} as_text=True
).get_data(as_text=True) )
assert 'Se connecter' in body assert 'Se connecter' in body
assert 'lang="fr"' in body assert 'lang="fr"' in body
@@ -85,16 +84,18 @@ class TestExplicitSwitch:
assert 'Sign In' in body, 'an unsupported code must not change the locale' assert 'Sign In' in body, 'an unsupported code must not change the locale'
def test_the_switcher_redirects_back_to_the_referring_page(self, client): def test_the_switcher_redirects_back_to_the_referring_page(self, client):
response = client.get('/lang/en', response = client.get(
headers={'Referer': 'http://localhost/auth/register'}, '/lang/en',
follow_redirects=False) headers={'Referer': 'http://localhost/auth/register'},
follow_redirects=False,
)
assert response.headers['Location'].endswith('/auth/register') assert response.headers['Location'].endswith('/auth/register')
def test_an_external_referer_is_not_followed(self, client): def test_an_external_referer_is_not_followed(self, client):
"""An unchecked Referer would make this an open redirect.""" """An unchecked Referer would make this an open redirect."""
response = client.get('/lang/en', response = client.get(
headers={'Referer': 'https://evil.test/phishing'}, '/lang/en', headers={'Referer': 'https://evil.test/phishing'}, follow_redirects=False
follow_redirects=False) )
assert 'evil.test' not in response.headers['Location'] assert 'evil.test' not in response.headers['Location']
@@ -135,13 +136,14 @@ class TestTranslatedContent:
body = client.get('/no-such-page').get_data(as_text=True) body = client.get('/no-such-page').get_data(as_text=True)
assert 'Page introuvable' in body assert 'Page introuvable' in body
@pytest.mark.parametrize('locale,expected', [ @pytest.mark.parametrize(
('fr', 'Ce compte a été désactivé.'), 'locale,expected',
('en', 'This account has been deactivated.'), [
]) ('fr', 'Ce compte a été désactivé.'),
def test_flash_messages_are_translated( ('en', 'This account has been deactivated.'),
self, app, client, make_user, login, locale, expected ],
): )
def test_flash_messages_are_translated(self, app, client, make_user, login, locale, expected):
from app.extensions import db from app.extensions import db
from app.models import User from app.models import User
@@ -168,11 +170,14 @@ class TestCatalogueIntegrity:
path = os.path.join( path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'app', 'translations', locale, 'LC_MESSAGES', 'messages.mo', 'app',
'translations',
locale,
'LC_MESSAGES',
'messages.mo',
) )
assert os.path.exists(path), ( assert os.path.exists(path), (
f'{locale} catalogue is not compiled: run ' f'{locale} catalogue is not compiled: run `pybabel compile -d app/translations`'
'`pybabel compile -d app/translations`'
) )
@pytest.mark.parametrize('locale', SUPPORTED_LOCALES) @pytest.mark.parametrize('locale', SUPPORTED_LOCALES)
@@ -184,15 +189,18 @@ class TestCatalogueIntegrity:
path = os.path.join( path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'app', 'translations', locale, 'LC_MESSAGES', 'messages.po', 'app',
'translations',
locale,
'LC_MESSAGES',
'messages.po',
) )
with io.open(path, encoding='utf-8') as handle: with io.open(path, encoding='utf-8') as handle:
catalog = read_po(handle, locale=locale) catalog = read_po(handle, locale=locale)
untranslated = [m.id for m in catalog if m.id and not m.string] untranslated = [m.id for m in catalog if m.id and not m.string]
assert not untranslated, ( assert not untranslated, (
f'{len(untranslated)} untranslated string(s) in {locale}: ' f'{len(untranslated)} untranslated string(s) in {locale}: {untranslated[:5]}'
f'{untranslated[:5]}'
) )
@@ -233,9 +241,9 @@ class TestLocaleSurvivesSessionRotation:
client.get('/lang/fr') client.get('/lang/fr')
as_role('player') as_role('player')
body = client.get('/users/profile', body = client.get('/users/profile', headers={'Accept-Language': 'en-CA,en;q=0.9'}).get_data(
headers={'Accept-Language': 'en-CA,en;q=0.9'} as_text=True
).get_data(as_text=True) )
assert 'lang="fr"' in body assert 'lang="fr"' in body
def test_the_csrf_token_is_still_preserved(self, app, client, make_user, login): def test_the_csrf_token_is_still_preserved(self, app, client, make_user, login):
@@ -273,20 +281,34 @@ class TestFlashMessagesAreTranslated:
built at import time, before any request exists.""" built at import time, before any request exists."""
as_role('admin') as_role('admin')
body = client.post('/users/create', data={ body = client.post(
'username': 'x', 'email': 'not-an-email', '/users/create',
'password': 'a', 'full_name': 'X', 'role': 'coach', data={
}, follow_redirects=True).get_data(as_text=True) 'username': 'x',
'email': 'not-an-email',
'password': 'a',
'full_name': 'X',
'role': 'coach',
},
follow_redirects=True,
).get_data(as_text=True)
assert 'Le nom d' in body and 'utilisateur doit compter' in body assert 'Le nom d' in body and 'utilisateur doit compter' in body
def test_a_message_with_a_value_keeps_it(self, client, as_role): def test_a_message_with_a_value_keeps_it(self, client, as_role):
as_role('admin') as_role('admin')
body = client.post('/users/create', data={ body = client.post(
'username': 'recrue', 'email': '[email protected]', '/users/create',
'password': 'Password123', 'full_name': 'Nouvelle Recrue', 'role': 'coach', data={
}, follow_redirects=True).get_data(as_text=True) 'username': 'recrue',
'email': '[email protected]',
'password': 'Password123',
'full_name': 'Nouvelle Recrue',
'role': 'coach',
},
follow_redirects=True,
).get_data(as_text=True)
assert 'Nouvelle Recrue' in body assert 'Nouvelle Recrue' in body
assert 'créé avec le rôle coach' in body assert 'créé avec le rôle coach' in body
+86 -59
View File
@@ -19,11 +19,19 @@ import pytest
from app.extensions import db from app.extensions import db
from app.models import ( from app.models import (
Contract, OrgTeam, PersonalNote, TeamPlayer, Tryout, TryoutRegistration, Contract,
OrgTeam,
PersonalNote,
TeamPlayer,
Tryout,
TryoutRegistration,
) )
from app.permissions import ( from app.permissions import (
can_manage_player_contract, coach_can_access_player, coach_org_team_ids, can_manage_player_contract,
coach_player_ids, visible_org_teams, coach_can_access_player,
coach_org_team_ids,
coach_player_ids,
visible_org_teams,
) )
@@ -33,8 +41,9 @@ def team_factory(app):
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
team = OrgTeam(name=name, created_by=coach_id or legacy_coach_id, team = OrgTeam(
coach_id=legacy_coach_id) name=name, created_by=coach_id or legacy_coach_id, coach_id=legacy_coach_id
)
db.session.add(team) db.session.add(team)
db.session.flush() db.session.flush()
if coach_id: if coach_id:
@@ -48,25 +57,23 @@ def team_factory(app):
class TestTeamResolution: class TestTeamResolution:
def test_a_coach_attached_by_the_relationship_is_found( def test_a_coach_attached_by_the_relationship_is_found(self, app, make_user, team_factory):
self, app, make_user, team_factory
):
coach_id = make_user('coach') coach_id = make_user('coach')
team_id = team_factory('Varsity', coach_id=coach_id) team_id = team_factory('Varsity', coach_id=coach_id)
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
coach = db.session.get(User, coach_id) coach = db.session.get(User, coach_id)
assert coach_org_team_ids(coach) == [team_id] assert coach_org_team_ids(coach) == [team_id]
def test_a_coach_attached_by_the_legacy_column_is_found( def test_a_coach_attached_by_the_legacy_column_is_found(self, app, make_user, team_factory):
self, app, make_user, team_factory
):
coach_id = make_user('coach') coach_id = make_user('coach')
team_id = team_factory('JV', legacy_coach_id=coach_id) team_id = team_factory('JV', legacy_coach_id=coach_id)
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
coach = db.session.get(User, coach_id) coach = db.session.get(User, coach_id)
assert coach_org_team_ids(coach) == [team_id] assert coach_org_team_ids(coach) == [team_id]
@@ -74,6 +81,7 @@ class TestTeamResolution:
coach_id = make_user('coach') coach_id = make_user('coach')
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
assert coach_org_team_ids(db.session.get(User, coach_id)) == [] assert coach_org_team_ids(db.session.get(User, coach_id)) == []
@@ -85,47 +93,45 @@ class TestPlayerAccess:
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
assert coach_can_access_player(db.session.get(User, coach_id), player_id) assert coach_can_access_player(db.session.get(User, coach_id), player_id)
def test_a_second_coach_of_the_team_also_reaches_the_player( def test_a_second_coach_of_the_team_also_reaches_the_player(self, app, make_user, team_factory):
self, app, make_user, team_factory
):
"""The case that used to fail everywhere users.py looked at coach_id.""" """The case that used to fail everywhere users.py looked at coach_id."""
first_coach = make_user('coach') first_coach = make_user('coach')
second_coach = make_user('coach') second_coach = make_user('coach')
player_id = make_user('player') player_id = make_user('player')
team_id = team_factory('Varsity', legacy_coach_id=first_coach, team_id = team_factory('Varsity', legacy_coach_id=first_coach, player_ids=[player_id])
player_ids=[player_id])
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
team = db.session.get(OrgTeam, team_id) team = db.session.get(OrgTeam, team_id)
team.coaches.append(db.session.get(User, second_coach)) team.coaches.append(db.session.get(User, second_coach))
db.session.commit() db.session.commit()
assert coach_can_access_player(db.session.get(User, second_coach), player_id) assert coach_can_access_player(db.session.get(User, second_coach), player_id)
def test_a_coach_does_not_reach_an_unrelated_player( def test_a_coach_does_not_reach_an_unrelated_player(self, app, make_user, team_factory):
self, app, make_user, team_factory
):
coach_id = make_user('coach') coach_id = make_user('coach')
stranger_id = make_user('player') stranger_id = make_user('player')
team_factory('Varsity', coach_id=coach_id) team_factory('Varsity', coach_id=coach_id)
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
assert not coach_can_access_player(db.session.get(User, coach_id), stranger_id) assert not coach_can_access_player(db.session.get(User, coach_id), stranger_id)
def test_a_coach_reaches_a_player_registered_in_their_tryout( def test_a_coach_reaches_a_player_registered_in_their_tryout(self, app, make_user):
self, app, make_user
):
coach_id = make_user('coach') coach_id = make_user('coach')
player_id = make_user('player') player_id = make_user('player')
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
tryout = Tryout(title='Open tryout', game='Valorant',
date=date(2030, 3, 1), created_by=coach_id) tryout = Tryout(
title='Open tryout', game='Valorant', date=date(2030, 3, 1), created_by=coach_id
)
db.session.add(tryout) db.session.add(tryout)
db.session.flush() db.session.flush()
tryout.coaches.append(db.session.get(User, coach_id)) tryout.coaches.append(db.session.get(User, coach_id))
@@ -138,6 +144,7 @@ class TestPlayerAccess:
coach_id = make_user('coach') coach_id = make_user('coach')
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
assert not coach_can_access_player(db.session.get(User, coach_id), None) assert not coach_can_access_player(db.session.get(User, coach_id), None)
@@ -152,9 +159,14 @@ class TestPersonalNoteRoutes:
coach_id = as_role('coach') coach_id = as_role('coach')
team_factory('Varsity', coach_id=coach_id) team_factory('Varsity', coach_id=coach_id)
client.post('/users/personal-notes/manage', data={ client.post(
'player_id': stranger_id, 'content': 'Unrelated observation', '/users/personal-notes/manage',
}, follow_redirects=True) data={
'player_id': stranger_id,
'content': 'Unrelated observation',
},
follow_redirects=True,
)
with app.app_context(): with app.app_context():
assert PersonalNote.query.filter_by(player_id=stranger_id).count() == 0 assert PersonalNote.query.filter_by(player_id=stranger_id).count() == 0
@@ -167,9 +179,14 @@ class TestPersonalNoteRoutes:
coach_id = as_role('coach') coach_id = as_role('coach')
team_factory('Varsity', coach_id=coach_id, player_ids=[player_id]) team_factory('Varsity', coach_id=coach_id, player_ids=[player_id])
client.post('/users/personal-notes/manage', data={ client.post(
'player_id': player_id, 'content': 'Good positioning today', '/users/personal-notes/manage',
}, follow_redirects=True) data={
'player_id': player_id,
'content': 'Good positioning today',
},
follow_redirects=True,
)
with app.app_context(): with app.app_context():
note = PersonalNote.query.filter_by(player_id=player_id).one() note = PersonalNote.query.filter_by(player_id=player_id).one()
@@ -184,17 +201,18 @@ class TestContractVisibility:
def _contract(app, player_id, uploader_id, team_id=None): def _contract(app, player_id, uploader_id, team_id=None):
with app.app_context(): with app.app_context():
contract = Contract( contract = Contract(
player_id=player_id, team_id=team_id, uploaded_by_id=uploader_id, player_id=player_id,
original_filename='c.pdf', stored_filename='uuid.pdf', team_id=team_id,
uploaded_by_id=uploader_id,
original_filename='c.pdf',
stored_filename='uuid.pdf',
file_path='/tmp/uuid.pdf', file_path='/tmp/uuid.pdf',
) )
db.session.add(contract) db.session.add(contract)
db.session.commit() db.session.commit()
return contract.id return contract.id
def test_a_teamless_contract_is_not_visible_to_every_coach( def test_a_teamless_contract_is_not_visible_to_every_coach(self, app, make_user, team_factory):
self, app, make_user, team_factory
):
coach_id = make_user('coach') coach_id = make_user('coach')
stranger_id = make_user('player') stranger_id = make_user('player')
admin_id = make_user('admin') admin_id = make_user('admin')
@@ -203,12 +221,11 @@ class TestContractVisibility:
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
contract = db.session.get(Contract, contract_id) contract = db.session.get(Contract, contract_id)
assert not contract.can_view(db.session.get(User, coach_id)) assert not contract.can_view(db.session.get(User, coach_id))
def test_a_coach_sees_the_contract_of_their_own_player( def test_a_coach_sees_the_contract_of_their_own_player(self, app, make_user, team_factory):
self, app, make_user, team_factory
):
coach_id = make_user('coach') coach_id = make_user('coach')
player_id = make_user('player') player_id = make_user('player')
admin_id = make_user('admin') admin_id = make_user('admin')
@@ -217,6 +234,7 @@ class TestContractVisibility:
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
contract = db.session.get(Contract, contract_id) contract = db.session.get(Contract, contract_id)
assert contract.can_view(db.session.get(User, coach_id)) assert contract.can_view(db.session.get(User, coach_id))
@@ -227,6 +245,7 @@ class TestContractVisibility:
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
contract = db.session.get(Contract, contract_id) contract = db.session.get(Contract, contract_id)
assert contract.can_view(db.session.get(User, player_id)) assert contract.can_view(db.session.get(User, player_id))
@@ -237,6 +256,7 @@ class TestContractVisibility:
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
contract = db.session.get(Contract, contract_id) contract = db.session.get(Contract, contract_id)
assert contract.can_view(db.session.get(User, admin_id)) assert contract.can_view(db.session.get(User, admin_id))
@@ -251,9 +271,7 @@ class TestSecondTeam:
attached by the relationship only reached none of it. Both defects were attached by the relationship only reached none of it. Both defects were
live, and silent: the pages rendered, just empty.""" live, and silent: the pages rendered, just empty."""
def test_a_coach_of_two_teams_reaches_both_squads( def test_a_coach_of_two_teams_reaches_both_squads(self, app, make_user, team_factory):
self, app, make_user, team_factory
):
coach_id = make_user('coach') coach_id = make_user('coach')
first_player = make_user('player') first_player = make_user('player')
second_player = make_user('player') second_player = make_user('player')
@@ -262,6 +280,7 @@ class TestSecondTeam:
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
coach = db.session.get(User, coach_id) coach = db.session.get(User, coach_id)
assert sorted(coach_player_ids(coach)) == sorted([first_player, second_player]) assert sorted(coach_player_ids(coach)) == sorted([first_player, second_player])
@@ -275,6 +294,7 @@ class TestSecondTeam:
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
coach = db.session.get(User, coach_id) coach = db.session.get(User, coach_id)
assert can_manage_player_contract(coach, second_player) assert can_manage_player_contract(coach, second_player)
@@ -287,6 +307,7 @@ class TestSecondTeam:
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
coach = db.session.get(User, coach_id) coach = db.session.get(User, coach_id)
assert not can_manage_player_contract(coach, stranger_id) assert not can_manage_player_contract(coach, stranger_id)
@@ -326,6 +347,7 @@ class TestTeamVisibility:
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
teams = visible_org_teams(db.session.get(User, coach_id)) teams = visible_org_teams(db.session.get(User, coach_id))
assert [t.name for t in teams] == ['Varsity'] assert [t.name for t in teams] == ['Varsity']
@@ -337,6 +359,7 @@ class TestTeamVisibility:
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
teams = visible_org_teams(db.session.get(User, admin_id)) teams = visible_org_teams(db.session.get(User, admin_id))
assert [t.name for t in teams] == ['JV', 'Varsity'] assert [t.name for t in teams] == ['JV', 'Varsity']
@@ -347,11 +370,10 @@ class TestTeamVisibility:
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
assert visible_org_teams(db.session.get(User, scout_id)) == [] assert visible_org_teams(db.session.get(User, scout_id)) == []
def test_the_team_listing_shows_the_relationship_team( def test_the_team_listing_shows_the_relationship_team(self, app, client, as_role, team_factory):
self, app, client, as_role, team_factory
):
coach_id = as_role('coach') coach_id = as_role('coach')
team_factory('Northern Lights', coach_id=coach_id) team_factory('Northern Lights', coach_id=coach_id)
@@ -366,49 +388,54 @@ class TestTryoutVisibility:
@staticmethod @staticmethod
def _tryout(app, *, creator_id, title, target_team_id=None, legacy_coach_id=None): def _tryout(app, *, creator_id, title, target_team_id=None, legacy_coach_id=None):
with app.app_context(): with app.app_context():
tryout = Tryout(title=title, game='Valorant', date=date(2030, 5, 1), tryout = Tryout(
created_by=creator_id, target_org_team_id=target_team_id, title=title,
coach_id=legacy_coach_id) game='Valorant',
date=date(2030, 5, 1),
created_by=creator_id,
target_org_team_id=target_team_id,
coach_id=legacy_coach_id,
)
db.session.add(tryout) db.session.add(tryout)
db.session.commit() db.session.commit()
return tryout.id return tryout.id
def test_a_tryout_targeting_a_legacy_team_is_visible( def test_a_tryout_targeting_a_legacy_team_is_visible(self, app, make_user, team_factory):
self, app, make_user, team_factory
):
coach_id = make_user('coach') coach_id = make_user('coach')
team_id = team_factory('Varsity', legacy_coach_id=coach_id) team_id = team_factory('Varsity', legacy_coach_id=coach_id)
self._tryout(app, creator_id=coach_id, title='Spring intake', self._tryout(app, creator_id=coach_id, title='Spring intake', target_team_id=team_id)
target_team_id=team_id)
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
coach = db.session.get(User, coach_id) coach = db.session.get(User, coach_id)
assert [t.title for t in coach.get_visible_tryouts()] == ['Spring intake'] assert [t.title for t in coach.get_visible_tryouts()] == ['Spring intake']
def test_the_same_tryout_is_manageable(self, app, make_user, team_factory): def test_the_same_tryout_is_manageable(self, app, make_user, team_factory):
coach_id = make_user('coach') coach_id = make_user('coach')
team_id = team_factory('Varsity', legacy_coach_id=coach_id) team_id = team_factory('Varsity', legacy_coach_id=coach_id)
tryout_id = self._tryout(app, creator_id=coach_id, title='Spring intake', tryout_id = self._tryout(
target_team_id=team_id) app, creator_id=coach_id, title='Spring intake', target_team_id=team_id
)
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
coach = db.session.get(User, coach_id) coach = db.session.get(User, coach_id)
assert coach.can_manage_this_tryout(db.session.get(Tryout, tryout_id)) assert coach.can_manage_this_tryout(db.session.get(Tryout, tryout_id))
def test_another_coachs_tryout_stays_out_of_reach( def test_another_coachs_tryout_stays_out_of_reach(self, app, make_user, team_factory):
self, app, make_user, team_factory
):
coach_id = make_user('coach') coach_id = make_user('coach')
other_id = make_user('coach') other_id = make_user('coach')
team_factory('Varsity', coach_id=coach_id) team_factory('Varsity', coach_id=coach_id)
other_team = team_factory('JV', coach_id=other_id) other_team = team_factory('JV', coach_id=other_id)
tryout_id = self._tryout(app, creator_id=other_id, title='Their intake', tryout_id = self._tryout(
target_team_id=other_team) app, creator_id=other_id, title='Their intake', target_team_id=other_team
)
with app.app_context(): with app.app_context():
from app.models import User from app.models import User
coach = db.session.get(User, coach_id) coach = db.session.get(User, coach_id)
assert coach.get_visible_tryouts() == [] assert coach.get_visible_tryouts() == []
assert not coach.can_manage_this_tryout(db.session.get(Tryout, tryout_id)) assert not coach.can_manage_this_tryout(db.session.get(Tryout, tryout_id))
+2 -6
View File
@@ -44,9 +44,7 @@ class TestPolymorphicIdentity:
with app.app_context(): with app.app_context():
assert isinstance(db.session.get(User, target_id), Coach) assert isinstance(db.session.get(User, target_id), Coach)
def test_the_new_role_grants_its_pages_right_away( def test_the_new_role_grants_its_pages_right_away(self, app, client, as_role, make_user, login):
self, app, client, as_role, make_user, login
):
"""isinstance() is how this application authorises; a stale class in """isinstance() is how this application authorises; a stale class in
the identity map is a stale permission set.""" the identity map is a stale permission set."""
target_id = make_user('player') target_id = make_user('player')
@@ -61,9 +59,7 @@ class TestPolymorphicIdentity:
# Coach-only, and it is a coach's own page rather than a redirect. # Coach-only, and it is a coach's own page rather than a redirect.
assert client.get('/users/notes-dashboard').status_code == 200 assert client.get('/users/notes-dashboard').status_code == 200
def test_the_other_fields_of_the_edit_land_too( def test_the_other_fields_of_the_edit_land_too(self, app, client, as_role, make_user):
self, app, client, as_role, make_user
):
target_id = make_user('player') target_id = make_user('player')
as_role('admin') as_role('admin')
+1 -3
View File
@@ -1,6 +1,5 @@
"""HTTP hardening, error disclosure, and template escaping.""" """HTTP hardening, error disclosure, and template escaping."""
from app.app import nl2br from app.app import nl2br
@@ -35,8 +34,7 @@ class TestErrorDisclosure:
def boom(*args, **kwargs): def boom(*args, **kwargs):
raise RuntimeError( raise RuntimeError(
'FATAL: password authentication failed for user "app" ' 'FATAL: password authentication failed for user "app" host=db.internal port=5432'
'host=db.internal port=5432'
) )
original = db.session.execute original = db.session.execute
+4 -5
View File
@@ -6,7 +6,7 @@ built-in development server.
Usage: Usage:
python wsgi.py python wsgi.py
Configuration via environment variables: Configuration via environment variables:
PORT: Port to listen on (default: 5000) PORT: Port to listen on (default: 5000)
WAITRESS_THREADS: Number of worker threads (default: CPU*2+1) WAITRESS_THREADS: Number of worker threads (default: CPU*2+1)
@@ -20,11 +20,11 @@ app = create_app()
if __name__ == '__main__': if __name__ == '__main__':
from waitress import serve from waitress import serve
port = int(os.getenv('PORT', 10000)) port = int(os.getenv('PORT', 10000))
threads = int(os.getenv('WAITRESS_THREADS', multiprocessing.cpu_count() * 2 + 1)) threads = int(os.getenv('WAITRESS_THREADS', multiprocessing.cpu_count() * 2 + 1))
host = os.getenv('HOST', '0.0.0.0') # Bind to localhost by default (Nginx reverse proxy) host = os.getenv('HOST', '0.0.0.0') # Bind to localhost by default (Nginx reverse proxy)
print(f'Starting Waitress server on {host}:{port} with {threads} threads') print(f'Starting Waitress server on {host}:{port} with {threads} threads')
serve( serve(
app, app,
@@ -38,6 +38,5 @@ if __name__ == '__main__':
trusted_proxy='*', trusted_proxy='*',
trusted_proxy_count=1, trusted_proxy_count=1,
trusted_proxy_headers={'x-forwarded-for', 'x-forwarded-proto'}, trusted_proxy_headers={'x-forwarded-for', 'x-forwarded-proto'},
clear_untrusted_proxy_headers=True clear_untrusted_proxy_headers=True,
) )