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:
+52
-40
@@ -100,7 +100,8 @@ def build_csp(*, allow_inline_script, nonce=None):
|
||||
else:
|
||||
script_src = f"'self' 'nonce-{nonce}' https://cdn.jsdelivr.net"
|
||||
|
||||
return '; '.join([
|
||||
return '; '.join(
|
||||
[
|
||||
"default-src 'self'",
|
||||
f'script-src {script_src}',
|
||||
# style-src is a separate migration: inline style="" attributes are
|
||||
@@ -112,7 +113,8 @@ def build_csp(*, allow_inline_script, nonce=None):
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def create_app(config=None):
|
||||
@@ -170,12 +172,8 @@ def create_app(config=None):
|
||||
|
||||
# Side effects of create_app(), both on by default so that production and
|
||||
# development behave exactly as before. Tests turn them off.
|
||||
app.config['AUTO_CREATE_TABLES'] = (
|
||||
os.getenv('AUTO_CREATE_TABLES', 'true').lower() == 'true'
|
||||
)
|
||||
app.config['ENABLE_DISCORD_BOT'] = (
|
||||
os.getenv('ENABLE_DISCORD_BOT', 'true').lower() == 'true'
|
||||
)
|
||||
app.config['AUTO_CREATE_TABLES'] = os.getenv('AUTO_CREATE_TABLES', 'true').lower() == 'true'
|
||||
app.config['ENABLE_DISCORD_BOT'] = os.getenv('ENABLE_DISCORD_BOT', 'true').lower() == 'true'
|
||||
|
||||
# --- caller overrides win ---------------------------------------------
|
||||
if config:
|
||||
@@ -198,7 +196,9 @@ def create_app(config=None):
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
|
||||
|
||||
# Secure session cookie settings
|
||||
app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
|
||||
app.config['SESSION_COOKIE_SECURE'] = (
|
||||
os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
|
||||
)
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout
|
||||
@@ -245,12 +245,14 @@ def create_app(config=None):
|
||||
|
||||
@app.context_processor
|
||||
def inject_csp_nonce():
|
||||
return {'csp_nonce': '' if app.config['CSP_ALLOW_INLINE_SCRIPT']
|
||||
else g.get('csp_nonce', '')}
|
||||
return {
|
||||
'csp_nonce': '' if app.config['CSP_ALLOW_INLINE_SCRIPT'] else g.get('csp_nonce', '')
|
||||
}
|
||||
|
||||
@app.context_processor
|
||||
def inject_locales():
|
||||
from flask_babel import get_locale
|
||||
|
||||
return {
|
||||
'current_locale': str(get_locale() or i18n.DEFAULT_LOCALE),
|
||||
'supported_locales': i18n.SUPPORTED_LOCALES,
|
||||
@@ -259,6 +261,7 @@ def create_app(config=None):
|
||||
|
||||
# Configure structured logging
|
||||
from app.logging_config import configure_logging
|
||||
|
||||
configure_logging(app)
|
||||
|
||||
from app.routes.auth import auth_bp
|
||||
@@ -303,8 +306,7 @@ def create_app(config=None):
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
response.headers['Permissions-Policy'] = (
|
||||
'camera=(), microphone=(), geolocation=(), '
|
||||
'interest-cohort=(), payment=(), usb=()'
|
||||
'camera=(), microphone=(), geolocation=(), interest-cohort=(), payment=(), usb=()'
|
||||
)
|
||||
response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'
|
||||
response.headers['Content-Security-Policy'] = build_csp(
|
||||
@@ -383,9 +385,11 @@ def create_app(config=None):
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/coach-availability') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
if (
|
||||
request.path.startswith('/users/disponibilities')
|
||||
or request.path.startswith('/users/coach-availability')
|
||||
or request.path.startswith('/users/api/')
|
||||
):
|
||||
return jsonify({'error': 'Bad request', 'message': str(error)}), 400
|
||||
return render_template('errors/400.html', error=error), 400
|
||||
|
||||
@@ -399,10 +403,12 @@ def create_app(config=None):
|
||||
Returns:
|
||||
Response: Redirect to login for pages, JSON for API.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
if request.path.startswith('/users/disponibilities') or request.path.startswith(
|
||||
'/users/api/'
|
||||
):
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
from flask import flash as _flash
|
||||
|
||||
_flash('Please log in to access this page.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@@ -416,8 +422,9 @@ def create_app(config=None):
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
if request.path.startswith('/users/disponibilities') or request.path.startswith(
|
||||
'/users/api/'
|
||||
):
|
||||
return jsonify({'error': 'Forbidden', 'message': str(error)}), 403
|
||||
return render_template('errors/403.html', error=error), 403
|
||||
|
||||
@@ -431,8 +438,9 @@ def create_app(config=None):
|
||||
Returns:
|
||||
Response: Rendered error page or JSON for API requests.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
if request.path.startswith('/users/disponibilities') or request.path.startswith(
|
||||
'/users/api/'
|
||||
):
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
return render_template('errors/404.html', error=error), 404
|
||||
|
||||
@@ -446,12 +454,12 @@ def create_app(config=None):
|
||||
Returns:
|
||||
Response: JSON error for API or rendered page.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
'error': 'Too many requests',
|
||||
'message': 'Please try again later.'
|
||||
}), 429
|
||||
if request.path.startswith('/users/disponibilities') or request.path.startswith(
|
||||
'/users/api/'
|
||||
):
|
||||
return jsonify(
|
||||
{'error': 'Too many requests', 'message': 'Please try again later.'}
|
||||
), 429
|
||||
return render_template('errors/429.html', error=error), 429
|
||||
|
||||
@app.errorhandler(500)
|
||||
@@ -472,12 +480,15 @@ def create_app(config=None):
|
||||
# Roll back any failed database session
|
||||
db.session.rollback()
|
||||
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
if request.path.startswith('/users/disponibilities') or request.path.startswith(
|
||||
'/users/api/'
|
||||
):
|
||||
return jsonify(
|
||||
{
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred. Please try again later.'
|
||||
}), 500
|
||||
'message': 'An unexpected error occurred. Please try again later.',
|
||||
}
|
||||
), 500
|
||||
return render_template('errors/500.html'), 500
|
||||
|
||||
@app.errorhandler(HTTPException)
|
||||
@@ -490,13 +501,12 @@ def create_app(config=None):
|
||||
Returns:
|
||||
Response: JSON error for API, re-raises for others.
|
||||
"""
|
||||
if request.path.startswith('/users/disponibilities') or \
|
||||
request.path.startswith('/users/api/'):
|
||||
return jsonify({
|
||||
'error': error.name,
|
||||
'message': error.description,
|
||||
'code': error.code
|
||||
}), error.code
|
||||
if request.path.startswith('/users/disponibilities') or request.path.startswith(
|
||||
'/users/api/'
|
||||
):
|
||||
return jsonify(
|
||||
{'error': error.name, 'message': error.description, 'code': error.code}
|
||||
), error.code
|
||||
return error
|
||||
|
||||
# =========================================================================
|
||||
@@ -504,6 +514,7 @@ def create_app(config=None):
|
||||
# =========================================================================
|
||||
with app.app_context():
|
||||
import app.models as models # noqa: F401 — registers all models with SQLAlchemy
|
||||
|
||||
# NOTE: create_all() only ever creates missing tables. It never adds a
|
||||
# column to an existing one, so a model change is silently absent from
|
||||
# any database that already has the table. Replacing this with Alembic
|
||||
@@ -515,6 +526,7 @@ def create_app(config=None):
|
||||
if app.config['ENABLE_DISCORD_BOT']:
|
||||
try:
|
||||
from app.discord_bot import start_bot
|
||||
|
||||
start_bot(flask_app=app)
|
||||
except Exception as e:
|
||||
app.logger.warning('Could not start Discord bot: %s', e)
|
||||
|
||||
+157
-57
@@ -28,7 +28,9 @@ DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# File for persisting pending requests across bot restarts
|
||||
PENDING_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'discord_pending.json')
|
||||
PENDING_FILE = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'discord_pending.json'
|
||||
)
|
||||
|
||||
# Emoji constants
|
||||
CHECK_EMOJI = '✅' # Green checkmark
|
||||
@@ -71,7 +73,9 @@ class TeamTryoutsBot(commands.Bot):
|
||||
data = json.load(f)
|
||||
# Convert string keys back to int
|
||||
self.pending_requests = {int(k): v for k, v in data.items()}
|
||||
logger.info(f"Loaded {len(self.pending_requests)} pending requests from {PENDING_FILE}")
|
||||
logger.info(
|
||||
f"Loaded {len(self.pending_requests)} pending requests from {PENDING_FILE}"
|
||||
)
|
||||
else:
|
||||
logger.info("No pending requests file found, starting fresh.")
|
||||
except Exception as e:
|
||||
@@ -111,7 +115,7 @@ class TeamTryoutsBot(commands.Bot):
|
||||
self.send_daily_reminders,
|
||||
trigger=CronTrigger(hour=18, minute=0, timezone=self.timezone),
|
||||
id='daily_reminders',
|
||||
replace_existing=True
|
||||
replace_existing=True,
|
||||
)
|
||||
self.scheduler.start()
|
||||
logger.info('Daily reminder scheduler started (18:00 EDT)')
|
||||
@@ -185,32 +189,57 @@ class TeamTryoutsBot(commands.Bot):
|
||||
if handler_type == 'one_on_one':
|
||||
if self.flask_app:
|
||||
with self.flask_app.app_context():
|
||||
await self.handle_one_on_one_approve(user, payload.message_id, request_id, channel)
|
||||
await self.handle_one_on_one_approve(
|
||||
user, payload.message_id, request_id, channel
|
||||
)
|
||||
else:
|
||||
await self.handle_one_on_one_approve(user, payload.message_id, request_id, channel)
|
||||
await self.handle_one_on_one_approve(
|
||||
user, payload.message_id, request_id, channel
|
||||
)
|
||||
elif handler_type == 'schedule_addition':
|
||||
if self.flask_app:
|
||||
with self.flask_app.app_context():
|
||||
await self.handle_attendance_confirm(user, payload.message_id, request_id, channel)
|
||||
await self.handle_attendance_confirm(
|
||||
user, payload.message_id, request_id, channel
|
||||
)
|
||||
else:
|
||||
await self.handle_attendance_confirm(user, payload.message_id, request_id, channel)
|
||||
await self.handle_attendance_confirm(
|
||||
user, payload.message_id, request_id, channel
|
||||
)
|
||||
elif emoji_str == CROSS_EMOJI:
|
||||
if handler_type == 'one_on_one':
|
||||
if self.flask_app:
|
||||
with self.flask_app.app_context():
|
||||
await self.handle_one_on_one_reject(user, payload.message_id, request_id, channel)
|
||||
await self.handle_one_on_one_reject(
|
||||
user, payload.message_id, request_id, channel
|
||||
)
|
||||
else:
|
||||
await self.handle_one_on_one_reject(user, payload.message_id, request_id, channel)
|
||||
await self.handle_one_on_one_reject(
|
||||
user, payload.message_id, request_id, channel
|
||||
)
|
||||
elif handler_type == 'schedule_addition':
|
||||
if self.flask_app:
|
||||
with self.flask_app.app_context():
|
||||
await self.handle_attendance_decline(user, payload.message_id, request_id, channel)
|
||||
await self.handle_attendance_decline(
|
||||
user, payload.message_id, request_id, channel
|
||||
)
|
||||
else:
|
||||
await self.handle_attendance_decline(user, payload.message_id, request_id, channel)
|
||||
await self.handle_attendance_decline(
|
||||
user, payload.message_id, request_id, channel
|
||||
)
|
||||
|
||||
async def _send_one_on_one_dm(self, coach_name: str, coach_discord_id: str, player_name: str,
|
||||
team_name: str, date_str: str, start_time: str, end_time: str,
|
||||
points: str, request_id: int) -> int:
|
||||
async def _send_one_on_one_dm(
|
||||
self,
|
||||
coach_name: str,
|
||||
coach_discord_id: str,
|
||||
player_name: str,
|
||||
team_name: str,
|
||||
date_str: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
points: str,
|
||||
request_id: int,
|
||||
) -> int:
|
||||
"""Send a One on One request DM to a coach with reactions."""
|
||||
try:
|
||||
user_id = int(coach_discord_id)
|
||||
@@ -250,9 +279,15 @@ class TeamTryoutsBot(commands.Bot):
|
||||
logger.error(f"Error sending One on One DM: {e}")
|
||||
return None
|
||||
|
||||
async def _send_schedule_notification(self, user_id: int, event_type: str,
|
||||
event_title: str, event_date: str,
|
||||
event_time: str, reference_id: int) -> int:
|
||||
async def _send_schedule_notification(
|
||||
self,
|
||||
user_id: int,
|
||||
event_type: str,
|
||||
event_title: str,
|
||||
event_date: str,
|
||||
event_time: str,
|
||||
reference_id: int,
|
||||
) -> int:
|
||||
"""Send a schedule addition notification to a player.
|
||||
|
||||
Args:
|
||||
@@ -266,6 +301,7 @@ class TeamTryoutsBot(commands.Bot):
|
||||
try:
|
||||
# Look up the DB user to get their Discord user ID
|
||||
from app.models import User as DBUser
|
||||
|
||||
db_user = DBUser.query.get(user_id)
|
||||
if not db_user:
|
||||
logger.warning(f"DB user {user_id} not found for schedule notification")
|
||||
@@ -299,10 +335,16 @@ class TeamTryoutsBot(commands.Bot):
|
||||
await msg.add_reaction(CROSS_EMOJI)
|
||||
|
||||
# Track this pending request
|
||||
self.pending_requests[msg.id] = {'type': 'schedule_addition', 'id': reference_id, 'event_type': event_type}
|
||||
self.pending_requests[msg.id] = {
|
||||
'type': 'schedule_addition',
|
||||
'id': reference_id,
|
||||
'event_type': event_type,
|
||||
}
|
||||
self._save_pending()
|
||||
|
||||
logger.info(f"Sent {event_type} schedule notification to {db_user.username}, message_id={msg.id}")
|
||||
logger.info(
|
||||
f"Sent {event_type} schedule notification to {db_user.username}, message_id={msg.id}"
|
||||
)
|
||||
return msg.id
|
||||
|
||||
except Exception as e:
|
||||
@@ -346,7 +388,7 @@ class TeamTryoutsBot(commands.Bot):
|
||||
player_full_name=player_full_name,
|
||||
coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
|
||||
request=request,
|
||||
approved=True
|
||||
approved=True,
|
||||
)
|
||||
del self.pending_requests[message_id]
|
||||
self._save_pending()
|
||||
@@ -378,7 +420,11 @@ class TeamTryoutsBot(commands.Bot):
|
||||
refusal_note = None
|
||||
try:
|
||||
async for reply in channel.history(limit=20):
|
||||
if reply.author.id == coach.id and reply.reference and reply.reference.message_id == message_id:
|
||||
if (
|
||||
reply.author.id == coach.id
|
||||
and reply.reference
|
||||
and reply.reference.message_id == message_id
|
||||
):
|
||||
refusal_note = reply.content
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -390,7 +436,9 @@ class TeamTryoutsBot(commands.Bot):
|
||||
request.coach_rejection_message = refusal_note
|
||||
db.session.commit()
|
||||
|
||||
rejection_msg = f"❌ You have **rejected** the One on One session with {player_full_name}."
|
||||
rejection_msg = (
|
||||
f"❌ You have **rejected** the One on One session with {player_full_name}."
|
||||
)
|
||||
if refusal_note:
|
||||
rejection_msg += f"\n**Reason:** {refusal_note}"
|
||||
else:
|
||||
@@ -404,7 +452,7 @@ class TeamTryoutsBot(commands.Bot):
|
||||
coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
|
||||
request=request,
|
||||
approved=False,
|
||||
refusal_note=refusal_note
|
||||
refusal_note=refusal_note,
|
||||
)
|
||||
del self.pending_requests[message_id]
|
||||
self._save_pending()
|
||||
@@ -470,9 +518,15 @@ class TeamTryoutsBot(commands.Bot):
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling attendance decline: {e}\n{traceback.format_exc()}")
|
||||
|
||||
async def notify_player_about_one_on_one_direct(self, player_discord_id, player_full_name,
|
||||
coach_full_name, request,
|
||||
approved=True, refusal_note=None):
|
||||
async def notify_player_about_one_on_one_direct(
|
||||
self,
|
||||
player_discord_id,
|
||||
player_full_name,
|
||||
coach_full_name,
|
||||
request,
|
||||
approved=True,
|
||||
refusal_note=None,
|
||||
):
|
||||
"""Send confirmation to player about One on One response using pre-fetched data.
|
||||
|
||||
This method avoids session expiration issues by using data captured before
|
||||
@@ -521,7 +575,9 @@ class TeamTryoutsBot(commands.Bot):
|
||||
)
|
||||
|
||||
await player_user.send(message)
|
||||
logger.info(f"Sent One on One notification to player {player_full_name} (request {request.id})")
|
||||
logger.info(
|
||||
f"Sent One on One notification to player {player_full_name} (request {request.id})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in direct One on One notification: {e}")
|
||||
@@ -540,7 +596,13 @@ class TeamTryoutsBot(commands.Bot):
|
||||
async def _send_daily_reminders_impl(self):
|
||||
"""Internal implementation of daily reminders with proper app context."""
|
||||
try:
|
||||
from app.models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest
|
||||
from app.models import (
|
||||
Match,
|
||||
Tryout,
|
||||
MatchParticipant,
|
||||
TryoutRegistration,
|
||||
OneOnOneRequest,
|
||||
)
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
now = datetime.now(self.timezone)
|
||||
@@ -563,13 +625,13 @@ class TeamTryoutsBot(commands.Bot):
|
||||
await self.send_tryout_reminder(reg.player, tryout)
|
||||
|
||||
# Find One on One sessions for tomorrow (only approved ones)
|
||||
one_on_ones = OneOnOneRequest.query.options(
|
||||
joinedload(OneOnOneRequest.player),
|
||||
joinedload(OneOnOneRequest.coach)
|
||||
).filter(
|
||||
OneOnOneRequest.date == tomorrow,
|
||||
OneOnOneRequest.status == 'approved'
|
||||
).all()
|
||||
one_on_ones = (
|
||||
OneOnOneRequest.query.options(
|
||||
joinedload(OneOnOneRequest.player), joinedload(OneOnOneRequest.coach)
|
||||
)
|
||||
.filter(OneOnOneRequest.date == tomorrow, OneOnOneRequest.status == 'approved')
|
||||
.all()
|
||||
)
|
||||
for session in one_on_ones:
|
||||
if session.player and session.player.discord_user_id:
|
||||
await self.send_one_on_one_reminder(session.player, session)
|
||||
@@ -609,10 +671,18 @@ class TeamTryoutsBot(commands.Bot):
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending tryout reminder: {e}")
|
||||
|
||||
async def _send_one_on_one_response_dm(self, player_discord_id: str, player_full_name: str,
|
||||
coach_full_name: str, date_str: str, start_time: str,
|
||||
end_time: str, points: str, approved: bool,
|
||||
refusal_note: str = None) -> bool:
|
||||
async def _send_one_on_one_response_dm(
|
||||
self,
|
||||
player_discord_id: str,
|
||||
player_full_name: str,
|
||||
coach_full_name: str,
|
||||
date_str: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
points: str,
|
||||
approved: bool,
|
||||
refusal_note: str = None,
|
||||
) -> bool:
|
||||
"""Send a DM to a player notifying them of their One on One request response.
|
||||
|
||||
Called from the message queue when a coach accepts/rejects via the web app.
|
||||
@@ -652,7 +722,9 @@ class TeamTryoutsBot(commands.Bot):
|
||||
)
|
||||
|
||||
await player_user.send(message)
|
||||
logger.info(f"Sent One on One response DM to player {player_full_name} (approved={approved})")
|
||||
logger.info(
|
||||
f"Sent One on One response DM to player {player_full_name} (approved={approved})"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -691,13 +763,22 @@ def get_bot(flask_app=None):
|
||||
return bot_instance
|
||||
|
||||
|
||||
def send_one_on_one_dm(coach_name: str, coach_discord_id: str, player_name: str,
|
||||
team_name: str, date_str: str, start_time: str, end_time: str,
|
||||
points: str, request_id: int) -> bool:
|
||||
def send_one_on_one_dm(
|
||||
coach_name: str,
|
||||
coach_discord_id: str,
|
||||
player_name: str,
|
||||
team_name: str,
|
||||
date_str: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
points: str,
|
||||
request_id: int,
|
||||
) -> bool:
|
||||
"""Queue a One on One request DM to be sent by the bot."""
|
||||
bot = get_bot()
|
||||
try:
|
||||
bot.message_queue.put({
|
||||
bot.message_queue.put(
|
||||
{
|
||||
'type': 'one_on_one_request',
|
||||
'data': {
|
||||
'coach_name': coach_name,
|
||||
@@ -708,21 +789,29 @@ def send_one_on_one_dm(coach_name: str, coach_discord_id: str, player_name: str,
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
'points': points,
|
||||
'request_id': request_id
|
||||
'request_id': request_id,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error queuing One on One DM: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_schedule_notification(user_id: int, event_type: str, event_title: str,
|
||||
event_date: str, event_time: str, reference_id: int) -> bool:
|
||||
def send_schedule_notification(
|
||||
user_id: int,
|
||||
event_type: str,
|
||||
event_title: str,
|
||||
event_date: str,
|
||||
event_time: str,
|
||||
reference_id: int,
|
||||
) -> bool:
|
||||
"""Queue a schedule addition notification to be sent by the bot."""
|
||||
bot = get_bot()
|
||||
try:
|
||||
bot.message_queue.put({
|
||||
bot.message_queue.put(
|
||||
{
|
||||
'type': 'schedule_addition',
|
||||
'data': {
|
||||
'user_id': user_id,
|
||||
@@ -730,26 +819,35 @@ def send_schedule_notification(user_id: int, event_type: str, event_title: str,
|
||||
'event_title': event_title,
|
||||
'event_date': event_date,
|
||||
'event_time': event_time,
|
||||
'reference_id': reference_id
|
||||
'reference_id': reference_id,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error queuing schedule notification: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_one_on_one_response(player_discord_id: str, player_full_name: str,
|
||||
coach_full_name: str, date_str: str, start_time: str,
|
||||
end_time: str, points: str, approved: bool,
|
||||
refusal_note: str = None) -> bool:
|
||||
def send_one_on_one_response(
|
||||
player_discord_id: str,
|
||||
player_full_name: str,
|
||||
coach_full_name: str,
|
||||
date_str: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
points: str,
|
||||
approved: bool,
|
||||
refusal_note: str = None,
|
||||
) -> bool:
|
||||
"""Queue a One on One response DM to be sent to the player by the bot.
|
||||
|
||||
Called from Flask routes when a coach accepts/rejects via the web app.
|
||||
"""
|
||||
bot = get_bot()
|
||||
try:
|
||||
bot.message_queue.put({
|
||||
bot.message_queue.put(
|
||||
{
|
||||
'type': 'one_on_one_response',
|
||||
'data': {
|
||||
'player_discord_id': player_discord_id,
|
||||
@@ -761,8 +859,9 @@ def send_one_on_one_response(player_discord_id: str, player_full_name: str,
|
||||
'points': points,
|
||||
'approved': approved,
|
||||
'refusal_note': refusal_note,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error queuing One on One response DM: {e}")
|
||||
@@ -775,6 +874,7 @@ def start_bot(flask_app=None):
|
||||
|
||||
bot = get_bot(flask_app=flask_app)
|
||||
if DISCORD_BOT_TOKEN and bot_thread is None:
|
||||
|
||||
def run_bot():
|
||||
try:
|
||||
bot.run(DISCORD_BOT_TOKEN)
|
||||
|
||||
+1
-4
@@ -18,10 +18,7 @@ csrf = CSRFProtect()
|
||||
babel = Babel()
|
||||
|
||||
# Rate limiter for brute-force protection
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
default_limits=["200 per day", "50 per hour"]
|
||||
)
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=["200 per day", "50 per hour"])
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
|
||||
+16
-7
@@ -24,8 +24,18 @@ class SensitiveDataFilter(logging.Filter):
|
||||
|
||||
# Patterns to redact
|
||||
SENSITIVE_PATTERNS = [
|
||||
(re.compile(r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;)]+', re.IGNORECASE), '[REDACTED]'),
|
||||
(re.compile(r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*"[^"]*"', re.IGNORECASE), lambda m: m.group(0).split('=')[0] + '="[REDACTED]"'),
|
||||
(
|
||||
re.compile(
|
||||
r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;)]+', re.IGNORECASE
|
||||
),
|
||||
'[REDACTED]',
|
||||
),
|
||||
(
|
||||
re.compile(
|
||||
r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*"[^"]*"', re.IGNORECASE
|
||||
),
|
||||
lambda m: m.group(0).split('=')[0] + '="[REDACTED]"',
|
||||
),
|
||||
(re.compile(r'Authorization[:\s]+[^\s]+', re.IGNORECASE), 'Authorization: [REDACTED]'),
|
||||
(re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'),
|
||||
]
|
||||
@@ -88,8 +98,7 @@ def configure_logging(app):
|
||||
|
||||
# Formatter with timestamp, level, module, and message
|
||||
formatter = logging.Formatter(
|
||||
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s', datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -98,7 +107,7 @@ def configure_logging(app):
|
||||
error_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'errors.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=10
|
||||
backupCount=10,
|
||||
)
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
error_handler.setFormatter(formatter)
|
||||
@@ -111,7 +120,7 @@ def configure_logging(app):
|
||||
auth_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'auth.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=5
|
||||
backupCount=5,
|
||||
)
|
||||
auth_handler.setLevel(logging.INFO)
|
||||
auth_handler.setFormatter(formatter)
|
||||
@@ -129,7 +138,7 @@ def configure_logging(app):
|
||||
app_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'app.log'),
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=10
|
||||
backupCount=10,
|
||||
)
|
||||
app_handler.setLevel(log_level)
|
||||
app_handler.setFormatter(formatter)
|
||||
|
||||
+30
-15
@@ -3,23 +3,38 @@
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
org_team_coaches = db.Table('org_team_coaches',
|
||||
db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
org_team_coaches = db.Table(
|
||||
'org_team_coaches',
|
||||
db.Column(
|
||||
'org_team_id',
|
||||
db.Integer,
|
||||
db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
),
|
||||
db.Column(
|
||||
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
org_team_managers = db.Table('org_team_managers',
|
||||
db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
db.Column('manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
org_team_managers = db.Table(
|
||||
'org_team_managers',
|
||||
db.Column(
|
||||
'org_team_id',
|
||||
db.Integer,
|
||||
db.ForeignKey('org_teams.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
),
|
||||
db.Column(
|
||||
'manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
tryout_coaches = db.Table('tryout_coaches',
|
||||
db.Column('tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
primary_key=True),
|
||||
tryout_coaches = db.Table(
|
||||
'tryout_coaches',
|
||||
db.Column(
|
||||
'tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
db.Column(
|
||||
'coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
@@ -52,11 +52,7 @@ PLATFORM_CODES = {
|
||||
'Epic': 'epic',
|
||||
}
|
||||
|
||||
PLATFORM_DEFAULTS = {
|
||||
'Apex Legends': 'pc',
|
||||
'Rainbow Six Siege': 'ubi',
|
||||
'Rocket League': 'epic'
|
||||
}
|
||||
PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket League': 'epic'}
|
||||
|
||||
TRN_URLS = {
|
||||
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class BaseAvailability(db.Model):
|
||||
"""Shared schema for player disponibilities and coach availabilities."""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
day_of_week = db.Column(db.Integer, nullable=False)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Coach availability in 30-minute time blocks for One on One sessions."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.availability.base import BaseAvailability
|
||||
|
||||
|
||||
class CoachAvailability(BaseAvailability):
|
||||
"""Coach availability in 30-minute blocks for One on One sessions."""
|
||||
|
||||
__tablename__ = 'coach_availabilities'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Player availability in 30-minute time blocks."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.availability.base import BaseAvailability
|
||||
|
||||
|
||||
class PlayerDisponibility(BaseAvailability):
|
||||
"""Player availability in 30-minute blocks."""
|
||||
|
||||
__tablename__ = 'player_disponibilities'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Contract documents for players to sign."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Contract(db.Model):
|
||||
"""Contract documents for players to sign."""
|
||||
|
||||
__tablename__ = 'contracts'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Player evaluation record."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Evaluation(db.Model):
|
||||
"""Player evaluation record."""
|
||||
|
||||
__tablename__ = 'evaluations'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Match models — BaseMatch and its concrete subclasses."""
|
||||
|
||||
from app.models.match_model.base import BaseMatch
|
||||
from app.models.match_model.match import Match
|
||||
from app.models.match_model.team_match import TeamMatch
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Abstract base class for match models (Match + TeamMatch)."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class BaseMatch(db.Model):
|
||||
"""Shared schema for tryout-scoped matches and regular-season team matches."""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Match / scrimmage within a tryout."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.match_model.base import BaseMatch
|
||||
|
||||
|
||||
class Match(BaseMatch):
|
||||
"""Match / scrimmage within a tryout."""
|
||||
|
||||
__tablename__ = 'matches'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
@@ -21,8 +23,8 @@ class Match(BaseMatch):
|
||||
# deleting any match that had participants raised IntegrityError.
|
||||
# TeamMatch.participants already declared this; Match did not.
|
||||
participants = db.relationship(
|
||||
'MatchParticipant', backref='match', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
'MatchParticipant', backref='match', lazy='dynamic', cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
def get_participating_players(self):
|
||||
return [p.player_id for p in self.participants.all()]
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Regular-season match for an organisation team (not tied to a tryout)."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.match_model.base import BaseMatch
|
||||
|
||||
|
||||
class TeamMatch(BaseMatch):
|
||||
"""Regular-season match for an organisation team (not tied to a tryout)."""
|
||||
|
||||
__tablename__ = 'team_matches'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
@@ -13,8 +15,8 @@ class TeamMatch(BaseMatch):
|
||||
org_team = db.relationship('OrgTeam', backref='team_matches')
|
||||
creator = db.relationship('User', backref='created_team_matches')
|
||||
participants = db.relationship(
|
||||
'TeamMatchParticipant', backref='team_match', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
'TeamMatchParticipant', backref='team_match', lazy='dynamic', cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
def get_confirmed_count(self):
|
||||
all_p = self.participants.all()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Request from player to coach for a One on One session."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class OneOnOneRequest(db.Model):
|
||||
"""Request from player to coach for a One on One session."""
|
||||
|
||||
__tablename__ = 'one_on_one_requests'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Organisation team models."""
|
||||
|
||||
from app.models.org_team.org_team import OrgTeam
|
||||
from app.models.org_team.team_player import TeamPlayer
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Persistent organisation team (e.g. Varsity, JV)."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models._associations import org_team_coaches, org_team_managers
|
||||
from datetime import datetime
|
||||
@@ -6,6 +7,7 @@ from datetime import datetime
|
||||
|
||||
class OrgTeam(db.Model):
|
||||
"""Persistent organisation team (e.g. Varsity, JV)."""
|
||||
|
||||
__tablename__ = 'org_teams'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||
@@ -17,20 +19,30 @@ class OrgTeam(db.Model):
|
||||
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
coaches = db.relationship(
|
||||
'User', secondary=org_team_coaches, lazy='dynamic',
|
||||
backref=db.backref('coached_org_teams', lazy='dynamic'))
|
||||
'User',
|
||||
secondary=org_team_coaches,
|
||||
lazy='dynamic',
|
||||
backref=db.backref('coached_org_teams', lazy='dynamic'),
|
||||
)
|
||||
managers = db.relationship(
|
||||
'User', secondary=org_team_managers, lazy='dynamic',
|
||||
backref=db.backref('managed_org_teams', lazy='dynamic'))
|
||||
'User',
|
||||
secondary=org_team_managers,
|
||||
lazy='dynamic',
|
||||
backref=db.backref('managed_org_teams', lazy='dynamic'),
|
||||
)
|
||||
|
||||
coach = db.relationship(
|
||||
'User', foreign_keys=[coach_id],
|
||||
'User',
|
||||
foreign_keys=[coach_id],
|
||||
backref=db.backref('coached_org_team_legacy', uselist=False),
|
||||
viewonly=True)
|
||||
viewonly=True,
|
||||
)
|
||||
manager = db.relationship(
|
||||
'User', foreign_keys=[manager_id],
|
||||
'User',
|
||||
foreign_keys=[manager_id],
|
||||
backref=db.backref('managed_org_team_legacy', uselist=False),
|
||||
viewonly=True)
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
def get_coaches(self):
|
||||
coach_list = self.coaches.all()
|
||||
@@ -49,5 +61,7 @@ class OrgTeam(db.Model):
|
||||
return [tp.player for tp in self.team_players]
|
||||
|
||||
def get_players_with_status(self):
|
||||
return [{'player': tp.player, 'status': tp.status,
|
||||
'position': tp.position} for tp in self.team_players]
|
||||
return [
|
||||
{'player': tp.player, 'status': tp.status, 'position': tp.position}
|
||||
for tp in self.team_players
|
||||
]
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Many-to-many junction: player to org-team."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TeamPlayer(db.Model):
|
||||
"""Many-to-many: player to org-team."""
|
||||
|
||||
__tablename__ = 'team_players'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Participant models — BaseParticipant and its concrete subclasses."""
|
||||
|
||||
from app.models.participant.base import BaseParticipant
|
||||
from app.models.participant.match_participant import MatchParticipant
|
||||
from app.models.participant.team_match_participant import TeamMatchParticipant
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Abstract base class for match participant models."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class BaseParticipant(db.Model):
|
||||
"""Shared schema for match participants."""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Participant in a tryout-scoped match."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.participant.base import BaseParticipant
|
||||
|
||||
|
||||
class MatchParticipant(BaseParticipant):
|
||||
"""Participant in a tryout-scoped match."""
|
||||
|
||||
__tablename__ = 'match_participants'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Participant in a regular-season team match."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.participant.base import BaseParticipant
|
||||
|
||||
|
||||
class TeamMatchParticipant(BaseParticipant):
|
||||
"""Participant in a regular-season team match."""
|
||||
|
||||
__tablename__ = 'team_match_participants'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Personal notes from coach to individual player."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class PersonalNote(db.Model):
|
||||
"""Personal notes from coach to individual player."""
|
||||
|
||||
__tablename__ = 'personal_notes'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tryout-specific temporary team models."""
|
||||
|
||||
from app.models.team.team import Team
|
||||
from app.models.team.team_member import TeamMember
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Team(db.Model):
|
||||
"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout)."""
|
||||
|
||||
__tablename__ = 'teams'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Link between a player and a tryout-specific team."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TeamMember(db.Model):
|
||||
"""Link between a player and a tryout-specific team."""
|
||||
|
||||
__tablename__ = 'team_members'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Team improvement notes from coach."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TeamNote(db.Model):
|
||||
"""Team improvement notes from coach."""
|
||||
|
||||
__tablename__ = 'team_notes'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tryout models."""
|
||||
|
||||
from app.models.tryout.tryout import Tryout
|
||||
from app.models.tryout.tryout_registration import TryoutRegistration
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tryout event for player evaluations and team formation."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models._associations import tryout_coaches
|
||||
from datetime import datetime
|
||||
@@ -6,6 +7,7 @@ from datetime import datetime
|
||||
|
||||
class Tryout(db.Model):
|
||||
"""Tryout event for player evaluations and team formation."""
|
||||
|
||||
__tablename__ = 'tryouts'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
@@ -19,7 +21,9 @@ class Tryout(db.Model):
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
|
||||
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
|
||||
coach_id = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id'), nullable=True
|
||||
) # deprecated, kept for migration
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
|
||||
@@ -29,13 +33,16 @@ class Tryout(db.Model):
|
||||
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
|
||||
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
|
||||
teams = db.relationship('Team', backref='tryout', lazy='dynamic')
|
||||
target_org_team = db.relationship('OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id])
|
||||
target_org_team = db.relationship(
|
||||
'OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id]
|
||||
)
|
||||
|
||||
@property
|
||||
def is_ended(self):
|
||||
"""Tryout is considered ended after its end_date passes.
|
||||
Falls back to date if end_date is not set."""
|
||||
from datetime import date as date_type
|
||||
|
||||
today = date_type.today()
|
||||
if self.end_date is not None:
|
||||
return self.end_date < today
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Registration linking a player to a tryout."""
|
||||
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TryoutRegistration(db.Model):
|
||||
"""Registration linking a player to a tryout."""
|
||||
|
||||
__tablename__ = 'tryout_registrations'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Store gamertag per game for each user."""
|
||||
|
||||
from app.extensions import db
|
||||
from app.models._constants import TRN_URLS, PLATFORM_CODES, PLATFORM_DEFAULTS
|
||||
from urllib.parse import quote
|
||||
@@ -6,6 +7,7 @@ from urllib.parse import quote
|
||||
|
||||
class UserGamertag(db.Model):
|
||||
"""Store gamertag per game for each user."""
|
||||
|
||||
__tablename__ = 'user_gamertags'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
@@ -15,9 +17,7 @@ class UserGamertag(db.Model):
|
||||
|
||||
user = db.relationship('User', backref='gamertags')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'game', name='unique_user_game'),
|
||||
)
|
||||
__table_args__ = (db.UniqueConstraint('user_id', 'game', name='unique_user_game'),)
|
||||
|
||||
def get_trn_url(self):
|
||||
if self.game not in TRN_URLS:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout)."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
from app.models.user_model.admin import Admin
|
||||
from app.models.user_model.manager import Manager
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Admin / President — full access to everything."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Admin(User):
|
||||
"""President / super-admin — full access to everything."""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'admin'}
|
||||
|
||||
def can_evaluate(self):
|
||||
@@ -29,4 +31,5 @@ class Admin(User):
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
return Tryout.query.order_by(Tryout.date).all()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Coach — evaluates, schedules matches, manages their own org teams."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
@@ -13,6 +14,7 @@ class Coach(User):
|
||||
and ``get_visible_tryouts`` ignored it entirely. A coach attached only
|
||||
by that column therefore saw an empty calendar (ARCH-002).
|
||||
"""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'coach'}
|
||||
|
||||
def can_evaluate(self):
|
||||
@@ -26,6 +28,7 @@ class Coach(User):
|
||||
|
||||
def can_manage_this_tryout(self, tryout):
|
||||
from app.permissions import coach_manages_tryout
|
||||
|
||||
return coach_manages_tryout(self, tryout)
|
||||
|
||||
def can_manage_this_org_team(self, org_team):
|
||||
@@ -35,4 +38,5 @@ class Coach(User):
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.permissions import coach_tryouts
|
||||
|
||||
return coach_tryouts(self)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Manager — manages own tryouts, all org teams, all contracts."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Manager(User):
|
||||
"""Manager — manages own tryouts, all org teams, all contracts."""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'manager'}
|
||||
|
||||
def can_evaluate(self):
|
||||
@@ -27,6 +29,9 @@ class Manager(User):
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
from sqlalchemy import or_
|
||||
return Tryout.query.filter(
|
||||
or_(Tryout.created_by == self.id, Tryout.manager_id == self.id)
|
||||
).order_by(Tryout.date).all()
|
||||
|
||||
return (
|
||||
Tryout.query.filter(or_(Tryout.created_by == self.id, Tryout.manager_id == self.id))
|
||||
.order_by(Tryout.date)
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Player — registers for tryouts, manages their own profile."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Player(User):
|
||||
"""Player — registers for tryouts, manages their own profile."""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'player'}
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
@@ -13,18 +15,30 @@ class Player(User):
|
||||
|
||||
# tryouts they registered for
|
||||
player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()]
|
||||
tryouts = Tryout.query.filter(
|
||||
Tryout.id.in_(player_tryout_ids)
|
||||
).order_by(Tryout.date).all() if player_tryout_ids else []
|
||||
tryouts = (
|
||||
Tryout.query.filter(Tryout.id.in_(player_tryout_ids)).order_by(Tryout.date).all()
|
||||
if player_tryout_ids
|
||||
else []
|
||||
)
|
||||
|
||||
# plus tryouts where they participate in a match
|
||||
player_matches = Match.query.join(MatchParticipant).filter(
|
||||
player_matches = (
|
||||
Match.query.join(MatchParticipant)
|
||||
.filter(
|
||||
MatchParticipant.player_id == self.id,
|
||||
).all()
|
||||
)
|
||||
.all()
|
||||
)
|
||||
extra_ids = set(m.tryout_id for m in player_matches)
|
||||
extra = Tryout.query.filter(
|
||||
extra = (
|
||||
Tryout.query.filter(
|
||||
Tryout.id.in_(extra_ids),
|
||||
).order_by(Tryout.date).all() if extra_ids else []
|
||||
)
|
||||
.order_by(Tryout.date)
|
||||
.all()
|
||||
if extra_ids
|
||||
else []
|
||||
)
|
||||
|
||||
all_ids = {t.id for t in tryouts}
|
||||
return tryouts + [t for t in extra if t.id not in all_ids]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Scout — view-only access to tryouts and evaluations."""
|
||||
|
||||
from app.models.user_model.user import User
|
||||
|
||||
|
||||
class Scout(User):
|
||||
"""Scout — view-only access to tryouts and evaluations."""
|
||||
|
||||
__mapper_args__ = {'polymorphic_identity': 'scout'}
|
||||
|
||||
def can_evaluate(self):
|
||||
@@ -11,4 +13,5 @@ class Scout(User):
|
||||
|
||||
def get_visible_tryouts(self):
|
||||
from app.models.tryout.tryout import Tryout
|
||||
|
||||
return Tryout.query.order_by(Tryout.date).all()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Base User model — shared fields and polymorphic configuration."""
|
||||
|
||||
from app.extensions import db
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
@@ -10,6 +11,7 @@ class User(UserMixin, db.Model):
|
||||
Do not instantiate this class directly; use Admin, Manager, Coach, Player,
|
||||
or Scout so that `polymorphic_identity` is set correctly.
|
||||
"""
|
||||
|
||||
__tablename__ = 'users'
|
||||
|
||||
# --- columns -----------------------------------------------------------
|
||||
@@ -40,16 +42,15 @@ class User(UserMixin, db.Model):
|
||||
|
||||
# --- relationships (defined once on the base) --------------------------
|
||||
evaluations_given = db.relationship(
|
||||
'Evaluation', foreign_keys='Evaluation.evaluator_id',
|
||||
backref='evaluator', lazy='dynamic')
|
||||
'Evaluation', foreign_keys='Evaluation.evaluator_id', backref='evaluator', lazy='dynamic'
|
||||
)
|
||||
evaluations_received = db.relationship(
|
||||
'Evaluation', foreign_keys='Evaluation.player_id',
|
||||
backref='player', lazy='dynamic')
|
||||
tryout_registrations = db.relationship(
|
||||
'TryoutRegistration', backref='player', lazy='dynamic')
|
||||
'Evaluation', foreign_keys='Evaluation.player_id', backref='player', lazy='dynamic'
|
||||
)
|
||||
tryout_registrations = db.relationship('TryoutRegistration', backref='player', lazy='dynamic')
|
||||
team_assignments = db.relationship(
|
||||
'TeamMember', foreign_keys='TeamMember.player_id',
|
||||
backref='player_ref', lazy='dynamic')
|
||||
'TeamMember', foreign_keys='TeamMember.player_id', backref='player_ref', lazy='dynamic'
|
||||
)
|
||||
|
||||
# --- Flask-Login integration -------------------------------------------
|
||||
@property
|
||||
@@ -71,8 +72,9 @@ class User(UserMixin, db.Model):
|
||||
|
||||
def get_gamertags(self):
|
||||
"""Return gamertags as a dict keyed by game."""
|
||||
return {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform}
|
||||
for gt in self.gamertags}
|
||||
return {
|
||||
gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in self.gamertags
|
||||
}
|
||||
|
||||
def get_org_teams(self):
|
||||
"""Return all OrgTeams this player belongs to."""
|
||||
|
||||
+21
-6
@@ -33,6 +33,7 @@ from app.extensions import db
|
||||
# Team attachment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def coach_org_teams(coach):
|
||||
"""Organisation teams a coach is attached to, ordered by name.
|
||||
|
||||
@@ -47,12 +48,16 @@ def coach_org_teams(coach):
|
||||
"""
|
||||
from app.models import OrgTeam
|
||||
|
||||
return OrgTeam.query.filter(
|
||||
return (
|
||||
OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.coaches.any(id=coach.id),
|
||||
OrgTeam.coach_id == coach.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
)
|
||||
.order_by(OrgTeam.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def coach_org_team_ids(coach):
|
||||
@@ -81,12 +86,16 @@ def manager_org_teams(manager):
|
||||
"""
|
||||
from app.models import OrgTeam
|
||||
|
||||
return OrgTeam.query.filter(
|
||||
return (
|
||||
OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.managers.any(id=manager.id),
|
||||
OrgTeam.manager_id == manager.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
)
|
||||
.order_by(OrgTeam.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def attached_org_teams(user):
|
||||
@@ -154,6 +163,7 @@ def can_manage_org_team(user, org_team):
|
||||
# Reach over players
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def org_team_player_ids(team_ids):
|
||||
"""IDs of the players placed on any of these teams.
|
||||
|
||||
@@ -227,10 +237,14 @@ def coach_can_access_player(coach, player_id):
|
||||
if registered:
|
||||
return True
|
||||
|
||||
plays_a_match = MatchParticipant.query.join(Match).filter(
|
||||
plays_a_match = (
|
||||
MatchParticipant.query.join(Match)
|
||||
.filter(
|
||||
MatchParticipant.player_id == player_id,
|
||||
Match.tryout_id.in_(tryout_ids),
|
||||
).first()
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return plays_a_match is not None
|
||||
|
||||
|
||||
@@ -262,6 +276,7 @@ def can_manage_player_contract(user, player_id):
|
||||
# Tryouts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def coach_tryouts(coach, team_ids=None):
|
||||
"""Tryouts a coach manages, ordered by date.
|
||||
|
||||
|
||||
+30
-18
@@ -78,8 +78,7 @@ def is_safe_url(url):
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.netloc:
|
||||
return (parsed.netloc == request.host
|
||||
and parsed.scheme in ('', 'http', 'https'))
|
||||
return parsed.netloc == request.host and parsed.scheme in ('', 'http', 'https')
|
||||
# Relative targets must be rooted. 'dashboard' or 'javascript:...' are
|
||||
# not paths on this site.
|
||||
return url.startswith('/')
|
||||
@@ -124,6 +123,7 @@ def generate_captcha():
|
||||
dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys.
|
||||
"""
|
||||
import random
|
||||
|
||||
a = random.randint(1, 10)
|
||||
b = random.randint(1, 10)
|
||||
captcha_id = str(uuid.uuid4())
|
||||
@@ -199,8 +199,7 @@ def login():
|
||||
|
||||
if user and credentials_ok:
|
||||
if not user.is_active_account:
|
||||
log_auth_event('login.rejected.deactivated',
|
||||
username=username, user_id=user.id)
|
||||
log_auth_event('login.rejected.deactivated', username=username, user_id=user.id)
|
||||
flash(_('This account has been deactivated.'), 'danger')
|
||||
return render_template('pages/login.html')
|
||||
|
||||
@@ -219,9 +218,7 @@ def login():
|
||||
# back into French — the preference lives in the session, and
|
||||
# clearing it discards a decision the user just made.
|
||||
_preserved = {
|
||||
key: session[key]
|
||||
for key in ('csrf_token', LOCALE_SESSION_KEY)
|
||||
if key in session
|
||||
key: session[key] for key in ('csrf_token', LOCALE_SESSION_KEY) if key in session
|
||||
}
|
||||
session.clear()
|
||||
session.update(_preserved)
|
||||
@@ -232,8 +229,7 @@ def login():
|
||||
session.permanent = True
|
||||
|
||||
login_user(user)
|
||||
log_auth_event('login.success',
|
||||
username=user.username, user_id=user.id, role=user.role)
|
||||
log_auth_event('login.success', username=user.username, user_id=user.id, role=user.role)
|
||||
|
||||
# Validate redirect URL to prevent open redirect vulnerability
|
||||
next_page = request.args.get('next')
|
||||
@@ -248,20 +244,33 @@ def login():
|
||||
# who asked (SEC-017).
|
||||
if user:
|
||||
user.failed_login_attempts += 1
|
||||
log_auth_event('login.failure', username=username, user_id=user.id,
|
||||
attempts=user.failed_login_attempts)
|
||||
log_auth_event(
|
||||
'login.failure',
|
||||
username=username,
|
||||
user_id=user.id,
|
||||
attempts=user.failed_login_attempts,
|
||||
)
|
||||
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
||||
minutes = cooloff_minutes(user.failed_login_attempts)
|
||||
user.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
log_auth_event('account.throttled', username=username,
|
||||
user_id=user.id, minutes=minutes,
|
||||
attempts=user.failed_login_attempts)
|
||||
log_auth_event(
|
||||
'account.throttled',
|
||||
username=username,
|
||||
user_id=user.id,
|
||||
minutes=minutes,
|
||||
attempts=user.failed_login_attempts,
|
||||
)
|
||||
db.session.commit()
|
||||
else:
|
||||
log_auth_event('login.failure.unknown_user', username=username)
|
||||
|
||||
flash(_('Login unsuccessful. Please check your username and '
|
||||
'password, or ask a president for help.'), 'danger')
|
||||
flash(
|
||||
_(
|
||||
'Login unsuccessful. Please check your username and '
|
||||
'password, or ask a president for help.'
|
||||
),
|
||||
'danger',
|
||||
)
|
||||
|
||||
return render_template('pages/login.html')
|
||||
|
||||
@@ -375,6 +384,7 @@ def register():
|
||||
|
||||
# Create UserGamertag records for each selected game
|
||||
from app.models import UserGamertag
|
||||
|
||||
for game in selected_games:
|
||||
field_name = f'gamertag_{game}'
|
||||
gamertag_value = request.form.get(field_name, '').strip()
|
||||
@@ -458,8 +468,10 @@ def discord_callback():
|
||||
|
||||
if not expected_state or not secrets.compare_digest(expected_state, received_state):
|
||||
flash(
|
||||
_('Discord authorization could not be verified. '
|
||||
'Please start the connection again from this page.'),
|
||||
_(
|
||||
'Discord authorization could not be verified. '
|
||||
'Please start the connection again from this page.'
|
||||
),
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
+90
-42
@@ -8,8 +8,12 @@ from flask_login import login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Player,
|
||||
User, Tryout, Evaluation, TryoutRegistration,
|
||||
Admin,
|
||||
Player,
|
||||
User,
|
||||
Tryout,
|
||||
Evaluation,
|
||||
TryoutRegistration,
|
||||
GAME_POSITIONS,
|
||||
)
|
||||
from sqlalchemy import func
|
||||
@@ -74,22 +78,29 @@ def list_evaluations():
|
||||
sort_expr = sort_expr.desc()
|
||||
|
||||
if isinstance(user, Admin):
|
||||
evaluations = Evaluation.query \
|
||||
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \
|
||||
.order_by(sort_expr).all()
|
||||
avg_scores = db.session.query(
|
||||
evaluations = (
|
||||
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
|
||||
.order_by(sort_expr)
|
||||
.all()
|
||||
)
|
||||
avg_scores = (
|
||||
db.session.query(
|
||||
Evaluation.player_id,
|
||||
func.count(Evaluation.id).label('eval_count'),
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
).group_by(Evaluation.player_id).all()
|
||||
)
|
||||
.group_by(Evaluation.player_id)
|
||||
.all()
|
||||
)
|
||||
player_scores = {}
|
||||
for row in avg_scores:
|
||||
p = User.query.get(row.player_id)
|
||||
if p:
|
||||
player_scores[p.id] = {
|
||||
'player': p, 'count': row.eval_count,
|
||||
'player': p,
|
||||
'count': row.eval_count,
|
||||
'avg': round(row.avg_score, 1) if row.avg_score else 0,
|
||||
}
|
||||
else:
|
||||
@@ -97,17 +108,23 @@ def list_evaluations():
|
||||
# can_evaluate() is true for the four remaining roles. The former
|
||||
# `else` branch listed evaluations *received* — a player's view,
|
||||
# unreachable from this point (ARCH-007).
|
||||
evaluations = Evaluation.query \
|
||||
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) \
|
||||
.filter(Evaluation.evaluator_id == user.id) \
|
||||
.order_by(sort_expr).all()
|
||||
evaluations = (
|
||||
Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id)
|
||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id)
|
||||
.outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id)
|
||||
.filter(Evaluation.evaluator_id == user.id)
|
||||
.order_by(sort_expr)
|
||||
.all()
|
||||
)
|
||||
player_scores = {}
|
||||
|
||||
return render_template('pages/evaluations.html',
|
||||
evaluations=evaluations, player_scores=player_scores,
|
||||
sort_column=sort_column, sort_order=sort_order)
|
||||
return render_template(
|
||||
'pages/evaluations.html',
|
||||
evaluations=evaluations,
|
||||
player_scores=player_scores,
|
||||
sort_column=sort_column,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
|
||||
@evaluations_bp.route('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST'])
|
||||
@@ -123,9 +140,13 @@ def evaluate_player(tryout_id, player_id):
|
||||
flash(_('You do not have permission to evaluate players in this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
is_registered = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id,
|
||||
).first() is not None
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=player_id,
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
if not is_registered:
|
||||
flash(_('Player is not registered for this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
@@ -136,7 +157,9 @@ def evaluate_player(tryout_id, player_id):
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
existing_eval = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id, evaluator_id=current_user.id,
|
||||
tryout_id=tryout_id,
|
||||
player_id=player_id,
|
||||
evaluator_id=current_user.id,
|
||||
).first()
|
||||
|
||||
if request.method == 'POST':
|
||||
@@ -152,9 +175,21 @@ def evaluate_player(tryout_id, player_id):
|
||||
comments = request.form.get('comments')
|
||||
position = request.form.get('position_recommendation')
|
||||
|
||||
scores = [s for s in [mecanics, cohesion, communication, gamesense,
|
||||
versatility, discipline, analysis, sport_ethics, mental]
|
||||
if s is not None]
|
||||
scores = [
|
||||
s
|
||||
for s in [
|
||||
mecanics,
|
||||
cohesion,
|
||||
communication,
|
||||
gamesense,
|
||||
versatility,
|
||||
discipline,
|
||||
analysis,
|
||||
sport_ethics,
|
||||
mental,
|
||||
]
|
||||
if s is not None
|
||||
]
|
||||
overall = sum(scores) / len(scores) if scores else None
|
||||
|
||||
if existing_eval:
|
||||
@@ -173,14 +208,21 @@ def evaluate_player(tryout_id, player_id):
|
||||
flash(_('Evaluation updated!'), 'success')
|
||||
else:
|
||||
evaluation = Evaluation(
|
||||
tryout_id=tryout_id, player_id=player_id,
|
||||
tryout_id=tryout_id,
|
||||
player_id=player_id,
|
||||
evaluator_id=current_user.id,
|
||||
mecanics_score=mecanics, cohesion_score=cohesion,
|
||||
communication_score=communication, gamesense_score=gamesense,
|
||||
versatility_score=versatility, discipline_score=discipline,
|
||||
analysis_score=analysis, sport_ethics_score=sport_ethics,
|
||||
mental_score=mental, overall_score=overall,
|
||||
comments=comments, position_recommendation=position,
|
||||
mecanics_score=mecanics,
|
||||
cohesion_score=cohesion,
|
||||
communication_score=communication,
|
||||
gamesense_score=gamesense,
|
||||
versatility_score=versatility,
|
||||
discipline_score=discipline,
|
||||
analysis_score=analysis,
|
||||
sport_ethics_score=sport_ethics,
|
||||
mental_score=mental,
|
||||
overall_score=overall,
|
||||
comments=comments,
|
||||
position_recommendation=position,
|
||||
)
|
||||
db.session.add(evaluation)
|
||||
flash(_('Evaluation submitted successfully!'), 'success')
|
||||
@@ -191,16 +233,21 @@ def evaluate_player(tryout_id, player_id):
|
||||
evaluators = None
|
||||
if isinstance(current_user, Admin):
|
||||
all_evaluations = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id,
|
||||
tryout_id=tryout_id,
|
||||
player_id=player_id,
|
||||
).all()
|
||||
evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e}
|
||||
for e in all_evaluations]
|
||||
evaluators = [
|
||||
{'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations
|
||||
]
|
||||
|
||||
return render_template('pages/evaluate_player.html',
|
||||
tryout=tryout, player=player,
|
||||
return render_template(
|
||||
'pages/evaluate_player.html',
|
||||
tryout=tryout,
|
||||
player=player,
|
||||
existing_eval=existing_eval,
|
||||
evaluators=evaluators,
|
||||
game_positions=GAME_POSITIONS)
|
||||
game_positions=GAME_POSITIONS,
|
||||
)
|
||||
|
||||
|
||||
@evaluations_bp.route('/<int:tryout_id>/players')
|
||||
@@ -222,9 +269,10 @@ def players_to_evaluate(tryout_id):
|
||||
p = User.query.get(reg.player_id)
|
||||
if p and isinstance(p, Player):
|
||||
existing = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
|
||||
tryout_id=tryout_id,
|
||||
player_id=p.id,
|
||||
evaluator_id=current_user.id,
|
||||
).first()
|
||||
players.append({'player': p, 'evaluated': existing is not None,
|
||||
'registration': reg})
|
||||
players.append({'player': p, 'evaluated': existing is not None, 'registration': reg})
|
||||
|
||||
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
||||
+96
-36
@@ -8,9 +8,18 @@ from flask_login import login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player, Scout,
|
||||
User, Tryout, Evaluation, TryoutRegistration, TeamMember,
|
||||
Match, MatchParticipant,
|
||||
Admin,
|
||||
Manager,
|
||||
Coach,
|
||||
Player,
|
||||
Scout,
|
||||
User,
|
||||
Tryout,
|
||||
Evaluation,
|
||||
TryoutRegistration,
|
||||
TeamMember,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
)
|
||||
from app.permissions import coach_tryout_ids
|
||||
from sqlalchemy import func
|
||||
@@ -46,8 +55,9 @@ def set_language(locale):
|
||||
target = request.referrer
|
||||
if target and is_safe_url(target):
|
||||
return redirect(target)
|
||||
return redirect(url_for('main.dashboard') if current_user.is_authenticated
|
||||
else url_for('auth.login'))
|
||||
return redirect(
|
||||
url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login')
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route('/dashboard')
|
||||
@@ -70,47 +80,82 @@ def dashboard():
|
||||
stats['recent_users'] = User.query.order_by(User.created_at.desc()).limit(10).all()
|
||||
stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
|
||||
today = date.today()
|
||||
stats['upcoming_matches'] = Match.query.filter(
|
||||
Match.status == 'scheduled', Match.date >= today,
|
||||
).order_by(Match.date, Match.start_time).limit(5).all()
|
||||
stats['upcoming_matches'] = (
|
||||
Match.query.filter(
|
||||
Match.status == 'scheduled',
|
||||
Match.date >= today,
|
||||
)
|
||||
.order_by(Match.date, Match.start_time)
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
elif isinstance(user, Manager):
|
||||
stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count()
|
||||
stats['active_tryouts'] = Tryout.query.filter_by(
|
||||
created_by=user.id, status='in_progress').count()
|
||||
created_by=user.id, status='in_progress'
|
||||
).count()
|
||||
stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
||||
stats['my_tryouts'] = Tryout.query.filter_by(
|
||||
created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
|
||||
stats['my_tryouts'] = (
|
||||
Tryout.query.filter_by(created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
|
||||
)
|
||||
today = date.today()
|
||||
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
|
||||
stats['upcoming_matches'] = Match.query.filter(
|
||||
stats['upcoming_matches'] = (
|
||||
Match.query.filter(
|
||||
Match.tryout_id.in_(manager_tryout_ids),
|
||||
Match.status == 'scheduled', Match.date >= today,
|
||||
).order_by(Match.date, Match.start_time).limit(5).all() if manager_tryout_ids else []
|
||||
Match.status == 'scheduled',
|
||||
Match.date >= today,
|
||||
)
|
||||
.order_by(Match.date, Match.start_time)
|
||||
.limit(5)
|
||||
.all()
|
||||
if manager_tryout_ids
|
||||
else []
|
||||
)
|
||||
|
||||
elif isinstance(user, Coach):
|
||||
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
||||
registrations = TryoutRegistration.query.filter(
|
||||
TryoutRegistration.status.in_(['registered', 'attended'])).all()
|
||||
TryoutRegistration.status.in_(['registered', 'attended'])
|
||||
).all()
|
||||
registered_player_ids = [r.player_id for r in registrations]
|
||||
evaluated_player_ids = [e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()]
|
||||
evaluated_player_ids = [
|
||||
e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()
|
||||
]
|
||||
stats['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids))
|
||||
stats['my_recent_evaluations'] = Evaluation.query.filter_by(
|
||||
evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all()
|
||||
stats['my_recent_evaluations'] = (
|
||||
Evaluation.query.filter_by(evaluator_id=user.id)
|
||||
.order_by(Evaluation.created_at.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
today = date.today()
|
||||
# Was: the first team matching the legacy coach_id column, and only
|
||||
# the tryouts targeting it. A coach attached by the many-to-many
|
||||
# relationship, or coaching a second team, saw no upcoming match.
|
||||
tryout_ids = coach_tryout_ids(user)
|
||||
stats['upcoming_matches'] = Match.query.filter(
|
||||
stats['upcoming_matches'] = (
|
||||
Match.query.filter(
|
||||
Match.tryout_id.in_(tryout_ids),
|
||||
Match.status == 'scheduled', Match.date >= today,
|
||||
).order_by(Match.date, Match.start_time).limit(5).all() if tryout_ids else []
|
||||
Match.status == 'scheduled',
|
||||
Match.date >= today,
|
||||
)
|
||||
.order_by(Match.date, Match.start_time)
|
||||
.limit(5)
|
||||
.all()
|
||||
if tryout_ids
|
||||
else []
|
||||
)
|
||||
|
||||
elif isinstance(user, Player):
|
||||
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
|
||||
stats['my_registrations'] = TryoutRegistration.query.filter_by(
|
||||
player_id=user.id).order_by(TryoutRegistration.registered_at.desc()).limit(5).all()
|
||||
stats['my_registrations'] = (
|
||||
TryoutRegistration.query.filter_by(player_id=user.id)
|
||||
.order_by(TryoutRegistration.registered_at.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
today = date.today()
|
||||
next_matches = []
|
||||
@@ -121,10 +166,15 @@ def dashboard():
|
||||
player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all()
|
||||
player_team_ids = [tm.team_id for tm in player_team_memberships]
|
||||
|
||||
upcoming_matches = Match.query.filter(
|
||||
upcoming_matches = (
|
||||
Match.query.filter(
|
||||
Match.tryout_id.in_(registered_tryout_ids),
|
||||
Match.status == 'scheduled', Match.date >= today,
|
||||
).order_by(Match.date, Match.start_time).all()
|
||||
Match.status == 'scheduled',
|
||||
Match.date >= today,
|
||||
)
|
||||
.order_by(Match.date, Match.start_time)
|
||||
.all()
|
||||
)
|
||||
|
||||
for match in upcoming_matches:
|
||||
is_participant = False
|
||||
@@ -132,32 +182,42 @@ def dashboard():
|
||||
if match.match_type == 'team_vs_team':
|
||||
if match.team1_id in player_team_ids:
|
||||
is_participant = True
|
||||
team = next((tm for tm in player_team_memberships
|
||||
if tm.team_id == match.team1_id), None)
|
||||
team = next(
|
||||
(tm for tm in player_team_memberships if tm.team_id == match.team1_id), None
|
||||
)
|
||||
elif match.team2_id in player_team_ids:
|
||||
is_participant = True
|
||||
team = next((tm for tm in player_team_memberships
|
||||
if tm.team_id == match.team2_id), None)
|
||||
team = next(
|
||||
(tm for tm in player_team_memberships if tm.team_id == match.team2_id), None
|
||||
)
|
||||
else:
|
||||
if match.id in player_match_ids:
|
||||
is_participant = True
|
||||
|
||||
if is_participant:
|
||||
next_matches.append({
|
||||
'tryout': match.tryout, 'match': match,
|
||||
next_matches.append(
|
||||
{
|
||||
'tryout': match.tryout,
|
||||
'match': match,
|
||||
'team': team.team if team else None,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
stats['next_matches'] = next_matches
|
||||
|
||||
elif isinstance(user, Scout):
|
||||
stats['total_players'] = User.query.filter_by(role='player').count()
|
||||
stats['total_evaluations'] = Evaluation.query.count()
|
||||
stats['avg_scores'] = db.session.query(
|
||||
stats['avg_scores'] = (
|
||||
db.session.query(
|
||||
Evaluation.player_id,
|
||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
||||
).group_by(Evaluation.player_id).order_by(
|
||||
func.avg(Evaluation.overall_score).desc()).limit(5).all()
|
||||
)
|
||||
.group_by(Evaluation.player_id)
|
||||
.order_by(func.avg(Evaluation.overall_score).desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
stats['top_players'] = []
|
||||
for row in stats['avg_scores']:
|
||||
p = User.query.get(row.player_id)
|
||||
|
||||
+204
-75
@@ -8,10 +8,21 @@ from flask_login import login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player, Scout,
|
||||
User, Tryout, Match, MatchParticipant, Team, TeamMember,
|
||||
TryoutRegistration, PlayerDisponibility,
|
||||
OneOnOneRequest, PersonalNote,
|
||||
Admin,
|
||||
Manager,
|
||||
Coach,
|
||||
Player,
|
||||
Scout,
|
||||
User,
|
||||
Tryout,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
Team,
|
||||
TeamMember,
|
||||
TryoutRegistration,
|
||||
PlayerDisponibility,
|
||||
OneOnOneRequest,
|
||||
PersonalNote,
|
||||
)
|
||||
from datetime import datetime, timedelta
|
||||
from app.discord_bot import send_schedule_notification
|
||||
@@ -73,42 +84,50 @@ def api_events():
|
||||
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
||||
|
||||
user_participant = MatchParticipant.query.filter_by(
|
||||
match_id=match.id, player_id=current_user.id,
|
||||
match_id=match.id,
|
||||
player_id=current_user.id,
|
||||
).first()
|
||||
|
||||
events.append({
|
||||
events.append(
|
||||
{
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'date': match.date.strftime('%Y-%m-%d'),
|
||||
'type': 'match', 'color': match_color,
|
||||
'type': 'match',
|
||||
'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status, 'description': match.description or '',
|
||||
'status': match.status,
|
||||
'description': match.description or '',
|
||||
'match_type': match.match_type,
|
||||
'tryout_id': tryout.id, 'match_id': match.id,
|
||||
'start_time': start_time_str, 'end_time': end_time_str,
|
||||
'tryout_id': tryout.id,
|
||||
'match_id': match.id,
|
||||
'start_time': start_time_str,
|
||||
'end_time': end_time_str,
|
||||
'participants': participants_str,
|
||||
'user_participant_id': user_participant.id if user_participant else None,
|
||||
'user_attendance_confirmed': user_participant.attendance_confirmed if user_participant else False,
|
||||
'user_attendance_confirmed': user_participant.attendance_confirmed
|
||||
if user_participant
|
||||
else False,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
# Add approved One on One sessions for the current user (player or coach)
|
||||
if isinstance(current_user, Player):
|
||||
one_on_ones = OneOnOneRequest.query.filter_by(
|
||||
player_id=current_user.id,
|
||||
status='approved'
|
||||
player_id=current_user.id, status='approved'
|
||||
).all()
|
||||
elif isinstance(current_user, Coach):
|
||||
one_on_ones = OneOnOneRequest.query.filter_by(
|
||||
coach_id=current_user.id,
|
||||
status='approved'
|
||||
coach_id=current_user.id, status='approved'
|
||||
).all()
|
||||
else:
|
||||
one_on_ones = []
|
||||
|
||||
for ooo in one_on_ones:
|
||||
events.append({
|
||||
events.append(
|
||||
{
|
||||
'id': f'one_on_one_{ooo.id}',
|
||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||
'date': ooo.date.strftime('%Y-%m-%d'),
|
||||
@@ -122,7 +141,8 @@ def api_events():
|
||||
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
|
||||
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
@@ -137,13 +157,21 @@ def api_events_for_tryout(tryout_id):
|
||||
is_registered = False
|
||||
player_in_match = False
|
||||
if isinstance(current_user, Player):
|
||||
is_registered = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id,
|
||||
).first() is not None
|
||||
player_matches = Match.query.join(MatchParticipant).filter(
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=current_user.id,
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
player_matches = (
|
||||
Match.query.join(MatchParticipant)
|
||||
.filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
).all()
|
||||
)
|
||||
.all()
|
||||
)
|
||||
player_in_match = len(player_matches) > 0
|
||||
|
||||
if not can_view and not is_registered and not player_in_match:
|
||||
@@ -152,7 +180,9 @@ def api_events_for_tryout(tryout_id):
|
||||
events = []
|
||||
|
||||
for match in tryout.matches:
|
||||
match_color = '#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
|
||||
match_color = (
|
||||
'#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
|
||||
)
|
||||
participants_str = ''
|
||||
if match.match_type == 'team_vs_team':
|
||||
teams = []
|
||||
@@ -162,8 +192,16 @@ def api_events_for_tryout(tryout_id):
|
||||
teams.append(match.team2.name)
|
||||
participants_str = f"{' vs '.join(teams)}"
|
||||
elif match.match_type == 'player_vs_player':
|
||||
team1_players = [p.player.username for p in match.participants.filter_by(team_side=1).all() if p.player]
|
||||
team2_players = [p.player.username for p in match.participants.filter_by(team_side=2).all() if p.player]
|
||||
team1_players = [
|
||||
p.player.username
|
||||
for p in match.participants.filter_by(team_side=1).all()
|
||||
if p.player
|
||||
]
|
||||
team2_players = [
|
||||
p.player.username
|
||||
for p in match.participants.filter_by(team_side=2).all()
|
||||
if p.player
|
||||
]
|
||||
if team1_players and team2_players:
|
||||
participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}"
|
||||
else:
|
||||
@@ -175,19 +213,25 @@ def api_events_for_tryout(tryout_id):
|
||||
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
|
||||
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
||||
|
||||
events.append({
|
||||
events.append(
|
||||
{
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'date': match.date.strftime('%Y-%m-%d'),
|
||||
'type': 'match', 'color': match_color,
|
||||
'type': 'match',
|
||||
'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status, 'match_type': match.match_type,
|
||||
'tryout_id': tryout.id, 'match_id': match.id,
|
||||
'status': match.status,
|
||||
'match_type': match.match_type,
|
||||
'tryout_id': tryout.id,
|
||||
'match_id': match.id,
|
||||
'participants': participants_str,
|
||||
'start_time': start_time_str, 'end_time': end_time_str,
|
||||
'start_time': start_time_str,
|
||||
'end_time': end_time_str,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
@@ -207,7 +251,9 @@ def create_match(tryout_id):
|
||||
|
||||
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||
all_players = [User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)]
|
||||
all_players = [
|
||||
User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)
|
||||
]
|
||||
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
|
||||
prefill_date = request.args.get('date', '')
|
||||
|
||||
@@ -222,15 +268,25 @@ def create_match(tryout_id):
|
||||
|
||||
if not start_time_str:
|
||||
flash(_('Start time is required. Please select a time slot.'), 'danger')
|
||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
|
||||
all_players=all_players, prefill_date=prefill_date)
|
||||
return render_template(
|
||||
'pages/match_form.html',
|
||||
tryout=tryout,
|
||||
teams=teams,
|
||||
all_players=all_players,
|
||||
prefill_date=prefill_date,
|
||||
)
|
||||
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date
|
||||
except (ValueError, TypeError):
|
||||
flash(_('Invalid date format.'), 'danger')
|
||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
|
||||
all_players=all_players, prefill_date=prefill_date)
|
||||
return render_template(
|
||||
'pages/match_form.html',
|
||||
tryout=tryout,
|
||||
teams=teams,
|
||||
all_players=all_players,
|
||||
prefill_date=prefill_date,
|
||||
)
|
||||
|
||||
start_time = None
|
||||
end_time = None
|
||||
@@ -244,12 +300,20 @@ def create_match(tryout_id):
|
||||
end_time = end_dt.time()
|
||||
except ValueError:
|
||||
flash(_('Invalid time format.'), 'danger')
|
||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players)
|
||||
return render_template(
|
||||
'pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players
|
||||
)
|
||||
|
||||
match = Match(
|
||||
tryout_id=tryout_id, title=title, description=description,
|
||||
date=date_obj, start_time=start_time, end_time=end_time,
|
||||
location=location, match_type=match_type, created_by=current_user.id,
|
||||
tryout_id=tryout_id,
|
||||
title=title,
|
||||
description=description,
|
||||
date=date_obj,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
location=location,
|
||||
match_type=match_type,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.session.add(match)
|
||||
db.session.flush()
|
||||
@@ -264,14 +328,18 @@ def create_match(tryout_id):
|
||||
match.team2_id = int(team2_id) if team2_id else None
|
||||
if match.team1_id:
|
||||
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
|
||||
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1)
|
||||
participant = MatchParticipant(
|
||||
match_id=match.id, player_id=m.player_id, team_side=1
|
||||
)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids.append(m.player_id)
|
||||
if match.team2_id:
|
||||
for m in TeamMember.query.filter_by(team_id=match.team2_id).all():
|
||||
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=2)
|
||||
participant = MatchParticipant(
|
||||
match_id=match.id, player_id=m.player_id, team_side=2
|
||||
)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
@@ -279,8 +347,12 @@ def create_match(tryout_id):
|
||||
elif match_type == 'player_vs_player':
|
||||
team1_player_ids = request.form.get('team1_player_ids', '')
|
||||
team2_player_ids = request.form.get('team2_player_ids', '')
|
||||
team1_ids = [int(p) for p in team1_player_ids.split(',') if p] if team1_player_ids else []
|
||||
team2_ids = [int(p) for p in team2_player_ids.split(',') if p] if team2_player_ids else []
|
||||
team1_ids = (
|
||||
[int(p) for p in team1_player_ids.split(',') if p] if team1_player_ids else []
|
||||
)
|
||||
team2_ids = (
|
||||
[int(p) for p in team2_player_ids.split(',') if p] if team2_player_ids else []
|
||||
)
|
||||
for pid in team1_ids:
|
||||
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1)
|
||||
db.session.add(participant)
|
||||
@@ -305,20 +377,34 @@ def create_match(tryout_id):
|
||||
|
||||
# Discord notifications
|
||||
event_date_str = date_obj.strftime('%Y-%m-%d')
|
||||
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
|
||||
event_time_str = (
|
||||
f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}"
|
||||
if start_time and end_time
|
||||
else 'TBD'
|
||||
)
|
||||
for i, player_id in enumerate(notified_player_ids):
|
||||
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
|
||||
reference_id = (
|
||||
notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
|
||||
)
|
||||
send_schedule_notification(
|
||||
user_id=player_id, event_type='match', event_title=match.title,
|
||||
event_date=event_date_str, event_time=event_time_str,
|
||||
user_id=player_id,
|
||||
event_type='match',
|
||||
event_title=match.title,
|
||||
event_date=event_date_str,
|
||||
event_time=event_time_str,
|
||||
reference_id=reference_id,
|
||||
)
|
||||
|
||||
flash(_('Match scheduled successfully!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
|
||||
all_players=all_players, prefill_date=prefill_date)
|
||||
return render_template(
|
||||
'pages/match_form.html',
|
||||
tryout=tryout,
|
||||
teams=teams,
|
||||
all_players=all_players,
|
||||
prefill_date=prefill_date,
|
||||
)
|
||||
|
||||
|
||||
@matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
|
||||
@@ -357,15 +443,25 @@ def edit_match(match_id):
|
||||
match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash(_('Invalid date format.'), 'danger')
|
||||
return render_template('pages/match_form.html', match=match, tryout=tryout,
|
||||
teams=teams, all_players=all_players,
|
||||
current_player_ids=current_player_ids)
|
||||
return render_template(
|
||||
'pages/match_form.html',
|
||||
match=match,
|
||||
tryout=tryout,
|
||||
teams=teams,
|
||||
all_players=all_players,
|
||||
current_player_ids=current_player_ids,
|
||||
)
|
||||
|
||||
if not start_time_str:
|
||||
flash(_('Start time is required.'), 'danger')
|
||||
return render_template('pages/match_form.html', match=match, tryout=tryout,
|
||||
teams=teams, all_players=all_players,
|
||||
current_player_ids=current_player_ids)
|
||||
return render_template(
|
||||
'pages/match_form.html',
|
||||
match=match,
|
||||
tryout=tryout,
|
||||
teams=teams,
|
||||
all_players=all_players,
|
||||
current_player_ids=current_player_ids,
|
||||
)
|
||||
|
||||
try:
|
||||
match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
@@ -397,23 +493,37 @@ def edit_match(match_id):
|
||||
match.team2_id = new_team2_id
|
||||
if match.team1_id:
|
||||
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
|
||||
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1)
|
||||
participant = MatchParticipant(
|
||||
match_id=match.id, player_id=m.player_id, team_side=1
|
||||
)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids.append(m.player_id)
|
||||
if match.team2_id:
|
||||
for m in TeamMember.query.filter_by(team_id=match.team2_id).all():
|
||||
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=2)
|
||||
participant = MatchParticipant(
|
||||
match_id=match.id, player_id=m.player_id, team_side=2
|
||||
)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
notified_participant_ids.append(participant.id)
|
||||
notified_player_ids.append(m.player_id)
|
||||
else:
|
||||
if match.team1_id:
|
||||
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team1_id).all()])
|
||||
notified_player_ids.extend(
|
||||
[
|
||||
m.player_id
|
||||
for m in TeamMember.query.filter_by(team_id=match.team1_id).all()
|
||||
]
|
||||
)
|
||||
if match.team2_id:
|
||||
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team2_id).all()])
|
||||
notified_player_ids.extend(
|
||||
[
|
||||
m.player_id
|
||||
for m in TeamMember.query.filter_by(team_id=match.team2_id).all()
|
||||
]
|
||||
)
|
||||
elif match.match_type == 'player_vs_player':
|
||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||
team1_str = request.form.get('team1_player_ids', '')
|
||||
@@ -446,15 +556,22 @@ def edit_match(match_id):
|
||||
# Discord notifications
|
||||
end_time_val = match.end_time or (match.start_time if match.start_time else None)
|
||||
if match.start_time and end_time_val:
|
||||
event_time_str = f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}"
|
||||
event_time_str = (
|
||||
f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}"
|
||||
)
|
||||
else:
|
||||
event_time_str = 'TBD'
|
||||
event_date_str = match.date.strftime('%Y-%m-%d')
|
||||
for i, player_id in enumerate(notified_player_ids):
|
||||
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
|
||||
reference_id = (
|
||||
notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
|
||||
)
|
||||
send_schedule_notification(
|
||||
user_id=player_id, event_type='match', event_title=match.title,
|
||||
event_date=event_date_str, event_time=event_time_str,
|
||||
user_id=player_id,
|
||||
event_type='match',
|
||||
event_title=match.title,
|
||||
event_date=event_date_str,
|
||||
event_time=event_time_str,
|
||||
reference_id=reference_id,
|
||||
)
|
||||
|
||||
@@ -469,12 +586,17 @@ def edit_match(match_id):
|
||||
'team_side': p.team_side,
|
||||
}
|
||||
|
||||
return render_template('pages/match_form.html', match=match, tryout=tryout,
|
||||
teams=teams, all_players=all_players,
|
||||
return render_template(
|
||||
'pages/match_form.html',
|
||||
match=match,
|
||||
tryout=tryout,
|
||||
teams=teams,
|
||||
all_players=all_players,
|
||||
current_player_ids=current_player_ids,
|
||||
team1_player_ids=team1_player_ids,
|
||||
team2_player_ids=team2_player_ids,
|
||||
participants_map=participants_map)
|
||||
participants_map=participants_map,
|
||||
)
|
||||
|
||||
|
||||
@matches_bp.route('/api/manageable-tryouts')
|
||||
@@ -488,11 +610,14 @@ def api_manageable_tryouts():
|
||||
manageable = []
|
||||
for t in tryouts:
|
||||
if current_user.can_manage_this_tryout(t):
|
||||
manageable.append({
|
||||
'id': t.id, 'title': t.title,
|
||||
manageable.append(
|
||||
{
|
||||
'id': t.id,
|
||||
'title': t.title,
|
||||
'date': t.date.strftime('%Y-%m-%d'),
|
||||
'end_date': t.end_date.strftime('%Y-%m-%d') if t.end_date else None,
|
||||
})
|
||||
}
|
||||
)
|
||||
return jsonify(manageable)
|
||||
|
||||
|
||||
@@ -514,7 +639,8 @@ def delete_match(match_id):
|
||||
# Only the context link is dropped. Participants go through the
|
||||
# relationship's delete-orphan cascade.
|
||||
PersonalNote.query.filter_by(match_id=match_id).update(
|
||||
{'match_id': None}, synchronize_session=False)
|
||||
{'match_id': None}, synchronize_session=False
|
||||
)
|
||||
|
||||
db.session.delete(match)
|
||||
db.session.commit()
|
||||
@@ -536,7 +662,8 @@ def get_players_available_at_time(date_str, time_str):
|
||||
available_players = []
|
||||
for player in players:
|
||||
disponibilities = PlayerDisponibility.query.filter_by(
|
||||
player_id=player.id, day_of_week=day_of_week,
|
||||
player_id=player.id,
|
||||
day_of_week=day_of_week,
|
||||
).all()
|
||||
for disp in disponibilities:
|
||||
disp_start = disp.start_time.hour * 60 + disp.start_time.minute
|
||||
@@ -575,8 +702,10 @@ def toggle_presence(match_id, participant_id):
|
||||
|
||||
participant.attendance_confirmed = not participant.attendance_confirmed
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
return jsonify(
|
||||
{
|
||||
'participant_id': participant.id,
|
||||
'attendance_confirmed': participant.attendance_confirmed,
|
||||
'player_name': participant.player.username if participant.player else 'Unknown',
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
+90
-38
@@ -8,8 +8,14 @@ from flask_login import login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player,
|
||||
OrgTeam, TeamMatch, TeamMatchParticipant, TeamPlayer,
|
||||
Admin,
|
||||
Manager,
|
||||
Coach,
|
||||
Player,
|
||||
OrgTeam,
|
||||
TeamMatch,
|
||||
TeamMatchParticipant,
|
||||
TeamPlayer,
|
||||
)
|
||||
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
||||
from datetime import datetime, timedelta
|
||||
@@ -41,9 +47,13 @@ def list_matches():
|
||||
elif isinstance(current_user, (Coach, Player)):
|
||||
teams = visible_org_teams(current_user)
|
||||
team_ids = [t.id for t in teams]
|
||||
matches_query = TeamMatch.query.filter(
|
||||
matches_query = (
|
||||
TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id.in_(team_ids),
|
||||
) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
)
|
||||
if team_ids
|
||||
else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
)
|
||||
else:
|
||||
teams = []
|
||||
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
@@ -58,18 +68,25 @@ def list_matches():
|
||||
confirmed, total = tm.get_confirmed_count()
|
||||
participants = []
|
||||
for p in tm.participants.all():
|
||||
participants.append({
|
||||
'id': p.id, 'player': p.player,
|
||||
participants.append(
|
||||
{
|
||||
'id': p.id,
|
||||
'player': p.player,
|
||||
'is_confirmed': p.is_confirmed,
|
||||
})
|
||||
match_data.append({
|
||||
'match': tm, 'participants': participants,
|
||||
'confirmed_count': confirmed, 'total_count': total,
|
||||
})
|
||||
}
|
||||
)
|
||||
match_data.append(
|
||||
{
|
||||
'match': tm,
|
||||
'participants': participants,
|
||||
'confirmed_count': confirmed,
|
||||
'total_count': total,
|
||||
}
|
||||
)
|
||||
|
||||
return render_template('pages/team_matches.html',
|
||||
teams=teams, match_data=match_data,
|
||||
now=datetime.utcnow())
|
||||
return render_template(
|
||||
'pages/team_matches.html', teams=teams, match_data=match_data, now=datetime.utcnow()
|
||||
)
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
|
||||
@@ -87,6 +104,7 @@ def create_match(team_id):
|
||||
default_title = 'Practice' if is_practice else f'Team Match — {team.name}'
|
||||
|
||||
if is_practice and request.method == 'GET':
|
||||
|
||||
class TryoutProxy:
|
||||
def __init__(self, team_obj):
|
||||
self.id = 0
|
||||
@@ -98,10 +116,16 @@ def create_match(team_id):
|
||||
proxy_tryout = TryoutProxy(team)
|
||||
all_players = [tp.player for tp in team_players if tp.player]
|
||||
|
||||
return render_template('pages/match_form.html',
|
||||
tryout=proxy_tryout, teams=[], all_players=all_players,
|
||||
prefill_date=prefill_date, is_practice=True,
|
||||
team_id=team_id, team=team)
|
||||
return render_template(
|
||||
'pages/match_form.html',
|
||||
tryout=proxy_tryout,
|
||||
teams=[],
|
||||
all_players=all_players,
|
||||
prefill_date=prefill_date,
|
||||
is_practice=True,
|
||||
team_id=team_id,
|
||||
team=team,
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title', default_title)
|
||||
@@ -114,16 +138,24 @@ def create_match(team_id):
|
||||
|
||||
if not date_str:
|
||||
flash(_('Date is required.'), 'danger')
|
||||
return render_template('pages/team_match_form.html', team=team,
|
||||
team_players=team_players, prefill_date=prefill_date)
|
||||
return render_template(
|
||||
'pages/team_match_form.html',
|
||||
team=team,
|
||||
team_players=team_players,
|
||||
prefill_date=prefill_date,
|
||||
)
|
||||
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash(_('Invalid date format.'), 'danger')
|
||||
return render_template('pages/team_match_form.html', team=team,
|
||||
team_players=team_players, prefill_date=prefill_date,
|
||||
is_practice=is_practice)
|
||||
return render_template(
|
||||
'pages/team_match_form.html',
|
||||
team=team,
|
||||
team_players=team_players,
|
||||
prefill_date=prefill_date,
|
||||
is_practice=is_practice,
|
||||
)
|
||||
|
||||
start_time = None
|
||||
end_time = None
|
||||
@@ -138,16 +170,24 @@ def create_match(team_id):
|
||||
end_time = end_dt.time()
|
||||
except ValueError:
|
||||
flash(_('Invalid time format.'), 'danger')
|
||||
return render_template('pages/team_match_form.html', team=team,
|
||||
team_players=team_players, prefill_date=prefill_date,
|
||||
is_practice=is_practice)
|
||||
return render_template(
|
||||
'pages/team_match_form.html',
|
||||
team=team,
|
||||
team_players=team_players,
|
||||
prefill_date=prefill_date,
|
||||
is_practice=is_practice,
|
||||
)
|
||||
|
||||
team_match = TeamMatch(
|
||||
org_team_id=team_id, title=title,
|
||||
org_team_id=team_id,
|
||||
title=title,
|
||||
description=description or None,
|
||||
opponent=opponent or None,
|
||||
date=date_obj, start_time=start_time, end_time=end_time,
|
||||
location=location or None, created_by=current_user.id,
|
||||
date=date_obj,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
location=location or None,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.session.add(team_match)
|
||||
db.session.flush()
|
||||
@@ -155,7 +195,8 @@ def create_match(team_id):
|
||||
notified_participant_ids = []
|
||||
for tp in team_players:
|
||||
participant = TeamMatchParticipant(
|
||||
team_match_id=team_match.id, player_id=tp.player_id,
|
||||
team_match_id=team_match.id,
|
||||
player_id=tp.player_id,
|
||||
)
|
||||
db.session.add(participant)
|
||||
db.session.flush()
|
||||
@@ -165,14 +206,22 @@ def create_match(team_id):
|
||||
|
||||
# Discord notifications
|
||||
event_date_str = date_obj.strftime('%Y-%m-%d')
|
||||
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
|
||||
event_time_str = (
|
||||
f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}"
|
||||
if start_time and end_time
|
||||
else 'TBD'
|
||||
)
|
||||
|
||||
for i, tp in enumerate(team_players):
|
||||
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else team_match.id
|
||||
reference_id = (
|
||||
notified_participant_ids[i] if i < len(notified_participant_ids) else team_match.id
|
||||
)
|
||||
send_schedule_notification(
|
||||
user_id=tp.player_id, event_type='match',
|
||||
user_id=tp.player_id,
|
||||
event_type='match',
|
||||
event_title=team_match.title,
|
||||
event_date=event_date_str, event_time=event_time_str,
|
||||
event_date=event_date_str,
|
||||
event_time=event_time_str,
|
||||
reference_id=reference_id,
|
||||
)
|
||||
|
||||
@@ -229,8 +278,9 @@ def edit_match(match_id):
|
||||
flash(_('Match updated successfully!'), 'success')
|
||||
return redirect(url_for('team_matches.list_matches'))
|
||||
|
||||
return render_template('pages/team_match_form.html',
|
||||
match=team_match, team=team, team_players=[])
|
||||
return render_template(
|
||||
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
|
||||
)
|
||||
|
||||
|
||||
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
|
||||
@@ -282,8 +332,10 @@ def toggle_presence(match_id, participant_id):
|
||||
|
||||
participant.is_confirmed = not participant.is_confirmed
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
return jsonify(
|
||||
{
|
||||
'participant_id': participant.id,
|
||||
'is_confirmed': participant.is_confirmed,
|
||||
'player_name': participant.player.username if participant.player else 'Unknown',
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
+100
-32
@@ -8,9 +8,19 @@ from flask_login import login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player,
|
||||
OrgTeam, User, PersonalNote, TeamNote, Tryout, TeamPlayer,
|
||||
TeamMatch, Contract, OneOnOneRequest,
|
||||
Admin,
|
||||
Manager,
|
||||
Coach,
|
||||
Player,
|
||||
OrgTeam,
|
||||
User,
|
||||
PersonalNote,
|
||||
TeamNote,
|
||||
Tryout,
|
||||
TeamPlayer,
|
||||
TeamMatch,
|
||||
Contract,
|
||||
OneOnOneRequest,
|
||||
)
|
||||
from app.permissions import visible_org_teams
|
||||
from datetime import datetime
|
||||
@@ -33,11 +43,21 @@ def list_teams():
|
||||
|
||||
teams = visible_org_teams(current_user)
|
||||
|
||||
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||
coaches = (
|
||||
User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
managers = (
|
||||
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
all_players = User.query.filter_by(role='player').order_by(User.username).all()
|
||||
return render_template('pages/teams.html', teams=teams, coaches=coaches,
|
||||
managers=managers, all_players=all_players, can_manage=can_manage)
|
||||
return render_template(
|
||||
'pages/teams.html',
|
||||
teams=teams,
|
||||
coaches=coaches,
|
||||
managers=managers,
|
||||
all_players=all_players,
|
||||
can_manage=can_manage,
|
||||
)
|
||||
|
||||
|
||||
@teams_bp.route('/my-teams')
|
||||
@@ -55,29 +75,40 @@ def my_teams():
|
||||
team_data = []
|
||||
|
||||
for org_team in player_teams:
|
||||
matches = TeamMatch.query.filter(
|
||||
matches = (
|
||||
TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id == org_team.id,
|
||||
TeamMatch.status == 'scheduled',
|
||||
).order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc()).all()
|
||||
)
|
||||
.order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
matches_data = []
|
||||
for tm in matches:
|
||||
confirmed, total = tm.get_confirmed_count()
|
||||
participant = TeamMatchParticipant.query.filter_by(
|
||||
team_match_id=tm.id, player_id=current_user.id,
|
||||
team_match_id=tm.id,
|
||||
player_id=current_user.id,
|
||||
).first()
|
||||
matches_data.append({
|
||||
matches_data.append(
|
||||
{
|
||||
'match': tm,
|
||||
'participant_id': participant.id if participant else None,
|
||||
'is_confirmed': participant.is_confirmed if participant else False,
|
||||
'confirmed_count': confirmed, 'total_count': total,
|
||||
})
|
||||
'confirmed_count': confirmed,
|
||||
'total_count': total,
|
||||
}
|
||||
)
|
||||
|
||||
team_data.append({
|
||||
'team': org_team, 'matches': matches_data,
|
||||
team_data.append(
|
||||
{
|
||||
'team': org_team,
|
||||
'matches': matches_data,
|
||||
'coaches': org_team.get_coaches(),
|
||||
'managers': org_team.get_managers(),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return render_template('pages/my_teams.html', team_data=team_data, now=now)
|
||||
|
||||
@@ -215,11 +246,12 @@ def delete_team(team_id):
|
||||
|
||||
# Entities that survive it.
|
||||
Tryout.query.filter_by(target_org_team_id=team_id).update(
|
||||
{'target_org_team_id': None}, synchronize_session=False)
|
||||
Contract.query.filter_by(team_id=team_id).update(
|
||||
{'team_id': None}, synchronize_session=False)
|
||||
{'target_org_team_id': None}, synchronize_session=False
|
||||
)
|
||||
Contract.query.filter_by(team_id=team_id).update({'team_id': None}, synchronize_session=False)
|
||||
OneOnOneRequest.query.filter_by(org_team_id=team_id).update(
|
||||
{'org_team_id': None}, synchronize_session=False)
|
||||
{'org_team_id': None}, synchronize_session=False
|
||||
)
|
||||
|
||||
db.session.delete(team)
|
||||
db.session.commit()
|
||||
@@ -247,14 +279,24 @@ def add_coach(team_id):
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
if team.coaches.filter_by(id=coach.id).first():
|
||||
flash(_('%(username)s is already a coach of %(name)s.', username=coach.username, name=team.name), 'info')
|
||||
flash(
|
||||
_(
|
||||
'%(username)s is already a coach of %(name)s.',
|
||||
username=coach.username,
|
||||
name=team.name,
|
||||
),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
team.coaches.append(coach)
|
||||
if not team.coach_id:
|
||||
team.coach_id = coach.id
|
||||
db.session.commit()
|
||||
flash(_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name), 'success')
|
||||
flash(
|
||||
_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@@ -278,14 +320,24 @@ def add_manager(team_id):
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
if team.managers.filter_by(id=manager.id).first():
|
||||
flash(_('%(username)s is already a manager of %(name)s.', username=manager.username, name=team.name), 'info')
|
||||
flash(
|
||||
_(
|
||||
'%(username)s is already a manager of %(name)s.',
|
||||
username=manager.username,
|
||||
name=team.name,
|
||||
),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
team.managers.append(manager)
|
||||
if not team.manager_id:
|
||||
team.manager_id = manager.id
|
||||
db.session.commit()
|
||||
flash(_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name), 'success')
|
||||
flash(
|
||||
_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@@ -361,7 +413,10 @@ def add_player(team_id):
|
||||
|
||||
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
|
||||
if existing:
|
||||
flash(_('%(username)s is already on %(name)s.', username=player.username, name=team.name), 'info')
|
||||
flash(
|
||||
_('%(username)s is already on %(name)s.', username=player.username, name=team.name),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
|
||||
@@ -383,12 +438,18 @@ def remove_player(team_id, player_id):
|
||||
player = User.query.get_or_404(player_id)
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||
if not tp:
|
||||
flash(_('%(username)s is not on %(name)s.', username=player.username, name=team.name), 'danger')
|
||||
flash(
|
||||
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
db.session.delete(tp)
|
||||
db.session.commit()
|
||||
flash(_('%(username)s removed from %(name)s.', username=player.username, name=team.name), 'success')
|
||||
flash(
|
||||
_('%(username)s removed from %(name)s.', username=player.username, name=team.name),
|
||||
'success',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
|
||||
@@ -406,10 +467,14 @@ def toggle_player_status(team_id, player_id):
|
||||
|
||||
tp.status = 'substitute' if tp.status == 'starter' else 'starter'
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'success': True, 'player_id': player_id,
|
||||
'new_status': tp.status, 'player_name': tp.player.username,
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
'success': True,
|
||||
'player_id': player_id,
|
||||
'new_status': tp.status,
|
||||
'player_name': tp.player.username,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
|
||||
@@ -446,7 +511,10 @@ def add_player_note(team_id, player_id):
|
||||
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||
if not tp:
|
||||
flash(_('%(username)s is not on %(name)s.', username=player.username, name=team.name), 'danger')
|
||||
flash(
|
||||
_('%(username)s is not on %(name)s.', username=player.username, name=team.name),
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('teams.list_teams'))
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
|
||||
+221
-83
@@ -9,10 +9,23 @@ from flask_login import login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player, Scout,
|
||||
User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember,
|
||||
OrgTeam, Match, MatchParticipant, PersonalNote,
|
||||
ESPORT_GAMES, GAME_POSITIONS,
|
||||
Admin,
|
||||
Manager,
|
||||
Coach,
|
||||
Player,
|
||||
Scout,
|
||||
User,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
Evaluation,
|
||||
Team,
|
||||
TeamMember,
|
||||
OrgTeam,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
PersonalNote,
|
||||
ESPORT_GAMES,
|
||||
GAME_POSITIONS,
|
||||
)
|
||||
from datetime import datetime
|
||||
|
||||
@@ -44,8 +57,12 @@ def create_tryout():
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||
managers = (
|
||||
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
coaches = (
|
||||
User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title')
|
||||
@@ -63,8 +80,14 @@ def create_tryout():
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash(_('Invalid start date format.'), 'danger')
|
||||
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
return render_template(
|
||||
'pages/tryout_form.html',
|
||||
tryout=None,
|
||||
org_teams=org_teams,
|
||||
managers=managers,
|
||||
coaches=coaches,
|
||||
esport_games=ESPORT_GAMES,
|
||||
)
|
||||
|
||||
end_date_obj = None
|
||||
if end_date_str:
|
||||
@@ -72,19 +95,35 @@ def create_tryout():
|
||||
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||
if end_date_obj < date_obj:
|
||||
flash(_('End date cannot be before start date.'), 'danger')
|
||||
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
return render_template(
|
||||
'pages/tryout_form.html',
|
||||
tryout=None,
|
||||
org_teams=org_teams,
|
||||
managers=managers,
|
||||
coaches=coaches,
|
||||
esport_games=ESPORT_GAMES,
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
flash(_('Invalid end date format.'), 'danger')
|
||||
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
return render_template(
|
||||
'pages/tryout_form.html',
|
||||
tryout=None,
|
||||
org_teams=org_teams,
|
||||
managers=managers,
|
||||
coaches=coaches,
|
||||
esport_games=ESPORT_GAMES,
|
||||
)
|
||||
|
||||
tryout = Tryout(
|
||||
title=title, description=description, game=game, date=date_obj,
|
||||
title=title,
|
||||
description=description,
|
||||
game=game,
|
||||
date=date_obj,
|
||||
end_date=end_date_obj,
|
||||
location=location,
|
||||
max_players=int(max_players) if max_players else None,
|
||||
created_by=current_user.id, status='upcoming',
|
||||
created_by=current_user.id,
|
||||
status='upcoming',
|
||||
target_org_team_id=int(target_org_team_id) if target_org_team_id else None,
|
||||
manager_id=int(manager_id) if manager_id else None,
|
||||
)
|
||||
@@ -100,8 +139,14 @@ def create_tryout():
|
||||
flash(_('Tryout created successfully!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
return render_template(
|
||||
'pages/tryout_form.html',
|
||||
tryout=None,
|
||||
org_teams=org_teams,
|
||||
managers=managers,
|
||||
coaches=coaches,
|
||||
esport_games=ESPORT_GAMES,
|
||||
)
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST'])
|
||||
@@ -119,8 +164,12 @@ def edit_tryout(tryout_id):
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.full_name).all()
|
||||
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all()
|
||||
managers = (
|
||||
User.query.filter_by(role='manager', is_active_account=True).order_by(User.full_name).all()
|
||||
)
|
||||
coaches = (
|
||||
User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all()
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title')
|
||||
@@ -138,8 +187,14 @@ def edit_tryout(tryout_id):
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
flash(_('Invalid start date format.'), 'danger')
|
||||
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
return render_template(
|
||||
'pages/tryout_form.html',
|
||||
tryout=tryout,
|
||||
org_teams=org_teams,
|
||||
managers=managers,
|
||||
coaches=coaches,
|
||||
esport_games=ESPORT_GAMES,
|
||||
)
|
||||
|
||||
end_date_obj = None
|
||||
if end_date_str:
|
||||
@@ -147,12 +202,24 @@ def edit_tryout(tryout_id):
|
||||
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||
if end_date_obj < date_obj:
|
||||
flash(_('End date cannot be before start date.'), 'danger')
|
||||
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
return render_template(
|
||||
'pages/tryout_form.html',
|
||||
tryout=tryout,
|
||||
org_teams=org_teams,
|
||||
managers=managers,
|
||||
coaches=coaches,
|
||||
esport_games=ESPORT_GAMES,
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
flash(_('Invalid end date format.'), 'danger')
|
||||
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
return render_template(
|
||||
'pages/tryout_form.html',
|
||||
tryout=tryout,
|
||||
org_teams=org_teams,
|
||||
managers=managers,
|
||||
coaches=coaches,
|
||||
esport_games=ESPORT_GAMES,
|
||||
)
|
||||
|
||||
tryout.title = title
|
||||
tryout.description = description
|
||||
@@ -175,8 +242,14 @@ def edit_tryout(tryout_id):
|
||||
flash(_('Tryout updated successfully!'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
|
||||
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
|
||||
return render_template(
|
||||
'pages/tryout_form.html',
|
||||
tryout=tryout,
|
||||
org_teams=org_teams,
|
||||
managers=managers,
|
||||
coaches=coaches,
|
||||
esport_games=ESPORT_GAMES,
|
||||
)
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>')
|
||||
@@ -193,12 +266,21 @@ def view_tryout(tryout_id):
|
||||
elif isinstance(current_user, Coach):
|
||||
can_view = current_user.can_manage_this_tryout(tryout)
|
||||
elif isinstance(current_user, Player):
|
||||
is_registered = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id).first() is not None
|
||||
player_in_match = MatchParticipant.query.join(Match).filter(
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
player_in_match = (
|
||||
MatchParticipant.query.join(Match)
|
||||
.filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
).first() is not None
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
can_view = is_registered or player_in_match
|
||||
elif isinstance(current_user, Scout):
|
||||
can_view = True
|
||||
@@ -215,39 +297,55 @@ def view_tryout(tryout_id):
|
||||
if current_user.can_evaluate():
|
||||
for p in registered_players:
|
||||
existing = Evaluation.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
|
||||
tryout_id=tryout_id,
|
||||
player_id=p.id,
|
||||
evaluator_id=current_user.id,
|
||||
).first()
|
||||
player_eval_status[p.id] = existing is not None
|
||||
|
||||
is_registered = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id,
|
||||
).first() is not None
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id,
|
||||
player_id=current_user.id,
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
||||
team_data = []
|
||||
for team in teams:
|
||||
members = TeamMember.query.filter_by(team_id=team.id).all()
|
||||
team_data.append({
|
||||
team_data.append(
|
||||
{
|
||||
'team': team,
|
||||
'members': [{'player': User.query.get(m.player_id), 'position': m.position}
|
||||
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_view_calendar = can_edit
|
||||
if isinstance(current_user, Player):
|
||||
player_in_match = MatchParticipant.query.join(Match).filter(
|
||||
player_in_match = (
|
||||
MatchParticipant.query.join(Match)
|
||||
.filter(
|
||||
MatchParticipant.player_id == current_user.id,
|
||||
Match.tryout_id == tryout_id,
|
||||
).first() is not None
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
can_view_calendar = is_registered or player_in_match
|
||||
|
||||
all_players = None
|
||||
if can_edit:
|
||||
all_players = User.query.filter_by(role='player').order_by(User.username).all()
|
||||
|
||||
matches = Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
|
||||
matches = (
|
||||
Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
|
||||
)
|
||||
match_data = []
|
||||
for match in matches:
|
||||
all_participants = list(match.participants.all())
|
||||
@@ -257,47 +355,79 @@ def view_tryout(tryout_id):
|
||||
player_presence = []
|
||||
for p in all_participants:
|
||||
if p.player:
|
||||
player_presence.append({
|
||||
'participant_id': p.id, 'player_id': p.player_id,
|
||||
player_presence.append(
|
||||
{
|
||||
'participant_id': p.id,
|
||||
'player_id': p.player_id,
|
||||
'player_name': p.player.username,
|
||||
'attendance_confirmed': p.attendance_confirmed,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
if match.match_type == 'team_vs_team':
|
||||
participants = {
|
||||
'team1': match.team1.name if match.team1 else 'TBD',
|
||||
'team2': match.team2.name if match.team2 else 'TBD',
|
||||
'team1_players': [{'name': m.player.username, 'position': m.position}
|
||||
for m in match.team1.members.all()] if match.team1 else [],
|
||||
'team2_players': [{'name': m.player.username, 'position': m.position}
|
||||
for m in match.team2.members.all()] if match.team2 else [],
|
||||
'team1_players': [
|
||||
{'name': m.player.username, 'position': m.position}
|
||||
for m in match.team1.members.all()
|
||||
]
|
||||
if match.team1
|
||||
else [],
|
||||
'team2_players': [
|
||||
{'name': m.player.username, 'position': m.position}
|
||||
for m in match.team2.members.all()
|
||||
]
|
||||
if match.team2
|
||||
else [],
|
||||
}
|
||||
elif match.match_type == 'player_vs_player':
|
||||
team1_players = [{'name': p.player.username, 'position': p.position}
|
||||
for p in match.participants.filter_by(team_side=1).all() if p.player]
|
||||
team2_players = [{'name': p.player.username, 'position': p.position}
|
||||
for p in match.participants.filter_by(team_side=2).all() if p.player]
|
||||
team1_players = [
|
||||
{'name': p.player.username, 'position': p.position}
|
||||
for p in match.participants.filter_by(team_side=1).all()
|
||||
if p.player
|
||||
]
|
||||
team2_players = [
|
||||
{'name': p.player.username, 'position': p.position}
|
||||
for p in match.participants.filter_by(team_side=2).all()
|
||||
if p.player
|
||||
]
|
||||
participants = {
|
||||
'team1': 'Team 1', 'team2': 'Team 2',
|
||||
'team1_players': team1_players, 'team2_players': team2_players,
|
||||
'team1': 'Team 1',
|
||||
'team2': 'Team 2',
|
||||
'team1_players': team1_players,
|
||||
'team2_players': team2_players,
|
||||
}
|
||||
else:
|
||||
participants = [p.player.username for p in match.participants.all()]
|
||||
|
||||
match_data.append({
|
||||
'match': match, 'participants': participants,
|
||||
'confirmed_count': confirmed_count, 'total_count': total_count,
|
||||
match_data.append(
|
||||
{
|
||||
'match': match,
|
||||
'participants': participants,
|
||||
'confirmed_count': confirmed_count,
|
||||
'total_count': total_count,
|
||||
'player_presence': player_presence,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return render_template('pages/view_tryout.html',
|
||||
tryout=tryout, registered_players=registered_players,
|
||||
evaluations=evaluations, player_eval_status=player_eval_status,
|
||||
is_registered=is_registered, registrations=registrations,
|
||||
team_data=team_data, can_edit=can_edit,
|
||||
can_view_calendar=can_view_calendar, all_players=all_players,
|
||||
matches=matches, match_data=match_data,
|
||||
game_positions=GAME_POSITIONS, now=datetime.utcnow())
|
||||
return render_template(
|
||||
'pages/view_tryout.html',
|
||||
tryout=tryout,
|
||||
registered_players=registered_players,
|
||||
evaluations=evaluations,
|
||||
player_eval_status=player_eval_status,
|
||||
is_registered=is_registered,
|
||||
registrations=registrations,
|
||||
team_data=team_data,
|
||||
can_edit=can_edit,
|
||||
can_view_calendar=can_view_calendar,
|
||||
all_players=all_players,
|
||||
matches=matches,
|
||||
match_data=match_data,
|
||||
game_positions=GAME_POSITIONS,
|
||||
now=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/register', methods=['POST'])
|
||||
@@ -314,7 +444,8 @@ def register_for_tryout(tryout_id):
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
existing = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=current_user.id).first()
|
||||
tryout_id=tryout_id, player_id=current_user.id
|
||||
).first()
|
||||
if existing:
|
||||
flash(_('You are already registered for this tryout.'), 'info')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
@@ -358,7 +489,8 @@ def update_registration_status(tryout_id, player_id):
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
registration = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id).first_or_404()
|
||||
tryout_id=tryout_id, player_id=player_id
|
||||
).first_or_404()
|
||||
new_status = request.form.get('status')
|
||||
if new_status in ['registered', 'attended', 'no_show']:
|
||||
registration.status = new_status
|
||||
@@ -385,10 +517,12 @@ def register_player(tryout_id):
|
||||
flash(_('Can only register players.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
existing = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player.id).first()
|
||||
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first()
|
||||
if existing:
|
||||
flash(_('%(username)s is already registered for this tryout.', username=player.username), 'info')
|
||||
flash(
|
||||
_('%(username)s is already registered for this tryout.', username=player.username),
|
||||
'info',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if tryout.max_players:
|
||||
@@ -416,7 +550,8 @@ def remove_player(tryout_id, player_id):
|
||||
player = User.query.get_or_404(player_id)
|
||||
|
||||
registration = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id).first()
|
||||
tryout_id=tryout_id, player_id=player_id
|
||||
).first()
|
||||
if registration:
|
||||
db.session.delete(registration)
|
||||
|
||||
@@ -479,8 +614,10 @@ def add_to_team(tryout_id, team_id):
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
# Only players registered for this tryout may be placed on its teams.
|
||||
is_registered = TryoutRegistration.query.filter_by(
|
||||
tryout_id=tryout_id, player_id=player_id).first() is not None
|
||||
is_registered = (
|
||||
TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first()
|
||||
is not None
|
||||
)
|
||||
if not is_registered:
|
||||
flash(_('That player is not registered for this tryout.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
@@ -513,24 +650,25 @@ def delete_tryout(tryout_id):
|
||||
# about a player, not tryout data. Only their context links are cleared.
|
||||
# Missing this step made the deletion fail on the foreign keys below.
|
||||
PersonalNote.query.filter_by(tryout_id=tryout_id).update(
|
||||
{'tryout_id': None}, synchronize_session=False)
|
||||
{'tryout_id': None}, synchronize_session=False
|
||||
)
|
||||
if match_ids:
|
||||
PersonalNote.query.filter(PersonalNote.match_id.in_(match_ids)).update(
|
||||
{'match_id': None}, synchronize_session=False)
|
||||
{'match_id': None}, synchronize_session=False
|
||||
)
|
||||
if team_ids:
|
||||
PersonalNote.query.filter(PersonalNote.team_id.in_(team_ids)).update(
|
||||
{'team_id': None}, synchronize_session=False)
|
||||
{'team_id': None}, synchronize_session=False
|
||||
)
|
||||
|
||||
if match_ids:
|
||||
MatchParticipant.query.filter(
|
||||
MatchParticipant.match_id.in_(match_ids)
|
||||
).delete(synchronize_session=False)
|
||||
MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
Match.query.filter(Match.id.in_(match_ids)).delete(synchronize_session=False)
|
||||
|
||||
if team_ids:
|
||||
TeamMember.query.filter(
|
||||
TeamMember.team_id.in_(team_ids)
|
||||
).delete(synchronize_session=False)
|
||||
TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).delete(synchronize_session=False)
|
||||
Team.query.filter(Team.id.in_(team_ids)).delete(synchronize_session=False)
|
||||
|
||||
TryoutRegistration.query.filter_by(tryout_id=tryout_id).delete()
|
||||
|
||||
+429
-158
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,7 @@ class BackupError(Exception):
|
||||
# Connection handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_database_url(url):
|
||||
"""Split a SQLAlchemy/PostgreSQL URL into pg_dump connection settings.
|
||||
|
||||
@@ -110,14 +111,19 @@ def build_dump_command(conn, output_path):
|
||||
"""
|
||||
return [
|
||||
PG_DUMP,
|
||||
'--host', conn['host'],
|
||||
'--port', conn['port'],
|
||||
'--username', conn['user'],
|
||||
'--dbname', conn['dbname'],
|
||||
'--host',
|
||||
conn['host'],
|
||||
'--port',
|
||||
conn['port'],
|
||||
'--username',
|
||||
conn['user'],
|
||||
'--dbname',
|
||||
conn['dbname'],
|
||||
'--format=custom',
|
||||
'--no-owner',
|
||||
'--no-privileges',
|
||||
'--file', output_path,
|
||||
'--file',
|
||||
output_path,
|
||||
]
|
||||
|
||||
|
||||
@@ -133,6 +139,7 @@ def dump_environment(conn):
|
||||
# Backup steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_backup_dir():
|
||||
"""Create the backup directory if it doesn't exist."""
|
||||
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||
@@ -201,7 +208,9 @@ def verify_backup(backup_path):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[PG_RESTORE, '--list', backup_path],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print(f'[WARNING] {PG_RESTORE} not found: archive left unverified.')
|
||||
@@ -214,8 +223,7 @@ def verify_backup(backup_path):
|
||||
print(f'[ERROR] Archive is not readable: {result.stderr.strip()}')
|
||||
return False
|
||||
|
||||
table_count = sum(1 for line in result.stdout.splitlines()
|
||||
if ' TABLE DATA ' in line)
|
||||
table_count = sum(1 for line in result.stdout.splitlines() if ' TABLE DATA ' in line)
|
||||
if table_count == 0:
|
||||
print('[ERROR] Archive contains no table data.')
|
||||
return False
|
||||
@@ -282,6 +290,7 @@ def cleanup_old_backups():
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
"""Run the full backup process.
|
||||
|
||||
@@ -290,8 +299,9 @@ def main(argv=None):
|
||||
previous version returned 0 even when it had backed up nothing.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description='Team Tryouts backup')
|
||||
parser.add_argument('--verify-only', metavar='ARCHIVE',
|
||||
help='Verify an existing archive and exit')
|
||||
parser.add_argument(
|
||||
'--verify-only', metavar='ARCHIVE', help='Verify an existing archive and exit'
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.verify_only:
|
||||
|
||||
@@ -37,14 +37,26 @@ def generate_self_signed_cert():
|
||||
|
||||
print('[INFO] Generating self-signed certificate for localhost...')
|
||||
try:
|
||||
subprocess.run([
|
||||
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
|
||||
'-keyout', KEY_FILE,
|
||||
'-out', CERT_FILE,
|
||||
'-days', '365',
|
||||
subprocess.run(
|
||||
[
|
||||
'openssl',
|
||||
'req',
|
||||
'-x509',
|
||||
'-newkey',
|
||||
'rsa:2048',
|
||||
'-keyout',
|
||||
KEY_FILE,
|
||||
'-out',
|
||||
CERT_FILE,
|
||||
'-days',
|
||||
'365',
|
||||
'-nodes',
|
||||
'-subj', '/CN=localhost'
|
||||
], check=True, capture_output=True)
|
||||
'-subj',
|
||||
'/CN=localhost',
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
print('[OK] Certificate generated: certs/localhost.pem')
|
||||
except FileNotFoundError:
|
||||
print('[ERROR] OpenSSL not found. Install OpenSSL or use:')
|
||||
|
||||
@@ -128,7 +128,9 @@ def check_https_headers(url):
|
||||
all_ok = False
|
||||
|
||||
if 'SameSite' in cookie:
|
||||
print(f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}')
|
||||
print(
|
||||
f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}'
|
||||
)
|
||||
else:
|
||||
print('[WARN] Cookies missing SameSite attribute')
|
||||
all_ok = False
|
||||
@@ -168,7 +170,7 @@ def check_dependencies():
|
||||
[sys.executable, '-m', 'pip_audit', '--format', 'json'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
@@ -182,15 +184,10 @@ def check_dependencies():
|
||||
# clean. Treating the array itself as the vulnerability list
|
||||
# reported all ~45 installed packages as vulnerable on every
|
||||
# run, which is why this check was pure noise.
|
||||
affected = [
|
||||
dep for dep in data.get('dependencies', [])
|
||||
if dep.get('vulns')
|
||||
]
|
||||
affected = [dep for dep in data.get('dependencies', []) if dep.get('vulns')]
|
||||
if affected:
|
||||
for dep in affected:
|
||||
ids = ', '.join(
|
||||
v.get('id', '?') for v in dep.get('vulns', [])
|
||||
)
|
||||
ids = ', '.join(v.get('id', '?') for v in dep.get('vulns', []))
|
||||
print(f'[FAIL] {dep["name"]}=={dep["version"]}: {ids}')
|
||||
return False
|
||||
else:
|
||||
@@ -282,12 +279,15 @@ def check_flask_config():
|
||||
sys.path.insert(0, root)
|
||||
|
||||
from app.app import create_app
|
||||
|
||||
# Inspect configuration only: no schema creation, no Discord bot.
|
||||
app = create_app({
|
||||
app = create_app(
|
||||
{
|
||||
'SQLALCHEMY_DATABASE_URI': os.getenv('DATABASE_URL') or 'sqlite:///:memory:',
|
||||
'AUTO_CREATE_TABLES': False,
|
||||
'ENABLE_DISCORD_BOT': False,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
# Check session cookie settings
|
||||
cookie_checks = [
|
||||
@@ -353,10 +353,16 @@ def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='Security validation scanner')
|
||||
parser.add_argument('--url', default='http://localhost:5000',
|
||||
help='Application URL to check headers (default: http://localhost:5000)')
|
||||
parser.add_argument('--skip-http', action='store_true',
|
||||
help='Skip the live HTTP header check (no server running, e.g. in CI)')
|
||||
parser.add_argument(
|
||||
'--url',
|
||||
default='http://localhost:5000',
|
||||
help='Application URL to check headers (default: http://localhost:5000)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skip-http',
|
||||
action='store_true',
|
||||
help='Skip the live HTTP header check (no server running, e.g. in CI)',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Plain ASCII: the box-drawing characters this banner used crashed the
|
||||
|
||||
+33
-37
@@ -12,7 +12,15 @@ Usage:
|
||||
|
||||
import re
|
||||
from flask_babel import lazy_gettext as _l
|
||||
from marshmallow import Schema, fields, validate, ValidationError, pre_load, validates_schema, EXCLUDE
|
||||
from marshmallow import (
|
||||
Schema,
|
||||
fields,
|
||||
validate,
|
||||
ValidationError,
|
||||
pre_load,
|
||||
validates_schema,
|
||||
EXCLUDE,
|
||||
)
|
||||
from app.models import USER_TYPES
|
||||
|
||||
|
||||
@@ -20,9 +28,7 @@ from app.models import USER_TYPES
|
||||
# Custom Validators
|
||||
# =============================================================================
|
||||
|
||||
PASSWORD_POLICY = re.compile(
|
||||
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$'
|
||||
)
|
||||
PASSWORD_POLICY = re.compile(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$')
|
||||
"""Password policy: minimum 8 chars, 1 uppercase, 1 lowercase, 1 digit."""
|
||||
|
||||
|
||||
@@ -39,10 +45,9 @@ def validate_password(value):
|
||||
ValidationError: If password does not meet requirements.
|
||||
"""
|
||||
if not PASSWORD_POLICY.match(value):
|
||||
raise ValidationError(_l(
|
||||
'Password must be at least 8 characters with uppercase, '
|
||||
'lowercase, and a number.'
|
||||
))
|
||||
raise ValidationError(
|
||||
_l('Password must be at least 8 characters with uppercase, lowercase, and a number.')
|
||||
)
|
||||
|
||||
|
||||
def validate_username(value):
|
||||
@@ -58,9 +63,9 @@ def validate_username(value):
|
||||
ValidationError: If username does not meet requirements.
|
||||
"""
|
||||
if not re.match(r'^[a-zA-Z0-9_-]{3,30}$', value):
|
||||
raise ValidationError(_l(
|
||||
'Username must be 3-30 characters (letters, numbers, underscore, hyphen).'
|
||||
))
|
||||
raise ValidationError(
|
||||
_l('Username must be 3-30 characters (letters, numbers, underscore, hyphen).')
|
||||
)
|
||||
|
||||
|
||||
def validate_discord_username(value):
|
||||
@@ -120,12 +125,14 @@ def validate_phone(value):
|
||||
# Validation Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class StripMixin(Schema):
|
||||
"""Mixin that automatically strips whitespace from all string fields
|
||||
and ignores unknown fields (e.g., csrf_token from Flask-WTF)."""
|
||||
|
||||
class Meta:
|
||||
"""Marshmallow Meta options."""
|
||||
|
||||
unknown = EXCLUDE # Ignore csrf_token and other unknown fields
|
||||
|
||||
@pre_load
|
||||
@@ -150,6 +157,7 @@ class LoginSchema(StripMixin):
|
||||
username: 3-30 chars, required.
|
||||
password: Non-empty, required.
|
||||
"""
|
||||
|
||||
username = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=80, error=_l('Username is required.')),
|
||||
@@ -174,6 +182,7 @@ class RegisterSchema(StripMixin):
|
||||
discord_username: Optional, valid format.
|
||||
league_os_profile: Optional URL.
|
||||
"""
|
||||
|
||||
username = fields.String(
|
||||
required=True,
|
||||
validate=[
|
||||
@@ -245,6 +254,7 @@ class CreateUserSchema(StripMixin):
|
||||
role: Must be valid role, required.
|
||||
phone: Optional, valid phone format.
|
||||
"""
|
||||
|
||||
username = fields.String(
|
||||
required=True,
|
||||
validate=[
|
||||
@@ -267,10 +277,7 @@ class CreateUserSchema(StripMixin):
|
||||
)
|
||||
role = fields.String(
|
||||
required=True,
|
||||
validate=validate.OneOf(
|
||||
USER_TYPES,
|
||||
error=_l('Invalid role selected.')
|
||||
),
|
||||
validate=validate.OneOf(USER_TYPES, error=_l('Invalid role selected.')),
|
||||
)
|
||||
phone = fields.String(
|
||||
validate=validate_phone,
|
||||
@@ -294,6 +301,7 @@ class EditUserSchema(StripMixin):
|
||||
league_os_profile: Optional.
|
||||
games: Optional list.
|
||||
"""
|
||||
|
||||
full_name = fields.String(
|
||||
required=True,
|
||||
validate=validate.Length(min=1, max=100, error=_l('Full name is required.')),
|
||||
@@ -304,10 +312,7 @@ class EditUserSchema(StripMixin):
|
||||
)
|
||||
role = fields.String(
|
||||
required=True,
|
||||
validate=validate.OneOf(
|
||||
USER_TYPES,
|
||||
error=_l('Invalid role selected.')
|
||||
),
|
||||
validate=validate.OneOf(USER_TYPES, error=_l('Invalid role selected.')),
|
||||
)
|
||||
is_active_account = fields.Boolean(load_default=True)
|
||||
phone = fields.String(
|
||||
@@ -353,6 +358,7 @@ class EditProfileSchema(StripMixin):
|
||||
league_os_profile: Optional.
|
||||
games: Optional list.
|
||||
"""
|
||||
|
||||
username = fields.String(
|
||||
required=True,
|
||||
validate=[
|
||||
@@ -404,6 +410,7 @@ class UploadContractSchema(StripMixin):
|
||||
player_id: Integer, required.
|
||||
notes: Optional text.
|
||||
"""
|
||||
|
||||
player_id = fields.Integer(
|
||||
required=True,
|
||||
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.
|
||||
points: Optional text.
|
||||
"""
|
||||
|
||||
date = fields.String(
|
||||
required=True,
|
||||
validate=validate.Regexp(
|
||||
r'^\d{4}-\d{2}-\d{2}$',
|
||||
error=_l('Date must be in YYYY-MM-DD format.')
|
||||
r'^\d{4}-\d{2}-\d{2}$', error=_l('Date must be in YYYY-MM-DD format.')
|
||||
),
|
||||
)
|
||||
start_time = fields.String(
|
||||
required=True,
|
||||
validate=validate.Regexp(
|
||||
r'^\d{2}:\d{2}$',
|
||||
error=_l('Start time must be in HH:MM format.')
|
||||
),
|
||||
validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('Start time must be in HH:MM format.')),
|
||||
)
|
||||
end_time = fields.String(
|
||||
required=True,
|
||||
validate=validate.Regexp(
|
||||
r'^\d{2}:\d{2}$',
|
||||
error=_l('End time must be in HH:MM format.')
|
||||
),
|
||||
validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('End time must be in HH:MM format.')),
|
||||
)
|
||||
points = fields.String(
|
||||
validate=validate.Length(max=2000, error=_l('Points must be 2000 characters or less.')),
|
||||
@@ -459,17 +460,12 @@ class DisponibilityAddSchema(StripMixin):
|
||||
day_of_week: Integer 0-6, required.
|
||||
start_time: Time string (HH:MM), required.
|
||||
"""
|
||||
|
||||
day_of_week = fields.Integer(
|
||||
required=True,
|
||||
validate=validate.Range(
|
||||
min=0, max=6,
|
||||
error=_l('Day must be 0 (Monday) to 6 (Sunday).')
|
||||
),
|
||||
validate=validate.Range(min=0, max=6, error=_l('Day must be 0 (Monday) to 6 (Sunday).')),
|
||||
)
|
||||
start_time = fields.String(
|
||||
required=True,
|
||||
validate=validate.Regexp(
|
||||
r'^\d{2}:\d{2}$',
|
||||
error=_l('Start time must be in HH:MM format.')
|
||||
),
|
||||
validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('Start time must be in HH:MM format.')),
|
||||
)
|
||||
+17
-7
@@ -15,13 +15,22 @@ def seed_database():
|
||||
print("Deleting existing data...")
|
||||
tables = [
|
||||
'one_on_one_requests',
|
||||
'player_disponibilities', 'coach_availabilities',
|
||||
'match_participants', 'team_match_participants',
|
||||
'matches', 'team_matches',
|
||||
'team_players', 'team_members', 'teams',
|
||||
'evaluations', 'tryout_registrations', 'tryouts',
|
||||
'org_team_coaches', 'org_team_managers',
|
||||
'team_notes', 'personal_notes',
|
||||
'player_disponibilities',
|
||||
'coach_availabilities',
|
||||
'match_participants',
|
||||
'team_match_participants',
|
||||
'matches',
|
||||
'team_matches',
|
||||
'team_players',
|
||||
'team_members',
|
||||
'teams',
|
||||
'evaluations',
|
||||
'tryout_registrations',
|
||||
'tryouts',
|
||||
'org_team_coaches',
|
||||
'org_team_managers',
|
||||
'team_notes',
|
||||
'personal_notes',
|
||||
'user_gamertags',
|
||||
'org_teams',
|
||||
'users',
|
||||
@@ -51,6 +60,7 @@ def seed_database():
|
||||
|
||||
if __name__ == '__main__':
|
||||
from app.app import create_app
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
seed_database()
|
||||
@@ -183,6 +183,7 @@ def as_role(app, client, make_user, login):
|
||||
user_id = make_user(role, **kwargs)
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
username = _db.session.get(User, user_id).username
|
||||
response = login(username)
|
||||
assert response.status_code in (301, 302), (
|
||||
|
||||
+60
-22
@@ -106,10 +106,17 @@ class TestAdministrativeEvents:
|
||||
def test_account_creation_is_recorded(self, client, as_role, auth_log):
|
||||
as_role('admin')
|
||||
|
||||
client.post('/users/create', data={
|
||||
'username': 'newcoach', 'email': '[email protected]',
|
||||
'password': 'Password123', 'full_name': 'New Coach', 'role': 'coach',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
'/users/create',
|
||||
data={
|
||||
'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')
|
||||
|
||||
@@ -117,10 +124,16 @@ class TestAdministrativeEvents:
|
||||
target_id = make_user('player')
|
||||
as_role('admin')
|
||||
|
||||
client.post(f'/users/{target_id}/edit', data={
|
||||
'full_name': 'Promoted', 'email': '[email protected]t',
|
||||
'role': 'coach', 'is_active_account': 'on',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
f'/users/{target_id}/edit',
|
||||
data={
|
||||
'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)
|
||||
assert 'previous_role=player' in message
|
||||
@@ -135,17 +148,21 @@ class TestAdministrativeEvents:
|
||||
|
||||
assert _has_event(auth_log, 'account.deleted')
|
||||
|
||||
def test_a_self_service_password_change_is_recorded(
|
||||
self, app, client, as_role, auth_log
|
||||
):
|
||||
def test_a_self_service_password_change_is_recorded(self, app, client, as_role, auth_log):
|
||||
user_id = as_role('player')
|
||||
with app.app_context():
|
||||
username = db.session.get(User, user_id).username
|
||||
|
||||
client.post('/users/profile/edit', data={
|
||||
'username': username, 'full_name': 'Same Name',
|
||||
'email': '[email protected]', 'password': 'BrandNew123',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
'/users/profile/edit',
|
||||
data={
|
||||
'username': username,
|
||||
'full_name': 'Same Name',
|
||||
'email': '[email protected]',
|
||||
'password': 'BrandNew123',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
assert _has_event(auth_log, 'account.password_changed')
|
||||
|
||||
@@ -162,8 +179,13 @@ class TestRedaction:
|
||||
from app.logging_config import SensitiveDataFilter
|
||||
|
||||
record = logging.LogRecord(
|
||||
'test', logging.ERROR, 'x.py', 1,
|
||||
'Database failure: %s', ('password=hunter2 host=db.internal',), None,
|
||||
'test',
|
||||
logging.ERROR,
|
||||
'x.py',
|
||||
1,
|
||||
'Database failure: %s',
|
||||
('password=hunter2 host=db.internal',),
|
||||
None,
|
||||
)
|
||||
SensitiveDataFilter().filter(record)
|
||||
|
||||
@@ -175,8 +197,13 @@ class TestRedaction:
|
||||
from app.logging_config import SensitiveDataFilter
|
||||
|
||||
record = logging.LogRecord(
|
||||
'test', logging.INFO, 'x.py', 1,
|
||||
'Calling Discord with %s', ('Bearer abcdef123456',), None,
|
||||
'test',
|
||||
logging.INFO,
|
||||
'x.py',
|
||||
1,
|
||||
'Calling Discord with %s',
|
||||
('Bearer abcdef123456',),
|
||||
None,
|
||||
)
|
||||
SensitiveDataFilter().filter(record)
|
||||
|
||||
@@ -186,8 +213,13 @@ class TestRedaction:
|
||||
from app.logging_config import SensitiveDataFilter
|
||||
|
||||
record = logging.LogRecord(
|
||||
'test', logging.INFO, 'x.py', 1,
|
||||
'event=login.success username=%s', ('alice',), None,
|
||||
'test',
|
||||
logging.INFO,
|
||||
'x.py',
|
||||
1,
|
||||
'event=login.success username=%s',
|
||||
('alice',),
|
||||
None,
|
||||
)
|
||||
SensitiveDataFilter().filter(record)
|
||||
|
||||
@@ -197,6 +229,12 @@ class TestRedaction:
|
||||
from app.logging_config import SensitiveDataFilter
|
||||
|
||||
record = logging.LogRecord(
|
||||
'test', logging.INFO, 'x.py', 1, 'broken %d', ('not-a-number',), None,
|
||||
'test',
|
||||
logging.INFO,
|
||||
'x.py',
|
||||
1,
|
||||
'broken %d',
|
||||
('not-a-number',),
|
||||
None,
|
||||
)
|
||||
assert SensitiveDataFilter().filter(record) is True
|
||||
|
||||
+21
-18
@@ -121,20 +121,26 @@ class TestLogout:
|
||||
with app_with_csrf.app_context():
|
||||
from app.extensions import hash_password
|
||||
from app.models import Player
|
||||
user = Player(username='navtest', password_hash=hash_password('Password123'),
|
||||
role='player', full_name='Nav Test', email='[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.commit()
|
||||
|
||||
page = client.get('/auth/login').get_data(as_text=True)
|
||||
token = re.search(r'name="csrf_token" value="([^"]+)"', page).group(1)
|
||||
client.post('/auth/login', data={'username': 'navtest',
|
||||
'password': 'Password123',
|
||||
'csrf_token': token})
|
||||
client.post(
|
||||
'/auth/login',
|
||||
data={'username': 'navtest', 'password': 'Password123', 'csrf_token': token},
|
||||
)
|
||||
|
||||
body = client.get('/users/profile').get_data(as_text=True)
|
||||
form = re.search(
|
||||
r'<form method="POST" action="/auth/logout".*?</form>', body, re.S)
|
||||
form = re.search(r'<form method="POST" action="/auth/logout".*?</form>', body, re.S)
|
||||
assert form is not None, 'no logout form in the navigation'
|
||||
assert 'name="csrf_token"' in form.group(0)
|
||||
|
||||
@@ -150,9 +156,7 @@ class TestLoginRejection:
|
||||
response = client.get('/users/profile', follow_redirects=False)
|
||||
assert response.status_code in (301, 302)
|
||||
|
||||
def test_login_failure_message_does_not_reveal_account_existence(
|
||||
self, client, make_user, app
|
||||
):
|
||||
def test_login_failure_message_does_not_reveal_account_existence(self, client, make_user, app):
|
||||
"""Compares the rendered flash messages rather than looking for a
|
||||
known substring: the site is served in French by default, so an
|
||||
English marker would silently match nothing on both sides and make
|
||||
@@ -171,9 +175,7 @@ class TestLoginRejection:
|
||||
|
||||
assert failure_message(username) == failure_message('no-such-account')
|
||||
|
||||
def test_the_message_stays_the_same_past_the_attempt_threshold(
|
||||
self, client, make_user, app
|
||||
):
|
||||
def test_the_message_stays_the_same_past_the_attempt_threshold(self, client, make_user, app):
|
||||
"""The tally used to be counted out loud — "3 attempt(s) remaining"
|
||||
— which is the same disclosure, spread over five requests."""
|
||||
user_id = make_user('player')
|
||||
@@ -200,13 +202,13 @@ class TestFailedAttemptThrottle:
|
||||
@staticmethod
|
||||
def _exhaust(client, username, times=6):
|
||||
for _ in range(times):
|
||||
client.post('/auth/login',
|
||||
client.post(
|
||||
'/auth/login',
|
||||
data={'username': username, 'password': 'WrongPassword1'},
|
||||
follow_redirects=True)
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
def test_the_owner_still_gets_in_after_the_threshold(
|
||||
self, app, client, make_user, login
|
||||
):
|
||||
def test_the_owner_still_gets_in_after_the_threshold(self, app, client, make_user, login):
|
||||
user_id = make_user('player')
|
||||
with app.app_context():
|
||||
username = db.session.get(User, user_id).username
|
||||
@@ -294,6 +296,7 @@ class TestRedirectValidation:
|
||||
|
||||
with app.test_request_context('/auth/login'):
|
||||
from flask import request
|
||||
|
||||
assert is_safe_url(f'http://{request.host}/dashboard')
|
||||
|
||||
def test_the_login_redirect_refuses_to_leave_the_site(self, client, make_user, app):
|
||||
|
||||
+85
-48
@@ -61,9 +61,7 @@ class TestVerticalAccess:
|
||||
def test_only_admin_reaches_user_management(self, client, as_role, role, route):
|
||||
as_role(role)
|
||||
response = client.get(route, follow_redirects=False)
|
||||
assert _redirected(response), (
|
||||
f'{role} reached {route}, which is meant to be admin-only'
|
||||
)
|
||||
assert _redirected(response), f'{role} reached {route}, which is meant to be admin-only'
|
||||
|
||||
def test_admin_reaches_user_management(self, client, as_role):
|
||||
as_role('admin')
|
||||
@@ -106,8 +104,10 @@ class TestHorizontalAccess:
|
||||
owner_id = make_user('player')
|
||||
with app.app_context():
|
||||
slot = PlayerDisponibility(
|
||||
player_id=owner_id, day_of_week=1,
|
||||
start_time=time(10, 0), end_time=time(10, 30),
|
||||
player_id=owner_id,
|
||||
day_of_week=1,
|
||||
start_time=time(10, 0),
|
||||
end_time=time(10, 30),
|
||||
)
|
||||
db.session.add(slot)
|
||||
db.session.commit()
|
||||
@@ -134,8 +134,11 @@ class TestNestedResourceOwnership:
|
||||
|
||||
with app.app_context():
|
||||
tryout = Tryout(
|
||||
title=title, game='Valorant', date=date(2030, 1, 1),
|
||||
created_by=owner_id, status='upcoming',
|
||||
title=title,
|
||||
game='Valorant',
|
||||
date=date(2030, 1, 1),
|
||||
created_by=owner_id,
|
||||
status='upcoming',
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
@@ -144,9 +147,7 @@ class TestNestedResourceOwnership:
|
||||
db.session.commit()
|
||||
return tryout.id, team.id
|
||||
|
||||
def test_cannot_add_a_player_to_a_team_of_another_tryout(
|
||||
self, app, client, as_role, make_user
|
||||
):
|
||||
def test_cannot_add_a_player_to_a_team_of_another_tryout(self, app, client, as_role, make_user):
|
||||
from app.models import TeamMember, TryoutRegistration
|
||||
|
||||
other_admin = make_user('admin')
|
||||
@@ -157,8 +158,7 @@ class TestNestedResourceOwnership:
|
||||
|
||||
player_id = make_user('player')
|
||||
with app.app_context():
|
||||
db.session.add(TryoutRegistration(
|
||||
tryout_id=own_tryout_id, player_id=player_id))
|
||||
db.session.add(TryoutRegistration(tryout_id=own_tryout_id, player_id=player_id))
|
||||
db.session.commit()
|
||||
|
||||
response = client.post(
|
||||
@@ -167,15 +167,11 @@ class TestNestedResourceOwnership:
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 404, (
|
||||
'a team belonging to another tryout was accepted'
|
||||
)
|
||||
assert response.status_code == 404, 'a team belonging to another tryout was accepted'
|
||||
with app.app_context():
|
||||
assert TeamMember.query.filter_by(team_id=foreign_team_id).count() == 0
|
||||
|
||||
def test_cannot_add_a_player_who_is_not_registered(
|
||||
self, app, client, as_role, make_user
|
||||
):
|
||||
def test_cannot_add_a_player_who_is_not_registered(self, app, client, as_role, make_user):
|
||||
from app.models import TeamMember
|
||||
|
||||
manager_id = as_role('manager')
|
||||
@@ -191,9 +187,7 @@ class TestNestedResourceOwnership:
|
||||
with app.app_context():
|
||||
assert TeamMember.query.filter_by(team_id=team_id).count() == 0
|
||||
|
||||
def test_a_registered_player_can_still_be_added(
|
||||
self, app, client, as_role, make_user
|
||||
):
|
||||
def test_a_registered_player_can_still_be_added(self, app, client, as_role, make_user):
|
||||
"""Guard against over-correcting: the normal path must keep working."""
|
||||
from app.models import TeamMember, TryoutRegistration
|
||||
|
||||
@@ -243,11 +237,15 @@ class TestInputValidation:
|
||||
user_id = as_role('player')
|
||||
payload = '<img src=x onerror=alert(1)>'
|
||||
|
||||
client.post('/users/profile/edit', data={
|
||||
client.post(
|
||||
'/users/profile/edit',
|
||||
data={
|
||||
'username': payload,
|
||||
'full_name': 'Legit Name',
|
||||
'email': '[email protected]',
|
||||
}, follow_redirects=True)
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert db.session.get(User, user_id).username != payload
|
||||
@@ -257,12 +255,16 @@ class TestInputValidation:
|
||||
with app.app_context():
|
||||
before = db.session.get(User, user_id).password_hash
|
||||
|
||||
client.post('/users/profile/edit', data={
|
||||
client.post(
|
||||
'/users/profile/edit',
|
||||
data={
|
||||
'username': _username(app, user_id),
|
||||
'full_name': 'Legit Name',
|
||||
'email': '[email protected]',
|
||||
'password': 'a',
|
||||
}, follow_redirects=True)
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert db.session.get(User, user_id).password_hash == before, (
|
||||
@@ -272,13 +274,17 @@ class TestInputValidation:
|
||||
def test_create_user_enforces_the_password_policy(self, app, client, as_role):
|
||||
as_role('admin')
|
||||
|
||||
client.post('/users/create', data={
|
||||
client.post(
|
||||
'/users/create',
|
||||
data={
|
||||
'username': 'weakling',
|
||||
'email': '[email protected]',
|
||||
'password': 'a',
|
||||
'full_name': 'Weak Account',
|
||||
'role': 'admin',
|
||||
}, follow_redirects=True)
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
created = User.query.filter_by(username='weakling').first()
|
||||
@@ -292,11 +298,15 @@ class TestInputValidation:
|
||||
with app.app_context():
|
||||
taken = db.session.get(User, other_id).email
|
||||
|
||||
response = client.post(f'/users/{target_id}/edit', data={
|
||||
response = client.post(
|
||||
f'/users/{target_id}/edit',
|
||||
data={
|
||||
'full_name': 'Target',
|
||||
'email': taken,
|
||||
'role': 'player',
|
||||
}, follow_redirects=False)
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code < 500, 'duplicate email produced a server error'
|
||||
|
||||
@@ -305,18 +315,21 @@ class TestAdminSafety:
|
||||
def test_the_last_admin_cannot_demote_itself(self, app, client, as_role):
|
||||
admin_id = as_role('admin')
|
||||
|
||||
client.post(f'/users/{admin_id}/edit', data={
|
||||
client.post(
|
||||
f'/users/{admin_id}/edit',
|
||||
data={
|
||||
'full_name': 'Admin',
|
||||
'email': '[email protected]',
|
||||
'role': 'player',
|
||||
}, follow_redirects=True)
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert db.session.get(User, admin_id).role == 'admin', (
|
||||
'the only administrator demoted itself; no interface can undo this'
|
||||
)
|
||||
|
||||
|
||||
def test_an_admin_cannot_change_its_own_role_even_with_others_present(
|
||||
self, app, client, as_role, make_user
|
||||
):
|
||||
@@ -324,9 +337,15 @@ class TestAdminSafety:
|
||||
make_user('admin')
|
||||
admin_id = as_role('admin')
|
||||
|
||||
client.post(f'/users/{admin_id}/edit', data={
|
||||
'full_name': 'Admin', 'email': '[email protected]', 'role': 'player',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
f'/users/{admin_id}/edit',
|
||||
data={
|
||||
'full_name': 'Admin',
|
||||
'email': '[email protected]',
|
||||
'role': 'player',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert db.session.get(User, admin_id).role == 'admin'
|
||||
@@ -336,10 +355,16 @@ class TestAdminSafety:
|
||||
other_id = make_user('admin')
|
||||
as_role('admin')
|
||||
|
||||
client.post(f'/users/{other_id}/edit', data={
|
||||
'full_name': 'Other', 'email': '[email protected]t',
|
||||
'role': 'coach', 'is_active_account': 'on',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
f'/users/{other_id}/edit',
|
||||
data={
|
||||
'full_name': 'Other',
|
||||
'email': '[email protected]',
|
||||
'role': 'coach',
|
||||
'is_active_account': 'on',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert db.session.get(User, other_id).role == 'coach'
|
||||
@@ -350,11 +375,16 @@ class TestCsrf:
|
||||
"""CSRFProtect is global. This pins that down so a future
|
||||
@csrf.exempt cannot slip in unnoticed."""
|
||||
client = app_with_csrf.test_client()
|
||||
response = client.post('/auth/login', data={
|
||||
'username': 'someone', 'password': 'Password123',
|
||||
})
|
||||
response = client.post(
|
||||
'/auth/login',
|
||||
data={
|
||||
'username': 'someone',
|
||||
'password': 'Password123',
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestCorsPolicy:
|
||||
"""SEC-WEB-003 — with no origins configured, flask-cors defaulted to '*'
|
||||
and, credentials being allowed, echoed back the caller's Origin."""
|
||||
@@ -367,12 +397,19 @@ class TestCorsPolicy:
|
||||
def test_configured_origins_are_still_honoured(self, app_with_csrf):
|
||||
from app.app import create_app
|
||||
|
||||
application = create_app({
|
||||
'SECRET_KEY': 'test', 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
|
||||
'TESTING': True, 'FORCE_HTTPS': False, 'ENABLE_DISCORD_BOT': False,
|
||||
'AUTO_CREATE_TABLES': False, 'CORS_ALLOWED_ORIGINS': 'https://trusted.test',
|
||||
})
|
||||
application = create_app(
|
||||
{
|
||||
'SECRET_KEY': 'test',
|
||||
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
|
||||
'TESTING': True,
|
||||
'FORCE_HTTPS': False,
|
||||
'ENABLE_DISCORD_BOT': False,
|
||||
'AUTO_CREATE_TABLES': False,
|
||||
'CORS_ALLOWED_ORIGINS': 'https://trusted.test',
|
||||
}
|
||||
)
|
||||
response = application.test_client().get(
|
||||
'/auth/login', headers={'Origin': 'https://trusted.test'})
|
||||
'/auth/login', headers={'Origin': 'https://trusted.test'}
|
||||
)
|
||||
|
||||
assert response.headers.get('Access-Control-Allow-Origin') == 'https://trusted.test'
|
||||
|
||||
@@ -37,8 +37,7 @@ class TestUrlParsing:
|
||||
|
||||
def test_the_sqlalchemy_dialect_suffix_is_accepted(self):
|
||||
"""SQLAlchemy writes postgresql+psycopg://, which pg_dump rejects."""
|
||||
conn = parse_database_url(
|
||||
'postgresql+psycopg://u:p@localhost/tryouts')
|
||||
conn = parse_database_url('postgresql+psycopg://u:p@localhost/tryouts')
|
||||
assert conn['dbname'] == 'tryouts'
|
||||
|
||||
def test_the_default_port_is_applied(self):
|
||||
@@ -119,5 +118,4 @@ class TestExitCodes:
|
||||
assert backup_module.main([]) == 1
|
||||
|
||||
def test_verifying_a_missing_archive_fails(self, tmp_path):
|
||||
assert backup_module.main(
|
||||
['--verify-only', str(tmp_path / 'nope.dump')]) == 1
|
||||
assert backup_module.main(['--verify-only', str(tmp_path / 'nope.dump')]) == 1
|
||||
|
||||
+22
-18
@@ -25,7 +25,10 @@ from app.extensions import db
|
||||
def match_factory(app):
|
||||
"""Create a tryout with one player_scrim match and one participant."""
|
||||
from app.models import (
|
||||
Match, MatchParticipant, Tryout, TryoutRegistration,
|
||||
Match,
|
||||
MatchParticipant,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
)
|
||||
|
||||
def _make(owner_id, player_id, *, description=None, username=None):
|
||||
@@ -39,24 +42,30 @@ def match_factory(app):
|
||||
player.username = username
|
||||
|
||||
tryout = Tryout(
|
||||
title='Spring tryout', game='Valorant', date=date(2030, 5, 1),
|
||||
created_by=owner_id, status='upcoming',
|
||||
title='Spring tryout',
|
||||
game='Valorant',
|
||||
date=date(2030, 5, 1),
|
||||
created_by=owner_id,
|
||||
status='upcoming',
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
|
||||
db.session.add(TryoutRegistration(
|
||||
tryout_id=tryout.id, player_id=player_id))
|
||||
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
|
||||
|
||||
match = Match(
|
||||
tryout_id=tryout.id, title='Scrim A', description=description,
|
||||
date=date(2030, 5, 2), start_time=time(18, 0), end_time=time(19, 0),
|
||||
match_type='player_scrim', created_by=owner_id,
|
||||
tryout_id=tryout.id,
|
||||
title='Scrim A',
|
||||
description=description,
|
||||
date=date(2030, 5, 2),
|
||||
start_time=time(18, 0),
|
||||
end_time=time(19, 0),
|
||||
match_type='player_scrim',
|
||||
created_by=owner_id,
|
||||
)
|
||||
db.session.add(match)
|
||||
db.session.flush()
|
||||
db.session.add(MatchParticipant(
|
||||
match_id=match.id, player_id=player_id))
|
||||
db.session.add(MatchParticipant(match_id=match.id, player_id=player_id))
|
||||
db.session.commit()
|
||||
return tryout.id, match.id
|
||||
|
||||
@@ -68,9 +77,7 @@ def _match_event(payload):
|
||||
|
||||
|
||||
class TestCalendarEventPayload:
|
||||
def test_description_is_returned_verbatim(
|
||||
self, app, client, as_role, make_user, match_factory
|
||||
):
|
||||
def test_description_is_returned_verbatim(self, app, client, as_role, make_user, match_factory):
|
||||
player_id = make_user('player')
|
||||
admin_id = as_role('admin')
|
||||
match_factory(admin_id, player_id, description='Bring your own peripherals')
|
||||
@@ -92,9 +99,7 @@ class TestCalendarEventPayload:
|
||||
assert '<br>' not in props['description']
|
||||
assert props['participants'] not in props['description']
|
||||
|
||||
def test_an_empty_description_stays_empty(
|
||||
self, app, client, as_role, make_user, match_factory
|
||||
):
|
||||
def test_an_empty_description_stays_empty(self, app, client, as_role, make_user, match_factory):
|
||||
player_id = make_user('player')
|
||||
admin_id = as_role('admin')
|
||||
match_factory(admin_id, player_id, description=None)
|
||||
@@ -132,8 +137,7 @@ class TestLegacyHostileData:
|
||||
):
|
||||
player_id = make_user('player')
|
||||
admin_id = as_role('admin')
|
||||
match_factory(admin_id, player_id,
|
||||
description='Normal text', username=self.PAYLOAD)
|
||||
match_factory(admin_id, player_id, description='Normal text', username=self.PAYLOAD)
|
||||
|
||||
props = _match_event(client.get('/matches/api/events').get_json())['extendedProps']
|
||||
|
||||
|
||||
@@ -32,8 +32,10 @@ def contract_for(app, tmp_path):
|
||||
path.write_bytes(PDF_BYTES)
|
||||
with app.app_context():
|
||||
contract = Contract(
|
||||
player_id=player_id, uploaded_by_id=uploader_id,
|
||||
original_filename='contract.pdf', stored_filename=stored,
|
||||
player_id=player_id,
|
||||
uploaded_by_id=uploader_id,
|
||||
original_filename='contract.pdf',
|
||||
stored_filename=stored,
|
||||
file_path=str(path),
|
||||
)
|
||||
db.session.add(contract)
|
||||
@@ -54,7 +56,8 @@ class TestSignedUpload:
|
||||
client.post(
|
||||
f'/users/contracts/{contract_id}/upload_signed',
|
||||
data={'signed_file': (io.BytesIO(NOT_PDF_BYTES), 'signed.pdf')},
|
||||
content_type='multipart/form-data', follow_redirects=True,
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
@@ -63,9 +66,7 @@ class TestSignedUpload:
|
||||
assert contract.signed_file_path is None
|
||||
assert not os.path.exists(directory / 'signed_deadbeef.pdf')
|
||||
|
||||
def test_a_foreign_extension_is_refused(
|
||||
self, app, client, as_role, make_user, contract_for
|
||||
):
|
||||
def test_a_foreign_extension_is_refused(self, app, client, as_role, make_user, contract_for):
|
||||
player_id = as_role('player')
|
||||
admin_id = make_user('admin')
|
||||
contract_id, _directory = contract_for(player_id, admin_id)
|
||||
@@ -73,15 +74,14 @@ class TestSignedUpload:
|
||||
client.post(
|
||||
f'/users/contracts/{contract_id}/upload_signed',
|
||||
data={'signed_file': (io.BytesIO(b'<?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():
|
||||
assert db.session.get(Contract, contract_id).status != 'signed'
|
||||
|
||||
def test_a_real_pdf_still_goes_through(
|
||||
self, app, client, as_role, make_user, contract_for
|
||||
):
|
||||
def test_a_real_pdf_still_goes_through(self, app, client, as_role, make_user, contract_for):
|
||||
"""Guard against over-correcting: signing a contract is the point."""
|
||||
player_id = as_role('player')
|
||||
admin_id = make_user('admin')
|
||||
@@ -90,7 +90,8 @@ class TestSignedUpload:
|
||||
client.post(
|
||||
f'/users/contracts/{contract_id}/upload_signed',
|
||||
data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')},
|
||||
content_type='multipart/form-data', follow_redirects=True,
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
@@ -110,7 +111,8 @@ class TestSignedUpload:
|
||||
client.post(
|
||||
f'/users/contracts/{contract_id}/upload_signed',
|
||||
data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')},
|
||||
content_type='multipart/form-data', follow_redirects=True,
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
|
||||
+3
-4
@@ -21,7 +21,8 @@ from app.app import build_csp
|
||||
|
||||
TEMPLATE_ROOT = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'app', 'templates',
|
||||
'app',
|
||||
'templates',
|
||||
)
|
||||
|
||||
#: Attributes a nonce can never authorise.
|
||||
@@ -126,9 +127,7 @@ class TestInlineHandlerRatchet:
|
||||
continue
|
||||
offenders.append(f'{relative}: {tag}')
|
||||
|
||||
assert not offenders, (
|
||||
'inline <script> without nonce="{{ csp_nonce }}": ' + str(offenders)
|
||||
)
|
||||
assert not offenders, 'inline <script> without nonce="{{ csp_nonce }}": ' + str(offenders)
|
||||
|
||||
def test_the_shared_layout_is_free_of_them(self):
|
||||
"""base.html and macros.html render on every single page."""
|
||||
|
||||
@@ -19,23 +19,29 @@ from app.app import normalise_database_url
|
||||
|
||||
|
||||
class TestNormalisation:
|
||||
@pytest.mark.parametrize('given', [
|
||||
@pytest.mark.parametrize(
|
||||
'given',
|
||||
[
|
||||
'postgresql://user:pass@host:5432/tryouts',
|
||||
'postgres://user:pass@host:5432/tryouts',
|
||||
])
|
||||
],
|
||||
)
|
||||
def test_a_driverless_postgres_url_gets_psycopg(self, given):
|
||||
assert normalise_database_url(given).startswith('postgresql+psycopg://')
|
||||
|
||||
def test_the_rest_of_the_url_is_untouched(self):
|
||||
result = normalise_database_url(
|
||||
'postgresql://u:p%40s[email protected]:5432/tryouts?sslmode=require')
|
||||
assert result == (
|
||||
'postgresql+psycopg://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')
|
||||
|
||||
@pytest.mark.parametrize('given', [
|
||||
@pytest.mark.parametrize(
|
||||
'given',
|
||||
[
|
||||
'postgresql+psycopg://user:pass@host/db',
|
||||
'postgresql+psycopg2://user:pass@host/db',
|
||||
])
|
||||
],
|
||||
)
|
||||
def test_an_explicit_driver_is_left_alone(self, given):
|
||||
"""Naming psycopg2 stays possible for an environment that has it."""
|
||||
assert normalise_database_url(given) == given
|
||||
|
||||
+59
-32
@@ -17,8 +17,17 @@ import pytest
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Match, MatchParticipant, OrgTeam, PersonalNote, Team, TeamMatch,
|
||||
TeamMember, TeamNote, TeamPlayer, Tryout, TryoutRegistration,
|
||||
Match,
|
||||
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.flush()
|
||||
|
||||
tryout = Tryout(title='Spring', game='Valorant', date=date(2030, 4, 1),
|
||||
created_by=admin_id, target_org_team_id=org_team.id)
|
||||
tryout = Tryout(
|
||||
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.flush()
|
||||
|
||||
@@ -47,36 +61,57 @@ def world(app, make_user):
|
||||
db.session.add(team)
|
||||
db.session.flush()
|
||||
|
||||
match = Match(tryout_id=tryout.id, title='Scrim', date=date(2030, 4, 2),
|
||||
start_time=time(18, 0), match_type='player_scrim',
|
||||
created_by=admin_id)
|
||||
match = Match(
|
||||
tryout_id=tryout.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.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),
|
||||
MatchParticipant(match_id=match.id, player_id=player_id),
|
||||
TeamPlayer(player_id=player_id, org_team_id=org_team.id),
|
||||
TeamNote(org_team_id=org_team.id, coach_id=coach_id, content='Team note'),
|
||||
TeamMatch(org_team_id=org_team.id, title='Season match',
|
||||
date=date(2030, 4, 5), created_by=admin_id),
|
||||
TeamMatch(
|
||||
org_team_id=org_team.id,
|
||||
title='Season match',
|
||||
date=date(2030, 4, 5),
|
||||
created_by=admin_id,
|
||||
),
|
||||
# A note referencing all three contexts at once.
|
||||
PersonalNote(player_id=player_id, coach_id=coach_id,
|
||||
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),
|
||||
])
|
||||
match_id=match.id,
|
||||
team_id=team.id,
|
||||
tryout_id=tryout.id,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'admin_id': admin_id, 'coach_id': coach_id, 'player_id': player_id,
|
||||
'org_team_id': org_team.id, 'tryout_id': tryout.id,
|
||||
'team_id': team.id, 'match_id': match.id,
|
||||
'admin_id': admin_id,
|
||||
'coach_id': coach_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):
|
||||
from app.models import User
|
||||
|
||||
with app.app_context():
|
||||
username = db.session.get(User, admin_id).username
|
||||
login(username)
|
||||
@@ -89,8 +124,7 @@ class TestDeleteMatch:
|
||||
def test_a_populated_match_can_be_deleted(self, app, client, world, login):
|
||||
_login_admin(client, app, world['admin_id'], login)
|
||||
|
||||
response = client.post(f"/matches/{world['match_id']}/delete",
|
||||
follow_redirects=False)
|
||||
response = client.post(f"/matches/{world['match_id']}/delete", follow_redirects=False)
|
||||
|
||||
assert response.status_code < 500, 'deleting a used match raised'
|
||||
with app.app_context():
|
||||
@@ -102,8 +136,7 @@ class TestDeleteMatch:
|
||||
client.post(f"/matches/{world['match_id']}/delete", follow_redirects=True)
|
||||
|
||||
with app.app_context():
|
||||
assert MatchParticipant.query.filter_by(
|
||||
match_id=world['match_id']).count() == 0
|
||||
assert MatchParticipant.query.filter_by(match_id=world['match_id']).count() == 0
|
||||
|
||||
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;
|
||||
@@ -125,8 +158,7 @@ class TestDeleteTryout:
|
||||
def test_a_populated_tryout_can_be_deleted(self, app, client, world, login):
|
||||
_login_admin(client, app, world['admin_id'], login)
|
||||
|
||||
response = client.post(f"/tryouts/{world['tryout_id']}/delete",
|
||||
follow_redirects=False)
|
||||
response = client.post(f"/tryouts/{world['tryout_id']}/delete", follow_redirects=False)
|
||||
|
||||
assert response.status_code < 500
|
||||
with app.app_context():
|
||||
@@ -140,8 +172,7 @@ class TestDeleteTryout:
|
||||
with app.app_context():
|
||||
assert db.session.get(Match, world['match_id']) is None
|
||||
assert db.session.get(Team, world['team_id']) is None
|
||||
assert TryoutRegistration.query.filter_by(
|
||||
tryout_id=world['tryout_id']).count() == 0
|
||||
assert TryoutRegistration.query.filter_by(tryout_id=world['tryout_id']).count() == 0
|
||||
|
||||
def test_notes_survive_the_tryout(self, app, client, world, 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):
|
||||
_login_admin(client, app, world['admin_id'], login)
|
||||
|
||||
response = client.post(f"/teams/{world['org_team_id']}/delete",
|
||||
follow_redirects=False)
|
||||
response = client.post(f"/teams/{world['org_team_id']}/delete", follow_redirects=False)
|
||||
|
||||
assert response.status_code < 500
|
||||
with app.app_context():
|
||||
@@ -177,12 +207,9 @@ class TestDeleteTeam:
|
||||
client.post(f"/teams/{world['org_team_id']}/delete", follow_redirects=True)
|
||||
|
||||
with app.app_context():
|
||||
assert TeamNote.query.filter_by(
|
||||
org_team_id=world['org_team_id']).count() == 0
|
||||
assert TeamMatch.query.filter_by(
|
||||
org_team_id=world['org_team_id']).count() == 0
|
||||
assert TeamPlayer.query.filter_by(
|
||||
org_team_id=world['org_team_id']).count() == 0
|
||||
assert TeamNote.query.filter_by(org_team_id=world['org_team_id']).count() == 0
|
||||
assert TeamMatch.query.filter_by(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):
|
||||
"""A tryout outlives the team it was aimed at."""
|
||||
|
||||
+18
-16
@@ -24,7 +24,8 @@ def discord_configured(monkeypatch):
|
||||
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', '123456789012345678')
|
||||
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_SECRET', 'not-a-real-secret')
|
||||
monkeypatch.setattr(
|
||||
auth_module, 'DISCORD_REDIRECT_URI',
|
||||
auth_module,
|
||||
'DISCORD_REDIRECT_URI',
|
||||
'https://example.test/auth/discord/callback',
|
||||
)
|
||||
|
||||
@@ -50,16 +51,17 @@ class TestAuthorizationRequest:
|
||||
assert sess[auth_module.DISCORD_STATE_KEY] == sent
|
||||
|
||||
def test_two_requests_get_different_states(self, client, discord_configured):
|
||||
first = _authorize_params(
|
||||
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
|
||||
second = _authorize_params(
|
||||
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
|
||||
first = _authorize_params(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
|
||||
|
||||
def test_scopes_and_redirect_are_preserved(self, client, discord_configured):
|
||||
params = _authorize_params(
|
||||
client.get('/auth/discord/login', follow_redirects=False))
|
||||
params = _authorize_params(client.get('/auth/discord/login', follow_redirects=False))
|
||||
|
||||
assert params['scope'][0] == 'identify connections'
|
||||
assert params['response_type'][0] == 'code'
|
||||
@@ -87,8 +89,7 @@ class TestCallbackStateValidation:
|
||||
def test_a_callback_without_state_is_rejected(self, client, discord_configured):
|
||||
client.get('/auth/discord/login', follow_redirects=False)
|
||||
|
||||
response = client.get(
|
||||
'/auth/discord/callback?code=attacker-code', follow_redirects=False)
|
||||
response = client.get('/auth/discord/callback?code=attacker-code', follow_redirects=False)
|
||||
|
||||
assert '/auth/register' in response.headers['Location']
|
||||
|
||||
@@ -96,8 +97,8 @@ class TestCallbackStateValidation:
|
||||
client.get('/auth/discord/login', follow_redirects=False)
|
||||
|
||||
response = client.get(
|
||||
'/auth/discord/callback?code=attacker-code&state=forged',
|
||||
follow_redirects=False)
|
||||
'/auth/discord/callback?code=attacker-code&state=forged', follow_redirects=False
|
||||
)
|
||||
|
||||
assert '/auth/register' in response.headers['Location']
|
||||
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):
|
||||
"""No /discord/login beforehand: nothing to match against."""
|
||||
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']
|
||||
|
||||
def test_the_state_is_single_use(self, client, discord_configured):
|
||||
"""Consumed on the first callback, valid or not, so it cannot be
|
||||
replayed."""
|
||||
state = _authorize_params(
|
||||
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
|
||||
state = _authorize_params(client.get('/auth/discord/login', follow_redirects=False))[
|
||||
'state'
|
||||
][0]
|
||||
|
||||
client.get(f'/auth/discord/callback?code=x&state={state}',
|
||||
follow_redirects=False)
|
||||
client.get(f'/auth/discord/callback?code=x&state={state}', follow_redirects=False)
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
assert auth_module.DISCORD_STATE_KEY not in sess
|
||||
|
||||
+63
-41
@@ -28,28 +28,27 @@ class TestDefaults:
|
||||
assert 'Se connecter' in body
|
||||
|
||||
def test_the_html_lang_attribute_follows_the_locale(self, client):
|
||||
english = client.get('/auth/login',
|
||||
headers={'Accept-Language': 'en-CA,en;q=0.9'})
|
||||
english = client.get('/auth/login', headers={'Accept-Language': 'en-CA,en;q=0.9'})
|
||||
assert 'lang="en"' in english.get_data(as_text=True)
|
||||
|
||||
|
||||
class TestBrowserNegotiation:
|
||||
def test_an_english_browser_is_served_english(self, client):
|
||||
body = client.get('/auth/login',
|
||||
headers={'Accept-Language': 'en-CA,en;q=0.9'}
|
||||
).get_data(as_text=True)
|
||||
body = client.get('/auth/login', headers={'Accept-Language': 'en-CA,en;q=0.9'}).get_data(
|
||||
as_text=True
|
||||
)
|
||||
assert 'Sign In' in body
|
||||
|
||||
def test_an_unsupported_language_falls_back_to_french(self, client):
|
||||
body = client.get('/auth/login',
|
||||
headers={'Accept-Language': 'de-DE,de;q=0.9'}
|
||||
).get_data(as_text=True)
|
||||
body = client.get('/auth/login', headers={'Accept-Language': 'de-DE,de;q=0.9'}).get_data(
|
||||
as_text=True
|
||||
)
|
||||
assert 'Se connecter' in body
|
||||
|
||||
def test_a_french_browser_is_served_french(self, client):
|
||||
body = client.get('/auth/login',
|
||||
headers={'Accept-Language': 'fr-CA,fr;q=0.9'}
|
||||
).get_data(as_text=True)
|
||||
body = client.get('/auth/login', headers={'Accept-Language': 'fr-CA,fr;q=0.9'}).get_data(
|
||||
as_text=True
|
||||
)
|
||||
assert 'Se connecter' in body
|
||||
|
||||
|
||||
@@ -65,9 +64,9 @@ class TestExplicitSwitch:
|
||||
"""Someone on an English machine who picks French must keep French."""
|
||||
client.get('/lang/fr')
|
||||
|
||||
body = client.get('/auth/login',
|
||||
headers={'Accept-Language': 'en-CA,en;q=0.9'}
|
||||
).get_data(as_text=True)
|
||||
body = client.get('/auth/login', headers={'Accept-Language': 'en-CA,en;q=0.9'}).get_data(
|
||||
as_text=True
|
||||
)
|
||||
|
||||
assert 'Se connecter' 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'
|
||||
|
||||
def test_the_switcher_redirects_back_to_the_referring_page(self, client):
|
||||
response = client.get('/lang/en',
|
||||
response = client.get(
|
||||
'/lang/en',
|
||||
headers={'Referer': 'http://localhost/auth/register'},
|
||||
follow_redirects=False)
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.headers['Location'].endswith('/auth/register')
|
||||
|
||||
def test_an_external_referer_is_not_followed(self, client):
|
||||
"""An unchecked Referer would make this an open redirect."""
|
||||
response = client.get('/lang/en',
|
||||
headers={'Referer': 'https://evil.test/phishing'},
|
||||
follow_redirects=False)
|
||||
response = client.get(
|
||||
'/lang/en', headers={'Referer': 'https://evil.test/phishing'}, follow_redirects=False
|
||||
)
|
||||
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)
|
||||
assert 'Page introuvable' in body
|
||||
|
||||
@pytest.mark.parametrize('locale,expected', [
|
||||
@pytest.mark.parametrize(
|
||||
'locale,expected',
|
||||
[
|
||||
('fr', 'Ce compte a été désactivé.'),
|
||||
('en', 'This account has been deactivated.'),
|
||||
])
|
||||
def test_flash_messages_are_translated(
|
||||
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.models import User
|
||||
|
||||
@@ -168,11 +170,14 @@ class TestCatalogueIntegrity:
|
||||
|
||||
path = os.path.join(
|
||||
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), (
|
||||
f'{locale} catalogue is not compiled: run '
|
||||
'`pybabel compile -d app/translations`'
|
||||
f'{locale} catalogue is not compiled: run `pybabel compile -d app/translations`'
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize('locale', SUPPORTED_LOCALES)
|
||||
@@ -184,15 +189,18 @@ class TestCatalogueIntegrity:
|
||||
|
||||
path = os.path.join(
|
||||
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:
|
||||
catalog = read_po(handle, locale=locale)
|
||||
|
||||
untranslated = [m.id for m in catalog if m.id and not m.string]
|
||||
assert not untranslated, (
|
||||
f'{len(untranslated)} untranslated string(s) in {locale}: '
|
||||
f'{untranslated[:5]}'
|
||||
f'{len(untranslated)} untranslated string(s) in {locale}: {untranslated[:5]}'
|
||||
)
|
||||
|
||||
|
||||
@@ -233,9 +241,9 @@ class TestLocaleSurvivesSessionRotation:
|
||||
client.get('/lang/fr')
|
||||
as_role('player')
|
||||
|
||||
body = client.get('/users/profile',
|
||||
headers={'Accept-Language': 'en-CA,en;q=0.9'}
|
||||
).get_data(as_text=True)
|
||||
body = client.get('/users/profile', headers={'Accept-Language': 'en-CA,en;q=0.9'}).get_data(
|
||||
as_text=True
|
||||
)
|
||||
assert 'lang="fr"' in body
|
||||
|
||||
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."""
|
||||
as_role('admin')
|
||||
|
||||
body = client.post('/users/create', data={
|
||||
'username': 'x', 'email': 'not-an-email',
|
||||
'password': 'a', 'full_name': 'X', 'role': 'coach',
|
||||
}, follow_redirects=True).get_data(as_text=True)
|
||||
body = client.post(
|
||||
'/users/create',
|
||||
data={
|
||||
'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
|
||||
|
||||
def test_a_message_with_a_value_keeps_it(self, client, as_role):
|
||||
as_role('admin')
|
||||
|
||||
body = client.post('/users/create', data={
|
||||
'username': 'recrue', 'email': '[email protected]',
|
||||
'password': 'Password123', 'full_name': 'Nouvelle Recrue', 'role': 'coach',
|
||||
}, follow_redirects=True).get_data(as_text=True)
|
||||
body = client.post(
|
||||
'/users/create',
|
||||
data={
|
||||
'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 'créé avec le rôle coach' in body
|
||||
|
||||
+86
-59
@@ -19,11 +19,19 @@ import pytest
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Contract, OrgTeam, PersonalNote, TeamPlayer, Tryout, TryoutRegistration,
|
||||
Contract,
|
||||
OrgTeam,
|
||||
PersonalNote,
|
||||
TeamPlayer,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
)
|
||||
from app.permissions import (
|
||||
can_manage_player_contract, coach_can_access_player, coach_org_team_ids,
|
||||
coach_player_ids, visible_org_teams,
|
||||
can_manage_player_contract,
|
||||
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():
|
||||
from app.models import User
|
||||
|
||||
team = OrgTeam(name=name, created_by=coach_id or legacy_coach_id,
|
||||
coach_id=legacy_coach_id)
|
||||
team = OrgTeam(
|
||||
name=name, created_by=coach_id or legacy_coach_id, coach_id=legacy_coach_id
|
||||
)
|
||||
db.session.add(team)
|
||||
db.session.flush()
|
||||
if coach_id:
|
||||
@@ -48,25 +57,23 @@ def team_factory(app):
|
||||
|
||||
|
||||
class TestTeamResolution:
|
||||
def test_a_coach_attached_by_the_relationship_is_found(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_coach_attached_by_the_relationship_is_found(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
team_id = team_factory('Varsity', coach_id=coach_id)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert coach_org_team_ids(coach) == [team_id]
|
||||
|
||||
def test_a_coach_attached_by_the_legacy_column_is_found(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_coach_attached_by_the_legacy_column_is_found(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
team_id = team_factory('JV', legacy_coach_id=coach_id)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert coach_org_team_ids(coach) == [team_id]
|
||||
|
||||
@@ -74,6 +81,7 @@ class TestTeamResolution:
|
||||
coach_id = make_user('coach')
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
assert coach_org_team_ids(db.session.get(User, coach_id)) == []
|
||||
|
||||
|
||||
@@ -85,47 +93,45 @@ class TestPlayerAccess:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
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(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_second_coach_of_the_team_also_reaches_the_player(self, app, make_user, team_factory):
|
||||
"""The case that used to fail everywhere users.py looked at coach_id."""
|
||||
first_coach = make_user('coach')
|
||||
second_coach = make_user('coach')
|
||||
player_id = make_user('player')
|
||||
team_id = team_factory('Varsity', legacy_coach_id=first_coach,
|
||||
player_ids=[player_id])
|
||||
team_id = team_factory('Varsity', legacy_coach_id=first_coach, player_ids=[player_id])
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
team = db.session.get(OrgTeam, team_id)
|
||||
team.coaches.append(db.session.get(User, second_coach))
|
||||
db.session.commit()
|
||||
|
||||
assert coach_can_access_player(db.session.get(User, second_coach), player_id)
|
||||
|
||||
def test_a_coach_does_not_reach_an_unrelated_player(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_coach_does_not_reach_an_unrelated_player(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
stranger_id = make_user('player')
|
||||
team_factory('Varsity', coach_id=coach_id)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
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(
|
||||
self, app, make_user
|
||||
):
|
||||
def test_a_coach_reaches_a_player_registered_in_their_tryout(self, app, make_user):
|
||||
coach_id = make_user('coach')
|
||||
player_id = make_user('player')
|
||||
|
||||
with app.app_context():
|
||||
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.flush()
|
||||
tryout.coaches.append(db.session.get(User, coach_id))
|
||||
@@ -138,6 +144,7 @@ class TestPlayerAccess:
|
||||
coach_id = make_user('coach')
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
assert not coach_can_access_player(db.session.get(User, coach_id), None)
|
||||
|
||||
|
||||
@@ -152,9 +159,14 @@ class TestPersonalNoteRoutes:
|
||||
coach_id = as_role('coach')
|
||||
team_factory('Varsity', coach_id=coach_id)
|
||||
|
||||
client.post('/users/personal-notes/manage', data={
|
||||
'player_id': stranger_id, 'content': 'Unrelated observation',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
'/users/personal-notes/manage',
|
||||
data={
|
||||
'player_id': stranger_id,
|
||||
'content': 'Unrelated observation',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert PersonalNote.query.filter_by(player_id=stranger_id).count() == 0
|
||||
@@ -167,9 +179,14 @@ class TestPersonalNoteRoutes:
|
||||
coach_id = as_role('coach')
|
||||
team_factory('Varsity', coach_id=coach_id, player_ids=[player_id])
|
||||
|
||||
client.post('/users/personal-notes/manage', data={
|
||||
'player_id': player_id, 'content': 'Good positioning today',
|
||||
}, follow_redirects=True)
|
||||
client.post(
|
||||
'/users/personal-notes/manage',
|
||||
data={
|
||||
'player_id': player_id,
|
||||
'content': 'Good positioning today',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
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):
|
||||
with app.app_context():
|
||||
contract = Contract(
|
||||
player_id=player_id, team_id=team_id, uploaded_by_id=uploader_id,
|
||||
original_filename='c.pdf', stored_filename='uuid.pdf',
|
||||
player_id=player_id,
|
||||
team_id=team_id,
|
||||
uploaded_by_id=uploader_id,
|
||||
original_filename='c.pdf',
|
||||
stored_filename='uuid.pdf',
|
||||
file_path='/tmp/uuid.pdf',
|
||||
)
|
||||
db.session.add(contract)
|
||||
db.session.commit()
|
||||
return contract.id
|
||||
|
||||
def test_a_teamless_contract_is_not_visible_to_every_coach(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_teamless_contract_is_not_visible_to_every_coach(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
stranger_id = make_user('player')
|
||||
admin_id = make_user('admin')
|
||||
@@ -203,12 +221,11 @@ class TestContractVisibility:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
contract = db.session.get(Contract, contract_id)
|
||||
assert not contract.can_view(db.session.get(User, coach_id))
|
||||
|
||||
def test_a_coach_sees_the_contract_of_their_own_player(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_coach_sees_the_contract_of_their_own_player(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
player_id = make_user('player')
|
||||
admin_id = make_user('admin')
|
||||
@@ -217,6 +234,7 @@ class TestContractVisibility:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
contract = db.session.get(Contract, contract_id)
|
||||
assert contract.can_view(db.session.get(User, coach_id))
|
||||
|
||||
@@ -227,6 +245,7 @@ class TestContractVisibility:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
contract = db.session.get(Contract, contract_id)
|
||||
assert contract.can_view(db.session.get(User, player_id))
|
||||
|
||||
@@ -237,6 +256,7 @@ class TestContractVisibility:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
contract = db.session.get(Contract, contract_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
|
||||
live, and silent: the pages rendered, just empty."""
|
||||
|
||||
def test_a_coach_of_two_teams_reaches_both_squads(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_coach_of_two_teams_reaches_both_squads(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
first_player = make_user('player')
|
||||
second_player = make_user('player')
|
||||
@@ -262,6 +280,7 @@ class TestSecondTeam:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert sorted(coach_player_ids(coach)) == sorted([first_player, second_player])
|
||||
|
||||
@@ -275,6 +294,7 @@ class TestSecondTeam:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert can_manage_player_contract(coach, second_player)
|
||||
|
||||
@@ -287,6 +307,7 @@ class TestSecondTeam:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert not can_manage_player_contract(coach, stranger_id)
|
||||
|
||||
@@ -326,6 +347,7 @@ class TestTeamVisibility:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
teams = visible_org_teams(db.session.get(User, coach_id))
|
||||
assert [t.name for t in teams] == ['Varsity']
|
||||
|
||||
@@ -337,6 +359,7 @@ class TestTeamVisibility:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
teams = visible_org_teams(db.session.get(User, admin_id))
|
||||
assert [t.name for t in teams] == ['JV', 'Varsity']
|
||||
|
||||
@@ -347,11 +370,10 @@ class TestTeamVisibility:
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
assert visible_org_teams(db.session.get(User, scout_id)) == []
|
||||
|
||||
def test_the_team_listing_shows_the_relationship_team(
|
||||
self, app, client, as_role, team_factory
|
||||
):
|
||||
def test_the_team_listing_shows_the_relationship_team(self, app, client, as_role, team_factory):
|
||||
coach_id = as_role('coach')
|
||||
team_factory('Northern Lights', coach_id=coach_id)
|
||||
|
||||
@@ -366,49 +388,54 @@ class TestTryoutVisibility:
|
||||
@staticmethod
|
||||
def _tryout(app, *, creator_id, title, target_team_id=None, legacy_coach_id=None):
|
||||
with app.app_context():
|
||||
tryout = Tryout(title=title, game='Valorant', date=date(2030, 5, 1),
|
||||
created_by=creator_id, target_org_team_id=target_team_id,
|
||||
coach_id=legacy_coach_id)
|
||||
tryout = Tryout(
|
||||
title=title,
|
||||
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.commit()
|
||||
return tryout.id
|
||||
|
||||
def test_a_tryout_targeting_a_legacy_team_is_visible(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_a_tryout_targeting_a_legacy_team_is_visible(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
team_id = team_factory('Varsity', legacy_coach_id=coach_id)
|
||||
self._tryout(app, creator_id=coach_id, title='Spring intake',
|
||||
target_team_id=team_id)
|
||||
self._tryout(app, creator_id=coach_id, title='Spring intake', target_team_id=team_id)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
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):
|
||||
coach_id = make_user('coach')
|
||||
team_id = team_factory('Varsity', legacy_coach_id=coach_id)
|
||||
tryout_id = self._tryout(app, creator_id=coach_id, title='Spring intake',
|
||||
target_team_id=team_id)
|
||||
tryout_id = self._tryout(
|
||||
app, creator_id=coach_id, title='Spring intake', target_team_id=team_id
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert coach.can_manage_this_tryout(db.session.get(Tryout, tryout_id))
|
||||
|
||||
def test_another_coachs_tryout_stays_out_of_reach(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
def test_another_coachs_tryout_stays_out_of_reach(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
other_id = make_user('coach')
|
||||
team_factory('Varsity', coach_id=coach_id)
|
||||
other_team = team_factory('JV', coach_id=other_id)
|
||||
tryout_id = self._tryout(app, creator_id=other_id, title='Their intake',
|
||||
target_team_id=other_team)
|
||||
tryout_id = self._tryout(
|
||||
app, creator_id=other_id, title='Their intake', target_team_id=other_team
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert coach.get_visible_tryouts() == []
|
||||
assert not coach.can_manage_this_tryout(db.session.get(Tryout, tryout_id))
|
||||
|
||||
@@ -44,9 +44,7 @@ class TestPolymorphicIdentity:
|
||||
with app.app_context():
|
||||
assert isinstance(db.session.get(User, target_id), Coach)
|
||||
|
||||
def test_the_new_role_grants_its_pages_right_away(
|
||||
self, app, client, as_role, make_user, login
|
||||
):
|
||||
def test_the_new_role_grants_its_pages_right_away(self, app, client, as_role, make_user, login):
|
||||
"""isinstance() is how this application authorises; a stale class in
|
||||
the identity map is a stale permission set."""
|
||||
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.
|
||||
assert client.get('/users/notes-dashboard').status_code == 200
|
||||
|
||||
def test_the_other_fields_of_the_edit_land_too(
|
||||
self, app, client, as_role, make_user
|
||||
):
|
||||
def test_the_other_fields_of_the_edit_land_too(self, app, client, as_role, make_user):
|
||||
target_id = make_user('player')
|
||||
as_role('admin')
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""HTTP hardening, error disclosure, and template escaping."""
|
||||
|
||||
|
||||
from app.app import nl2br
|
||||
|
||||
|
||||
@@ -35,8 +34,7 @@ class TestErrorDisclosure:
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise RuntimeError(
|
||||
'FATAL: password authentication failed for user "app" '
|
||||
'host=db.internal port=5432'
|
||||
'FATAL: password authentication failed for user "app" host=db.internal port=5432'
|
||||
)
|
||||
|
||||
original = db.session.execute
|
||||
|
||||
Reference in New Issue
Block a user