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)
+186 -86
View File
@@ -28,11 +28,13 @@ 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):
@@ -71,7 +73,9 @@ 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:
@@ -111,7 +115,7 @@ 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)')
@@ -185,32 +189,57 @@ class TeamTryoutsBot(commands.Bot):
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, async def _send_one_on_one_dm(
team_name: str, date_str: str, start_time: str, end_time: str, self,
points: str, request_id: int) -> int: 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)
@@ -250,9 +279,15 @@ class TeamTryoutsBot(commands.Bot):
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:
@@ -266,6 +301,7 @@ 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")
@@ -299,10 +335,16 @@ class TeamTryoutsBot(commands.Bot):
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:
@@ -346,7 +388,7 @@ 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()
@@ -378,7 +420,11 @@ class TeamTryoutsBot(commands.Bot):
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:
@@ -390,7 +436,9 @@ class TeamTryoutsBot(commands.Bot):
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:
@@ -404,7 +452,7 @@ 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()
@@ -470,9 +518,15 @@ class TeamTryoutsBot(commands.Bot):
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
@@ -521,7 +575,9 @@ class TeamTryoutsBot(commands.Bot):
) )
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}")
@@ -540,7 +596,13 @@ 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)
@@ -563,13 +625,13 @@ class TeamTryoutsBot(commands.Bot):
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)
@@ -609,10 +671,18 @@ class TeamTryoutsBot(commands.Bot):
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.
@@ -652,7 +722,9 @@ class TeamTryoutsBot(commands.Bot):
) )
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:
@@ -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}")
@@ -775,6 +874,7 @@ def start_bot(flask_app=None):
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)
+1 -4
View File
@@ -18,10 +18,7 @@ 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):
+16 -7
View File
@@ -24,8 +24,18 @@ class SensitiveDataFilter(logging.Filter):
# 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]'),
] ]
@@ -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)
+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
),
) )
+1 -5
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}',
+2
View File
@@ -1,10 +1,12 @@
"""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)
@@ -1,10 +1,12 @@
"""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)
@@ -1,10 +1,12 @@
"""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)
+2
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)
+2
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)
+1
View File
@@ -1,4 +1,5 @@
"""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
+2
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)
+4 -2
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()]
+4 -2
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,8 +15,8 @@ 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()
+2
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)
+1
View File
@@ -1,4 +1,5 @@
"""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
+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
]
+2
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)
+1
View File
@@ -1,4 +1,5 @@
"""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
+2
View File
@@ -1,10 +1,12 @@
"""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)
@@ -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)
@@ -1,10 +1,12 @@
"""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)
+2
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)
+1
View File
@@ -1,4 +1,5 @@
"""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
+2
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)
+2
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)
+2
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)
+1
View File
@@ -1,4 +1,5 @@
"""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
+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
+2
View File
@@ -1,10 +1,12 @@
"""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)
+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.
+31 -19
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'))
+95 -47
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)
+102 -42
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,32 +182,42 @@ 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)
+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',
}
)
+107 -39
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()
+229 -91
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()
+478 -207
View File
File diff suppressed because it is too large Load Diff
+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:')
+24 -18
View File
@@ -128,7 +128,9 @@ def check_https_headers(url):
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
@@ -168,7 +170,7 @@ def check_dependencies():
[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:
@@ -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:
@@ -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 = [
@@ -353,10 +353,16 @@ def main():
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
+33 -37
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,9 +28,7 @@ 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."""
@@ -39,10 +45,9 @@ def validate_password(value):
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):
@@ -58,9 +63,9 @@ def validate_username(value):
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):
@@ -120,12 +125,14 @@ 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
@@ -150,6 +157,7 @@ class LoginSchema(StripMixin):
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.')),
@@ -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=[
@@ -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,
@@ -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(
@@ -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=[
@@ -404,6 +410,7 @@ class UploadContractSchema(StripMixin):
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.')),
@@ -424,26 +431,20 @@ class OneOnOneRequestSchema(StripMixin):
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.')),
@@ -459,17 +460,12 @@ class DisponibilityAddSchema(StripMixin):
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.')
),
) )
+17 -7
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
+1 -2
View File
@@ -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,
) )