Author SHA1 Message Date
cedrick2711 2b943c5c22 régler problème avec le bouton pour sauvegarder les dispo 2026-08-14 12:03:39 -04:00
cedrick2711 5865df400f changé la photo du site pour le logo UdeS 2026-08-11 13:32:27 -04:00
cedrick2711 9744e0b8c3 cache local dans le register page 2026-08-10 20:06:58 -04:00
cedrick2711 d007900f6c régler problème de dispos des coachs 2026-08-10 17:20:07 -04:00
cedrick2711 bb0bc1c192 ajout de plateforme de base pour les url TRN 2026-08-06 20:13:39 -04:00
cedrick2711 f89e4deb30 régler problème avec mise a jour des status de message du discord bot 2026-08-06 15:26:00 -04:00
cedrick2711 fcf10bcdff bug fix:
Manager ne pouvait pas voir les tryouts.
probleme avec discord bot
2026-08-06 14:46:51 -04:00
cedrick2711 68e3da6601 fix probleme avec dispos 2026-08-06 13:41:53 -04:00
cedrick2711 d25b35c928 régler erreur 500 sur changement de role par admin 2026-08-04 22:06:43 -04:00
cedrick2711 aeba4d78cf régler problème de changement de rôle 2026-08-04 21:52:52 -04:00
cedrick2711 7bb80229db added a clear_db to start fresh with only an admin 2026-08-04 13:09:19 -04:00
cedrick2711 0dd4ecdb4c Erreur de frappe dans un des dossiers 2026-08-04 12:57:18 -04:00
cedrick2711 0f7788e973 Changement du layout de la page d'enregistrement 2026-08-04 12:35:25 -04:00
cedrick2711 47d5ec47e7 added discord oauth2 to get basic user info to complete profile when registering 2026-08-04 12:25:03 -04:00
cedrick2711 10af8c0d22 small change for prod 2026-08-03 23:08:44 -04:00
cedrick2711 7d30aff986 Corriger erreur d'enregistrement 2026-08-03 21:51:23 -04:00
m3ow 409741642a Update wsgi.py 2026-08-03 16:43:46 -04:00
m3ow 056cea0635 Update .gitea/workflows/git-to-ptero.yaml 2026-08-02 19:46:16 -04:00
m3ow d3b5700977 Add .gitea/workflows/git-to-ptero.yaml 2026-08-02 18:39:08 -04:00
clubesportsudes fd258de8c9 Update app/.env.exemple
CI - Security & Lint / Security Audit (push) Has been cancelled
CI - Security & Lint / Lint with Ruff (push) Has been cancelled
CI - Security & Lint / Security Scan (push) Has been cancelled
CI - Security & Lint / Tests (push) Has been cancelled
2026-08-02 18:36:54 -04:00
cedrick2711 d6fe505628 Merge branch 'main' of https://github.com/cedrick2711/team-tryouts 2026-07-30 13:25:05 -04:00
cedrick2711 35c07c263d Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts 2026-07-30 13:25:02 -04:00
cedrick2711 251060ce58 Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts into dev 2026-07-30 13:24:46 -04:00
cedrick2711 51e6b81ac7 régler problème ou les coachs ne voyait pas leur tryouts 2026-07-30 13:24:43 -04:00
cedrick2711 7c375f45dd mise à jour du README 2026-07-30 09:52:32 -04:00
cedrick2711 2d3721b201 Ajout d'un .env.exemple pour simplifier la collaboration 2026-07-30 08:07:28 -04:00
cedrick2711 e629067434 Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts 2026-07-29 20:25:01 -04:00
cedrick2711 19c6740edb régler problème avec le bot discord et ajouter un panneau pour gérer les one on one (accepter ,refuser, confirmer) 2026-07-29 20:24:10 -04:00
cedrick2711 962e621fee régler problème ou on ne voyait pas les dates pour book un One on One 2026-07-29 17:17:11 -04:00
cedrick2711 5e7d7324de Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts 2026-07-29 17:01:41 -04:00
cedrick2711 ad7b932c0a régler les problèmes de route 2026-07-29 17:01:28 -04:00
cedrick2711 da38f90641 Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts 2026-07-29 15:59:45 -04:00
cedrick2711 1702139250 corriger erreur de nom 2026-07-29 15:58:40 -04:00
cedrick2711 b02316ed7c Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts 2026-07-29 15:55:36 -04:00
cedrick2711 3bfe9a99a5 ajustement pour la nouvelle db 2026-07-29 15:53:29 -04:00
cedrick2711 a5bdbefa89 rajout de db dans gitignore 2026-07-29 14:58:14 -04:00
cedrick2711 80ff12f51a rajout de la db dans gitignore 2026-07-29 14:57:01 -04:00
cedrick2711 1337d8e244 delete requirement 2026-07-29 14:49:01 -04:00
cedrick2711 f1b2641718 Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts 2026-07-29 14:48:52 -04:00
cedrick2711 98eecc5e87 ajustement du gitignore 2026-07-29 14:47:58 -04:00
cedrick2711 b80c5b7aa2 UML avec la derniere version du projet 2026-07-29 14:46:49 -04:00
cedrick2711 fc1bdc57c4 régler les imports des fichiers après redistribution 2026-07-29 14:16:01 -04:00
cedrick2711 b69eaabbba remodulation du projet en POO et changement de l'organisation des fichiers 2026-07-29 13:57:06 -04:00
cedrick2711 3feae80767 remodulation du projet et des classes 2026-07-28 23:33:31 -04:00
cedrick2711 90ac3eb804 Uml et fonctionnel dans un document .html 2026-07-28 18:41:05 -04:00
cedrick2711 a0f18a886c Correction des présences et correction de plusieurs erreurs mineur de déplacement/parcours de l'utilisateur, harmonisation des processus 2026-07-28 18:11:02 -04:00
cedrick2711 9b05c7e770 petit ajustement pour render 2026-07-28 15:52:17 -04:00
cedrick2711 4e9d93d8c8 Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts 2026-07-28 15:45:48 -04:00
cedrick2711 b982cd0318 ajout de match régulier pour les équipes et de pratiques
Ajout d'un profil public cliquable pour les utilisateurs
déplacement du profil
2026-07-28 12:39:53 -04:00
cedrick2711 1c97f3a181 changement d'IP pour Render 2026-07-27 13:00:21 -04:00
cedrick2711 f48e1104e7 Merge branch 'main' of https://github.com/cedrick2711/team-tryouts 2026-07-27 12:57:27 -04:00
cedrick2711 b0b898f1e8 changement de port pour Render 2026-07-27 12:57:24 -04:00
Cédrick Martel de99643dd9 Rename requirement.txt to requirements.txt 2026-07-27 12:50:54 -04:00
144 changed files with 8716 additions and 5210 deletions
+42
View File
@@ -0,0 +1,42 @@
name: Push to SFTP
on:
workflow_dispatch:
# push:
# branches:
# - main # Optional: Run automatically on pushes to the main branch
jobs:
deploy-to-sftp:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install lftp and ssh
run: sudo apt-get update && sudo apt-get install -y lftp openssh-client
- name: Set up SSH Private Key
env:
# Binds the secret to a secure environment variable
SSH_PRIVATE_KEY: ${{ secrets.SSH }}
run: |
mkdir -p ~/.ssh
# Uses the environment variable, so the raw key is never printed in the execution log
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
- name: Push files via SFTP with progress
run: |
# The mirror command below uses the -R (reverse) flag
# to push from local './' to remote './'
# Connection is made using 'open' inside the execution block to enforce SSH key usage
lftp -e "set sftp:connect-program 'ssh -a -x -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -o BatchMode=yes -o PasswordAuthentication=no'; \
set sftp:auto-confirm yes; \
set net:max-retries 5; \
set net:timeout 30; \
set cmd:fail-exit yes; \
open -u ${{ secrets.SSH_USER }}, sftp://sftp.node4.immortal.host:2022; \
mirror -R --verbose --parallel=4 ./ ./; \
quit"
+4 -4
View File
@@ -19,7 +19,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v7
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
@@ -37,7 +37,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v7
uses: actions/setup-python@v5
with:
python-version: '3.12'
@@ -57,7 +57,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v7
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
@@ -79,7 +79,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v7
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
+6
View File
@@ -19,3 +19,9 @@ htmlcov/
.certs/
*.pem
docs/
*.html
instance/
*.db
+44 -33
View File
@@ -1,38 +1,61 @@
### Plateforme centralisée de tryouts
# Plateforme centralisée de tryouts
## Security Configuration
### Required Environment Variables
Before deploying, create a `.env` file with the following:
Before deploying, create a `.env` file which integrates everything in the .env.exemple.
Ensure you follow the comments of the exemple if you are to use this tool in production.
```
# Flask Configuration (REQUIRED)
SECRET_KEY=your-secure-random-secret-key-here
# Production Settings
FLASK_DEBUG=false
FORCE_HTTPS=true
SESSION_COOKIE_SECURE=true
```
### Security Features Implemented
- **Rate Limiting**: Login endpoint limited to 10 requests per minute to prevent brute-force attacks
- **Secure Session Cookies**: HTTPOnly, SameSite=Lax, and Secure flags enabled
- **Secure Session Cookies**: HTTPSOnly, SameSite=Lax, and Secure flags enabled
- **CSRF Protection**: Enabled by default on all forms
- **HTTPS Enforcement**: Automatic redirect to HTTPS in production
- **Security Headers**: X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, HSTS
- **Open Redirect Prevention**: URL validation on login redirect
- **Authorization Checks**: Proper ownership validation on all sensitive operations
- **nginx**: reverse-proxy and load balancer
- **Waitress WSGI**: Production ready WSGI
## Discord Integration for One on One Requests
### When true in .env:
- **Forces HTTPS only**
- **Forcer secure cookies**
The application supports sending Discord direct messages to coaches when players request One on One sessions.
## App details
### Code
- Full python backend using flask
- statics are pure HTML and CSS
- Some js to add logic to styling and showing certain pages/cards
### Functionalities
- **User base with sign-ins**: Forces users to create an account and register pertinent information for tryouts and teams. The admin can attribute them a role.
- **User-Role-Based Permissions**: admin - full acces, coach/manager - access to team management, player - views what he is registered in (no management), scout - view only
- **Tryout Management**: manage internal tryout teams, organise internal tryouts matches (3 formats, team vs team, PvP, scrim). Coaches can Evaluate players based on 10 criteria
- **Team Management**: manage teams for the season, create matches and practices. When planning a practice there will be a calendar showing player availabitlities slots to help chose a time
- **Coach and Player Availabilities**: Allow better planning for the coaches, and for players to book One on Ones with their coach.
- **Player Notes**: Coaches can give notes to their players. The players will see them and there is a history which keeps the most recent notes.
- **Team Notes**: Coaches can give notes to their teams, where all players from that team can see the note.
- **One on One**: Players can request a One on One meeting with their coach. This sends a discord dm to the coach to accept or refuse. The player is then notified of the response.
- **Availabilities**: Allow players and coach to enter the moments they are available. Allows for easier practice setup and One on One planning.
## Discord Integration
The application supports sending Discord direct messages to coaches when players request One on One sessions,
when matches/tryouts/practices are created and a player is in it, and the players get match reminders 24h before a match.
When sending a **One on One** request, the coach can accept via the platform or react to the discord message to answer the booking request.
Same thing with **matches** and **practices**, the players can react or answer on the platform.
### Setup Instructions
#### 1. Create a Discord Bot
#### 1. Create a Discord Bot (Not needed for UdeS user, the bot already exists)
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Create a new application
@@ -40,34 +63,21 @@ The application supports sending Discord direct messages to coaches when players
4. Copy the bot token - this will be your `DISCORD_BOT_TOKEN`
5. Enable the "Message Content Intent" under Privileged Gateway Intents (required for sending messages)
#### 2. Configure Environment Variables
#### 2. Add Bot to your server
Add the following to your `.env` file (create one if it doesn't exist):
```
DISCORD_BOT_TOKEN=your_bot_token_here
DISCORD_WEBHOOK_URL=optional_webhook_url_for_backup
```
- `DISCORD_BOT_TOKEN`: Required for sending direct messages to coaches
- `DISCORD_WEBHOOK_URL`: Optional fallback for webhook-based notifications
#### 3. Add Coaches to the Bot
For the bot to send DMs to coaches:
1. Each coach must have the bot added to their Discord server OR be friends with the bot
2. Coaches need to add their Discord User ID to their profile:
For the bot to send DMs:
1. Each user must have the bot added to their Discord server OR be friends with the bot
2. Users need to add their Discord User ID to their profile:
- Enable Developer Mode in Discord (User Settings → Advanced → Developer Mode)
- Right-click on their profile → Copy ID
- Enter this numeric ID in the "Discord User ID" field in their profile settings
### How It Works
When a player submits a One on One request:
1. The system checks if the coach has a Discord User ID configured
2. If configured, a direct message is sent to the coach via the Discord bot
3. If the bot fails or no Discord User ID is set, the system falls back to the webhook URL (if configured)
4. The message includes player name, team, requested date/time, and discussion points
### Message Format
@@ -77,3 +87,4 @@ The Discord DM includes:
- Requested date and time slot
- Discussion points (if provided)
- Link to the application for approval/rejection
- Two provided reactions to accept or refuse via discord
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+32
View File
@@ -0,0 +1,32 @@
# Team Tryouts Application - Environment Variables
# Copy this file to .env and fill in the values for production
# Security Configuration
# Generate a secure random secret key: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=flask_app_secret_key
# Set to 'true' in production to enable secure cookies (requires HTTPS)
SESSION_COOKIE_SECURE=false
FORCE_HTTPS=false
# Flask Debug Mode - Set to 'true' only in development
FLASK_DEBUG=true
# Discord Bot Token (required for notifications)
# This is the UdeS Esports BOT token, it will send notifications to people that have their
# Dicord_User_ID in the db / remove if you don't want discord notifs.
DISCORD_BOT_TOKEN=my_discord_bot_token
# Discord OAuth2 Configuration (for "Connect Discord" on sign-up page)
# Create an application at https://discord.com/developers/applications
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
#where to find the db (hosted on render for now)
DATABASE_URL=URI_vers_db_posgres
#Where the app will be hosted (corresponds to: localhost:5000 in local)
HOST=127.0.0.1
PORT=5000
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+21 -29
View File
@@ -7,7 +7,7 @@ the Flask application instance with comprehensive security hardening.
import os
from flask import Flask, request, redirect, jsonify, render_template, url_for
from flask_cors import CORS
from extensions import db, login_manager, csrf, hash_password, check_password, limiter
from app.extensions import db, login_manager, csrf, hash_password, check_password, limiter
from sqlalchemy import text
from werkzeug.exceptions import HTTPException
import markupsafe
@@ -55,7 +55,11 @@ def create_app():
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
if not app.config['SECRET_KEY']:
raise RuntimeError('SECRET_KEY environment variable must be set for security')
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///team_tryouts.db')
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
if not app.config['SQLALCHEMY_DATABASE_URI']:
raise RuntimeError(
'DATABASE_URL environment variable must be set to a PostgreSQL connection string'
)
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['WTF_CSRF_ENABLED'] = True
@@ -96,16 +100,17 @@ def create_app():
limiter.init_app(app)
# Configure structured logging
from logging_config import configure_logging
from app.logging_config import configure_logging
configure_logging(app)
from routes.auth import auth_bp
from routes.tryouts import tryouts_bp
from routes.evaluations import evaluations_bp
from routes.users import users_bp
from routes.main import main_bp
from routes.teams import teams_bp
from routes.matches import matches_bp
from app.routes.auth import auth_bp
from app.routes.tryouts import tryouts_bp
from app.routes.evaluations import evaluations_bp
from app.routes.users import users_bp
from app.routes.main import main_bp
from app.routes.teams import teams_bp
from app.routes.matches import matches_bp
from app.routes.team_matches import team_matches_bp
app.register_blueprint(auth_bp)
app.register_blueprint(tryouts_bp)
@@ -114,6 +119,7 @@ def create_app():
app.register_blueprint(main_bp)
app.register_blueprint(teams_bp)
app.register_blueprint(matches_bp)
app.register_blueprint(team_matches_bp)
# Register custom Jinja filters
app.jinja_env.filters['nl2br'] = nl2br
@@ -145,7 +151,7 @@ def create_app():
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; "
"font-src 'self' https://cdnjs.cloudflare.com; "
"img-src 'self' data:; "
"img-src 'self' data: https://cdn.discordapp.com; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
@@ -340,27 +346,13 @@ def create_app():
# Database Initialization
# =========================================================================
with app.app_context():
import models
from models import User
try:
# Check if the database schema is up to date by testing a query
db.session.execute(text('SELECT games, team_side FROM match_participants LIMIT 1'))
import app.models as models # noqa: F401 — registers all models with SQLAlchemy
db.create_all()
except Exception:
# If there's a schema mismatch, drop and recreate all tables
db.session.rollback()
db.drop_all()
db.create_all()
# Seed database if empty
if User.query.count() == 0:
from seed import seed_database
seed_database()
# Start the Discord bot for notifications
try:
from discord_bot import start_bot
start_bot()
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)
@@ -376,4 +368,4 @@ if __name__ == '__main__':
'Running in DEBUG mode with Flask built-in server. '
'This is NOT suitable for production. Use wsgi.py instead.'
)
app.run(debug=debug_mode, host='127.0.0.1', port=5000)
app.run(debug=debug_mode, host='0.0.0.0', port=10000)
+324 -69
View File
@@ -7,9 +7,11 @@ This module provides a persistent bot that handles:
"""
import os
import json
import logging
import asyncio
import threading
import traceback
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from queue import Queue, Empty
@@ -21,11 +23,13 @@ from dotenv import load_dotenv
load_dotenv()
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
print(DISCORD_BOT_TOKEN or 'FAILED TO PRINT BOT TOKEN')
# Configure logging
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')
# Emoji constants
CHECK_EMOJI = '' # Green checkmark
CROSS_EMOJI = '' # Red X
@@ -37,26 +41,55 @@ class TeamTryoutsBot(commands.Bot):
Handles One on One requests, schedule additions, and daily reminders.
"""
def __init__(self):
def __init__(self, flask_app=None):
intents = Intents.default()
intents.message_content = True
intents.dm_messages = True
intents.dm_reactions = True
intents.reactions = True
intents.guilds = True
intents.members = True
super().__init__(command_prefix='!', intents=intents)
self.flask_app = flask_app
self.pending_requests = {} # Maps message_id to {type, id} for reaction handling
self.message_queue = Queue() # Thread-safe queue for messages from Flask
self.scheduler = AsyncIOScheduler()
self.timezone = ZoneInfo('America/Toronto') # EDT timezone
def _load_pending(self):
"""Load pending requests from the JSON file."""
try:
if os.path.exists(PENDING_FILE):
with open(PENDING_FILE, 'r') as f:
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}")
else:
logger.info("No pending requests file found, starting fresh.")
except Exception as e:
logger.error(f"Error loading pending requests: {e}")
def _save_pending(self):
"""Save pending requests to the JSON file."""
try:
with open(PENDING_FILE, 'w') as f:
json.dump(self.pending_requests, f, indent=2)
except Exception as e:
logger.error(f"Error saving pending requests: {e}")
async def setup_hook(self):
"""Called when the bot is ready."""
self._load_pending()
logger.info(f'TeamTryoutsBot logged in as {self.user}')
async def on_ready(self):
"""Log when the bot is ready and start background tasks."""
try:
guilds = [g.name for g in self.guilds]
logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user} | Guilds: {guilds}')
except Exception:
logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user}')
# Start the queue processing task
@@ -92,45 +125,82 @@ class TeamTryoutsBot(commands.Bot):
if item.get('type') == 'one_on_one_request':
await self._send_one_on_one_dm(**item['data'])
elif item.get('type') == 'schedule_addition':
if self.flask_app:
with self.flask_app.app_context():
await self._send_schedule_notification(**item['data'])
else:
await self._send_schedule_notification(**item['data'])
elif item.get('type') == 'one_on_one_response':
await self._send_one_on_one_response_dm(**item['data'])
except Exception as e:
logger.error(f"Error processing queue: {e}")
logger.error(f"Error processing queue: {e}\n{traceback.format_exc()}")
await asyncio.sleep(0.1)
def is_dm_channel(self, channel) -> bool:
"""Check if a channel is a DM channel."""
return hasattr(channel, 'recipient') or hasattr(channel, 'recipients')
async def on_reaction_add(self, reaction, user):
"""Handle when a reaction is added to a message."""
if user.bot:
async def on_raw_reaction_add(self, payload):
"""Handle when a reaction is added to a message (works even after bot restart)."""
# Ignore bot's own reactions
if payload.user_id == self.user.id:
return
if not self.is_dm_channel(reaction.message.channel):
# Check if this is a pending request we're tracking
if payload.message_id not in self.pending_requests:
return
message_id = reaction.message.id
if message_id not in self.pending_requests:
# Fetch the channel and check if it's a DM
try:
channel = await self.fetch_channel(payload.channel_id)
except Exception:
return
request_info = self.pending_requests[message_id]
emoji_str = str(reaction.emoji)
if not self.is_dm_channel(channel):
return
# Fetch the user who reacted
try:
user = await self.fetch_user(payload.user_id)
except Exception:
return
if user is None:
return
request_info = self.pending_requests[payload.message_id]
emoji_str = str(payload.emoji)
handler_type = request_info.get('type')
request_id = request_info.get('id')
if emoji_str == CHECK_EMOJI:
if handler_type == 'one_on_one':
await self.handle_one_on_one_approve(user, message_id, request_id, reaction.message)
if self.flask_app:
with self.flask_app.app_context():
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)
elif handler_type == 'schedule_addition':
await self.handle_attendance_confirm(user, message_id, request_id, reaction.message)
if self.flask_app:
with self.flask_app.app_context():
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)
elif emoji_str == CROSS_EMOJI:
if handler_type == 'one_on_one':
await self.handle_one_on_one_reject(user, message_id, request_id, reaction.message)
if self.flask_app:
with self.flask_app.app_context():
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)
elif handler_type == 'schedule_addition':
await self.handle_attendance_decline(user, message_id, request_id, reaction.message)
if self.flask_app:
with self.flask_app.app_context():
await self.handle_attendance_decline(user, payload.message_id, request_id, channel)
else:
await self.handle_attendance_decline(user, payload.message_id, request_id, channel)
async def _send_one_on_one_dm(self, coach_name: str, coach_discord_id: str, player_name: str,
team_name: str, date_str: str, start_time: str, end_time: str,
@@ -165,6 +235,7 @@ class TeamTryoutsBot(commands.Bot):
# Track this pending request
self.pending_requests[msg.id] = {'type': 'one_on_one', 'id': request_id}
self._save_pending()
logger.info(f"Sent One on One DM with reactions, message_id={msg.id}")
return msg.id
@@ -176,10 +247,32 @@ class TeamTryoutsBot(commands.Bot):
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."""
"""Send a schedule addition notification to a player.
Args:
user_id: Database primary key of the User (NOT Discord user ID).
event_type: 'match' or 'tryout'.
event_title: Title of the event.
event_date: Date string.
event_time: Time string.
reference_id: ID of the MatchParticipant or TryoutRegistration record.
"""
try:
user = await self.fetch_user(user_id)
# Look up the DB user to get their Discord user ID
from app.models import User as DBUser
db_user = DBUser.query.get(user_id)
if not db_user:
logger.warning(f"DB user {user_id} not found for schedule notification")
return None
if not db_user.discord_user_id:
logger.warning(f"User {db_user.username} has no Discord user ID, cannot send DM")
return None
discord_uid = int(db_user.discord_user_id)
user = await self.fetch_user(discord_uid)
if not user:
logger.warning(f"Could not fetch Discord user {discord_uid}")
return None
event_name = "Match" if event_type == 'match' else "Tryout"
@@ -201,65 +294,84 @@ class TeamTryoutsBot(commands.Bot):
# Track this pending request
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, message_id={msg.id}")
logger.info(f"Sent {event_type} schedule notification to {db_user.username}, message_id={msg.id}")
return msg.id
except Exception as e:
logger.error(f"Error sending schedule notification: {e}")
return None
async def handle_one_on_one_approve(self, coach, message_id, request_id, original_message):
async def handle_one_on_one_approve(self, coach, message_id, request_id, channel):
"""Handle coach approving a One on One request."""
try:
from models import OneOnOneRequest, db
from sqlalchemy.orm import joinedload
from app.models import OneOnOneRequest
from app.extensions import db
request = OneOnOneRequest.query.options(
joinedload(OneOnOneRequest.player),
joinedload(OneOnOneRequest.coach)
).get(request_id)
request = OneOnOneRequest.query.get(request_id)
if not request:
return
if request.coach.discord_user_id != str(coach.id):
await original_message.channel.send("⚠️ You are not the intended recipient.")
await channel.send("⚠️ You are not the intended recipient.")
return
# Re-attach to current session (object may be detached across app contexts)
request = db.session.merge(request)
# Capture data before commit
player_full_name = request.player.full_name if request.player else 'Unknown'
player_discord_id = request.player.discord_user_id if request.player else None
coach_obj = request.coach
request.status = 'approved'
request.responded_at = datetime.utcnow()
db.session.commit()
await original_message.channel.send(
f"✅ You have **approved** the One on One session with {request.player.full_name}."
await channel.send(
f"✅ You have **approved** the One on One session with {player_full_name}."
)
await self.notify_player_about_one_on_one(request, approved=True)
# Notify player via Discord
if player_discord_id:
await self.notify_player_about_one_on_one_direct(
player_discord_id=player_discord_id,
player_full_name=player_full_name,
coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
request=request,
approved=True
)
del self.pending_requests[message_id]
self._save_pending()
except Exception as e:
logger.error(f"Error handling approval: {e}")
logger.error(f"Error handling approval: {e}\n{traceback.format_exc()}")
async def handle_one_on_one_reject(self, coach, message_id, request_id, original_message):
async def handle_one_on_one_reject(self, coach, message_id, request_id, channel):
"""Handle coach rejecting a One on One request."""
try:
from models import OneOnOneRequest, db
from sqlalchemy.orm import joinedload
from app.models import OneOnOneRequest
from app.extensions import db
request = OneOnOneRequest.query.options(
joinedload(OneOnOneRequest.player),
joinedload(OneOnOneRequest.coach)
).get(request_id)
request = OneOnOneRequest.query.get(request_id)
if not request:
return
if request.coach.discord_user_id != str(coach.id):
await original_message.channel.send("⚠️ You are not the intended recipient.")
await channel.send("⚠️ You are not the intended recipient.")
return
# Re-attach to current session (object may be detached across app contexts)
request = db.session.merge(request)
player_full_name = request.player.full_name if request.player else 'Unknown'
player_discord_id = request.player.discord_user_id if request.player else None
coach_obj = request.coach
refusal_note = None
try:
async for reply in original_message.channel.history(limit=20):
async for reply in channel.history(limit=20):
if reply.author.id == coach.id and reply.reference and reply.reference.message_id == message_id:
refusal_note = reply.content
break
@@ -272,23 +384,33 @@ 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 {request.player.full_name}."
rejection_msg = f"❌ You have **rejected** the One on One session with {player_full_name}."
if refusal_note:
rejection_msg += f"\n**Reason:** {refusal_note}"
else:
rejection_msg += "\n\n️ The player has been notified that you are not available."
await original_message.channel.send(rejection_msg)
await self.notify_player_about_one_on_one(request, approved=False, refusal_note=refusal_note)
await channel.send(rejection_msg)
if player_discord_id:
await self.notify_player_about_one_on_one_direct(
player_discord_id=player_discord_id,
player_full_name=player_full_name,
coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
request=request,
approved=False,
refusal_note=refusal_note
)
del self.pending_requests[message_id]
self._save_pending()
except Exception as e:
logger.error(f"Error handling rejection: {e}")
logger.error(f"Error handling rejection: {e}\n{traceback.format_exc()}")
async def handle_attendance_confirm(self, player, message_id, reference_id, original_message):
async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
"""Handle player confirming attendance for a match/tryout."""
try:
from models import MatchParticipant, TryoutRegistration, Match, Tryout, db
from app.models import MatchParticipant, TryoutRegistration, Match, Tryout
from app.extensions import db
request_info = self.pending_requests[message_id]
event_type = request_info.get('event_type')
@@ -296,24 +418,28 @@ class TeamTryoutsBot(commands.Bot):
if event_type == 'match':
participant = MatchParticipant.query.get(reference_id)
if participant:
participant = db.session.merge(participant)
participant.attendance_confirmed = True
elif event_type == 'tryout':
registration = TryoutRegistration.query.get(reference_id)
if registration:
registration = db.session.merge(registration)
registration.attendance_confirmed = True
db.session.commit()
await original_message.channel.send("✅ Your attendance has been confirmed!")
await channel.send("✅ Your attendance has been confirmed!")
del self.pending_requests[message_id]
self._save_pending()
except Exception as e:
logger.error(f"Error handling attendance confirmation: {e}")
logger.error(f"Error handling attendance confirmation: {e}\n{traceback.format_exc()}")
async def handle_attendance_decline(self, player, message_id, reference_id, original_message):
async def handle_attendance_decline(self, player, message_id, reference_id, channel):
"""Handle player declining attendance for a match/tryout."""
try:
from models import MatchParticipant, TryoutRegistration, Match, Tryout, db
from app.models import MatchParticipant, TryoutRegistration, Match, Tryout
from app.extensions import db
request_info = self.pending_requests[message_id]
event_type = request_info.get('event_type')
@@ -321,47 +447,82 @@ class TeamTryoutsBot(commands.Bot):
if event_type == 'match':
participant = MatchParticipant.query.get(reference_id)
if participant:
participant = db.session.merge(participant)
db.session.delete(participant)
elif event_type == 'tryout':
registration = TryoutRegistration.query.get(reference_id)
if registration:
registration = db.session.merge(registration)
registration.status = 'no_show'
db.session.commit()
await original_message.channel.send("❌ Your attendance has been declined.")
await channel.send("❌ Your attendance has been declined.")
del self.pending_requests[message_id]
self._save_pending()
except Exception as e:
logger.error(f"Error handling attendance decline: {e}")
logger.error(f"Error handling attendance decline: {e}\n{traceback.format_exc()}")
async def notify_player_about_one_on_one(self, request, approved=True, refusal_note=None):
"""Send confirmation to player about One on One response."""
"""Send confirmation to player about One on One response.
This is the legacy method kept for backward compatibility with any
callers that pass a fully-loaded request object.
"""
try:
# Ensure player and coach relationships are loaded
player = request.player
coach = request.coach
if not player:
logger.warning(f"Player not found for request {request.id}")
if not player or not player.discord_user_id:
logger.warning(f"Player has no Discord user ID for request {request.id}")
return
if not coach:
logger.warning(f"Coach not found for request {request.id}")
return
if not player.discord_user_id:
await self.notify_player_about_one_on_one_direct(
player_discord_id=player.discord_user_id,
player_full_name=player.full_name,
coach_full_name=coach.full_name,
request=request,
approved=approved,
refusal_note=refusal_note
)
except Exception as e:
logger.error(f"Error notifying player about One on One: {e}")
async def notify_player_about_one_on_one_direct(self, player_discord_id, player_full_name,
coach_full_name, request,
approved=True, refusal_note=None):
"""Send confirmation to player about One on One response using pre-fetched data.
This method avoids session expiration issues by using data captured before
the database commit.
Args:
player_discord_id: The player's Discord user ID string.
player_full_name: The player's full name.
coach_full_name: The coach's full name.
request: The OneOnOneRequest object (for date/time/points data only).
approved: Whether the session was approved.
refusal_note: Optional coach refusal reason.
"""
try:
if not player_discord_id:
logger.warning(f"Player has no Discord user ID for request {request.id}")
return
player_user = await self.fetch_user(int(player.discord_user_id))
player_user = await self.fetch_user(int(player_discord_id))
if not player_user:
logger.warning(f"Could not fetch Discord user for player {player.id}")
logger.warning(f"Could not fetch Discord user {player_discord_id}")
return
if approved:
message = (
"🎉 **One on One Session Confirmed!**\n\n"
f"Your coach **{coach.full_name}** has approved your request:\n"
f"Your coach **{coach_full_name}** has approved your request:\n"
f"**Date:** {request.date.strftime('%A, %B %d, %Y')}\n"
f"**Time:** {request.start_time.strftime('%I:%M %p')} - {request.end_time.strftime('%I:%M %p')}\n"
f"**Discussion Points:** {request.points or 'No specific points provided'}\n\n"
@@ -371,27 +532,39 @@ class TeamTryoutsBot(commands.Bot):
if refusal_note:
message = (
"😞 **One on One Session Rejected**\n\n"
f"Your coach **{coach.full_name}** has declined:\n"
f"Your coach **{coach_full_name}** has declined:\n"
f"**Reason:** {refusal_note}\n\n"
"Please try selecting a different time slot."
)
else:
message = (
"😞 **One on One Session Unavailable**\n\n"
f"Your coach **{coach.full_name}** is not available.\n\n"
f"Your coach **{coach_full_name}** is not available.\n\n"
"Please try selecting a different time slot."
)
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 notifying player about One on One: {e}")
logger.error(f"Error in direct One on One notification: {e}")
async def send_daily_reminders(self):
"""Send daily reminders at 18:00 EDT for events in 24-48 hours."""
try:
from models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest, db
if self.flask_app:
with self.flask_app.app_context():
await self._send_daily_reminders_impl()
else:
await self._send_daily_reminders_impl()
except Exception as e:
logger.error(f"Error sending daily reminders: {e}\n{traceback.format_exc()}")
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.extensions import db
from sqlalchemy.orm import joinedload
now = datetime.now(self.timezone)
@@ -460,6 +633,56 @@ 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:
"""Send a DM to a player notifying them of their One on One request response.
Called from the message queue when a coach accepts/rejects via the web app.
"""
try:
if not player_discord_id:
logger.warning("Cannot send response DM: no player_discord_id")
return False
player_user = await self.fetch_user(int(player_discord_id))
if not player_user:
logger.warning(f"Could not fetch Discord user {player_discord_id}")
return False
if approved:
message = (
"🎉 **One on One Session Confirmed!**\n\n"
f"Your coach **{coach_full_name}** has approved your request:\n"
f"**Date:** {date_str}\n"
f"**Time:** {start_time} - {end_time}\n"
f"**Discussion Points:** {points or 'No specific points provided'}\n\n"
"Please prepare for your session!"
)
else:
if refusal_note:
message = (
"😞 **One on One Session Rejected**\n\n"
f"Your coach **{coach_full_name}** has declined:\n"
f"**Reason:** {refusal_note}\n\n"
"Please try selecting a different time slot."
)
else:
message = (
"😞 **One on One Session Unavailable**\n\n"
f"Your coach **{coach_full_name}** is not available.\n\n"
"Please try selecting a different time slot."
)
await player_user.send(message)
logger.info(f"Sent One on One response DM to player {player_full_name} (approved={approved})")
return True
except Exception as e:
logger.error(f"Error sending One on One response DM: {e}")
return False
async def send_one_on_one_reminder(self, player, session):
"""Send One on One reminder to player."""
try:
@@ -482,11 +705,13 @@ bot_instance = None
bot_thread = None
def get_bot():
def get_bot(flask_app=None):
"""Get or create the bot instance."""
global bot_instance
if bot_instance is None:
bot_instance = TeamTryoutsBot()
bot_instance = TeamTryoutsBot(flask_app=flask_app)
elif flask_app is not None and bot_instance.flask_app is None:
bot_instance.flask_app = flask_app
return bot_instance
@@ -538,11 +763,41 @@ def send_schedule_notification(user_id: int, event_type: str, event_title: str,
return False
def start_bot():
def send_one_on_one_response(player_discord_id: str, player_full_name: str,
coach_full_name: str, date_str: str, start_time: str,
end_time: str, points: str, approved: bool,
refusal_note: str = None) -> bool:
"""Queue a One on One response DM to be sent to the player by the bot.
Called from Flask routes when a coach accepts/rejects via the web app.
"""
bot = get_bot()
try:
bot.message_queue.put({
'type': 'one_on_one_response',
'data': {
'player_discord_id': player_discord_id,
'player_full_name': player_full_name,
'coach_full_name': coach_full_name,
'date_str': date_str,
'start_time': start_time,
'end_time': end_time,
'points': points,
'approved': approved,
'refusal_note': refusal_note,
}
})
return True
except Exception as e:
logger.error(f"Error queuing One on One response DM: {e}")
return False
def start_bot(flask_app=None):
"""Start the Discord bot in the background."""
global bot_thread
bot = get_bot()
bot = get_bot(flask_app=flask_app)
if DISCORD_BOT_TOKEN and bot_thread is None:
def run_bot():
try:
View File
+2 -3
View File
@@ -128,11 +128,10 @@ def configure_logging(app):
app.logger.addHandler(app_handler)
# -------------------------------------------------------------------------
# 4. Console Handler (for development)
# 4. Console Handler (always enabled for debugging in both dev and production)
# -------------------------------------------------------------------------
if os.getenv('FLASK_DEBUG', 'false').lower() == 'true':
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_handler.setLevel(log_level)
console_handler.setFormatter(formatter)
console_handler.addFilter(sensitive_filter)
app.logger.addHandler(console_handler)
+95
View File
@@ -0,0 +1,95 @@
"""All models — split into individual files for maintainability.
Import this module to register all models with SQLAlchemy and expose every
class, constant, and helper for use throughout the application.
Usage::
from app.models import User, Admin, Evaluation, ESPORT_GAMES, ...
Backward-compatible — no consumer changes needed.
"""
# =========================================================================
# Layer 0: constants (no app deps)
# =========================================================================
from app.models._constants import (
USER_TYPES,
ESPORT_GAMES,
GAME_POSITIONS,
GAME_PLATFORMS,
PLATFORM_CODES,
TRN_URLS,
)
# =========================================================================
# Layer 1: loaders & associations
# =========================================================================
from app.models._loaders import load_user # noqa: F401 — registers Flask-Login callback
# =========================================================================
# Layer 2: abstract base classes
# =========================================================================
from app.models.availability.base import BaseAvailability
from app.models.match_model.base import BaseMatch
from app.models.participant.base import BaseParticipant
# =========================================================================
# Layer 3: user hierarchy (polymorphic)
# =========================================================================
from app.models.user_model.user import User
from app.models.user_model.admin import Admin
from app.models.user_model.manager import Manager
from app.models.user_model.coach import Coach
from app.models.user_model.player import Player
from app.models.user_model.scout import Scout
# =========================================================================
# Layer 4: org_team + junction
# =========================================================================
from app.models.org_team.org_team import OrgTeam
from app.models.org_team.team_player import TeamPlayer
# =========================================================================
# Layer 5: concrete availability models
# =========================================================================
from app.models.availability.player_disponibility import PlayerDisponibility
from app.models.availability.coach_availability import CoachAvailability
# =========================================================================
# Layer 6: tryout + registration
# =========================================================================
from app.models.tryout.tryout import Tryout
from app.models.tryout.tryout_registration import TryoutRegistration
# =========================================================================
# Layer 7: evaluation
# =========================================================================
from app.models.evaluation import Evaluation
# =========================================================================
# Layer 8: tryout-specific teams
# =========================================================================
from app.models.team.team import Team
from app.models.team.team_member import TeamMember
# =========================================================================
# Layer 9: matches (tryout-scoped + regular-season)
# =========================================================================
from app.models.match_model.match import Match
from app.models.match_model.team_match import TeamMatch
# =========================================================================
# Layer 10: participants
# =========================================================================
from app.models.participant.match_participant import MatchParticipant
from app.models.participant.team_match_participant import TeamMatchParticipant
# =========================================================================
# Layer 11: remaining standalone models
# =========================================================================
from app.models.user_gamertag import UserGamertag
from app.models.contract import Contract
from app.models.team_note import TeamNote
from app.models.personal_note import PersonalNote
from app.models.one_on_one_request import OneOnOneRequest
+25
View File
@@ -0,0 +1,25 @@
"""Many-to-many association tables for OrgTeam ↔ User relationships."""
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_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),
)
+70
View File
@@ -0,0 +1,70 @@
"""Global constants shared by all model files.
Contains game lists, position mappings, platform codes, and TRN URL templates.
"""
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout']
ESPORT_GAMES = [
'Valorant',
'League of Legends',
'Counter-Strike 2',
'Apex Legends',
'Overwatch 2',
'Rainbow Six Siege',
'Rocket League',
'Super Smash Bros.',
]
GAME_POSITIONS = {
'League of Legends': ['Top Lane', 'Jungle', 'Mid Lane', 'ADC', 'Support'],
'Valorant': ['Controller', 'Initiator', 'Duelist', 'Sentinel', 'Flex'],
'Counter-Strike 2': ['AWPer', 'Entry Fragger', 'Lurker', 'In-Game Leader', 'Support'],
'Rainbow Six Siege': ['Entry', 'Support', 'Breacher', 'Anchor', 'Flex'],
'Overwatch 2': ['Tank', 'Damage', 'Support'],
'Apex Legends': [],
'Rocket League': [],
'Super Smash Bros.': [],
}
GAME_PLATFORMS = {
'Valorant': [],
'League of Legends': [],
'Counter-Strike 2': [],
'Apex Legends': ['PC', 'PlayStation', 'Xbox', 'Nintendo Switch'],
'Overwatch 2': [],
'Rainbow Six Siege': ['Ubisoft', 'PlayStation', 'Xbox'],
'Rocket League': ['Epic', 'PlayStation', 'Xbox', 'Nintendo Switch'],
'Super Smash Bros.': ['Nintendo Switch'],
}
PLATFORM_CODES = {
'Ubisoft': 'ubi',
'PlayStation': 'psn',
'Xbox': 'xbl',
'Nintendo Switch': 'switch',
'PC': 'pc',
'Steam': 'steam',
'Epic': 'epic',
}
PLATFORM_DEFAULTS = {
'Apex Legends': 'pc',
'Rainbow Six Siege': 'ubi',
'Rocket League': 'epic'
}
TRN_URLS = {
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
'League of Legends': 'https://tracker.gg/lol/profile/{username}',
'Counter-Strike 2': 'https://tracker.gg/cs2/profile/steam/{username}',
'Apex Legends': 'https://tracker.gg/apex/profile/{platform}/{username}',
'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}',
'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}',
'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}',
'Super Smash Bros.': 'https://tracker.gg/smash/profile/{username}',
}
+14
View File
@@ -0,0 +1,14 @@
"""Flask-Login user loader — registered with login_manager in models.py."""
from app.extensions import login_manager
@login_manager.user_loader
def load_user(user_id):
"""Load a user by ID for Flask-Login session management.
Returns the correct polymorphic subclass (Admin, Coach, Player, etc.)
automatically because SQLAlchemy resolves the identity column.
"""
from app.models.user_model.user import User
return User.query.get(int(user_id))
+7
View File
@@ -0,0 +1,7 @@
"""Availability models — BaseAvailability and its concrete subclasses."""
from app.models.availability.base import BaseAvailability
from app.models.availability.player_disponibility import PlayerDisponibility
from app.models.availability.coach_availability import CoachAvailability
__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability']
+14
View File
@@ -0,0 +1,14 @@
"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability)."""
from app.extensions import db
from datetime import datetime
class BaseAvailability(db.Model):
"""Shared schema for player disponibilities and coach availabilities."""
__abstract__ = True
day_of_week = db.Column(db.Integer, nullable=False)
start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -0,0 +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)
coach = db.relationship('User', backref='coach_availabilities')
@@ -0,0 +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)
player = db.relationship('User', backref='disponibilities')
+51
View File
@@ -0,0 +1,51 @@
"""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)
team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
uploaded_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
original_filename = db.Column(db.String(255), nullable=False)
stored_filename = db.Column(db.String(255), nullable=False)
file_path = db.Column(db.String(500), nullable=False)
signed_filename = db.Column(db.String(255), nullable=True)
signed_file_path = db.Column(db.String(500), nullable=True)
status = db.Column(db.String(20), default='pending')
notes = db.Column(db.Text, nullable=True)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
signed_at = db.Column(db.DateTime, nullable=True)
player = db.relationship('User', foreign_keys=[player_id], backref='contracts')
team = db.relationship('OrgTeam', foreign_keys=[team_id])
uploader = db.relationship('User', foreign_keys=[uploaded_by_id])
def can_view(self, user):
if user.id == self.player_id:
return True
from app.models.user_model.admin import Admin
from app.models.user_model.manager import Manager
from app.models.user_model.coach import Coach
from app.models.user_model.user import User
from app.models.org_team.org_team import OrgTeam
if isinstance(user, Admin):
return True
if isinstance(user, Manager):
player = User.query.get(self.player_id)
if player and player.get_org_teams():
return True
if isinstance(user, Coach):
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
if org_team and (not self.team_id or self.team_id == org_team.id):
return True
return False
def can_upload_signed(self, user):
return user.id == self.player_id
+30
View File
@@ -0,0 +1,30 @@
"""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)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
evaluator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
mecanics_score = db.Column(db.Integer, nullable=True)
cohesion_score = db.Column(db.Integer, nullable=True)
communication_score = db.Column(db.Integer, nullable=True)
gamesense_score = db.Column(db.Integer, nullable=True)
versatility_score = db.Column(db.Integer, nullable=True)
discipline_score = db.Column(db.Integer, nullable=True)
analysis_score = db.Column(db.Integer, nullable=True)
sport_ethics_score = db.Column(db.Integer, nullable=True)
mental_score = db.Column(db.Integer, nullable=True)
overall_score = db.Column(db.Float, nullable=True)
comments = db.Column(db.Text, nullable=True)
position_recommendation = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'),
)
+6
View File
@@ -0,0 +1,6 @@
"""Match models — BaseMatch and its concrete subclasses."""
from app.models.match_model.base import BaseMatch
from app.models.match_model.match import Match
from app.models.match_model.team_match import TeamMatch
__all__ = ['BaseMatch', 'Match', 'TeamMatch']
+18
View File
@@ -0,0 +1,18 @@
"""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)
description = db.Column(db.Text, nullable=True)
date = db.Column(db.Date, nullable=False)
start_time = db.Column(db.Time, nullable=True)
end_time = db.Column(db.Time, nullable=True)
location = db.Column(db.String(200), nullable=True)
status = db.Column(db.String(20), default='scheduled')
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
+22
View File
@@ -0,0 +1,22 @@
"""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)
match_type = db.Column(db.String(20), nullable=False)
team1_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
team2_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
creator = db.relationship('User', backref='created_matches')
tryout = db.relationship('Tryout', backref='matches')
team1 = db.relationship('Team', foreign_keys=[team1_id], backref='matches_as_team1')
team2 = db.relationship('Team', foreign_keys=[team2_id], backref='matches_as_team2')
participants = db.relationship('MatchParticipant', backref='match', lazy='dynamic')
def get_participating_players(self):
return [p.player_id for p in self.participants.all()]
+22
View File
@@ -0,0 +1,22 @@
"""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)
opponent = db.Column(db.String(200), nullable=True)
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')
def get_confirmed_count(self):
all_p = self.participants.all()
confirmed = sum(1 for p in all_p if p.is_confirmed)
return confirmed, len(all_p)
+25
View File
@@ -0,0 +1,25 @@
"""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)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True)
date = db.Column(db.Date, nullable=False)
start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False)
points = db.Column(db.Text, nullable=True)
status = db.Column(db.String(20), default='pending')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
responded_at = db.Column(db.DateTime, nullable=True)
discord_message_id = db.Column(db.BigInteger, nullable=True)
coach_rejection_message = db.Column(db.Text, nullable=True)
player = db.relationship('User', foreign_keys=[player_id], backref='one_on_one_requests')
coach = db.relationship('User', foreign_keys=[coach_id])
team = db.relationship('OrgTeam', foreign_keys=[org_team_id])
+5
View File
@@ -0,0 +1,5 @@
"""Organisation team models."""
from app.models.org_team.org_team import OrgTeam
from app.models.org_team.team_player import TeamPlayer
__all__ = ['OrgTeam', 'TeamPlayer']
+53
View File
@@ -0,0 +1,53 @@
"""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
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)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
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'))
managers = db.relationship(
'User', secondary=org_team_managers, lazy='dynamic',
backref=db.backref('managed_org_teams', lazy='dynamic'))
coach = db.relationship(
'User', foreign_keys=[coach_id],
backref=db.backref('coached_org_team_legacy', uselist=False),
viewonly=True)
manager = db.relationship(
'User', foreign_keys=[manager_id],
backref=db.backref('managed_org_team_legacy', uselist=False),
viewonly=True)
def get_coaches(self):
coach_list = self.coaches.all()
if not coach_list and self.coach:
return [self.coach]
return coach_list
def get_managers(self):
manager_list = self.managers.all()
if not manager_list and self.manager:
return [self.manager]
return manager_list
@property
def players(self):
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]
+21
View File
@@ -0,0 +1,21 @@
"""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)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
status = db.Column(db.String(20), nullable=False, default='starter')
position = db.Column(db.String(50), nullable=True)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
player = db.relationship('User', foreign_keys=[player_id], backref='team_placements')
org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players')
__table_args__ = (
db.UniqueConstraint('player_id', 'org_team_id', name='unique_player_org_team'),
)
+6
View File
@@ -0,0 +1,6 @@
"""Participant models — BaseParticipant and its concrete subclasses."""
from app.models.participant.base import BaseParticipant
from app.models.participant.match_participant import MatchParticipant
from app.models.participant.team_match_participant import TeamMatchParticipant
__all__ = ['BaseParticipant', 'MatchParticipant', 'TeamMatchParticipant']
+11
View File
@@ -0,0 +1,11 @@
"""Abstract base class for match participant models."""
from app.extensions import db
from datetime import datetime
class BaseParticipant(db.Model):
"""Shared schema for match participants."""
__abstract__ = True
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
@@ -0,0 +1,15 @@
"""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)
team_side = db.Column(db.Integer, nullable=True)
position = db.Column(db.String(50), nullable=True)
attendance_confirmed = db.Column(db.Boolean, default=False)
player = db.relationship('User')
@@ -0,0 +1,13 @@
"""Participant in a regular-season team match."""
from app.extensions import db
from app.models.participant.base import BaseParticipant
class TeamMatchParticipant(BaseParticipant):
"""Participant in a regular-season team match."""
__tablename__ = 'team_match_participants'
id = db.Column(db.Integer, primary_key=True)
team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False)
is_confirmed = db.Column(db.Boolean, default=False)
player = db.relationship('User')
+24
View File
@@ -0,0 +1,24 @@
"""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)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True)
team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=True)
player = db.relationship('User', foreign_keys=[player_id], backref='personal_notes')
coach = db.relationship('User', foreign_keys=[coach_id])
match = db.relationship('Match', foreign_keys=[match_id])
team = db.relationship('Team', foreign_keys=[team_id])
tryout = db.relationship('Tryout', foreign_keys=[tryout_id])
+5
View File
@@ -0,0 +1,5 @@
"""Tryout-specific temporary team models."""
from app.models.team.team import Team
from app.models.team.team_member import TeamMember
__all__ = ['Team', 'TeamMember']
+16
View File
@@ -0,0 +1,16 @@
"""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)
name = db.Column(db.String(100), nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
creator = db.relationship('User', backref='created_teams')
members = db.relationship('TeamMember', backref='team', lazy='dynamic')
+15
View File
@@ -0,0 +1,15 @@
"""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)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
position = db.Column(db.String(50), nullable=True)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
player = db.relationship('User', overlaps="player_ref,team_assignments")
+17
View File
@@ -0,0 +1,17 @@
"""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)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
team = db.relationship('OrgTeam', backref='team_notes')
coach = db.relationship('User', foreign_keys=[coach_id])
+5
View File
@@ -0,0 +1,5 @@
"""Tryout models."""
from app.models.tryout.tryout import Tryout
from app.models.tryout.tryout_registration import TryoutRegistration
__all__ = ['Tryout', 'TryoutRegistration']
+42
View File
@@ -0,0 +1,42 @@
"""Tryout event for player evaluations and team formation."""
from app.extensions import db
from app.models._associations import tryout_coaches
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)
description = db.Column(db.Text, nullable=True)
game = db.Column(db.String(50), nullable=False)
date = db.Column(db.Date, nullable=False)
end_date = db.Column(db.Date, nullable=True)
location = db.Column(db.String(200), nullable=True)
status = db.Column(db.String(20), default='upcoming')
max_players = db.Column(db.Integer, nullable=True)
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
created_at = db.Column(db.DateTime, default=datetime.utcnow)
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
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])
@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
return self.date < today
+14
View File
@@ -0,0 +1,14 @@
"""Registration linking a player to a tryout."""
from app.extensions import db
from datetime import datetime
class TryoutRegistration(db.Model):
"""Registration linking a player to a tryout."""
__tablename__ = 'tryout_registrations'
id = db.Column(db.Integer, primary_key=True)
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
registered_at = db.Column(db.DateTime, default=datetime.utcnow)
status = db.Column(db.String(20), default='registered')
notes = db.Column(db.Text, nullable=True)
+44
View File
@@ -0,0 +1,44 @@
"""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
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)
game = db.Column(db.String(50), nullable=False)
gamertag = db.Column(db.String(120), nullable=False)
platform = db.Column(db.String(30), nullable=True)
user = db.relationship('User', backref='gamertags')
__table_args__ = (
db.UniqueConstraint('user_id', 'game', name='unique_user_game'),
)
def get_trn_url(self):
if self.game not in TRN_URLS:
return None
url = TRN_URLS[self.game]
encoded_gamertag = quote(self.gamertag, safe='')
# Resolve platform: use user's selection, or fall back to game default
platform = self.platform
if not platform:
platform = PLATFORM_DEFAULTS.get(self.game, '')
if '{platform_code}' in url and '{username}' in url:
platform_code = PLATFORM_CODES.get(
platform,
platform.lower().replace(' ', '-') if platform else '',
)
return url.format(platform_code=platform_code, username=encoded_gamertag)
elif '{platform}' in url and '{username}' in url:
return url.format(
platform=platform.lower().replace(' ', '-') if platform else '',
username=encoded_gamertag,
)
elif '{username}' in url:
return url.format(username=encoded_gamertag)
return url
+9
View File
@@ -0,0 +1,9 @@
"""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
from app.models.user_model.coach import Coach
from app.models.user_model.player import Player
from app.models.user_model.scout import Scout
__all__ = ['User', 'Admin', 'Manager', 'Coach', 'Player', 'Scout']
+32
View File
@@ -0,0 +1,32 @@
"""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):
return True
def can_manage_users(self):
return True
def can_manage_teams(self):
return True
def can_manage_tryouts(self):
return True
def can_schedule_matches(self):
return True
def can_manage_this_tryout(self, tryout):
return True
def can_manage_this_org_team(self, org_team):
return True
def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout
return Tryout.query.order_by(Tryout.date).all()
+54
View File
@@ -0,0 +1,54 @@
"""Coach — evaluates, schedules matches, manages their own org team."""
from app.models.user_model.user import User
from app.extensions import db
class Coach(User):
"""Coach — evaluates, schedules matches, manages their own org team."""
__mapper_args__ = {'polymorphic_identity': 'coach'}
def can_evaluate(self):
return True
def can_schedule_matches(self):
return True
def can_manage_tryouts(self):
return True
def can_manage_this_tryout(self, tryout):
from app.models.org_team.org_team import OrgTeam
if tryout.target_org_team_id:
is_coach_of_target = OrgTeam.query.filter(
OrgTeam.id == tryout.target_org_team_id,
OrgTeam.coaches.any(id=self.id),
).first() is not None
if is_coach_of_target:
return True
# Check many-to-many coaches relationship
if any(c.id == self.id for c in tryout.coaches):
return True
# Backward compat: check deprecated coach_id
if tryout.coach_id == self.id:
return True
return False
def can_manage_this_org_team(self, org_team):
if org_team.coaches.filter_by(id=self.id).first():
return True
if org_team.coach_id == self.id:
return True
return False
def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout
from app.models._associations import tryout_coaches
team_ids = [t.id for t in self.coached_org_teams.all()]
conditions = []
if team_ids:
conditions.append(Tryout.target_org_team_id.in_(team_ids))
# Check many-to-many coaches
conditions.append(Tryout.coaches.any(id=self.id))
# Backward compat: check deprecated coach_id
conditions.append(Tryout.coach_id == self.id)
return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all()
+32
View File
@@ -0,0 +1,32 @@
"""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):
return True
def can_manage_teams(self):
return True
def can_manage_tryouts(self):
return True
def can_schedule_matches(self):
return True
def can_manage_this_tryout(self, tryout):
return tryout.created_by == self.id or tryout.manager_id == self.id
def can_manage_this_org_team(self, org_team):
return True
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()
+30
View File
@@ -0,0 +1,30 @@
"""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):
from app.models.tryout.tryout import Tryout
from app.models.match_model.match import Match
from app.models.participant.match_participant import MatchParticipant
# 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 []
# plus tryouts where they participate in a match
player_matches = Match.query.join(MatchParticipant).filter(
MatchParticipant.player_id == self.id,
).all()
extra_ids = set(m.tryout_id for m in player_matches)
extra = Tryout.query.filter(
Tryout.id.in_(extra_ids),
).order_by(Tryout.date).all() if extra_ids else []
all_ids = {t.id for t in tryouts}
return tryouts + [t for t in extra if t.id not in all_ids]
+14
View File
@@ -0,0 +1,14 @@
"""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):
return True
def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout
return Tryout.query.order_by(Tryout.date).all()
+93
View File
@@ -0,0 +1,93 @@
"""Base User model — shared fields and polymorphic configuration."""
from app.extensions import db
from flask_login import UserMixin
from datetime import datetime
class User(UserMixin, db.Model):
"""Base user model — shared fields for every role.
Do not instantiate this class directly; use Admin, Manager, Coach, Player,
or Scout so that `polymorphic_identity` is set correctly.
"""
__tablename__ = 'users'
# --- columns -----------------------------------------------------------
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
role = db.Column(db.String(20), nullable=False, default='player') # polymorphic discriminator
full_name = db.Column(db.String(100), nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
phone = db.Column(db.String(20), nullable=True)
is_active_account = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
failed_login_attempts = db.Column(db.Integer, default=0)
locked_until = db.Column(db.DateTime, nullable=True)
# E-Sports fields
games = db.Column(db.Text, nullable=True) # comma-separated (only meaningful for Player)
discord_username = db.Column(db.String(128), nullable=True)
discord_user_id = db.Column(db.String(64), nullable=True)
league_os_profile = db.Column(db.String(256), nullable=True)
# --- polymorphic configuration -----------------------------------------
__mapper_args__ = {
'polymorphic_identity': 'user',
'polymorphic_on': role,
}
# --- relationships (defined once on the base) --------------------------
evaluations_given = db.relationship(
'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')
team_assignments = db.relationship(
'TeamMember', foreign_keys='TeamMember.player_id',
backref='player_ref', lazy='dynamic')
# --- shared helper methods ---------------------------------------------
def get_games_list(self):
"""Return the user's games as a list."""
if self.games:
return [g.strip() for g in self.games.split(',') if g.strip()]
return []
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}
def get_org_teams(self):
"""Return all OrgTeams this player belongs to."""
return [tp.org_team for tp in self.team_placements]
# --- stubs (overridden in subclasses) ----------------------------------
def can_evaluate(self):
return False
def can_manage_users(self):
return False
def can_manage_teams(self):
return False
def can_manage_tryouts(self):
return False
def can_schedule_matches(self):
return False
def can_manage_this_tryout(self, tryout):
return False
def can_manage_this_org_team(self, org_team):
return False
def get_visible_tryouts(self):
return []
View File
+207 -17
View File
@@ -6,19 +6,36 @@ password policy enforcement and CAPTCHA verification.
"""
import uuid
import os
from datetime import datetime, timedelta
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
from flask_login import login_user, logout_user, login_required, current_user
from extensions import db, hash_password, check_password, limiter
from models import User, ESPORT_GAMES
from validators import RegisterSchema, LoginSchema
from app.extensions import db, hash_password, check_password, limiter
from app.models import User, Player, ESPORT_GAMES
from app.validators import RegisterSchema, LoginSchema
from marshmallow import ValidationError
from urllib.parse import urlparse
import requests
# Account lockout settings
MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15
# Discord OAuth2 configuration
DISCORD_CLIENT_ID = os.getenv('DISCORD_CLIENT_ID')
DISCORD_CLIENT_SECRET = os.getenv('DISCORD_CLIENT_SECRET')
DISCORD_REDIRECT_URI = os.getenv('DISCORD_REDIRECT_URI')
DISCORD_API_BASE = 'https://discord.com/api/v10'
# Mapping from Discord connection platform to E-Sports games
DISCORD_PLATFORM_TO_GAMES = {
'steam': ['Counter-Strike 2'],
'battlenet': ['Overwatch 2'],
'epicgames': ['Rocket League'],
'xbox': ['Apex Legends', 'Rainbow Six Siege', 'Rocket League'],
'playstation': ['Apex Legends', 'Rainbow Six Siege', 'Rocket League'],
}
def is_safe_url(url):
"""Validate that a URL is safe for redirection (same origin).
@@ -170,7 +187,7 @@ def login():
@auth_bp.route('/register', methods=['GET', 'POST'])
@limiter.limit("3 per hour")
@limiter.limit("20 per hour")
def register():
"""Handle new player registration with CAPTCHA and password policy.
@@ -187,34 +204,43 @@ def register():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
# Generate CAPTCHA for GET requests
captcha = generate_captcha()
if request.method == 'POST':
# Build form data from request to preserve state across re-renders
form_data = dict(request.form)
form_data['games'] = request.form.getlist('games')
# Validate CAPTCHA first
captcha_answer = request.form.get('captcha_answer', '')
if not verify_captcha(captcha_answer):
flash('Incorrect CAPTCHA answer. Please try again.', 'danger')
captcha = generate_captcha() # Generate new captcha
captcha = generate_captcha()
# Clear password fields only on CAPTCHA failure
form_data.pop('password', None)
form_data.pop('confirm_password', None)
return render_template(
'pages/register.html',
esport_games=ESPORT_GAMES,
captcha=captcha
captcha=captcha,
form_data=form_data,
)
# Validate input with marshmallow schema
register_schema = RegisterSchema()
try:
validated = register_schema.load(request.form)
validated = register_schema.load(form_data)
except ValidationError as err:
for field, messages in err.messages.items():
for msg in messages:
flash(f'{field}: {msg}', 'danger')
captcha = generate_captcha()
# Clear password fields on validation failure
form_data.pop('password', None)
form_data.pop('confirm_password', None)
return render_template(
'pages/register.html',
esport_games=ESPORT_GAMES,
captcha=captcha
captcha=captcha,
form_data=form_data,
)
username = validated['username']
@@ -223,30 +249,36 @@ def register():
full_name = validated['full_name']
phone = validated.get('phone')
selected_games = validated.get('games', [])
trn_username = request.form.get('trn_username', '').strip() or None
discord_username = validated.get('discord_username')
discord_user_id = validated.get('discord_user_id')
league_os_profile = validated.get('league_os_profile')
if User.query.filter_by(username=username).first():
flash('Username already exists.', 'danger')
captcha = generate_captcha()
form_data.pop('password', None)
form_data.pop('confirm_password', None)
return render_template(
'pages/register.html',
esport_games=ESPORT_GAMES,
captcha=captcha
captcha=captcha,
form_data=form_data,
)
if User.query.filter_by(email=email).first():
flash('Email already registered.', 'danger')
captcha = generate_captcha()
form_data.pop('password', None)
form_data.pop('confirm_password', None)
return render_template(
'pages/register.html',
esport_games=ESPORT_GAMES,
captcha=captcha
captcha=captcha,
form_data=form_data,
)
hashed_password = hash_password(password)
user = User(
user = Player(
username=username,
password_hash=hashed_password,
role='player',
@@ -255,15 +287,173 @@ def register():
phone=phone,
games=','.join(selected_games) if selected_games else None,
discord_username=discord_username,
league_os_profile=league_os_profile
discord_user_id=discord_user_id,
league_os_profile=league_os_profile,
)
db.session.add(user)
db.session.commit()
# 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()
if gamertag_value:
gamertag = UserGamertag(
user_id=user.id,
game=game,
gamertag=gamertag_value,
)
db.session.add(gamertag)
db.session.commit()
# Clear Discord OAuth data from session after successful registration
session.pop('discord_oauth', None)
flash('Your account has been created! You can now log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('pages/register.html', esport_games=ESPORT_GAMES, captcha=captcha)
# GET request — render empty form
captcha = generate_captcha()
return render_template(
'pages/register.html',
esport_games=ESPORT_GAMES,
captcha=captcha,
form_data={},
)
@auth_bp.route('/discord/login')
def discord_login():
"""Redirect the user to Discord's OAuth2 authorization page.
Requests the 'identify' and 'connections' scopes so we can retrieve
the user's Discord username, ID, and linked gaming accounts.
Returns:
Response: Redirect to Discord authorization URL.
"""
if not DISCORD_CLIENT_ID:
flash('Discord OAuth2 is not configured.', 'danger')
return redirect(url_for('auth.register'))
params = {
'client_id': DISCORD_CLIENT_ID,
'redirect_uri': DISCORD_REDIRECT_URI,
'response_type': 'code',
'scope': 'identify connections',
}
query = '&'.join(f'{k}={requests.utils.quote(v)}' for k, v in params.items())
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{query}'
return redirect(auth_url)
@auth_bp.route('/discord/callback')
def discord_callback():
"""Handle the OAuth2 callback from Discord.
Exchanges the authorization code for an access token, then fetches
the user's profile (/users/@me) and connections (/users/@me/connections).
Results are stored in the session and the user is redirected back to
the registration form where fields will be pre-filled.
Returns:
Response: Redirect to registration page.
"""
code = request.args.get('code')
if not code:
flash('Discord authorization failed. No code received.', 'danger')
return redirect(url_for('auth.register'))
# Exchange the authorization code for an access token
token_data = {
'client_id': DISCORD_CLIENT_ID,
'client_secret': DISCORD_CLIENT_SECRET,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': DISCORD_REDIRECT_URI,
}
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
try:
token_response = requests.post(
f'{DISCORD_API_BASE}/oauth2/token',
data=token_data,
headers=headers,
timeout=10,
)
token_response.raise_for_status()
token_json = token_response.json()
access_token = token_json.get('access_token')
except requests.RequestException as e:
flash(f'Failed to connect to Discord. Please try again.', 'danger')
return redirect(url_for('auth.register'))
if not access_token:
flash('Failed to obtain Discord access token.', 'danger')
return redirect(url_for('auth.register'))
auth_headers = {'Authorization': f'Bearer {access_token}'}
# Fetch the user's Discord profile
try:
user_response = requests.get(
f'{DISCORD_API_BASE}/users/@me',
headers=auth_headers,
timeout=10,
)
user_response.raise_for_status()
user_data = user_response.json()
except requests.RequestException:
flash('Failed to fetch Discord user profile.', 'danger')
return redirect(url_for('auth.register'))
# Fetch the user's connected gaming accounts
connections = []
try:
conn_response = requests.get(
f'{DISCORD_API_BASE}/users/@me/connections',
headers=auth_headers,
timeout=10,
)
conn_response.raise_for_status()
connections = conn_response.json()
except requests.RequestException:
# Non-critical: we can still proceed without connections
pass
# Build gamertag suggestions from Discord connections
gamertag_suggestions = {}
for conn in connections:
platform = conn.get('type', '')
name = conn.get('name', '').strip()
if not name or platform not in DISCORD_PLATFORM_TO_GAMES:
continue
for game in DISCORD_PLATFORM_TO_GAMES[platform]:
# Only set if not already set (first connection wins)
if game not in gamertag_suggestions:
gamertag_suggestions[game] = name
# Build a list of games to auto-select (unambiguous platform mappings)
auto_select_games = []
for conn in connections:
platform = conn.get('type', '')
if platform in ('steam', 'battlenet', 'epicgames'):
for game in DISCORD_PLATFORM_TO_GAMES[platform]:
if game not in auto_select_games:
auto_select_games.append(game)
# Store in session for the registration form to use
session['discord_oauth'] = {
'id': user_data.get('id'),
'username': user_data.get('username'),
'avatar': user_data.get('avatar'),
'gamertag_suggestions': gamertag_suggestions,
'auto_select_games': auto_select_games,
}
flash('Discord account connected! Your profile has been pre-filled.', 'success')
return redirect(url_for('auth.register'))
@auth_bp.route('/logout')
@@ -1,12 +1,16 @@
"""Evaluation routes for assessing player performance during tryouts.
This module handles player evaluation creation, management, and viewing.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from extensions import db
from models import User, Tryout, Evaluation, TryoutRegistration, GAME_POSITIONS, OrgTeam
from app.extensions import db
from app.models import (
Admin, Coach, Manager, Player,
User, Tryout, Evaluation, TryoutRegistration,
OrgTeam, GAME_POSITIONS,
)
from sqlalchemy import func
from sqlalchemy.orm import aliased
@@ -14,14 +18,7 @@ evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
def validate_score(score_value):
"""Validate that a score is between 1 and 10.
Args:
score_value: The score value to validate (can be None, string, or int).
Returns:
int or None: The validated score or None if invalid/empty.
"""
"""Validate that a score is between 1 and 10."""
if score_value is None:
return None
try:
@@ -36,32 +33,18 @@ def validate_score(score_value):
@evaluations_bp.route('')
@login_required
def list_evaluations():
"""List all evaluations accessible to the current user.
President: All evaluations with player score summaries.
Evaluators (coach/manager): Their given evaluations.
Supports sorting by any column header via 'sort' and 'order' query parameters.
Returns:
Response: Rendered evaluations list template.
"""
"""List all evaluations accessible to the current user."""
user = current_user
# Players are not allowed to view evaluations
if user.role == 'player':
if isinstance(user, Player):
flash('You do not have permission to view evaluations.', 'danger')
return redirect(url_for('main.dashboard'))
# Get sort parameters
sort_column = request.args.get('sort', 'created_at')
sort_order = request.args.get('order', 'desc')
# Validate sort_order
if sort_order not in ('asc', 'desc'):
sort_order = 'desc'
# Map sort columns to SQLAlchemy expressions using aliased User models for relationship sorting
player_alias = aliased(User, name='eval_player')
evaluator_alias = aliased(User, name='eval_evaluator')
@@ -89,7 +72,7 @@ def list_evaluations():
else:
sort_expr = sort_expr.desc()
if user.role == 'president':
if isinstance(user, Admin):
evaluations = Evaluation.query \
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
@@ -98,14 +81,16 @@ def list_evaluations():
avg_scores = db.session.query(
Evaluation.player_id,
func.count(Evaluation.id).label('eval_count'),
func.avg(Evaluation.overall_score).label('avg_score')
func.avg(Evaluation.overall_score).label('avg_score'),
).group_by(Evaluation.player_id).all()
player_scores = {}
for row in avg_scores:
p = User.query.get(row.player_id)
if p:
player_scores[p.id] = {'player': p, 'count': row.eval_count, 'avg': round(row.avg_score, 1) if row.avg_score else 0}
player_scores[p.id] = {
'player': p, 'count': row.eval_count,
'avg': round(row.avg_score, 1) if row.avg_score else 0,
}
elif user.can_evaluate():
evaluations = Evaluation.query \
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
@@ -123,53 +108,38 @@ def list_evaluations():
.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'])
@login_required
def evaluate_player(tryout_id, player_id):
"""Evaluate a specific player in a tryout.
GET: Render the evaluation form with any existing evaluation.
POST: Create or update the evaluation for the player.
Args:
tryout_id: The ID of the tryout.
player_id: The ID of the player to evaluate.
Returns:
Response: Evaluation form or redirect to tryout view.
"""
"""Evaluate a specific player in a tryout."""
if not current_user.can_evaluate():
flash('You do not have permission to evaluate players.', 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
# Check if user has permission to evaluate players in this tryout
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to evaluate players in this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
# Check if player is registered for this tryout
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id
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))
player = User.query.get_or_404(player_id)
if player.role != 'player':
if not isinstance(player, Player):
flash('Can only evaluate players.', 'danger')
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':
@@ -185,7 +155,9 @@ 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:
@@ -204,21 +176,14 @@ 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')
@@ -227,13 +192,15 @@ def evaluate_player(tryout_id, player_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
evaluators = None
if current_user.role == 'president':
all_evaluations = Evaluation.query.filter_by(tryout_id=tryout_id, player_id=player_id).all()
evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations]
if isinstance(current_user, Admin):
all_evaluations = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=player_id,
).all()
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,
tryout=tryout, player=player,
existing_eval=existing_eval,
evaluators=evaluators,
game_positions=GAME_POSITIONS)
@@ -242,24 +209,12 @@ def evaluate_player(tryout_id, player_id):
@evaluations_bp.route('/<int:tryout_id>/players')
@login_required
def players_to_evaluate(tryout_id):
"""List players that need evaluation in a specific tryout.
Shows all registered players and marks which ones have already been evaluated
by the current user.
Args:
tryout_id: The ID of the tryout.
Returns:
Response: Rendered players-to-evaluate template.
"""
"""List players that need evaluation in a specific tryout."""
if not current_user.can_evaluate():
flash('Permission denied.', 'danger')
return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id)
# Check if user has permission to evaluate players in this tryout
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to evaluate players in this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
@@ -268,12 +223,11 @@ def players_to_evaluate(tryout_id):
players = []
for reg in registrations:
p = User.query.get(reg.player_id)
if p and p.role == 'player':
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)
+40 -59
View File
@@ -1,27 +1,25 @@
"""Main dashboard routes for the Team Tryouts application.
This module provides the main dashboard view with role-specific statistics.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, render_template, redirect, url_for, flash
from flask_login import login_required, current_user
from extensions import db
from models import User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember, Match, MatchParticipant, OrgTeam
from app.extensions import db
from app.models import (
Admin, Manager, Coach, Player, Scout,
User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember,
Match, MatchParticipant, OrgTeam,
)
from sqlalchemy import func
from datetime import datetime, date
from datetime import date
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
def index():
"""Redirect root URL to login page.
This is the entry point for the application when no specific route is provided.
Returns:
Response: Redirect to login page.
"""
"""Redirect root URL to login page."""
return redirect(url_for('auth.login'))
@@ -30,20 +28,12 @@ def index():
def dashboard():
"""Render the main dashboard with role-specific statistics.
Displays different statistics based on the user's role:
- President: Overview of all users, tryouts, and evaluations
- Manager: Their created tryouts and evaluations
- Coach: Their evaluations and pending evaluations
- Player: Their registrations, evaluations, and upcoming matches
- Scout: Top-rated players across all tryouts
Returns:
Response: Rendered dashboard template with user stats.
Each User subclass provides its own stats view.
"""
user = current_user
stats = {}
if user.role == 'president':
if isinstance(user, Admin):
stats['total_users'] = User.query.count()
stats['total_players'] = User.query.filter_by(role='player').count()
stats['total_tryouts'] = Tryout.query.count()
@@ -54,101 +44,92 @@ def dashboard():
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
Match.status == 'scheduled', Match.date >= today,
).order_by(Match.date, Match.start_time).limit(5).all()
elif user.role == 'manager':
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()
stats['active_tryouts'] = Tryout.query.filter_by(
created_by=user.id, status='in_progress').count()
stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
stats['my_tryouts'] = Tryout.query.filter_by(created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
stats['my_tryouts'] = Tryout.query.filter_by(
created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
today = date.today()
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
stats['upcoming_matches'] = Match.query.filter(
Match.tryout_id.in_(manager_tryout_ids),
Match.status == 'scheduled',
Match.date >= today
Match.status == 'scheduled', Match.date >= today,
).order_by(Match.date, Match.start_time).limit(5).all() if manager_tryout_ids else []
elif user.role == 'coach':
elif isinstance(user, Coach):
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
stats['pending_evaluations'] = 0
registrations = TryoutRegistration.query.filter(TryoutRegistration.status.in_(['registered', 'attended'])).all()
registrations = TryoutRegistration.query.filter(
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()]
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()
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
coach_tryout_ids = [t.id for t in Tryout.query.filter_by(target_org_team_id=org_team.id).all()] if org_team else []
coach_tryout_ids = [t.id for t in Tryout.query.filter_by(
target_org_team_id=org_team.id).all()] if org_team else []
stats['upcoming_matches'] = Match.query.filter(
Match.tryout_id.in_(coach_tryout_ids),
Match.status == 'scheduled',
Match.date >= today
Match.status == 'scheduled', Match.date >= today,
).order_by(Match.date, Match.start_time).limit(5).all() if coach_tryout_ids else []
elif user.role == 'player':
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()
# Get upcoming matches for the player
today = date.today()
next_matches = []
# Get all tryouts the player is registered for (not just the 5 most recent)
all_registrations = TryoutRegistration.query.filter_by(player_id=user.id).all()
registered_tryout_ids = [r.tryout_id for r in all_registrations]
# Get all matches where player is a participant (any match type)
player_participant_matches = MatchParticipant.query.filter_by(player_id=user.id).all()
player_match_ids = [p.match_id for p in player_participant_matches]
# Get all team memberships for this player
player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all()
player_team_ids = [tm.team_id for tm in player_team_memberships]
# Find all scheduled matches in registered tryouts
upcoming_matches = Match.query.filter(
Match.tryout_id.in_(registered_tryout_ids),
Match.status == 'scheduled',
Match.date >= today
Match.status == 'scheduled', Match.date >= today,
).order_by(Match.date, Match.start_time).all()
for match in upcoming_matches:
is_participant = False
team = None
if match.match_type == 'team_vs_team':
# Check if player is on either 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:
# For player_vs_player and player_scrim, check MatchParticipant
if match.id in player_match_ids:
is_participant = True
if is_participant:
tryout = match.tryout
next_matches.append({
'tryout': tryout,
'match': match,
'team': team.team if team else None
'tryout': match.tryout, 'match': match,
'team': team.team if team else None,
})
stats['next_matches'] = next_matches
elif user.role == 'scout':
elif isinstance(user, Scout):
stats['total_players'] = User.query.filter_by(role='player').count()
stats['total_evaluations'] = Evaluation.query.count()
stats['avg_scores'] = db.session.query(
Evaluation.player_id,
func.avg(Evaluation.overall_score).label('avg_score')
).group_by(Evaluation.player_id).order_by(func.avg(Evaluation.overall_score).desc()).limit(5).all()
func.avg(Evaluation.overall_score).label('avg_score'),
).group_by(Evaluation.player_id).order_by(
func.avg(Evaluation.overall_score).desc()).limit(5).all()
stats['top_players'] = []
for row in stats['avg_scores']:
p = User.query.get(row.player_id)
+162 -358
View File
@@ -1,73 +1,53 @@
"""Match scheduling routes for managing scrimmages and matches within tryouts.
This module handles calendar views, match creation, and player availability.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from extensions import db
from models import User, Tryout, Match, MatchParticipant, Team, TeamMember, OrgTeam, TryoutRegistration, PlayerDisponibility
from app.extensions import db
from app.models import (
Admin, Manager, Coach, Player, Scout,
User, Tryout, Match, MatchParticipant, Team, TeamMember,
OrgTeam, TryoutRegistration, PlayerDisponibility,
OneOnOneRequest,
)
from datetime import datetime, time, timedelta
from discord_bot import send_schedule_notification
from app.discord_bot import send_schedule_notification
matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
def can_schedule_match():
"""Check if user can schedule matches (coaches and above).
"""Check if user can schedule matches (Admin, Manager, Coach, Scout)."""
return isinstance(current_user, (Admin, Manager, Coach, Scout))
Returns:
bool: True if user is president, manager, coach, or scout.
def get_visible_tryouts_for_user():
"""Get tryouts that the current user can see based on their role.
Delegates to the polymorphic User subclass.
"""
return current_user.role in ['president', 'manager', 'coach', 'scout']
return current_user.get_visible_tryouts()
@matches_bp.route('/calendar')
@login_required
def calendar():
"""Render the calendar view showing all tryouts and matches.
Returns:
Response: Rendered calendar template.
"""
"""Render the calendar view."""
return render_template('pages/calendar.html')
@matches_bp.route('/api/events')
@login_required
def api_events():
"""API endpoint returning calendar events for FullCalendar.
Returns tryout events and match events with participant information.
Returns:
Response: JSON array of calendar events.
"""
"""API endpoint returning calendar events for FullCalendar."""
events = []
# Get tryouts based on user permissions (this already filters by user's role)
tryouts = get_visible_tryouts_for_user()
for tryout in tryouts:
events.append({
'id': f'tryout_{tryout.id}',
'title': tryout.title,
'date': tryout.date.strftime('%Y-%m-%d'),
'type': 'tryout',
'color': '#3b82f6', # Blue for tryouts
'extendedProps': {
'location': tryout.location or 'TBD',
'status': tryout.status,
'description': tryout.description or '',
'tryout_id': tryout.id
}
})
# Add matches for this tryout
for match in tryout.matches:
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
# Build match description with participants
match_desc = match.description or ''
participants_str = ''
if match.match_type == 'team_vs_team':
@@ -79,35 +59,65 @@ def api_events():
participants_str = f"{' vs '.join(teams)}"
match_desc = participants_str + (f"<br>{match.description}" if match.description else '')
else:
# Player scrim - show all participants
player_names = []
for p in match.participants.all():
player_name = p.player.username if p.player else 'Unknown Player'
player_names.append(player_name)
player_names.append(p.player.username if p.player else 'Unknown Player')
participants_str = ', '.join(player_names) if player_names else 'No players'
match_desc = participants_str + (f"<br>{match.description}" if match.description else '')
# Include time for calendar display
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
user_participant = MatchParticipant.query.filter_by(
match_id=match.id, player_id=current_user.id,
).first()
events.append({
'id': f'match_{match.id}',
'title': match.title,
'date': match.date.strftime('%Y-%m-%d'),
'type': 'match',
'color': match_color,
'type': 'match', 'color': match_color,
'extendedProps': {
'location': match.location or tryout.location or 'TBD',
'status': match.status,
'description': match_desc,
'status': match.status, 'description': match_desc,
'match_type': match.match_type,
'tryout_id': tryout.id,
'match_id': match.id,
'start_time': start_time_str,
'end_time': end_time_str,
'participants': participants_str
}
'tryout_id': tryout.id, 'match_id': match.id,
'start_time': start_time_str, 'end_time': end_time_str,
'participants': participants_str,
'user_participant_id': user_participant.id if user_participant else None,
'user_attendance_confirmed': user_participant.attendance_confirmed if user_participant else False,
},
})
# Add approved One on One sessions for the current user (player or coach)
if isinstance(current_user, Player):
one_on_ones = OneOnOneRequest.query.filter_by(
player_id=current_user.id,
status='approved'
).all()
elif isinstance(current_user, Coach):
one_on_ones = OneOnOneRequest.query.filter_by(
coach_id=current_user.id,
status='approved'
).all()
else:
one_on_ones = []
for ooo in one_on_ones:
events.append({
'id': f'one_on_one_{ooo.id}',
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'date': ooo.date.strftime('%Y-%m-%d'),
'type': 'one_on_one',
'color': '#8b5cf6',
'extendedProps': {
'location': 'Discord / Voice Chat',
'status': 'approved',
'description': ooo.points or 'One on One session',
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
},
})
return jsonify(events)
@@ -116,63 +126,29 @@ def api_events():
@matches_bp.route('/api/events/<int:tryout_id>')
@login_required
def api_events_for_tryout(tryout_id):
"""API endpoint returning calendar events for a specific tryout.
Args:
tryout_id: The ID of the tryout to get events for.
Returns:
Response: JSON array of calendar events for the tryout.
"""
"""API endpoint returning calendar events for a specific tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
# Check if user can view this tryout
can_view = current_user.can_manage_this_tryout(tryout)
# For players, check if they're registered or participating in a match
is_registered = False
player_in_match = False
if current_user.role == 'player':
if isinstance(current_user, Player):
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id
tryout_id=tryout_id, player_id=current_user.id,
).first() is not None
# Check if player is participating in any matches for this tryout
player_matches = Match.query.join(MatchParticipant).filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id
Match.tryout_id == tryout_id,
).all()
player_in_match = len(player_matches) > 0
# Non-participating players cannot see the calendar
if not can_view and not is_registered and not player_in_match:
return jsonify([])
events = []
# Add tryout date as an event (read-only, for context)
events.append({
'id': f'tryout_{tryout.id}',
'title': f'Tryout: {tryout.title}',
'date': tryout.date.strftime('%Y-%m-%d'),
'type': 'tryout',
'color': '#3b82f6', # Blue for tryouts
'extendedProps': {
'location': tryout.location or 'TBD',
'status': tryout.status,
'description': tryout.description or '',
'tryout_id': tryout.id
}
})
for match in tryout.matches:
# Determine color based on match type
if match.match_type == 'team_vs_team' or match.match_type == 'player_vs_player':
match_color = '#10b981' # Green for team matches
else:
match_color = '#f59e0b' # Orange for scrims
# Build participant string with proper grouping
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 = []
@@ -182,27 +158,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':
# Get players grouped by team side
team1_players = []
for p in match.participants.filter_by(team_side=1).all():
if p.player:
team1_players.append(p.player.username)
team2_players = []
for p in match.participants.filter_by(team_side=2).all():
if p.player:
team2_players.append(p.player.username)
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:
participants_str = 'TBD vs TBD'
else:
player_names = []
for p in match.participants.all():
player_name = p.player.username if p.player else 'Unknown Player'
player_names.append(player_name)
player_names = [p.player.username for p in match.participants.all() if p.player]
participants_str = ', '.join(player_names) if player_names else 'No players'
# Include time for calendar display
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
@@ -210,92 +175,37 @@ def api_events_for_tryout(tryout_id):
'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)
def get_visible_tryouts_for_user():
"""Get tryouts that the current user can see based on their role.
Permission hierarchy:
- President: All tryouts
- Manager: Only their created tryouts
- Coach: Tryouts targeting their org team
- Player: Tryouts they're registered for or matches they're in
- Scout: All tryouts
Returns:
list: Query result of Tryout objects.
"""
if current_user.role == 'president':
return Tryout.query.order_by(Tryout.date).all()
elif current_user.role == 'manager':
return Tryout.query.filter_by(created_by=current_user.id).order_by(Tryout.date).all()
elif current_user.role == 'coach':
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
if org_team:
return Tryout.query.filter_by(target_org_team_id=org_team.id).order_by(Tryout.date).all()
return []
elif current_user.role == 'player':
# Get tryouts player is registered for
player_tryout_ids = [r.tryout_id for r in current_user.tryout_registrations.all()]
tryouts = Tryout.query.filter(Tryout.id.in_(player_tryout_ids)).order_by(Tryout.date).all() if player_tryout_ids else []
# Also include matches where player is participating
player_matches = Match.query.join(MatchParticipant).filter(
MatchParticipant.player_id == current_user.id
).all()
player_match_tryout_ids = list(set(m.tryout_id for m in player_matches))
additional_tryouts = Tryout.query.filter(
Tryout.id.in_(player_match_tryout_ids)
).order_by(Tryout.date).all() if player_match_tryout_ids else []
# Combine and deduplicate
all_tryouts = tryouts + [t for t in additional_tryouts if t.id not in player_tryout_ids]
return all_tryouts
else: # scout
return Tryout.query.order_by(Tryout.date).all()
@matches_bp.route('/create/<int:tryout_id>', methods=['GET', 'POST'])
@login_required
def create_match(tryout_id):
"""Create a new match/scrimmage within a tryout.
GET: Render the match creation form.
POST: Create a match with submitted details.
Args:
tryout_id: The ID of the tryout to create the match for.
Returns:
Response: Create form or redirect to tryout view.
"""
"""Create a new match / scrimmage within a tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
# Check if user can manage this tryout (president, manager, or coach)
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to schedule matches for this tryout.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.is_ended:
flash('This tryout has ended. Matches can no longer be created or modified.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=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 = sorted([p for p in all_players if p], key=lambda x: x.username)
prefill_date = request.args.get('date', '')
if request.method == 'POST':
title = request.form.get('title')
@@ -306,26 +216,25 @@ def create_match(tryout_id):
location = request.form.get('location')
match_type = request.form.get('match_type')
# Start time is now mandatory
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)
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)
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
try:
start_time = datetime.strptime(start_time_str, '%H:%M').time()
# Auto-calculate end time if not provided (start + 30 minutes)
if end_time_str:
end_time = datetime.strptime(end_time_str, '%H:%M').time()
else:
# Auto-calculate end time as start + 30 minutes
start_dt = datetime.combine(date_obj, start_time)
end_dt = start_dt + timedelta(minutes=30)
end_time = end_dt.time()
@@ -334,30 +243,21 @@ def create_match(tryout_id):
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() # Get match.id before commit
db.session.flush()
# Collect player IDs for Discord notifications
notified_player_ids = []
notified_participant_ids = []
# Handle team vs team matches
if match_type == 'team_vs_team':
team1_id = request.form.get('team1_id')
team2_id = request.form.get('team2_id')
match.team1_id = int(team1_id) if team1_id else None
match.team2_id = int(team2_id) if team2_id else None
# Create MatchParticipant records for all team members AND get notified player IDs
notified_participant_ids = []
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)
@@ -372,14 +272,11 @@ def create_match(tryout_id):
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
# Handle player vs player matches
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 []
notified_participant_ids = []
for pid in team1_ids:
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1)
db.session.add(participant)
@@ -391,11 +288,8 @@ def create_match(tryout_id):
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids = team1_ids + team2_ids
# Handle player scrim matches
elif match_type == 'player_scrim':
player_ids = request.form.getlist('player_ids')
notified_participant_ids = []
for pid in player_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
db.session.add(participant)
@@ -405,54 +299,28 @@ def create_match(tryout_id):
db.session.commit()
# Send Discord notifications to players (one per participant for proper attendance tracking)
# 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'
# Send Discord notifications with proper participant reference IDs
if match_type == 'team_vs_team':
for i, player_id in enumerate(notified_player_ids):
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,
reference_id=reference_id
)
else:
for i, player_id in enumerate(notified_player_ids):
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,
reference_id=reference_id
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)
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'])
@login_required
def edit_match(match_id):
"""Edit an existing match.
GET: Render the match edit form.
POST: Update match with submitted changes.
Args:
match_id: The ID of the match to edit.
Returns:
Response: Edit form or redirect to tryout view.
"""
"""Edit an existing match."""
match = Match.query.get_or_404(match_id)
tryout = match.tryout
@@ -460,13 +328,15 @@ def edit_match(match_id):
flash('You do not have permission to edit this match.', 'danger')
return redirect(url_for('matches.calendar'))
if tryout.is_ended:
flash('This tryout has ended. Matches can no longer be created or modified.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
teams = Team.query.filter_by(tryout_id=tryout.id).all()
# Only show players registered for this tryout
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all()
all_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
current_player_ids = [p.player_id for p in match.participants.all()]
# Get players grouped by team side for player_vs_player matches
team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()]
team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()]
@@ -483,20 +353,21 @@ 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)
# Start time is now mandatory
if not start_time_str:
flash('Start time is required. Please select a time slot.', 'danger')
return render_template('pages/match_form.html', match=match, tryout=tryout, teams=teams, all_players=all_players, current_player_ids=current_player_ids)
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)
try:
match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
# Auto-calculate end time if not provided (start + 30 minutes)
if end_time_str:
match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
else:
# Auto-calculate end time as start + 30 minutes
start_dt = datetime.combine(match.date, match.start_time)
end_dt = start_dt + timedelta(minutes=30)
match.end_time = end_dt.time()
@@ -507,18 +378,15 @@ def edit_match(match_id):
if status in ['scheduled', 'completed', 'cancelled']:
match.status = status
# Collect player IDs for Discord notifications
notified_player_ids = []
# Handle team vs team matches
notified_participant_ids = []
if match.match_type == 'team_vs_team':
team1_id = request.form.get('team1_id')
team2_id = request.form.get('team2_id')
new_team1_id = int(team1_id) if team1_id else None
new_team2_id = int(team2_id) if team2_id else None
# If teams changed, recreate MatchParticipant records
if new_team1_id != match.team1_id or new_team2_id != match.team2_id:
MatchParticipant.query.filter_by(match_id=match.id).delete()
match.team1_id = new_team1_id
@@ -538,35 +406,30 @@ def edit_match(match_id):
notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id)
else:
# Teams didn't change, still get notified player IDs
if match.team1_id:
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()])
# Handle player vs player matches - update participants
elif match.match_type == 'player_vs_player':
MatchParticipant.query.filter_by(match_id=match.id).delete()
team1_player_ids = request.form.getlist('team1_player_ids')
team2_player_ids = request.form.getlist('team2_player_ids')
notified_participant_ids = []
for pid in team1_player_ids:
team1_str = request.form.get('team1_player_ids', '')
team2_str = request.form.get('team2_player_ids', '')
t1_ids = [p for p in team1_str.split(',') if p.strip()] if team1_str else []
t2_ids = [p for p in team2_str.split(',') if p.strip()] if team2_str else []
for pid in t1_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=1)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
for pid in team2_player_ids:
for pid in t2_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=2)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
notified_player_ids = [int(p) for p in team1_player_ids] + [int(p) for p in team2_player_ids]
# Handle player scrim matches - update participants
notified_player_ids = [int(p) for p in t1_ids] + [int(p) for p in t2_ids]
elif match.match_type == 'player_scrim':
MatchParticipant.query.filter_by(match_id=match.id).delete()
player_ids = request.form.getlist('player_ids')
notified_participant_ids = []
for pid in player_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
db.session.add(participant)
@@ -576,9 +439,8 @@ def edit_match(match_id):
db.session.commit()
# Send Discord notifications to players
if match.match_type in ['player_vs_player', 'player_scrim']:
end_time_val = match.end_time if match.end_time else match.start_time if match.start_time else None
# 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')}"
else:
@@ -587,75 +449,61 @@ def edit_match(match_id):
for i, player_id in enumerate(notified_player_ids):
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,
reference_id=reference_id
)
elif match.match_type == 'team_vs_team':
event_date_str = match.date.strftime('%Y-%m-%d')
end_time_val = match.end_time if match.end_time else match.start_time if match.start_time else None
event_time_str = f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}" if match.start_time and end_time_val else 'TBD'
if notified_participant_ids:
for i, player_id in enumerate(notified_player_ids):
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
send_schedule_notification(
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
)
else:
for player_id in notified_player_ids:
send_schedule_notification(
user_id=player_id,
event_type='match',
event_title=match.title,
event_date=event_date_str,
event_time=event_time_str,
reference_id=match.id
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 updated successfully!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
# Build participant attendance map for the template
participants_map = {}
for p in match.participants.all():
participants_map[p.player_id] = {
'participant_id': p.id,
'attendance_confirmed': p.attendance_confirmed,
'team_side': p.team_side
'team_side': p.team_side,
}
return render_template('pages/match_form.html', match=match, tryout=tryout, teams=teams,
all_players=all_players, current_player_ids=current_player_ids,
team1_player_ids=team1_player_ids, team2_player_ids=team2_player_ids,
return render_template('pages/match_form.html', match=match, tryout=tryout,
teams=teams, all_players=all_players,
current_player_ids=current_player_ids,
team1_player_ids=team1_player_ids,
team2_player_ids=team2_player_ids,
participants_map=participants_map)
@matches_bp.route('/api/manageable-tryouts')
@login_required
def api_manageable_tryouts():
"""API endpoint returning tryouts the current user can manage."""
if not can_schedule_match():
return jsonify([])
tryouts = get_visible_tryouts_for_user()
manageable = []
for t in tryouts:
if current_user.can_manage_this_tryout(t):
manageable.append({
'id': t.id, 'title': t.title,
'date': t.date.strftime('%Y-%m-%d'),
'end_date': t.end_date.strftime('%Y-%m-%d') if t.end_date else None,
})
return jsonify(manageable)
@matches_bp.route('/<int:match_id>/delete', methods=['POST'])
@login_required
def delete_match(match_id):
"""Delete a match.
Args:
match_id: The ID of the match to delete.
Returns:
Response: Redirect to tryout view with status message.
"""
"""Delete a match."""
match = Match.query.get_or_404(match_id)
tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to delete this match.', 'danger')
return redirect(url_for('matches.calendar'))
if tryout.is_ended:
flash('This tryout has ended. Matches can no longer be deleted.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
db.session.delete(match)
db.session.commit()
flash('Match deleted successfully.', 'success')
@@ -663,72 +511,38 @@ def delete_match(match_id):
def get_players_available_at_time(date_str, time_str):
"""Get list of player IDs available at a specific date and time.
Checks player disponibility records to find who is available
during the specified time block.
Args:
date_str: Date in YYYY-MM-DD format.
time_str: Time in HH:MM format.
Returns:
list: List of available player IDs.
"""
"""Get list of player IDs available at a specific date and time."""
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
time_obj = datetime.strptime(time_str, '%H:%M').time()
except (ValueError, TypeError):
return []
# Calculate day of week (Python: 0=Monday, 6=Sunday)
# JavaScript: 0=Sunday, 6=Saturday, so we convert
date_parts = date_str.split('-')
date_for_day = datetime(int(date_parts[0]), int(date_parts[1]), int(date_parts[2]))
js_day = date_for_day.weekday()
date_for_day = datetime.strptime(date_str, '%Y-%m-%d')
day_of_week = date_for_day.weekday()
# Convert Python weekday (Mon=0) to our format (Mon=0)
day_of_week = js_day
# Get all active players
players = User.query.filter_by(role='player', is_active_account=True).all()
available_players = []
for player in players:
# Check if player has disponibility at this time
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:
# Check if time falls within disponibility block
disp_start = disp.start_time.hour * 60 + disp.start_time.minute
disp_end = disp.end_time.hour * 60 + disp.end_time.minute
match_time = time_obj.hour * 60 + time_obj.minute
if disp_start <= match_time < disp_end:
available_players.append(player.id)
break
return available_players
@matches_bp.route('/api/available_players/<date>/<time>')
@login_required
def api_available_players(date, time):
"""API endpoint to get players available at a specific date/time slot.
Args:
date: Date in YYYY-MM-DD format.
time: Time in HH:MM format.
Returns:
Response: JSON with list of available player IDs.
"""
"""API endpoint to get players available at a specific date/time slot."""
if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
return jsonify({'error': 'Unauthorized'}), 403
player_ids = get_players_available_at_time(date, time)
return jsonify({'available_player_ids': player_ids})
@@ -736,32 +550,22 @@ def api_available_players(date, time):
@matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
@login_required
def toggle_presence(match_id, participant_id):
"""Toggle the attendance_confirmed status for a match participant.
Accessible only to users who can manage the tryout.
Args:
match_id: The ID of the match.
participant_id: The ID of the MatchParticipant record.
Returns:
Response: JSON with new status.
"""
"""Toggle attendance_confirmed for a match participant."""
match = Match.query.get_or_404(match_id)
tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout):
return jsonify({'error': 'Unauthorized'}), 403
participant = MatchParticipant.query.get_or_404(participant_id)
if participant.match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400
is_self = participant.player_id == current_user.id
if not is_self and not current_user.can_manage_this_tryout(tryout):
return jsonify({'error': 'Unauthorized'}), 403
participant.attendance_confirmed = not participant.attendance_confirmed
db.session.commit()
return jsonify({
'participant_id': participant.id,
'attendance_confirmed': participant.attendance_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown'
'player_name': participant.player.username if participant.player else 'Unknown',
})
+309
View File
@@ -0,0 +1,309 @@
"""Team match management routes for regular season matches.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from app.extensions import db
from app.models import (
Admin, Manager, Coach, Player,
OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer,
)
from datetime import datetime, timedelta
from app.discord_bot import send_schedule_notification
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
def can_manage_team_match(team):
"""Check if current user can manage matches for this team."""
if isinstance(current_user, Admin):
return True
if isinstance(current_user, Manager):
return True
if isinstance(current_user, Coach):
if team.coaches.filter_by(id=current_user.id).first():
return True
if team.coach_id == current_user.id:
return True
return False
@team_matches_bp.route('')
@login_required
def list_matches():
"""List all team matches visible to the current user."""
filter_team_id = request.args.get('team_id', type=int)
if isinstance(current_user, Admin):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
matches_query = TeamMatch.query
elif isinstance(current_user, Manager):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
matches_query = TeamMatch.query
elif isinstance(current_user, Coach):
teams = OrgTeam.query.filter(
db.or_(
OrgTeam.coaches.any(id=current_user.id),
OrgTeam.coach_id == current_user.id,
)
).order_by(OrgTeam.name).all()
team_ids = [t.id for t in teams]
matches_query = TeamMatch.query.filter(
TeamMatch.org_team_id.in_(team_ids),
) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
elif isinstance(current_user, Player):
player_team_ids = [tp.org_team_id for tp in current_user.team_placements]
teams = OrgTeam.query.filter(OrgTeam.id.in_(player_team_ids)).all() if player_team_ids else []
matches_query = TeamMatch.query.filter(
TeamMatch.org_team_id.in_(player_team_ids),
) if player_team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
else:
teams = []
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
if filter_team_id:
matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id)
matches = matches_query.order_by(TeamMatch.date.desc()).all()
match_data = []
for tm in matches:
confirmed, total = tm.get_confirmed_count()
participants = []
for p in tm.participants.all():
participants.append({
'id': p.id, 'player': p.player,
'is_confirmed': p.is_confirmed,
})
match_data.append({
'match': tm, 'participants': participants,
'confirmed_count': confirmed, 'total_count': total,
})
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'])
@login_required
def create_match(team_id):
"""Create a new regular-season team match."""
team = OrgTeam.query.get_or_404(team_id)
if not can_manage_team_match(team):
flash('You do not have permission to schedule matches for this team.', 'danger')
return redirect(url_for('team_matches.list_matches'))
team_players = [tp for tp in TeamPlayer.query.filter_by(org_team_id=team_id).all()]
prefill_date = request.args.get('date', '')
is_practice = request.args.get('type') == 'practice'
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
self.title = team_obj.name
self.date = ''
self.game = ''
self.target_org_team = team_obj
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)
if request.method == 'POST':
title = request.form.get('title', default_title)
opponent = request.form.get('opponent', '').strip() if not is_practice else None
description = request.form.get('description', '')
date_str = request.form.get('date')
start_time_str = request.form.get('start_time')
end_time_str = request.form.get('end_time')
location = request.form.get('location', '')
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)
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)
start_time = None
end_time = None
if start_time_str:
try:
start_time = datetime.strptime(start_time_str, '%H:%M').time()
if end_time_str:
end_time = datetime.strptime(end_time_str, '%H:%M').time()
else:
start_dt = datetime.combine(date_obj, start_time)
end_dt = start_dt + timedelta(minutes=30)
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)
team_match = TeamMatch(
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,
)
db.session.add(team_match)
db.session.flush()
notified_participant_ids = []
for tp in team_players:
participant = TeamMatchParticipant(
team_match_id=team_match.id, player_id=tp.player_id,
)
db.session.add(participant)
db.session.flush()
notified_participant_ids.append(participant.id)
db.session.commit()
# 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'
for i, tp in enumerate(team_players):
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',
event_title=team_match.title,
event_date=event_date_str, event_time=event_time_str,
reference_id=reference_id,
)
flash(f'Team match "{title}" scheduled successfully!', 'success')
return redirect(url_for('team_matches.list_matches'))
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
@team_matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_match(match_id):
"""Edit an existing team match."""
team_match = TeamMatch.query.get_or_404(match_id)
team = team_match.org_team
if not can_manage_team_match(team):
flash('You do not have permission to edit this match.', 'danger')
return redirect(url_for('team_matches.list_matches'))
if request.method == 'POST':
team_match.title = request.form.get('title', team_match.title)
team_match.description = request.form.get('description', '') or None
team_match.opponent = request.form.get('opponent', '').strip() or None
date_str = request.form.get('date')
if date_str:
try:
team_match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash('Invalid date format.', 'danger')
return redirect(url_for('team_matches.edit_match', match_id=match_id))
start_time_str = request.form.get('start_time')
if start_time_str:
try:
team_match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
except ValueError:
pass
end_time_str = request.form.get('end_time')
if end_time_str:
try:
team_match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
except ValueError:
pass
team_match.location = request.form.get('location', '') or None
status = request.form.get('status')
if status in ['scheduled', 'completed', 'cancelled']:
team_match.status = status
db.session.commit()
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=[])
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
@login_required
def delete_match(match_id):
"""Delete a team match."""
team_match = TeamMatch.query.get_or_404(match_id)
team = team_match.org_team
if not can_manage_team_match(team):
flash('You do not have permission to delete this match.', 'danger')
return redirect(url_for('team_matches.list_matches'))
db.session.delete(team_match)
db.session.commit()
flash('Match deleted successfully.', 'success')
return redirect(url_for('team_matches.list_matches'))
@team_matches_bp.route('/api/manageable-teams')
@login_required
def api_manageable_teams():
"""API endpoint returning teams the current user can schedule matches for."""
if not current_user.can_schedule_matches():
return jsonify([])
if isinstance(current_user, (Admin, Manager)):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
elif isinstance(current_user, Coach):
teams = OrgTeam.query.filter(
db.or_(
OrgTeam.coaches.any(id=current_user.id),
OrgTeam.coach_id == current_user.id,
)
).order_by(OrgTeam.name).all()
else:
return jsonify([])
return jsonify([{'id': t.id, 'name': t.name} for t in teams])
@team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
@login_required
def toggle_presence(match_id, participant_id):
"""Toggle is_confirmed for a team match participant."""
team_match = TeamMatch.query.get_or_404(match_id)
team = team_match.org_team
participant = TeamMatchParticipant.query.get_or_404(participant_id)
if participant.team_match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400
can_toggle = can_manage_team_match(team) or participant.player_id == current_user.id
if not can_toggle:
return jsonify({'error': 'Unauthorized'}), 403
participant.is_confirmed = not participant.is_confirmed
db.session.commit()
return jsonify({
'participant_id': participant.id,
'is_confirmed': participant.is_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown',
})
+455
View File
@@ -0,0 +1,455 @@
"""Organization team management routes.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from app.extensions import db
from app.models import (
Admin, Manager, Coach, Player,
OrgTeam, User, Team, TeamMember,
PersonalNote, TeamNote, Tryout, TeamPlayer,
)
from datetime import datetime
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
@teams_bp.route('')
@login_required
def list_teams():
"""List all organization teams visible to the current user."""
can_manage = current_user.can_manage_teams()
if isinstance(current_user, Admin):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
elif isinstance(current_user, Coach):
teams = OrgTeam.query.filter(
db.or_(
OrgTeam.coaches.any(id=current_user.id),
OrgTeam.coach_id == current_user.id,
)
).order_by(OrgTeam.name).all()
elif isinstance(current_user, Manager):
teams = OrgTeam.query.filter(
db.or_(
OrgTeam.managers.any(id=current_user.id),
OrgTeam.manager_id == current_user.id,
)
).order_by(OrgTeam.name).all()
elif isinstance(current_user, Player):
flash('Use My Team(s) to view your teams.', 'info')
return redirect(url_for('teams.my_teams'))
else:
flash('You do not have permission to view teams.', 'danger')
return redirect(url_for('main.dashboard'))
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)
@teams_bp.route('/my-teams')
@login_required
def my_teams():
"""View the player's own teams with upcoming matches."""
if not isinstance(current_user, Player):
flash('This page is for players.', 'info')
return redirect(url_for('teams.list_teams'))
from app.models import TeamMatch, TeamMatchParticipant
player_teams = current_user.get_org_teams()
now = datetime.utcnow()
team_data = []
for org_team in player_teams:
matches = TeamMatch.query.filter(
TeamMatch.org_team_id == org_team.id,
TeamMatch.status == 'scheduled',
).order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc()).all()
matches_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,
).first()
matches_data.append({
'match': tm,
'participant_id': participant.id if participant else None,
'is_confirmed': participant.is_confirmed if participant else False,
'confirmed_count': confirmed, 'total_count': total,
})
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)
@teams_bp.route('/create', methods=['POST'])
@login_required
def create_team():
"""Create a new organization team."""
if not current_user.can_manage_teams():
flash('You do not have permission to create teams.', 'danger')
return redirect(url_for('teams.list_teams'))
name = request.form.get('name')
coach_id = request.form.get('coach_id')
manager_id = request.form.get('manager_id')
if not name:
flash('Team name is required.', 'danger')
return redirect(url_for('teams.list_teams'))
existing = OrgTeam.query.filter_by(name=name).first()
if existing:
flash(f'Team "{name}" already exists.', 'danger')
return redirect(url_for('teams.list_teams'))
team = OrgTeam(
name=name,
coach_id=int(coach_id) if coach_id else None,
manager_id=int(manager_id) if manager_id else None,
created_by=current_user.id,
)
db.session.add(team)
db.session.flush()
if coach_id:
coach_user = User.query.get(int(coach_id))
if coach_user:
team.coaches.append(coach_user)
if manager_id:
manager_user = User.query.get(int(manager_id))
if manager_user:
team.managers.append(manager_user)
db.session.commit()
flash(f'Team "{name}" created successfully!', 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/edit', methods=['POST'])
@login_required
def edit_team(team_id):
"""Edit an existing organization team."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash('You do not have permission to edit this team.', 'danger')
return redirect(url_for('teams.list_teams'))
name = request.form.get('name')
coach_id = request.form.get('coach_id')
manager_id = request.form.get('manager_id')
if not name:
flash('Team name is required.', 'danger')
return redirect(url_for('teams.list_teams'))
existing = OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first()
if existing:
flash(f'Team "{name}" already exists.', 'danger')
return redirect(url_for('teams.list_teams'))
if request.form.get('sync_staff') == '1':
coach_ids = request.form.getlist('coach_ids')
manager_ids = request.form.getlist('manager_ids')
team.coaches = []
for cid in coach_ids:
if cid and cid.strip():
coach_user = User.query.get(int(cid))
if coach_user and isinstance(coach_user, Coach):
team.coaches.append(coach_user)
coach_list = team.coaches.all()
team.coach_id = coach_list[0].id if coach_list else None
team.managers = []
for mid in manager_ids:
if mid and mid.strip():
manager_user = User.query.get(int(mid))
if manager_user and isinstance(manager_user, Manager):
team.managers.append(manager_user)
manager_list = team.managers.all()
team.manager_id = manager_list[0].id if manager_list else None
else:
team.coach_id = int(coach_id) if coach_id else None
team.manager_id = int(manager_id) if manager_id else None
if coach_id:
coach_user = User.query.get(int(coach_id))
if coach_user and not team.coaches.filter_by(id=coach_user.id).first():
team.coaches.append(coach_user)
if manager_id:
manager_user = User.query.get(int(manager_id))
if manager_user and not team.managers.filter_by(id=manager_user.id).first():
team.managers.append(manager_user)
db.session.commit()
flash(f'Team "{name}" updated successfully!', 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/delete', methods=['POST'])
@login_required
def delete_team(team_id):
"""Delete an organization team."""
if not current_user.can_manage_teams():
flash('You do not have permission to delete teams.', 'danger')
return redirect(url_for('teams.list_teams'))
team = OrgTeam.query.get_or_404(team_id)
name = team.name
tryouts = Tryout.query.filter_by(target_org_team_id=team_id).all()
for t in tryouts:
t.target_org_team_id = None
db.session.commit()
TeamPlayer.query.filter_by(org_team_id=team_id).delete()
db.session.commit()
db.session.delete(team)
db.session.commit()
flash(f'Team "{name}" deleted successfully.', 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
@login_required
def add_coach(team_id):
"""Add a coach to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger')
return redirect(url_for('teams.list_teams'))
coach_id = request.form.get('coach_id')
if not coach_id:
flash('Please select a coach.', 'danger')
return redirect(url_for('teams.list_teams'))
coach = User.query.get_or_404(int(coach_id))
if not isinstance(coach, Coach):
flash('Only coaches can be assigned as coach.', 'danger')
return redirect(url_for('teams.list_teams'))
if team.coaches.filter_by(id=coach.id).first():
flash(f'{coach.username} is already a coach of {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(f'{coach.username} added as coach of {team.name}.', 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
@login_required
def add_manager(team_id):
"""Add a manager to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger')
return redirect(url_for('teams.list_teams'))
manager_id = request.form.get('manager_id')
if not manager_id:
flash('Please select a manager.', 'danger')
return redirect(url_for('teams.list_teams'))
manager = User.query.get_or_404(int(manager_id))
if not isinstance(manager, Manager):
flash('Only managers can be assigned as manager.', 'danger')
return redirect(url_for('teams.list_teams'))
if team.managers.filter_by(id=manager.id).first():
flash(f'{manager.username} is already a manager of {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(f'{manager.username} added as manager of {team.name}.', 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_coach', methods=['POST'])
@login_required
def remove_coach(team_id):
"""Remove a coach from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger')
return redirect(url_for('teams.list_teams'))
coach_id = request.form.get('coach_id')
if coach_id:
coach = User.query.get(int(coach_id))
if coach and team.coaches.filter_by(id=coach.id).first():
team.coaches.remove(coach)
if team.coach_id == coach.id:
team.coach_id = None
else:
team.coaches = []
team.coach_id = None
db.session.commit()
flash(f'Coach removed from {team.name}.', 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
@login_required
def remove_manager(team_id):
"""Remove a manager from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger')
return redirect(url_for('teams.list_teams'))
manager_id = request.form.get('manager_id')
if manager_id:
manager = User.query.get(int(manager_id))
if manager and team.managers.filter_by(id=manager.id).first():
team.managers.remove(manager)
if team.manager_id == manager.id:
team.manager_id = None
else:
team.managers = []
team.manager_id = None
db.session.commit()
flash(f'Manager removed from {team.name}.', 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
@login_required
def add_player(team_id):
"""Add a player to an organization team."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger')
return redirect(url_for('teams.list_teams'))
player_id = request.form.get('player_id')
status = request.form.get('status', 'starter')
if not player_id:
flash('Please select a player.', 'danger')
return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(int(player_id))
if not isinstance(player, Player):
flash('Can only assign players to teams.', 'danger')
return redirect(url_for('teams.list_teams'))
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
if existing:
flash(f'{player.username} is already on {team.name}.', 'info')
return redirect(url_for('teams.list_teams'))
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
db.session.add(tp)
db.session.commit()
flash(f'{player.username} added to {team.name}!', 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
@login_required
def remove_player(team_id, player_id):
"""Remove a player from an organization team."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger')
return redirect(url_for('teams.list_teams'))
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(f'{player.username} is not on {team.name}.', 'danger')
return redirect(url_for('teams.list_teams'))
db.session.delete(tp)
db.session.commit()
flash(f'{player.username} removed from {team.name}.', 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
@login_required
def toggle_player_status(team_id, player_id):
"""Toggle a player's status between starter and substitute."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
return jsonify({'error': 'Permission denied'}), 403
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
return jsonify({'error': 'Player not found on this team'}), 404
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,
})
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
@login_required
def add_team_note(team_id):
"""Add a team improvement note (coaches only)."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash('You do not have permission to add notes to this team.', 'danger')
return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip()
if content:
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
db.session.add(note)
db.session.commit()
flash('Team notes added successfully!', 'success')
return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
@login_required
def add_player_note(team_id, player_id):
"""Add a personal note for a player (coaches only)."""
team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team):
flash('You do not have permission to add notes to this team.', 'danger')
return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id)
if not isinstance(player, Player):
flash('Can only add notes for players.', 'danger')
return redirect(url_for('teams.list_teams'))
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp:
flash(f'{player.username} is not on {team.name}.', 'danger')
return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip()
if content:
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
db.session.add(note)
db.session.commit()
flash(f'Note added for {player.username}!', 'success')
return redirect(url_for('teams.list_teams'))
+210 -207
View File
@@ -1,24 +1,26 @@
"""Tryout management routes for creating, viewing, and managing tryout events.
This module handles CRUD operations for tryouts and player registrations.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from extensions import db
from models import User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember, OrgTeam, Match, MatchParticipant, ESPORT_GAMES, GAME_POSITIONS
from app.extensions import db
from app.models import (
Admin, Manager, Coach, Player, Scout,
User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember,
OrgTeam, Match, MatchParticipant,
ESPORT_GAMES, GAME_POSITIONS,
)
from datetime import datetime
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
def can_manage():
"""Check if current user can manage tryouts.
Returns:
bool: True if user is president or manager.
"""
return current_user.role in ['president', 'manager']
"""Check if current user can manage tryouts (Admin or Manager)."""
return isinstance(current_user, (Admin, Manager))
@tryouts_bp.route('')
@@ -26,48 +28,16 @@ def can_manage():
def list_tryouts():
"""List all tryouts visible to the current user.
Shows tryouts filtered by user's role:
- President: All tryouts
- Manager: Only their created tryouts
- Coach: Tryouts targeting their org team
- Player: Only tryouts they are registered for or participating in
Returns:
Response: Rendered tryouts list template.
Delegates to the polymorphic User subclass's get_visible_tryouts() method.
"""
if current_user.role == 'president':
tryouts = Tryout.query.order_by(Tryout.date.desc()).all()
elif current_user.role == 'manager':
tryouts = Tryout.query.filter_by(created_by=current_user.id).order_by(Tryout.date.desc()).all()
elif current_user.role == 'coach':
# Coaches see tryouts targeting their org team
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
if org_team:
tryouts = Tryout.query.filter_by(target_org_team_id=org_team.id).order_by(Tryout.date.desc()).all()
else:
tryouts = []
elif current_user.role == 'player':
# Players only see tryouts they are registered for or participating in matches
from routes.matches import get_visible_tryouts_for_user
tryouts = get_visible_tryouts_for_user()
else:
tryouts = Tryout.query.order_by(Tryout.date.desc()).all()
tryouts = current_user.get_visible_tryouts()
return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow())
@tryouts_bp.route('/create', methods=['GET', 'POST'])
@login_required
def create_tryout():
"""Create a new tryout event.
GET: Render the tryout creation form.
POST: Create a tryout with the submitted details.
Requires president or manager role.
Returns:
Response: Create form or redirect to the new tryout.
"""
"""Create a new tryout event. Requires Admin or Manager."""
if not can_manage():
flash('You do not have permission to create tryouts.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
@@ -81,62 +51,72 @@ def create_tryout():
description = request.form.get('description')
game = request.form.get('game')
date_str = request.form.get('date')
end_date_str = request.form.get('end_date')
location = request.form.get('location')
max_players = request.form.get('max_players')
target_org_team_id = request.form.get('target_org_team_id')
manager_id = request.form.get('manager_id')
coach_id = request.form.get('coach_id')
coach_ids = request.form.getlist('coach_ids')
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash('Invalid date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams, managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
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)
end_date_obj = None
if end_date_str:
try:
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)
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)
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,
coach_id=int(coach_id) if coach_id else None
)
db.session.add(tryout)
db.session.flush()
# Assign coaches via many-to-many
if coach_ids:
coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all()
tryout.coaches = coach_users
db.session.commit()
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'])
@login_required
def edit_tryout(tryout_id):
"""Edit an existing tryout event.
GET: Render the tryout edit form with current data.
POST: Update the tryout with submitted changes.
Permission based on can_manage_this_tryout check.
Args:
tryout_id: The ID of the tryout to edit.
Returns:
Response: Edit form or redirect to tryout view.
"""
"""Edit an existing tryout event. Permission based on can_manage_this_tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
# Permission: president, manager (own tryouts), or coach (targets their team)
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to edit this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if tryout.is_ended:
flash('This tryout has ended and can no longer be modified.', 'danger')
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()
@@ -146,70 +126,80 @@ def edit_tryout(tryout_id):
description = request.form.get('description')
game = request.form.get('game')
date_str = request.form.get('date')
end_date_str = request.form.get('end_date')
location = request.form.get('location')
max_players = request.form.get('max_players')
target_org_team_id = request.form.get('target_org_team_id')
manager_id = request.form.get('manager_id')
coach_id = request.form.get('coach_id')
coach_ids = request.form.getlist('coach_ids')
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash('Invalid date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams, managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
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)
end_date_obj = None
if end_date_str:
try:
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)
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)
tryout.title = title
tryout.description = description
tryout.game = game
tryout.date = date_obj
tryout.end_date = end_date_obj
tryout.location = location
tryout.max_players = int(max_players) if max_players else None
tryout.target_org_team_id = int(target_org_team_id) if target_org_team_id else None
tryout.manager_id = int(manager_id) if manager_id else None
tryout.coach_id = int(coach_id) if coach_id else None
# Update coaches via many-to-many
if coach_ids:
coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all()
tryout.coaches = coach_users
else:
tryout.coaches = []
db.session.commit()
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>')
@login_required
def view_tryout(tryout_id):
"""View a specific tryout with all details.
Displays tryout information, registered players, evaluations, teams,
matches, and evaluation status information.
Args:
tryout_id: The ID of the tryout to view.
Returns:
Response: Rendered tryout detail template.
"""
"""View a specific tryout with all details. Permission via polymorphic dispatch."""
tryout = Tryout.query.get_or_404(tryout_id)
# Check if user has permission to view this tryout
can_view = False
if current_user.role == 'president':
if isinstance(current_user, Admin):
can_view = True
elif current_user.role == 'manager' and tryout.created_by == current_user.id:
can_view = True
elif current_user.role == 'coach':
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
if org_team and tryout.target_org_team_id == org_team.id:
can_view = True
elif current_user.role == 'player':
elif isinstance(current_user, Manager):
can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.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
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
Match.tryout_id == tryout_id,
).first() is not None
can_view = is_registered or player_in_match
elif current_user.role == 'scout':
elif isinstance(current_user, Scout):
can_view = True
if not can_view:
@@ -220,22 +210,16 @@ def view_tryout(tryout_id):
registered_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
evaluations = Evaluation.query.filter_by(tryout_id=tryout_id).all()
evaluator_ids = set(e.evaluator_id for e in evaluations)
player_ids_with_eval = set(e.player_id for e in evaluations)
# Check if current user has evaluated each player
player_eval_status = {}
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
tryout_id=tryout_id, player_id=current_user.id,
).first() is not None
teams = Team.query.filter_by(tryout_id=tryout_id).all()
@@ -244,97 +228,83 @@ def view_tryout(tryout_id):
members = TeamMember.query.filter_by(team_id=team.id).all()
team_data.append({
'team': team,
'members': [{'player': User.query.get(m.player_id), 'position': m.position} for m in members]
'members': [{'player': User.query.get(m.player_id), 'position': m.position}
for m in members],
})
# Determine if current user can edit this tryout
can_edit = current_user.can_manage_this_tryout(tryout)
# Determine if current user can view the calendar (managers/coaches can always see it)
# Players need to be registered or participating in a match
can_view_calendar = can_edit
if current_user.role == 'player':
# Check if player is participating in any matches for this tryout
if isinstance(current_user, Player):
player_in_match = MatchParticipant.query.join(Match).filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id
Match.tryout_id == tryout_id,
).first() is not None
can_view_calendar = is_registered or player_in_match
# Only expose all_players to users who can manage players in this tryout
all_players = None
if can_edit:
all_players = User.query.filter_by(role='player').order_by(User.username).all()
# Get matches for this tryout with participant info
matches = Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
match_data = []
for match in matches:
# Calculate presence stats
all_participants = list(match.participants.all())
confirmed_count = sum(1 for p in all_participants if p.attendance_confirmed)
total_count = len(all_participants)
player_presence = []
for p in all_participants:
if p.player:
player_presence.append({
'participant_id': p.id, 'player_id': p.player_id,
'player_name': p.player.username,
'attendance_confirmed': p.attendance_confirmed,
})
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': 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())
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'])
@login_required
def register_for_tryout(tryout_id):
"""Register a player for a tryout.
Allows players to register for tryouts. Validates that the tryout
is accepting registrations and not at capacity.
Args:
tryout_id: The ID of the tryout to register for.
Returns:
Response: Redirect to tryout view with status message.
"""
"""Register a player for a tryout. Only Players can self-register."""
tryout = Tryout.query.get_or_404(tryout_id)
if current_user.role != 'player':
if not isinstance(current_user, Player):
flash('Only players can register for tryouts.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -342,7 +312,8 @@ def register_for_tryout(tryout_id):
flash('This tryout is not accepting registrations.', 'danger')
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()
existing = TryoutRegistration.query.filter_by(
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))
@@ -363,16 +334,7 @@ def register_for_tryout(tryout_id):
@tryouts_bp.route('/<int:tryout_id>/status', methods=['POST'])
@login_required
def update_status(tryout_id):
"""Update the status of a tryout.
Changes tryout status between upcoming, in_progress, and completed.
Args:
tryout_id: The ID of the tryout to update.
Returns:
Response: Redirect to tryout view.
"""
"""Update the status of a tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger')
@@ -388,21 +350,14 @@ def update_status(tryout_id):
@tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST'])
@login_required
def update_registration_status(tryout_id, player_id):
"""Update the attendance status of a tryout registration.
Args:
tryout_id: The ID of the tryout.
player_id: The ID of the player whose status to update.
Returns:
Response: Redirect to tryout view.
"""
"""Update a registration's attendance status."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
registration = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first_or_404()
registration = TryoutRegistration.query.filter_by(
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
@@ -414,32 +369,23 @@ def update_registration_status(tryout_id, player_id):
@tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST'])
@login_required
def register_player(tryout_id):
"""Manually register a player for a tryout (by managers/coaches).
Allows authorized users to register players on their behalf.
Args:
tryout_id: The ID of the tryout.
Returns:
Response: Redirect to tryout view with status message.
"""
"""Manually register a player for a tryout (by managers/coaches)."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player_id = request.form.get('player_id')
if not player_id:
flash('Please select a player.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = User.query.get_or_404(int(player_id))
if player.role != 'player':
if not isinstance(player, Player):
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(f'{player.username} is already registered for this tryout.', 'info')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -457,17 +403,45 @@ def register_player(tryout_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST'])
@login_required
def remove_player(tryout_id, player_id):
"""Remove a registered player from a tryout (cascades to teams/matches)."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
player = User.query.get_or_404(player_id)
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id).first()
if registration:
db.session.delete(registration)
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
if team_ids:
TeamMember.query.filter(
TeamMember.team_id.in_(team_ids),
TeamMember.player_id == player_id,
).delete(synchronize_session=False)
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
if match_ids:
MatchParticipant.query.filter(
MatchParticipant.match_id.in_(match_ids),
MatchParticipant.player_id == player_id,
).delete(synchronize_session=False)
db.session.commit()
flash(f'{player.username} removed from tryout.', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST'])
@login_required
def create_team(tryout_id):
"""Create a tryout-specific team.
Args:
tryout_id: The ID of the tryout to create the team for.
Returns:
Response: Redirect to tryout view with status message.
"""
"""Create a tryout-specific team."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger')
@@ -485,15 +459,7 @@ def create_team(tryout_id):
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST'])
@login_required
def add_to_team(tryout_id, team_id):
"""Add a player to a tryout team.
Args:
tryout_id: The ID of the tryout.
team_id: The ID of the team to add the player to.
Returns:
Response: Redirect to tryout view with status message.
"""
"""Add a player to a tryout team."""
team = Team.query.get_or_404(team_id)
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
@@ -502,7 +468,6 @@ def add_to_team(tryout_id, team_id):
player_id = request.form.get('player_id')
position = request.form.get('position', '')
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
if existing:
flash('Player is already on this team.', 'info')
@@ -511,5 +476,43 @@ def add_to_team(tryout_id, team_id):
db.session.add(member)
db.session.commit()
flash('Player added to team!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
@login_required
def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to delete this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
# Delete match participants for all matches in this tryout
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
if match_ids:
MatchParticipant.query.filter(
MatchParticipant.match_id.in_(match_ids)
).delete(synchronize_session=False)
# Delete matches
Match.query.filter(Match.id.in_(match_ids)).delete(synchronize_session=False)
# Delete team members for all teams in this tryout
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
if team_ids:
TeamMember.query.filter(
TeamMember.team_id.in_(team_ids)
).delete(synchronize_session=False)
# Delete teams
Team.query.filter(Team.id.in_(team_ids)).delete(synchronize_session=False)
# Delete registrations
TryoutRegistration.query.filter_by(tryout_id=tryout_id).delete()
# Delete evaluations
Evaluation.query.filter_by(tryout_id=tryout_id).delete()
db.session.delete(tryout)
db.session.commit()
flash('Tryout deleted successfully.', 'success')
return redirect(url_for('tryouts.list_tryouts'))
+1270
View File
File diff suppressed because it is too large Load Diff
@@ -96,6 +96,7 @@ a:hover { color: var(--primary-dark); }
}
.logo i { color: var(--primary); font-size: 1.6rem; }
.logo-img { height: 40px; width: auto; }
.user-badge {
display: flex;
@@ -607,6 +608,12 @@ a:hover { color: var(--primary-dark); }
margin-bottom: 12px;
}
.auth-logo-img {
height: 80px;
width: auto;
margin-bottom: 12px;
}
.auth-header h2 {
font-size: 1.6rem;
font-weight: 700;
@@ -1231,9 +1238,9 @@ a:hover { color: var(--primary-dark); }
}
.merged-disponibility-time-block.selected {
background: var(--success);
color: white;
border-color: var(--success);
background: #6366f1 !important;
color: white !important;
border-color: #4f46e5 !important;
}
.merged-disponibility-time-block.high-availability {
@@ -1266,10 +1273,10 @@ a:hover { color: var(--primary-dark); }
/* Then specific states override the generic rule */
[data-theme="dark"] .merged-disponibility-time-block.selected {
background: var(--success) !important;
background: #6366f1 !important;
color: white !important;
border-color: var(--success) !important;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.5);
border-color: #818cf8 !important;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.5);
}
[data-theme="dark"] .merged-disponibility-time-block.high-availability {
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+1
View File
@@ -0,0 +1 @@
# supporting scripts package
@@ -264,7 +264,7 @@ def check_flask_config():
all_ok = True
try:
from app import create_app
from app.app import create_app
app = create_app()
# Check session cookie settings
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}400 Bad Request - TryoutPro{% endblock %}
{% block title %}400 Bad Request - UdeS team manager{% endblock %}
{% block page_title %}Bad Request{% endblock %}
{% block content %}
<div class="error-container">
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}403 Forbidden - TryoutPro{% endblock %}
{% block title %}403 Forbidden - UdeS team manager{% endblock %}
{% block page_title %}Access Denied{% endblock %}
{% block content %}
<div class="error-container">
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}404 Not Found - TryoutPro{% endblock %}
{% block title %}404 Not Found - UdeS team manager{% endblock %}
{% block page_title %}Page Not Found{% endblock %}
{% block content %}
<div class="error-container">
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}429 Too Many Requests - TryoutPro{% endblock %}
{% block title %}429 Too Many Requests - UdeS team manager{% endblock %}
{% block page_title %}Rate Limit Exceeded{% endblock %}
{% block content %}
<div class="error-container">
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}500 Server Error - TryoutPro{% endblock %}
{% block title %}500 Server Error - UdeS team manager{% endblock %}
{% block page_title %}Internal Server Error{% endblock %}
{% block content %}
<div class="error-container">
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Team Tryout Management{% endblock %}</title>
<title>{% block title %}UdeS team manager{% endblock %}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏆</text></svg>">
@@ -13,8 +13,8 @@
<nav class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="logo">
<i class="fas fa-trophy"></i>
<span>TryoutPro</span>
<img src="{{ url_for('static', filename='images/UdeS_logo.png') }}" alt="UdeS Logo" class="logo-img">
<span>UdeS team manager</span>
</div>
<div class="user-badge">
<div class="user-avatar">
@@ -53,12 +53,21 @@
</a>
</li>
{% endif %}
{% if current_user.role == 'player' %}
<li>
<a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint %}active{% endif %}">
<i class="fas fa-users-cog"></i>
<span>Teams</span>
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
<i class="fas fa-users"></i>
<span>My Team(s)</span>
</a>
</li>
{% else %}
<li>
<a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint and request.endpoint != 'teams.my_teams' %}active{% endif %}">
<i class="fas fa-users-cog"></i>
<span>Manage Teams</span>
</a>
</li>
{% endif %}
{% if current_user.can_manage_users() %}
<li>
<a href="{{ url_for('users.list_users') }}" class="{% if request.endpoint and 'users' in request.endpoint and request.endpoint != 'users.profile' %}active{% endif %}">
@@ -67,19 +76,7 @@
</a>
</li>
{% endif %}
<li>
<a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
<i class="fas fa-user"></i>
<span>My Profile</span>
</a>
</li>
{% if current_user.role == 'player' %}
<li>
<a href="{{ url_for('users.one_on_one') }}" class="{% if request.endpoint == 'users.one_on_one' %}active{% endif %}">
<i class="fas fa-calendar-check"></i>
<span>One on One</span>
</a>
</li>
<li>
<a href="{{ url_for('users.my_notes') }}" class="{% if request.endpoint == 'users.my_notes' %}active{% endif %}">
<i class="fas fa-sticky-note"></i>
@@ -88,16 +85,10 @@
</li>
{% endif %}
{% if current_user.role == 'coach' %}
<li>
<a href="{{ url_for('users.manage_coach_availability') }}" class="{% if request.endpoint == 'users.manage_coach_availability' %}active{% endif %}">
<i class="fas fa-clock"></i>
<span>Availability</span>
</a>
</li>
<li>
<a href="{{ url_for('users.notes_dashboard') }}" class="{% if request.endpoint == 'users.notes_dashboard' or request.endpoint == 'users.manage_team_notes' or request.endpoint == 'users.manage_personal_notes' %}active{% endif %}">
<i class="fas fa-sticky-note"></i>
<span>Notes</span>
<span>Notes & One on One</span>
</a>
</li>
{% endif %}
@@ -108,6 +99,12 @@
</a>
</li>
<li class="nav-divider"></li>
<li>
<a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
<i class="fas fa-user"></i>
<span>My Profile</span>
</a>
</li>
<li>
<a href="{{ url_for('auth.logout') }}" class="logout-link">
<i class="fas fa-sign-out-alt"></i>
@@ -163,9 +160,9 @@
</div>
<div class="auth-container">
<div class="auth-header">
<i class="fas fa-trophy"></i>
<h2>TryoutPro</h2>
<p>Team Tryout Management System</p>
<img src="{{ url_for('static', filename='images/UdeS_logo.png') }}" alt="UdeS Logo" class="auth-logo-img">
<h2>UdeS team manager</h2>
<p>UdeS team manager</p>
</div>
{% block auth_content %}{% endblock %}
</div>
@@ -7,7 +7,7 @@
{# Page Header Macro - renders title and breadcrumb #}
{% macro page_header(title, breadcrumb) %}
{% block title %}{{ title }} - TryoutPro{% endblock %}
{% block title %}{{ title }} - UdeS team manager{% endblock %}
{% block page_title %}{{ title }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">{{ breadcrumb }}</span>{% endblock %}
{% endmacro %}
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}Add Personal Note - TryoutPro{% endblock %}
{% block title %}Add Personal Note - UdeS team manager{% endblock %}
{% block page_title %}Add Personal Note{% endblock %}
{% block breadcrumb %}
<span class="breadcrumb">
+382
View File
@@ -0,0 +1,382 @@
{% extends "layouts/base.html" %}
{% block title %}Calendar - UdeS team manager{% endblock %}
{% block page_title %}Calendar{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / Calendar</span>{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-calendar-alt"></i> Schedule</h3>
<div class="header-actions">
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('dayGridMonth')">
<i class="fas fa-calendar"></i> Month
</button>
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('timeGridWeek')">
<i class="fas fa-calendar-week"></i> Week
</button>
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('timeGridDay')">
<i class="fas fa-calendar-day"></i> Day
</button>
<button type="button" class="btn btn-sm btn-outline" onclick="changeView('listMonth')">
<i class="fas fa-list"></i> List
</button>
</div>
</div>
</div>
<div class="card-body">
<div id="calendar"></div>
</div>
</div>
<!-- Create Event Modal (for clicking empty days) -->
<div id="createEventModal" class="modal hidden">
<div class="modal-backdrop" onclick="hideCreateEventModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-plus-circle"></i> Create New Event</h3>
<button class="modal-close" onclick="hideCreateEventModal()">&times;</button>
</div>
<div class="modal-body">
<p class="mb-3"><strong>Date:</strong> <span id="createEventDate"></span></p>
<input type="hidden" id="createEventDateInput"/>
<div class="card mb-3">
<div class="card-body">
<h5><i class="fas fa-futbol"></i> Schedule Tryout Match</h5>
<p class="text-muted small">Add a scrim/match inside an existing tryout</p>
<div class="form-inline">
<select id="createTryoutSelect" class="form-select" style="flex:1;">
<option value="">-- Select a tryout --</option>
</select>
<button class="btn btn-sm btn-primary ml-2" onclick="goToTryoutMatch()">
<i class="fas fa-arrow-right"></i> Go
</button>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h5><i class="fas fa-users"></i> Schedule Team Match</h5>
<p class="text-muted small">Regular season match for an org team</p>
<div class="form-inline">
<select id="createTeamSelect" class="form-select" style="flex:1;">
<option value="">-- Select a team --</option>
</select>
<button class="btn btn-sm btn-success ml-2" onclick="goToTeamMatch()">
<i class="fas fa-arrow-right"></i> Go
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<h5><i class="fas fa-calendar-plus"></i> Create New Tryout</h5>
<p class="text-muted small">Create a brand new tryout event</p>
<button class="btn btn-sm btn-info" onclick="goToCreateTryout()">
<i class="fas fa-plus"></i> Create Tryout
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Event Details Modal -->
<div id="eventModal" class="modal hidden">
<div class="modal-backdrop" onclick="hideEventModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">Event Details</h3>
<button class="modal-close" onclick="hideEventModal()">&times;</button>
</div>
<div class="modal-body">
<div id="modalContent"></div>
<div id="modalPresenceToggle" class="form-actions mt-3" style="display: none; justify-content: center;">
<button class="btn btn-sm btn-outline" id="calPresenceBtn">Confirm</button>
</div>
<div id="modalActions" class="form-actions mt-3" style="display: none;">
<button class="btn btn-sm btn-danger" id="deleteMatchBtn" style="display: none;">
<i class="fas fa-trash"></i> Delete Match
</button>
<button class="btn btn-sm btn-primary" id="editMatchBtn" style="display: none;">
<i class="fas fa-edit"></i> Edit Match
</button>
<button class="btn btn-sm btn-primary" id="viewTryoutBtn" style="display: none;">
<i class="fas fa-eye"></i> View Tryout
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<link href="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.js"></script>
<script>
var canScheduleMatches = {% if current_user.can_schedule_matches() %}true{% else %}false{% endif %};
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
},
events: '/matches/api/events',
eventClick: function(info) {
showEventModal(info.event);
},
dateClick: function(info) {
if (canScheduleMatches) {
var dateStr = info.dateStr;
showCreateEventModal(dateStr);
}
},
selectable: true,
select: function(info) {
if (canScheduleMatches) {
var dateStr = info.startStr;
showCreateEventModal(dateStr);
calendar.unselect();
}
},
slotMinTime: '12:00:00',
slotMaxTime: '24:00:00'
});
calendar.render();
window.fcCalendar = calendar;
// Pre-load tryout and team options for the create modal
if (canScheduleMatches) {
fetchTryoutOptions();
fetchTeamOptions();
}
});
function changeView(viewName) {
if (window.fcCalendar) {
window.fcCalendar.changeView(viewName);
}
}
// --- Create Event Modal ---
function showCreateEventModal(dateStr) {
document.getElementById('createEventDate').textContent = dateStr;
document.getElementById('createEventDateInput').value = dateStr;
// Reset dropdowns
document.getElementById('createTryoutSelect').value = '';
document.getElementById('createTeamSelect').value = '';
document.getElementById('createEventModal').classList.remove('hidden');
}
function hideCreateEventModal() {
document.getElementById('createEventModal').classList.add('hidden');
}
function goToTryoutMatch() {
var tryoutId = document.getElementById('createTryoutSelect').value;
if (!tryoutId) { alert('Please select a tryout.'); return; }
var date = document.getElementById('createEventDateInput').value;
window.location.href = '/matches/create/' + tryoutId + '?date=' + date;
}
function goToTeamMatch() {
var teamId = document.getElementById('createTeamSelect').value;
if (!teamId) { alert('Please select a team.'); return; }
var date = document.getElementById('createEventDateInput').value;
window.location.href = '/team-matches/' + teamId + '/create?date=' + date;
}
function goToCreateTryout() {
window.location.href = '/tryouts/create';
}
// Pre-fetch data for dropdowns
function fetchTryoutOptions() {
fetch('/matches/api/manageable-tryouts')
.then(function(r) { return r.json(); })
.then(function(data) {
var sel = document.getElementById('createTryoutSelect');
sel.innerHTML = '<option value="">-- Select a tryout --</option>';
var today = new Date().toISOString().split('T')[0];
data.forEach(function(t) {
// Only show tryouts that haven't ended
var tryoutEndDate = t.end_date || t.date;
if (tryoutEndDate >= today) {
sel.innerHTML += '<option value="' + t.id + '">' + t.title + ' (' + t.date + ')</option>';
}
});
})
.catch(function() {});
}
function fetchTeamOptions() {
fetch('/team-matches/api/manageable-teams')
.then(function(r) { return r.json(); })
.then(function(data) {
var sel = document.getElementById('createTeamSelect');
sel.innerHTML = '<option value="">-- Select a team --</option>';
data.forEach(function(t) {
sel.innerHTML += '<option value="' + t.id + '">' + t.name + '</option>';
});
})
.catch(function() {});
}
function showEventModal(event) {
var props = event.extendedProps;
var title = event.title;
var type = props.type;
var date = event.start ? event.start.toDateString() : '';
var content = '<div class="detail-grid">';
content += '<div class="detail-item"><span class="detail-label">Type</span><span class="detail-value">';
content += '<span class="badge badge-' + (props.match_type === 'team_vs_team' || props.match_type === 'player_vs_player' ? 'success' : 'warning') + '">';
content += (props.match_type === 'team_vs_team' ? 'Team Match' : (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim')) + '</span>';
content += '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Title</span><span class="detail-value">' + title + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Date</span><span class="detail-value">' + date + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Location</span><span class="detail-value">' + (props.location || 'TBD') + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Status</span><span class="detail-value">';
content += '<span class="badge badge-' + (props.status || 'scheduled') + '">' + (props.status || 'scheduled') + '</span>';
content += '</span></div>';
// Add team separation for matches
if (type === 'match' && props.participants) {
content += '<div class="detail-item full-width"><span class="detail-label">Teams</span><span class="detail-value">';
if (props.match_type === 'team_vs_team') {
var teams = props.participants.split(' vs ');
if (teams.length >= 2) {
content += '<div class="match-teams">';
content += '<div class="match-team"><span class="team-name">' + teams[0] + '</span></div>';
content += '<div class="match-vs">vs</div>';
content += '<div class="match-team"><span class="team-name">' + teams[1] + '</span></div>';
content += '</div>';
} else {
content += props.participants;
}
} else if (props.match_type === 'player_vs_player') {
var parts = props.participants.split(' vs ');
if (parts.length >= 2) {
content += '<div class="match-teams">';
content += '<div class="match-team"><span class="team-name">Team 1</span><ul class="team-players-list"><li>' + parts[0].split(', ').join('</li><li>') + '</li></ul></div>';
content += '<div class="match-vs">vs</div>';
content += '<div class="match-team"><span class="team-name">Team 2</span><ul class="team-players-list"><li>' + parts[1].split(', ').join('</li><li>') + '</li></ul></div>';
content += '</div>';
} else {
content += props.participants;
}
} else {
content += props.participants;
}
content += '</span></div>';
}
if (props.description) {
content += '<div class="detail-item full-width"><span class="detail-label">Description</span><span class="detail-value">' + props.description + '</span></div>';
}
content += '</div>';
document.getElementById('modalTitle').textContent = 'Match Details';
document.getElementById('modalContent').innerHTML = content;
// Reset buttons
document.getElementById('deleteMatchBtn').style.display = 'none';
document.getElementById('editMatchBtn').style.display = 'none';
document.getElementById('viewTryoutBtn').style.display = 'none';
// Show action buttons for matches (coaches and above)
if (type === 'match' && canScheduleMatches) {
document.getElementById('modalActions').style.display = 'flex';
document.getElementById('editMatchBtn').style.display = 'inline-flex';
document.getElementById('editMatchBtn').onclick = function() {
window.location.href = '/matches/' + props.match_id + '/edit';
};
document.getElementById('deleteMatchBtn').style.display = 'inline-flex';
document.getElementById('deleteMatchBtn').onclick = function() {
if (confirm('Are you sure you want to delete this match?')) {
deleteCalendarMatch(props.match_id);
}
};
} else {
document.getElementById('modalActions').style.display = 'none';
}
// Show presence toggle for matches where user is a participant
var presenceDiv = document.getElementById('modalPresenceToggle');
if (type === 'match' && props.user_participant_id) {
presenceDiv.style.display = 'flex';
var confirmed = props.user_attendance_confirmed || false;
var toggleBtn = document.getElementById('calPresenceBtn');
toggleBtn.textContent = confirmed ? '✅ Confirmed' : 'Confirm';
toggleBtn.className = 'btn btn-sm ' + (confirmed ? 'btn-success' : 'btn-outline');
toggleBtn.onclick = function() {
toggleCalendarPresence(props.match_id, props.user_participant_id, toggleBtn);
};
} else {
presenceDiv.style.display = 'none';
}
document.getElementById('eventModal').classList.remove('hidden');
}
function deleteCalendarMatch(matchId) {
fetch('/matches/' + matchId + '/delete', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(r) { return r.json().catch(function() { return {}; }); })
.then(function() {
hideEventModal();
if (window.fcCalendar) {
window.fcCalendar.refetchEvents();
}
})
.catch(function(err) {
console.error('Error deleting match:', err);
alert('Failed to delete match.');
});
}
function toggleCalendarPresence(matchId, participantId, btn) {
fetch('/matches/' + matchId + '/toggle-presence/' + participantId, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.attendance_confirmed) {
btn.classList.add('btn-success');
btn.classList.remove('btn-outline');
btn.textContent = '✅ Confirmed';
} else {
btn.classList.remove('btn-success');
btn.classList.add('btn-outline');
btn.textContent = 'Confirm';
}
})
.catch(function(err) {
console.error('Error toggling presence:', err);
});
}
function hideEventModal() {
document.getElementById('eventModal').classList.add('hidden');
}
</script>
{% endblock %}
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}Manage Availability - TryoutPro{% endblock %}
{% block title %}Manage Availability - UdeS team manager{% endblock %}
{% block page_title %}Manage Availability{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / Coach Availability</span>{% endblock %}
@@ -15,6 +15,9 @@
</div>
<div class="form-actions mt-4">
<button type="button" class="btn btn-primary" onclick="saveAvailability()">
<i class="fas fa-save"></i> Save Availability
</button>
<button type="button" class="btn btn-secondary" onclick="clearAllAvailability()">
<i class="fas fa-trash"></i> Clear All
</button>
@@ -168,13 +171,18 @@ function saveAvailability() {
fetch('{{ url_for("users.manage_coach_availability") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ slots: slots })
})
.then(response => response.json())
.then(data => {
if (data.success) {
flash('Availability saved!', 'success');
} else {
flash(data.error || 'Error saving availability.', 'danger');
}
})
.catch(function(error) {
@@ -188,7 +196,10 @@ function clearAllAvailability() {
return;
}
fetch('{{ url_for("users.clear_coach_availability") }}', { method: 'POST' })
fetch('{{ url_for("users.clear_coach_availability") }}', {
method: 'POST',
headers: { 'X-CSRFToken': '{{ csrf_token() }}' }
})
.then(response => response.json())
.then(data => {
if (data.success) {
@@ -1,10 +1,10 @@
{% extends "layouts/base.html" %}
{% block title %}Contracts - TryoutPro{% endblock %}
{% block title %}Contracts - UdeS team manager{% endblock %}
{% block page_title %}Contracts{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / Contracts</span>{% endblock %}
{% block header_actions %}
{% if current_user.role in ['president', 'manager', 'coach'] %}
{% if current_user.role in ['admin', 'manager', 'coach'] %}
<a href="{{ url_for('users.upload_contract') }}" class="btn btn-primary">
<i class="fas fa-upload"></i> Upload Contract
</a>
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}Create User - TryoutPro{% endblock %}
{% block title %}Create User - UdeS team manager{% endblock %}
{% block page_title %}Create User{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.list_users') }}">Users</a> / Create</span>{% endblock %}
@@ -1,11 +1,11 @@
{% extends "layouts/base.html" %}
{% block title %}Dashboard - TryoutPro{% endblock %}
{% block title %}Dashboard - UdeS team manager{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / Dashboard</span>{% endblock %}
{% block content %}
<div class="dashboard">
{% if user.role == 'president' %}
{% if user.role == 'admin' %}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon bg-primary">
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}Edit Profile - TryoutPro{% endblock %}
{% block title %}Edit Profile - UdeS team manager{% endblock %}
{% block page_title %}Edit Profile{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / Edit</span>{% endblock %}
@@ -104,25 +104,6 @@
<input type="password" id="password" name="password" placeholder="Enter new password">
</div>
{% if user.role == 'player' %}
<hr class="section-divider">
<h4 class="section-title"><i class="fas fa-clock"></i> My Disponibilities</h4>
<p class="text-muted small">Select your available time blocks for matches (5pm to 12am). Green = selected, Gray = available to select.</p>
<div id="disponibilities-grid">
<p class="text-muted">Loading...</p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="saveDisponibilities()">
<i class="fas fa-save"></i> Save Disponibilities
</button>
<button type="button" class="btn btn-secondary" onclick="clearDisponibilities()">
<i class="fas fa-trash"></i> Clear All
</button>
</div>
{% endif %}
<div class="form-actions">
<a href="{{ url_for('users.profile') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Save Changes</button>
@@ -165,177 +146,6 @@ document.addEventListener('DOMContentLoaded', function() {
// Show gamertag inputs for already selected games
toggleGamertagInputs();
{% if user.role == 'player' %}
renderDisponibilityGrid();
{% endif %}
});
{% if user.role == 'player' %}
// Generate time slots from 5pm (17:00) to 12am (24:00)
var TIME_SLOTS = [];
for (var h = 17; h <= 24; h++) {
for (var m = 0; m < 60; m += 30) {
if (h === 24 && m > 0) continue;
var displayHour;
var displayAmpm;
if (h === 24) {
displayHour = 12;
displayAmpm = 'AM';
} else if (h > 12) {
displayHour = h - 12;
displayAmpm = 'PM';
} else {
displayHour = h;
displayAmpm = 'PM';
}
var timeStr = (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
var displayTime = displayHour + ':' + (m < 10 ? '0' : '') + m + ' ' + displayAmpm;
TIME_SLOTS.push({ time: timeStr, display: displayTime });
}
}
var DAYS = [
{ value: 0, name: 'Monday' },
{ value: 1, name: 'Tuesday' },
{ value: 2, name: 'Wednesday' },
{ value: 3, name: 'Thursday' },
{ value: 4, name: 'Friday' },
{ value: 5, name: 'Saturday' },
{ value: 6, name: 'Sunday' }
];
// Store selected slots: {day: [time, time, ...]}
var selectedSlots = {};
function renderDisponibilityGrid() {
var grid = document.getElementById('disponibilities-grid');
grid.innerHTML = '<div style="margin-bottom: 10px;"><strong>Click time blocks to select your available hours</strong></div>';
var container = document.createElement('div');
container.className = 'disponibility-grid';
DAYS.forEach(function(day) {
var dayRow = document.createElement('div');
dayRow.className = 'disponibility-day-row';
var dayLabel = document.createElement('div');
dayLabel.className = 'disponibility-day-label';
dayLabel.textContent = day.name;
dayRow.appendChild(dayLabel);
var timeBlocks = document.createElement('div');
timeBlocks.className = 'disponibility-time-blocks';
TIME_SLOTS.forEach(function(slot) {
var block = document.createElement('div');
block.className = 'disponibility-time-block';
block.dataset.day = day.value;
block.dataset.time = slot.time;
block.textContent = slot.display;
block.onclick = function() {
toggleSlot(day.value, slot.time, block);
};
timeBlocks.appendChild(block);
});
dayRow.appendChild(timeBlocks);
container.appendChild(dayRow);
});
grid.appendChild(container);
loadMyDisponibilities();
}
function toggleSlot(day, time, element) {
if (!selectedSlots[day]) selectedSlots[day] = [];
var index = selectedSlots[day].indexOf(time);
if (index > -1) {
selectedSlots[day].splice(index, 1);
element.classList.remove('selected');
} else {
selectedSlots[day].push(time);
element.classList.add('selected');
}
}
function loadMyDisponibilities() {
fetch('{{ url_for("users.get_my_disponibilities") }}')
.then(function(response) { return response.json(); })
.then(function(data) {
selectedSlots = {};
for (var day in data) {
var slots = data[day];
slots.forEach(function(slot) {
selectedSlots[day] = selectedSlots[day] || [];
selectedSlots[day].push(slot.start_time);
});
}
document.querySelectorAll('.disponibility-time-block').forEach(function(block) {
var day = block.dataset.day;
var time = block.dataset.time;
if (selectedSlots[day] && selectedSlots[day].indexOf(time) > -1) {
block.classList.add('selected');
} else {
block.classList.remove('selected');
}
});
})
.catch(function(error) {
console.error('Error loading disponibilities:', error);
});
}
function saveDisponibilities() {
var slots = [];
for (var day in selectedSlots) {
selectedSlots[day].forEach(function(time) {
slots.push({ day_of_week: parseInt(day), start_time: time });
});
}
fetch('{{ url_for("users.add_disponibilities_bulk") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ slots: slots })
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
var msg = document.createElement('div');
msg.className = 'alert alert-success';
msg.style.marginTop = '10px';
msg.innerHTML = '<i class="fas fa-check"></i> Disponibilities saved successfully!';
document.getElementById('disponibilities-grid').appendChild(msg);
setTimeout(function() { msg.remove(); }, 3000);
}
})
.catch(function(error) {
console.error('Error saving disponibilities:', error);
});
}
function clearDisponibilities() {
if (!confirm('Are you sure you want to clear all your disponibilities?')) return;
fetch('{{ url_for("users.clear_disponibilities") }}', {
method: 'POST'
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
selectedSlots = {};
document.querySelectorAll('.disponibility-time-block').forEach(function(block) {
block.classList.remove('selected');
});
}
});
}
{% endif %}
</script>
{% endblock %}
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}Edit {{ user.username }} - TryoutPro{% endblock %}
{% block title %}Edit {{ user.username }} - UdeS team manager{% endblock %}
{% block page_title %}Edit User{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.list_users') }}">Users</a> / Edit</span>{% endblock %}
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}Evaluate {{ player.username }} - TryoutPro{% endblock %}
{% block title %}Evaluate {{ player.username }} - UdeS team manager{% endblock %}
{% block page_title %}Evaluate {{ player.username }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}">{{ tryout.title }}</a> / Evaluate</span>{% endblock %}
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}Evaluations - TryoutPro{% endblock %}
{% block title %}Evaluations - UdeS team manager{% endblock %}
{% block page_title %}Evaluations{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / Evaluations</span>{% endblock %}
@@ -21,7 +21,7 @@
</th>
{% endmacro %}
{% if current_user.role == 'president' and player_scores %}
{% if current_user.role == 'admin' and player_scores %}
<div class="stats-grid mb-4">
{% for pid, data in player_scores.items() %}
<div class="stat-card stat-card-sm">
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}Login - TryoutPro{% endblock %}
{% block title %}Login - UdeS team manager{% endblock %}
{% block auth_content %}
<form method="POST" action="{{ url_for('auth.login') }}" class="auth-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}{% if match %}Edit Match{% else %}Schedule Match{% endif %} - {{ tryout.title }} - TryoutPro{% endblock %}
{% block title %}{% if match %}Edit Match{% else %}Schedule Match{% endif %} - {{ tryout.title }} - UdeS team manager{% endblock %}
{% block page_title %}{% if match %}Edit Match{% else %}Schedule Match{% endif %}{% endblock %}
{% block breadcrumb %}
<span class="breadcrumb">
@@ -16,15 +16,19 @@
{% block content %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% else %}Schedule Match for {{ tryout.title }}{% endif %}</h3>
<h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% elif is_practice %}Schedule Practice — {{ team.name }}{% else %}Schedule Match for {{ tryout.title }}{% endif %}</h3>
{% if match %}
<p class="text-muted small">Match Type: <strong>{{ match.match_type.replace('_', ' ') | title }}</strong></p>
{% endif %}
</div>
<div class="card-body">
<form method="POST" action="{% if match %}{{ url_for('matches.edit_match', match_id=match.id) }}{% else %}{{ url_for('matches.create_match', tryout_id=tryout.id) }}{% endif %}" class="form" id="matchForm">
<form method="POST" action="{% if match %}{{ url_for('matches.edit_match', match_id=match.id) }}{% elif is_practice %}{{ url_for('team_matches.create_match', team_id=team_id) }}{% else %}{{ url_for('matches.create_match', tryout_id=tryout.id) }}{% endif %}" class="form" id="matchForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% if is_practice %}
<input type="hidden" name="title" value="Practice">
{% endif %}
{% if not is_practice %}
{% if not match %}
<div class="form-group">
<label for="match_type">Match Type</label>
@@ -37,16 +41,19 @@
{% else %}
<input type="hidden" name="match_type" value="{{ match.match_type }}">
{% endif %}
{% endif %}
{% if not is_practice %}
<div class="form-group">
<label for="title">Match Title</label>
<input type="text" name="title" id="title" class="form-input" value="{{ match.title if match else '' }}" placeholder="e.g., Alpha vs Bravo Scrimmage" required>
</div>
{% endif %}
<div class="form-row">
<div class="form-group">
<label for="date">Date</label>
<input type="date" name="date" id="date" class="form-input" value="{{ match.date.strftime('%Y-%m-%d') if match else tryout.date.strftime('%Y-%m-%d') }}" required>
<input type="date" name="date" id="date" class="form-input" value="{{ match.date.strftime('%Y-%m-%d') if match else (prefill_date if is_practice else tryout.date.strftime('%Y-%m-%d')) }}" required>
</div>
{% if match %}
<div class="form-group">
@@ -68,7 +75,12 @@
{% if current_user.can_manage_teams() or current_user.can_schedule_matches() %}
<hr class="section-divider">
<h4 class="section-title"><i class="fas fa-clock"></i> Select Match Time</h4>
<p class="text-muted small">Click time slots consecutively to set match duration. Players available in all selected time blocks are shown below.</p>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<p class="text-muted small" style="margin:0;">Click time slots consecutively to set match duration. Players available in all selected time blocks are shown below.</p>
<button type="button" class="btn btn-sm btn-outline" onclick="clearTimeSelection()" title="Reset time selection">
<i class="fas fa-undo"></i> Reset Time
</button>
</div>
<div id="merged-disponibility-grid" class="merged-disponibility-grid">
<p class="text-muted">Loading...</p>
@@ -568,10 +580,17 @@ document.addEventListener('DOMContentLoaded', function() {
{% endif %}
{% if not match %}
{% if is_practice %}
// Practices default to player_scrim mode
document.getElementById('player-scrim-section').classList.remove('hidden');
document.getElementById('team-vs-team-section').classList.add('hidden');
document.getElementById('player-vs-player-section').classList.add('hidden');
{% else %}
toggleMatchType();
// Show all players initially
updatePlayerPool();
{% endif %}
{% endif %}
});
function allDisponibilitiesInitialized() {
+278
View File
@@ -0,0 +1,278 @@
{% extends "layouts/base.html" %}
{% block title %}My Team(s) - UdeS team manager{% endblock %}
{% block page_title %}My Team(s){% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / My Team(s)</span>{% endblock %}
{% block content %}
{% if team_data %}
{% for item in team_data %}
{% set team = item.team %}
<div class="card mb-4">
<div class="card-header">
<h3><i class="fas fa-users"></i> {{ team.name }}</h3>
</div>
<!-- Staff bar -->
<div class="team-staff-bar">
<div class="staff-row">
<div class="staff-group">
<span class="staff-label"><i class="fas fa-chalkboard-teacher"></i> Coaches</span>
<div class="staff-items">
{% if item.coaches %}
{% for c in item.coaches %}
<span class="staff-tag">{{ c.username }}</span>
{% endfor %}
{% else %}
<span class="text-muted text-sm">None</span>
{% endif %}
</div>
</div>
<div class="staff-group">
<span class="staff-label"><i class="fas fa-user-tie"></i> Managers</span>
<div class="staff-items">
{% if item.managers %}
{% for m in item.managers %}
<span class="staff-tag manager-tag">{{ m.username }}</span>
{% endfor %}
{% else %}
<span class="text-muted text-sm">None</span>
{% endif %}
</div>
</div>
</div>
</div>
<div class="card-body">
<!-- Team Roster -->
<h4 class="mb-3"><i class="fas fa-users"></i> Team Roster</h4>
{% set roster = team.get_players_with_status() %}
{% if roster %}
<div class="table-container mb-4">
<table class="table">
<thead>
<tr>
<th>Player</th>
<th>Status</th>
<th>Position</th>
</tr>
</thead>
<tbody>
{% for entry in roster %}
<tr>
<td>
<div class="user-mini">
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
</div>
</td>
<td>
<span class="badge {% if entry.status == 'starter' %}badge-success{% else %}badge-warning{% endif %}">
{{ entry.status | capitalize }}
</span>
</td>
<td>{{ entry.position or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-3 text-muted">
<i class="fas fa-users-slash"></i> No players on this team.
</div>
{% endif %}
<!-- Team Matches -->
<h4 class="mb-3"><i class="fas fa-futbol"></i> Upcoming Matches</h4>
{% if item.matches %}
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Match</th>
<th>Opponent</th>
<th>Date</th>
<th>Time</th>
<th>Location</th>
<th>Presence</th>
<th>My Status</th>
</tr>
</thead>
<tbody>
{% for mdata in item.matches %}
{% set tm = mdata.match %}
<tr>
<td class="cell-title">{{ tm.title }}</td>
<td>
{% if tm.opponent %}
{{ tm.opponent }}
{% else %}
<span class="badge badge-info">Practice</span>
{% endif %}
</td>
<td>{{ tm.date.strftime('%m/%d/%Y') }}</td>
<td>
{% if tm.start_time and tm.end_time %}
{{ tm.start_time.strftime('%H:%M') }} - {{ tm.end_time.strftime('%H:%M') }}
{% else %}
TBD
{% endif %}
</td>
<td>{{ tm.location or '—' }}</td>
<td>
{% if mdata.total_count > 0 %}
<span title="{{ mdata.confirmed_count }} of {{ mdata.total_count }} confirmed">
{% if mdata.confirmed_count == mdata.total_count and mdata.total_count > 0 %}
✅ {{ mdata.confirmed_count }}/{{ mdata.total_count }}
{% elif mdata.confirmed_count > 0 %}
⏳ {{ mdata.confirmed_count }}/{{ mdata.total_count }}
{% else %}
❌ 0/{{ mdata.total_count }}
{% endif %}
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
{% if mdata.participant_id %}
<button class="btn btn-sm {% if mdata.is_confirmed %}btn-success{% else %}btn-outline{% endif %} presence-toggle-btn"
data-match-id="{{ tm.id }}"
data-participant-id="{{ mdata.participant_id }}"
onclick="togglePresence(this)">
{% if mdata.is_confirmed %}✅ Confirmed{% else %}Confirm{% endif %}
</button>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-3 text-muted">
<i class="fas fa-calendar-alt"></i> No upcoming matches scheduled.
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="card">
<div class="card-body text-center py-5">
<div class="empty-state">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h3>No Teams</h3>
<p class="text-muted">You are not currently assigned to any team.</p>
</div>
</div>
</div>
{% endif %}
<script>
function togglePresence(btn) {
var matchId = btn.getAttribute('data-match-id');
var participantId = btn.getAttribute('data-participant-id');
fetch('/team-matches/' + matchId + '/toggle-presence/' + participantId, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.is_confirmed) {
btn.classList.add('btn-success');
btn.classList.remove('btn-outline');
btn.innerHTML = '✅ Confirmed';
} else {
btn.classList.remove('btn-success');
btn.classList.add('btn-outline');
btn.innerHTML = 'Confirm';
}
location.reload();
})
.catch(function(error) {
console.error('Error:', error);
});
}
</script>
<style>
.team-staff-bar {
padding: 10px 20px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.staff-row {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.staff-group {
display: flex;
align-items: center;
gap: 8px;
}
.staff-label {
font-size: 0.8rem;
color: #64748b;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
.staff-label i {
margin-right: 3px;
font-size: 0.75rem;
}
.staff-items {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.staff-tag {
display: inline-flex;
align-items: center;
gap: 5px;
background: #e0e7ff;
color: #3730a3;
padding: 3px 10px;
border-radius: 14px;
font-size: 0.82rem;
font-weight: 500;
line-height: 1.3;
}
.staff-tag.manager-tag {
background: #fef3c7;
color: #92400e;
}
.text-sm {
font-size: 0.82rem;
}
.presence-toggle-btn {
min-width: 100px;
}
/* Dark mode */
[data-theme="dark"] .team-staff-bar {
background: var(--bg-tertiary);
border-bottom-color: var(--border-color);
}
[data-theme="dark"] .staff-label {
color: var(--text-muted);
}
[data-theme="dark"] .staff-tag {
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
}
[data-theme="dark"] .staff-tag.manager-tag {
background: rgba(229, 169, 57, 0.2);
color: #fcd34d;
}
</style>
{% endblock %}
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}Notes - TryoutPro{% endblock %}
{% block title %}Notes - UdeS team manager{% endblock %}
{% block page_title %}Notes{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / Notes</span>{% endblock %}
@@ -105,6 +105,94 @@
</div>
</div>
<!-- One on One Requests Section -->
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-calendar-check"></i> One on One Requests</h3>
{% if org_team %}
<span class="badge badge-esport">{{ org_team.name }}</span>
{% endif %}
</div>
<div class="card-body">
{% if one_on_one_requests %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Player</th>
<th>Date</th>
<th>Time</th>
<th>Discussion Points</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for req in one_on_one_requests %}
<tr>
<td>{{ req.player.username if req.player else 'Unknown' }}</td>
<td>{{ req.date.strftime('%b %d, %Y') }}</td>
<td>{{ req.start_time.strftime('%I:%M %p') }} - {{ req.end_time.strftime('%I:%M %p') }}</td>
<td>{{ req.points or 'N/A' }}</td>
<td>
{% if req.status == 'pending' %}
<span class="badge badge-warning">Pending</span>
{% elif req.status == 'approved' %}
<span class="badge badge-success">Approved</span>
{% elif req.status == 'rejected' %}
<span class="badge badge-danger">Rejected</span>
{% endif %}
</td>
<td>
{% if req.status == 'pending' %}
<form method="POST" action="{{ url_for('users.accept_one_on_one', request_id=req.id) }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-success" title="Accept">
<i class="fas fa-check"></i> Accept
</button>
</form>
<button type="button" class="btn btn-sm btn-danger" onclick="showRejectModal({{ req.id }})" title="Refuse">
<i class="fas fa-times"></i> Refuse
</button>
{% elif req.status == 'rejected' and req.coach_rejection_message %}
<span class="text-muted small" title="{{ req.coach_rejection_message }}">Reason: {{ req.coach_rejection_message[:50] }}{% if req.coach_rejection_message|length > 50 %}...{% endif %}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No One on One requests from your players yet.</p>
{% endif %}
</div>
</div>
<!-- Reject Modal -->
<div id="rejectModal" class="modal" style="display:none;">
<div class="modal-overlay" onclick="hideRejectModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h4><i class="fas fa-times-circle"></i> Reject One on One Request</h4>
<button type="button" class="modal-close" onclick="hideRejectModal()">&times;</button>
</div>
<form id="rejectForm" method="POST" action="">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="modal-body">
<div class="form-group">
<label for="rejection_reason">Reason for rejection (optional):</label>
<textarea name="rejection_reason" id="rejection_reason" class="form-textarea" rows="3" placeholder="Let the player know why this time doesn't work..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="hideRejectModal()">Cancel</button>
<button type="submit" class="btn btn-danger">Reject Request</button>
</div>
</form>
</div>
</div>
<div class="dashboard-grid mt-4">
<!-- Team Notes History -->
{% if org_team and team_notes %}
@@ -170,3 +258,19 @@
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
function showRejectModal(requestId) {
const modal = document.getElementById('rejectModal');
const form = document.getElementById('rejectForm');
form.action = "{{ url_for('users.reject_one_on_one', request_id=0) }}".replace('0', requestId);
modal.style.display = 'flex';
}
function hideRejectModal() {
document.getElementById('rejectModal').style.display = 'none';
document.getElementById('rejection_reason').value = '';
}
</script>
{% endblock %}
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}One on One - TryoutPro{% endblock %}
{% block title %}One on One - UdeS team manager{% endblock %}
{% block page_title %}One on One{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / One on One</span>{% endblock %}
@@ -54,7 +54,66 @@
{% endif %}
</div>
</div>
</div>
<!-- My One on One Requests Tracker -->
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-list-check"></i> My One on One Requests</h3>
</div>
<div class="card-body">
{% if my_requests %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Time</th>
<th>Discussion Points</th>
<th>Status</th>
<th>Coach Response</th>
</tr>
</thead>
<tbody>
{% for req in my_requests %}
<tr>
<td>{{ req.date.strftime('%b %d, %Y') }}</td>
<td>{{ req.start_time.strftime('%I:%M %p') }} - {{ req.end_time.strftime('%I:%M %p') }}</td>
<td>{{ req.points or 'N/A' }}</td>
<td>
{% if req.status == 'pending' %}
<span class="badge badge-warning"><i class="fas fa-clock"></i> Pending</span>
{% elif req.status == 'approved' %}
<span class="badge badge-success"><i class="fas fa-check-circle"></i> Approved</span>
{% elif req.status == 'rejected' %}
<span class="badge badge-danger"><i class="fas fa-times-circle"></i> Rejected</span>
{% endif %}
</td>
<td>
{% if req.status == 'approved' %}
<span class="text-success">Session confirmed!</span>
{% elif req.status == 'rejected' %}
{% if req.coach_rejection_message %}
<span class="text-danger small">{{ req.coach_rejection_message }}</span>
{% else %}
<span class="text-muted small">Coach is unavailable</span>
{% endif %}
{% else %}
<span class="text-muted small">Awaiting coach response...</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">You haven't made any One on One requests yet.</p>
{% endif %}
</div>
</div>
<div class="dashboard-grid mt-4">
<!-- One on One Request Section -->
<div class="card" style="grid-column: 1 / -1;">
<div class="card-header">
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}Personal Notes - TryoutPro{% endblock %}
{% block title %}Personal Notes - UdeS team manager{% endblock %}
{% block page_title %}Personal Notes{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / Personal Notes</span>{% endblock %}
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}My Notes - TryoutPro{% endblock %}
{% block title %}My Notes - UdeS team manager{% endblock %}
{% block page_title %}My Notes{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / One on One / My Notes</span>{% endblock %}
@@ -1,5 +1,5 @@
{% extends "layouts/base.html" %}
{% block title %}Players to Evaluate - TryoutPro{% endblock %}
{% block title %}Players to Evaluate - UdeS team manager{% endblock %}
{% block page_title %}Players to Evaluate{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('tryouts.view_tryout', tryout_id=tryout.id) }}">{{ tryout.title }}</a> / Evaluate</span>{% endblock %}
+607
View File
@@ -0,0 +1,607 @@
{% extends "layouts/base.html" %}
{% block title %}My Profile - UdeS team manager{% endblock %}
{% block page_title %}My Profile{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / Profile</span>{% endblock %}
{% block header_actions %}
<div class="header-actions">
<a href="{{ url_for('users.list_contracts') }}" class="btn btn-sm btn-info">
<i class="fas fa-file-contract"></i> Contracts
</a>
<a href="{{ url_for('users.edit_profile') }}" class="btn btn-sm btn-primary">
<i class="fas fa-edit"></i> Edit Profile
</a>
</div>
{% endblock %}
{% block content %}
<div class="dashboard-grid">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-user-circle"></i> Account Information</h3>
</div>
<div class="card-body">
<div class="profile-header">
<div class="user-avatar avatar-xl">{{ user.username[:2] | upper }}</div>
<div class="profile-info">
<h2>{{ user.username }}</h2>
<span class="text-muted" style="font-size: 1.1em;">{{ user.full_name }}</span>
<span class="badge badge-{{ user.role }} badge-lg">{{ user.role | capitalize }}</span>
<p class="text-muted">Member since {{ user.created_at.strftime('%B %Y') }}</p>
</div>
</div>
<div class="detail-grid mt-4">
<div class="detail-item">
<span class="detail-label">Username</span>
<span class="detail-value">{{ user.username }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Email</span>
<span class="detail-value">{{ user.email }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Phone</span>
<span class="detail-value">{{ user.phone or 'Not provided' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Account Status</span>
<span class="detail-value">
{% if user.is_active_account %}
<span class="badge badge-success">Active</span>
{% else %}
<span class="badge badge-danger">Inactive</span>
{% endif %}
</span>
</div>
</div>
</div>
</div>
<!-- E-Sports Profile Card -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-gamepad"></i> E-Sports Profile</h3>
</div>
<div class="card-body">
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label"><i class="fas fa-gamepad"></i> Gamertags</span>
<span class="detail-value">
{% set games_list = user.get_games_list() %}
{% if games_list %}
<div style="display: flex; flex-direction: column; gap: 8px;">
{% for game in games_list %}
{% set gt = user.gamertags | selectattr('game', 'equalto', game) | first %}
<div>
{% if gt and gt.get_trn_url() %}
<a href="{{ gt.get_trn_url() }}" target="_blank" rel="noopener noreferrer" class="trn-link">
<span class="badge badge-esport" style="margin-right: 8px;">{{ game }}</span>
<i class="fas fa-external-link-alt"></i> {{ gt.gamertag }}
{% if gt.platform %}<small>({{ gt.platform }})</small>{% endif %}
</a>
{% else %}
<span class="badge badge-esport">{{ game }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<span class="text-muted">Not specified</span>
{% endif %}
</span>
</div>
<div class="detail-item">
<span class="detail-label"><i class="fab fa-discord"></i> Discord</span>
<span class="detail-value">
{% if user.discord_username %}
{{ user.discord_username }}
{% else %}
<span class="text-muted">Not connected</span>
{% endif %}
</span>
</div>
<div class="detail-item">
<span class="detail-label"><i class="fas fa-link"></i> League OS</span>
<span class="detail-value">
{% if user.league_os_profile %}
<a href="{{ user.league_os_profile }}" target="_blank" rel="noopener noreferrer" class="trn-link">
<i class="fas fa-external-link-alt"></i> {{ user.league_os_profile }}
</a>
{% else %}
<span class="text-muted">Not connected</span>
{% endif %}
</span>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-chart-bar"></i> My Statistics</h3>
</div>
<div class="card-body">
{% if user.role == 'player' %}
<div class="profile-stats">
<div class="stat-item">
<h4>{{ user.tryout_registrations.count() }}</h4>
<p>Tryouts Registered</p>
</div>
<div class="stat-item">
<h4>{{ user.team_assignments.count() }}</h4>
<p>Team Assignments</p>
</div>
</div>
{% elif user.can_evaluate() %}
<div class="profile-stats">
<div class="stat-item">
<h4>{{ user.evaluations_given.count() }}</h4>
<p>Evaluations Given</p>
</div>
</div>
{% else %}
<p class="text-muted">No statistics available for this role.</p>
{% endif %}
</div>
</div>
<!-- Contracts Card -->
{% if user.role == 'player' %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-file-contract"></i> My Contracts</h3>
</div>
<div class="card-body">
{% if contracts %}
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Contracts Pending</span>
<span class="detail-value">
{% set pending = contracts | selectattr('status', 'equalto', 'pending') | list %}
<span class="badge badge-{{ 'warning' if pending|length > 0 else 'success' }}">{{ pending | length }}</span>
</span>
</div>
<div class="detail-item">
<span class="detail-label">Contracts Signed</span>
<span class="detail-value">
{% set signed = contracts | selectattr('status', 'equalto', 'signed') | list %}
<span class="badge {% if signed %}badge-success{% else %}badge-secondary{% endif %}">{{ signed | length }}</span>
</span>
</div>
</div>
<div class="mt-3">
<a href="{{ url_for('users.list_contracts') }}" class="btn btn-sm btn-primary">
<i class="fas fa-folder-open"></i> View All Contracts
</a>
</div>
{% else %}
<p class="text-muted">No contracts have been uploaded for you yet.</p>
{% endif %}
</div>
</div>
{% endif %}
<!-- Disponibilities Card (Players only) -->
{% if user.role == 'player' %}
<div class="card" style="grid-column: 1 / -1;">
<div class="card-header">
<h3><i class="fas fa-clock"></i> My Disponibilities</h3>
</div>
<div class="card-body">
<p class="text-muted small">Select your available time blocks for matches (5pm to 12am). Green = selected, Gray = available to select.</p>
<div id="disponibilities-grid">
<p class="text-muted">Loading...</p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="clearDisponibilities()">
<i class="fas fa-trash"></i> Clear All
</button>
</div>
</div>
</div>
{% endif %}
<!-- Coach Availability Card (Coaches only) -->
{% if user.role == 'coach' %}
<div class="card" style="grid-column: 1 / -1;">
<div class="card-header">
<h3><i class="fas fa-clock"></i> My Coaching Availability</h3>
<p class="text-muted small">Select time slots when you're available for One on One sessions (8am to 10pm).</p>
</div>
<div class="card-body">
<div class="availability-grid" id="availability-grid">
<p class="text-muted">Loading availability grid...</p>
</div>
<div class="form-actions mt-3">
<button type="button" class="btn btn-secondary" onclick="clearAllAvailability()">
<i class="fas fa-trash"></i> Clear All
</button>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
{% if user.role == 'coach' %}
<style>
.availability-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 12px;
margin-top: 10px;
}
.day-column {
background: var(--bg-secondary);
border-radius: 8px;
padding: 10px;
min-height: 300px;
}
.day-header {
text-align: center;
font-weight: 600;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
margin-bottom: 10px;
color: var(--primary);
}
.time-slot {
padding: 6px 8px;
margin: 4px 0;
border-radius: 4px;
font-size: 0.8rem;
text-align: center;
cursor: pointer;
transition: var(--transition);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.time-slot:hover { background: var(--primary-light); border-color: var(--primary); }
.time-slot.selected { background: var(--primary); color: white; border-color: var(--primary-dark); }
.time-slot.selected:hover { background: var(--danger); }
@media (max-width: 768px) { .availability-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 480px) { .availability-grid { grid-template-columns: 1fr; } }
</style>
<script>
const COACH_TIME_SLOTS = [];
for (let h = 8; h <= 22; h++) {
for (let m = 0; m < 60; m += 30) {
const timeStr = (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
const displayHour = h > 12 ? h - 12 : h;
const displayAmpm = h >= 12 ? 'PM' : 'AM';
const displayTime = displayHour + ':' + (m < 10 ? '0' : '') + m + ' ' + displayAmpm;
COACH_TIME_SLOTS.push({ time: timeStr, display: displayTime });
}
}
const COACH_DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
let coachSelectedSlots = {};
// Drag state for coach grid
let coachDragMode = false;
let coachDragAction = null;
function applyCoachSlotAction(dayOfWeek, timeStr, element) {
if (!coachSelectedSlots[dayOfWeek]) coachSelectedSlots[dayOfWeek] = [];
const index = coachSelectedSlots[dayOfWeek].indexOf(timeStr);
if (coachDragAction === 'select' && index === -1) {
coachSelectedSlots[dayOfWeek].push(timeStr);
element.classList.add('selected');
} else if (coachDragAction === 'deselect' && index > -1) {
coachSelectedSlots[dayOfWeek].splice(index, 1);
element.classList.remove('selected');
}
}
function loadCoachAvailability() {
{% for av in existing_availability %}
if (!coachSelectedSlots[{{ av.day_of_week }}]) coachSelectedSlots[{{ av.day_of_week }}] = [];
coachSelectedSlots[{{ av.day_of_week }}].push('{{ av.start_time.strftime('%H:%M') }}');
{% endfor %}
}
function renderCoachGrid() {
const grid = document.getElementById('availability-grid');
let html = '';
COACH_DAYS.forEach((day, dayIndex) => {
html += '<div class="day-column">';
html += '<div class="day-header">' + day.substring(0, 3) + '</div>';
COACH_TIME_SLOTS.forEach(slot => {
const isSelected = coachSelectedSlots[dayIndex] && coachSelectedSlots[dayIndex].includes(slot.time);
const cssClass = isSelected ? 'time-slot selected' : 'time-slot';
html += '<div class="' + cssClass + '" data-day="' + dayIndex + '" data-time="' + slot.time + '">' + slot.display + '</div>';
});
html += '</div>';
});
grid.innerHTML = html;
// Drag event listeners
grid.addEventListener('mousedown', function(e) {
const slot = e.target.closest('.time-slot');
if (!slot) return;
e.preventDefault();
coachDragMode = true;
const day = parseInt(slot.dataset.day);
const time = slot.dataset.time;
if (!coachSelectedSlots[day]) coachSelectedSlots[day] = [];
const isSelected = coachSelectedSlots[day].indexOf(time) > -1;
coachDragAction = isSelected ? 'deselect' : 'select';
applyCoachSlotAction(day, time, slot);
});
grid.addEventListener('mousemove', function(e) {
if (!coachDragMode) return;
const slot = e.target.closest('.time-slot');
if (!slot) return;
applyCoachSlotAction(parseInt(slot.dataset.day), slot.dataset.time, slot);
});
document.addEventListener('mouseup', function() {
if (coachDragMode) {
coachDragMode = false;
coachDragAction = null;
saveCoachAvailability();
}
});
}
function saveCoachAvailability() {
const slots = [];
for (let day in coachSelectedSlots) {
coachSelectedSlots[day].forEach(time => {
slots.push({ day_of_week: parseInt(day), start_time: time });
});
}
fetch('{{ url_for("users.manage_coach_availability") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ slots: slots })
})
.then(r => r.json())
.then(data => {
if (data.success) {
const msg = document.createElement('div');
msg.className = 'alert alert-success';
msg.style.marginTop = '10px';
msg.innerHTML = '<span><i class="fas fa-check"></i> Availability saved!</span>';
document.getElementById('availability-grid').appendChild(msg);
setTimeout(() => msg.remove(), 3000);
}
})
.catch(err => console.error('Save error:', err));
}
function clearAllAvailability() {
if (!confirm('Are you sure you want to clear all your availability slots?')) return;
fetch('{{ url_for("users.clear_coach_availability") }}', {
method: 'POST',
headers: { 'X-CSRFToken': '{{ csrf_token() }}' }
})
.then(r => r.json())
.then(data => {
if (data.success) {
coachSelectedSlots = {};
renderCoachGrid();
}
});
}
document.addEventListener('DOMContentLoaded', function() {
loadCoachAvailability();
renderCoachGrid();
});
</script>
{% endif %}
{% if user.role == 'player' %}
<script>
// Generate time slots from 5pm (17:00) to 12am (24:00)
var TIME_SLOTS = [];
for (var h = 17; h <= 24; h++) {
for (var m = 0; m < 60; m += 30) {
if (h === 24 && m > 0) continue;
var displayHour;
var displayAmpm;
if (h === 24) {
displayHour = 12;
displayAmpm = 'AM';
} else if (h > 12) {
displayHour = h - 12;
displayAmpm = 'PM';
} else {
displayHour = h;
displayAmpm = 'PM';
}
var timeStr = (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
var displayTime = displayHour + ':' + (m < 10 ? '0' : '') + m + ' ' + displayAmpm;
TIME_SLOTS.push({ time: timeStr, display: displayTime });
}
}
var DAYS = [
{ value: 0, name: 'Monday' },
{ value: 1, name: 'Tuesday' },
{ value: 2, name: 'Wednesday' },
{ value: 3, name: 'Thursday' },
{ value: 4, name: 'Friday' },
{ value: 5, name: 'Saturday' },
{ value: 6, name: 'Sunday' }
];
// Store selected slots: {day: [time, time, ...]}
var selectedSlots = {};
// Drag state
var dispDragMode = false;
var dispDragAction = null; // 'select' or 'deselect'
function applySlotAction(block, action) {
var day = parseInt(block.dataset.day);
var time = block.dataset.time;
if (!selectedSlots[day]) selectedSlots[day] = [];
var index = selectedSlots[day].indexOf(time);
if (action === 'select' && index === -1) {
selectedSlots[day].push(time);
block.classList.add('selected');
} else if (action === 'deselect' && index > -1) {
selectedSlots[day].splice(index, 1);
block.classList.remove('selected');
}
}
function renderDisponibilityGrid() {
var grid = document.getElementById('disponibilities-grid');
grid.innerHTML = '<div style="margin-bottom: 10px;"><strong>Click or click-and-drag to select your available hours</strong></div>';
var container = document.createElement('div');
container.className = 'disponibility-grid';
DAYS.forEach(function(day) {
var dayRow = document.createElement('div');
dayRow.className = 'disponibility-day-row';
var dayLabel = document.createElement('div');
dayLabel.className = 'disponibility-day-label';
dayLabel.textContent = day.name;
dayRow.appendChild(dayLabel);
var timeBlocks = document.createElement('div');
timeBlocks.className = 'disponibility-time-blocks';
TIME_SLOTS.forEach(function(slot) {
var block = document.createElement('div');
block.className = 'disponibility-time-block';
block.dataset.day = day.value;
block.dataset.time = slot.time;
block.textContent = slot.display;
timeBlocks.appendChild(block);
});
dayRow.appendChild(timeBlocks);
container.appendChild(dayRow);
});
// Drag event listeners on the container
container.addEventListener('mousedown', function(e) {
var block = e.target.closest('.disponibility-time-block');
if (!block) return;
e.preventDefault();
dispDragMode = true;
var day = parseInt(block.dataset.day);
var time = block.dataset.time;
if (!selectedSlots[day]) selectedSlots[day] = [];
var isSelected = selectedSlots[day].indexOf(time) > -1;
dispDragAction = isSelected ? 'deselect' : 'select';
applySlotAction(block, dispDragAction);
});
container.addEventListener('mousemove', function(e) {
if (!dispDragMode) return;
var block = e.target.closest('.disponibility-time-block');
if (!block) return;
applySlotAction(block, dispDragAction);
});
document.addEventListener('mouseup', function() {
if (dispDragMode) {
dispDragMode = false;
dispDragAction = null;
saveDisponibilities();
}
});
grid.appendChild(container);
loadMyDisponibilities();
}
function loadMyDisponibilities() {
fetch('{{ url_for("users.get_my_disponibilities") }}')
.then(function(response) { return response.json(); })
.then(function(data) {
selectedSlots = {};
for (var day in data) {
var slots = data[day];
slots.forEach(function(slot) {
selectedSlots[day] = selectedSlots[day] || [];
selectedSlots[day].push(slot.start_time);
});
}
document.querySelectorAll('.disponibility-time-block').forEach(function(block) {
var day = block.dataset.day;
var time = block.dataset.time;
if (selectedSlots[day] && selectedSlots[day].indexOf(time) > -1) {
block.classList.add('selected');
} else {
block.classList.remove('selected');
}
});
})
.catch(function(error) {
console.error('Error loading disponibilities:', error);
});
}
function saveDisponibilities() {
var slots = [];
for (var day in selectedSlots) {
selectedSlots[day].forEach(function(time) {
slots.push({ day_of_week: parseInt(day), start_time: time });
});
}
fetch('{{ url_for("users.add_disponibilities_bulk") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ slots: slots })
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
var msg = document.createElement('div');
msg.className = 'alert alert-success';
msg.style.marginTop = '10px';
msg.innerHTML = '<span><i class="fas fa-check"></i> Disponibilities saved successfully!</span>';
document.getElementById('disponibilities-grid').appendChild(msg);
setTimeout(function() { msg.remove(); }, 3000);
}
})
.catch(function(error) {
console.error('Error saving disponibilities:', error);
});
}
function clearDisponibilities() {
if (!confirm('Are you sure you want to clear all your disponibilities?')) return;
fetch('{{ url_for("users.clear_disponibilities") }}', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
selectedSlots = {};
document.querySelectorAll('.disponibility-time-block').forEach(function(block) {
block.classList.remove('selected');
});
}
});
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
renderDisponibilityGrid();
});
</script>
{% endif %}
{% endblock %}
+346
View File
@@ -0,0 +1,346 @@
{% extends "layouts/base.html" %}
{% block title %}Register - UdeS team manager{% endblock %}
{% block auth_content %}
<form method="POST" action="{{ url_for('auth.register') }}" class="auth-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<!-- ==================== Personal Info ==================== -->
<h4 class="section-title"><i class="fas fa-id-card"></i> Personal Information</h4>
<div class="form-group">
<label for="full_name"><i class="fas fa-id-card"></i> Full Name</label>
<input type="text" id="full_name" name="full_name" placeholder="Enter your full name"
value="{{ form_data.get('full_name', '') }}" required>
</div>
<div class="form-group">
<label for="username"><i class="fas fa-user"></i> Username</label>
<input type="text" id="username" name="username" placeholder="Choose a username"
value="{{ form_data.get('username', '') }}" required>
</div>
<div class="form-group">
<label for="email"><i class="fas fa-envelope"></i> Email</label>
<input type="email" id="email" name="email" placeholder="Enter your email"
value="{{ form_data.get('email', '') }}" required>
</div>
<div class="form-group">
<label for="phone"><i class="fas fa-phone"></i> Phone (Optional)</label>
<input type="tel" id="phone" name="phone" placeholder="Enter your phone number"
value="{{ form_data.get('phone', '') }}">
</div>
<hr class="section-divider">
<!-- ==================== Discord Connection (before games) ==================== -->
<h4 class="section-title"><i class="fab fa-discord"></i> Discord Connection</h4>
<p class="text-muted small">Connect your Discord account to automatically fill your gamertags from your connected game accounts.</p>
{% set discord_data = session.get('discord_oauth') %}
{% if discord_data %}
<div class="form-group discord-connected">
<div class="discord-connection-card">
<div class="discord-user-display">
{% if discord_data.avatar %}
{% set avatar_url = 'https://cdn.discordapp.com/avatars/' + discord_data.id|string + '/' + discord_data.avatar + '.png?size=64' %}
<img src="{{ avatar_url }}" alt="Discord Avatar" class="discord-avatar" width="48" height="48">
{% else %}
<div class="discord-avatar-placeholder">
<i class="fab fa-discord"></i>
</div>
{% endif %}
<div class="discord-user-info">
<span class="discord-username">{{ discord_data.username }}</span>
<span class="discord-connected-label text-success">
<i class="fas fa-check-circle"></i> Connected
</span>
</div>
</div>
<a href="{{ url_for('auth.discord_login') }}" class="btn btn-sm btn-outline discord-reconnect" onclick="saveFormDraft()">
<i class="fas fa-sync-alt"></i> Reconnect
</a>
</div>
<input type="hidden" name="discord_username" value="{{ discord_data.username }}">
<input type="hidden" name="discord_user_id" value="{{ discord_data.id }}">
<small class="form-text text-success">
<i class="fas fa-check-circle"></i> Discord connected. Game connections have been used to pre-fill your profile below.
</small>
</div>
{% else %}
<div class="form-group discord-connect-section">
<a href="{{ url_for('auth.discord_login') }}" class="btn btn-discord btn-block" onclick="saveFormDraft()">
<i class="fab fa-discord"></i> Connect Discord Account
</a>
<small class="form-text text-muted">Connect to pre-fill your gamertags from Steam, Battle.net, Xbox, etc.</small>
</div>
<div class="form-group">
<label for="discord_username"><i class="fab fa-discord"></i> Discord Username (Manual)</label>
<input type="text" id="discord_username" name="discord_username" placeholder="e.g. YourName"
value="{{ form_data.get('discord_username', '') }}">
</div>
{% endif %}
<hr class="section-divider">
<!-- ==================== E-Sports Profile ==================== -->
<h4 class="section-title"><i class="fas fa-gamepad"></i> E-Sports Profile</h4>
<p class="text-muted small">Set up your competitive gaming profile for tryouts.</p>
<div class="form-group">
<label><i class="fas fa-headset"></i> Games You Play</label>
<div class="checkbox-grid">
{% set selected_games = form_data.get('games', []) %}
{% set auto_select = session.get('discord_oauth', {}).get('auto_select_games', []) %}
{% for game in esport_games %}
{% set is_checked = game in selected_games or (not form_data and game in auto_select) %}
<label class="checkbox-label">
<input type="checkbox" name="games" value="{{ game }}"
{% if is_checked %}checked{% endif %}
onchange="toggleGamertagInput(this)">
<span>{{ game }}</span>
</label>
{% endfor %}
</div>
<small class="form-text text-muted">Select all games you're signing in for.</small>
</div>
<!-- Gamertag fields per game -->
{% set discord_suggestions = session.get('discord_oauth', {}).get('gamertag_suggestions', {}) %}
{% for game in esport_games %}
{% set game_id = game|replace(' ', '_') %}
{% set form_gamertag = form_data.get('gamertag_' ~ game, '') if form_data else '' %}
{% set suggested = discord_suggestions.get(game, '') %}
{% set gamertag_val = form_gamertag if form_gamertag else suggested %}
{% set game_checked = (form_data and game in selected_games) or (not form_data and game in auto_select) %}
{% set is_visible = gamertag_val or game_checked %}
<div class="form-group gamertag-group" id="gamertag_group_{{ game_id }}"
style="{% if is_visible %}display:block{% else %}display:none{% endif %}">
<label for="gamertag_{{ game }}"><i class="fas fa-gamepad"></i> {{ game }} Gamertag</label>
<input type="text" id="gamertag_{{ game }}" name="gamertag_{{ game }}"
placeholder="Enter your {{ game }} username"
value="{{ gamertag_val }}">
<small class="form-text text-muted">
Your in-game name for {{ game }}.
{% if suggested and not form_gamertag %}
<span class="text-success">Pre-filled from Discord connections.</span>
{% endif %}
</small>
</div>
{% endfor %}
<div class="form-group">
<label for="league_os_profile"><i class="fas fa-link"></i> League OS Connection (Optional)</label>
<input type="text" id="league_os_profile" name="league_os_profile"
placeholder="League OS profile link or ID"
value="{{ form_data.get('league_os_profile', '') }}">
<small class="form-text text-muted">Connect your League OS profile for organized play.</small>
</div>
<hr class="section-divider">
<h4 class="section-title"><i class="fas fa-shield-alt"></i> Security</h4>
<div class="form-group">
<label for="password"><i class="fas fa-lock"></i> Password</label>
<input type="password" id="password" name="password" placeholder="Create a password"
required minlength="6">
</div>
<div class="form-group">
<label for="confirm_password"><i class="fas fa-check-circle"></i> Confirm Password</label>
<input type="password" id="confirm_password" name="confirm_password" placeholder="Confirm your password"
required>
</div>
<div class="form-group">
<label for="captcha_answer"><i class="fas fa-calculator"></i> {{ captcha.question }}</label>
<input type="number" id="captcha_answer" name="captcha_answer" placeholder="Answer" required autocomplete="off">
</div>
<button type="submit" class="btn btn-primary btn-block">Create Account</button>
<p class="auth-link">Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a></p>
</form>
<script>
// Toggle gamertag input visibility when a game checkbox is checked/unchecked
function toggleGamertagInput(checkbox) {
var game = checkbox.value.replace(/ /g, '_');
var group = document.getElementById('gamertag_group_' + game);
if (group) {
group.style.display = checkbox.checked ? 'block' : 'none';
}
}
// Save form draft to sessionStorage before navigating to Discord OAuth
function saveFormDraft() {
var draft = {};
var form = document.querySelector('.auth-form');
if (!form) return;
var inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(function(input) {
if (!input.name) return;
if (input.type === 'checkbox') {
if (!draft[input.name]) draft[input.name] = [];
if (input.checked) draft[input.name].push(input.value);
} else if (input.type === 'password') {
// Never save passwords
} else {
draft[input.name] = input.value;
}
});
sessionStorage.setItem('register_form_draft', JSON.stringify(draft));
}
// Restore form draft from sessionStorage on page load
function restoreFormDraft() {
var saved = sessionStorage.getItem('register_form_draft');
if (!saved) return;
try {
var draft = JSON.parse(saved);
var hasServerData = document.querySelector('.auth-form input[name="full_name"]').value !== '';
if (hasServerData) return;
for (var key in draft) {
if (key === 'games') {
var values = draft[key];
var checkboxes = document.querySelectorAll('input[name="games"]');
checkboxes.forEach(function(cb) {
cb.checked = values.indexOf(cb.value) !== -1;
toggleGamertagInput(cb);
});
} else if (key === 'csrf_token' || key === 'captcha_answer' || key.indexOf('password') !== -1) {
// Skip CSRF token, CAPTCHA, and passwords
} else {
var input = document.querySelector('input[name="' + key + '"], textarea[name="' + key + '"]');
if (input) input.value = draft[key];
}
}
} catch(e) {
// Invalid JSON, ignore
}
}
// Clear draft on successful form submission
document.querySelector('.auth-form').addEventListener('submit', function() {
sessionStorage.removeItem('register_form_draft');
});
// On page load, ensure gamertag groups match checkbox state and restore draft
document.addEventListener('DOMContentLoaded', function() {
restoreFormDraft();
var checkboxes = document.querySelectorAll('input[name="games"]');
checkboxes.forEach(function(checkbox) {
toggleGamertagInput(checkbox);
});
});
</script>
<style>
/* Discord Connection Card */
.discord-connection-card {
display: flex;
align-items: center;
justify-content: space-between;
background: #2f3136;
border: 1px solid #40444b;
border-radius: 8px;
padding: 12px 16px;
margin-top: 4px;
}
.discord-user-display {
display: flex;
align-items: center;
gap: 12px;
}
.discord-avatar {
border-radius: 50%;
border: 2px solid #5865F2;
}
.discord-avatar-placeholder {
width: 48px;
height: 48px;
border-radius: 50%;
background: #5865F2;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: #fff;
}
.discord-user-info {
display: flex;
flex-direction: column;
}
.discord-username {
font-weight: 600;
font-size: 15px;
color: #fff;
}
.discord-connected-label {
font-size: 12px;
font-weight: 500;
}
.discord-connect-section {
background: #2f3136;
border: 1px dashed #5865F2;
border-radius: 8px;
padding: 16px;
text-align: center;
}
.discord-connect-section label {
display: block;
margin-bottom: 8px;
}
.btn-discord {
background-color: #5865F2;
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 6px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background-color 0.2s;
width: 100%;
}
.btn-discord:hover {
background-color: #4752C4;
color: #fff;
}
.btn-outline {
background: transparent;
color: #5865F2;
border: 1px solid #5865F2;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
transition: background-color 0.2s;
white-space: nowrap;
}
.btn-outline:hover {
background-color: #5865F2;
color: #fff;
}
.discord-reconnect {
font-size: 11px;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
.gamertag-group {
margin-bottom: 12px;
}
.gamertag-group label {
font-size: 13px;
font-weight: 500;
margin-bottom: 4px;
}
.text-success {
color: #57F287 !important;
}
</style>
{% endblock %}
+149
View File
@@ -0,0 +1,149 @@
{% extends "layouts/base.html" %}
{% block title %}{% if match %}Edit{% else %}Schedule{% endif %} Team Match - UdeS team manager{% endblock %}
{% block page_title %}{% if match %}Edit{% else %}Schedule{% endif %} Team Match{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('team_matches.list_matches') }}">Team Matches</a> / {% if match %}Edit{% else %}New{% endif %}</span>{% endblock %}
{% block content %}
<div class="card mb-4">
<div class="card-header">
<h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% else %}Schedule New Match{% endif %} — {{ team.name }}</h3>
</div>
<div class="card-body">
<form method="POST" action="{% if match %}{{ url_for('team_matches.edit_match', match_id=match.id) }}{% else %}{{ url_for('team_matches.create_match', team_id=team.id) }}{% endif %}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row">
<div class="form-group col-6">
<label for="title">Match Title</label>
<input type="text" id="title" name="title" class="form-input"
value="{{ match.title if match else 'Team Match — ' + team.name }}"
placeholder="e.g., Regular Season vs Opponent" required>
</div>
{% if not is_practice %}
<div class="form-group col-6">
<label for="opponent">Opponent (optional)</label>
<input type="text" id="opponent" name="opponent" class="form-input"
value="{{ match.opponent if match else '' }}"
placeholder="e.g., University of Toronto">
</div>
{% endif %}
</div>
<div class="form-row">
<div class="form-group col-4">
<label for="date">Date</label>
<input type="date" id="date" name="date" class="form-input"
value="{{ match.date.strftime('%Y-%m-%d') if match else '' }}"
required>
</div>
<div class="form-group col-4">
<label for="start_time">Start Time</label>
<input type="time" id="start_time" name="start_time" class="form-input"
value="{{ match.start_time.strftime('%H:%M') if match and match.start_time else '' }}">
</div>
<div class="form-group col-4">
<label for="end_time">End Time</label>
<input type="time" id="end_time" name="end_time" class="form-input"
value="{{ match.end_time.strftime('%H:%M') if match and match.end_time else '' }}">
<small class="text-muted">Auto-calculated if left empty (start + 30 min)</small>
</div>
</div>
<div class="form-row">
<div class="form-group col-6">
<label for="location">Location</label>
<input type="text" id="location" name="location" class="form-input"
value="{{ match.location if match else '' }}"
placeholder="e.g., Online, Gym A">
</div>
{% if match %}
<div class="form-group col-6">
<label for="status">Status</label>
<select id="status" name="status" class="form-select">
<option value="scheduled" {% if match.status == 'scheduled' %}selected{% endif %}>Scheduled</option>
<option value="completed" {% if match.status == 'completed' %}selected{% endif %}>Completed</option>
<option value="cancelled" {% if match.status == 'cancelled' %}selected{% endif %}>Cancelled</option>
</select>
</div>
{% endif %}
</div>
<div class="form-group">
<label for="description">Description (optional)</label>
<textarea id="description" name="description" class="form-input" rows="3"
placeholder="Additional details about the match...">{{ match.description if match else '' }}</textarea>
</div>
{% if not match %}
<!-- Player roster (pre-filled, read-only display) -->
<div class="card mt-4">
<div class="card-header">
<h4><i class="fas fa-users"></i> Team Roster (auto-included)</h4>
<span class="text-muted" style="font-size: 0.85rem;">All {{ team_players | length }} player(s) will be added automatically</span>
</div>
<div class="card-body">
{% if team_players %}
<div class="roster-list" style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for tp in team_players %}
<span class="staff-tag">
{{ tp.player.username }}
{% if tp.status == 'substitute' %}
<span class="text-muted" style="font-size: 0.7rem;">(sub)</span>
{% endif %}
</span>
{% endfor %}
</div>
{% else %}
<p class="text-muted text-center py-3">
<i class="fas fa-users-slash"></i> No players on this team. Add players in the Teams page first.
</p>
{% endif %}
</div>
</div>
{% else %}
<!-- Edit mode: show participants with presence status -->
<div class="card mt-4">
<div class="card-header">
<h4><i class="fas fa-users"></i> Participants</h4>
</div>
<div class="card-body">
{% if match.participants %}
<table class="table">
<thead>
<tr>
<th>Player</th>
<th>Presence</th>
</tr>
</thead>
<tbody>
{% for p in match.participants.all() %}
<tr>
<td>{{ p.player.username if p.player else 'Unknown' }}</td>
<td>
{% if p.is_confirmed %}
<span class="badge badge-success">✅ Confirmed</span>
{% else %}
<span class="badge badge-warning">⏳ Pending</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted text-center">No participants recorded.</p>
{% endif %}
</div>
</div>
{% endif %}
<div class="form-actions mt-4">
<a href="{{ url_for('team_matches.list_matches') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> {% if match %}Save Changes{% else %}Schedule Match{% endif %}
</button>
</div>
</form>
</div>
</div>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More