Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c03ecc8cfe |
@@ -1,42 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
@@ -79,7 +79,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
|||||||
+1
-7
@@ -18,10 +18,4 @@ htmlcov/
|
|||||||
*.log
|
*.log
|
||||||
|
|
||||||
.certs/
|
.certs/
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
docs/
|
|
||||||
*.html
|
|
||||||
|
|
||||||
instance/
|
|
||||||
*.db
|
|
||||||
@@ -1,61 +1,38 @@
|
|||||||
# Plateforme centralisée de tryouts
|
### Plateforme centralisée de tryouts
|
||||||
|
|
||||||
## Security Configuration
|
## Security Configuration
|
||||||
|
|
||||||
### Required Environment Variables
|
### Required Environment Variables
|
||||||
|
|
||||||
Before deploying, create a `.env` file which integrates everything in the .env.exemple.
|
Before deploying, create a `.env` file with the following:
|
||||||
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
|
### Security Features Implemented
|
||||||
|
|
||||||
- **Rate Limiting**: Login endpoint limited to 10 requests per minute to prevent brute-force attacks
|
- **Rate Limiting**: Login endpoint limited to 10 requests per minute to prevent brute-force attacks
|
||||||
- **Secure Session Cookies**: HTTPSOnly, SameSite=Lax, and Secure flags enabled
|
- **Secure Session Cookies**: HTTPOnly, SameSite=Lax, and Secure flags enabled
|
||||||
- **CSRF Protection**: Enabled by default on all forms
|
- **CSRF Protection**: Enabled by default on all forms
|
||||||
- **HTTPS Enforcement**: Automatic redirect to HTTPS in production
|
- **HTTPS Enforcement**: Automatic redirect to HTTPS in production
|
||||||
- **Security Headers**: X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, HSTS
|
- **Security Headers**: X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, HSTS
|
||||||
- **Open Redirect Prevention**: URL validation on login redirect
|
- **Open Redirect Prevention**: URL validation on login redirect
|
||||||
- **Authorization Checks**: Proper ownership validation on all sensitive operations
|
- **Authorization Checks**: Proper ownership validation on all sensitive operations
|
||||||
- **nginx**: reverse-proxy and load balancer
|
|
||||||
- **Waitress WSGI**: Production ready WSGI
|
|
||||||
|
|
||||||
### When true in .env:
|
## Discord Integration for One on One Requests
|
||||||
- **Forces HTTPS only**
|
|
||||||
- **Forcer secure cookies**
|
|
||||||
|
|
||||||
## App details
|
The application supports sending Discord direct messages to coaches when players request One on One sessions.
|
||||||
|
|
||||||
### 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
|
### Setup Instructions
|
||||||
|
|
||||||
#### 1. Create a Discord Bot (Not needed for UdeS user, the bot already exists)
|
#### 1. Create a Discord Bot
|
||||||
|
|
||||||
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
||||||
2. Create a new application
|
2. Create a new application
|
||||||
@@ -63,21 +40,34 @@ Same thing with **matches** and **practices**, the players can react or answer o
|
|||||||
4. Copy the bot token - this will be your `DISCORD_BOT_TOKEN`
|
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)
|
5. Enable the "Message Content Intent" under Privileged Gateway Intents (required for sending messages)
|
||||||
|
|
||||||
#### 2. Add Bot to your server
|
#### 2. Configure Environment Variables
|
||||||
|
|
||||||
For the bot to send DMs:
|
Add the following to your `.env` file (create one if it doesn't exist):
|
||||||
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:
|
```
|
||||||
|
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:
|
||||||
- Enable Developer Mode in Discord (User Settings → Advanced → Developer Mode)
|
- Enable Developer Mode in Discord (User Settings → Advanced → Developer Mode)
|
||||||
- Right-click on their profile → Copy ID
|
- Right-click on their profile → Copy ID
|
||||||
- Enter this numeric ID in the "Discord User ID" field in their profile settings
|
- Enter this numeric ID in the "Discord User ID" field in their profile settings
|
||||||
|
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
When a player submits a One on One request:
|
When a player submits a One on One request:
|
||||||
1. The system checks if the coach has a Discord User ID configured
|
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
|
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
|
### Message Format
|
||||||
|
|
||||||
@@ -86,5 +76,4 @@ The Discord DM includes:
|
|||||||
- Team name
|
- Team name
|
||||||
- Requested date and time slot
|
- Requested date and time slot
|
||||||
- Discussion points (if provided)
|
- Discussion points (if provided)
|
||||||
- Link to the application for approval/rejection
|
- 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.
+30
-22
@@ -7,7 +7,7 @@ the Flask application instance with comprehensive security hardening.
|
|||||||
import os
|
import os
|
||||||
from flask import Flask, request, redirect, jsonify, render_template, url_for
|
from flask import Flask, request, redirect, jsonify, render_template, url_for
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from app.extensions import db, login_manager, csrf, hash_password, check_password, limiter
|
from extensions import db, login_manager, csrf, hash_password, check_password, limiter
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
import markupsafe
|
import markupsafe
|
||||||
@@ -55,11 +55,7 @@ def create_app():
|
|||||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
|
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
|
||||||
if not app.config['SECRET_KEY']:
|
if not app.config['SECRET_KEY']:
|
||||||
raise RuntimeError('SECRET_KEY environment variable must be set for security')
|
raise RuntimeError('SECRET_KEY environment variable must be set for security')
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
|
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///team_tryouts.db')
|
||||||
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['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
app.config['WTF_CSRF_ENABLED'] = True
|
app.config['WTF_CSRF_ENABLED'] = True
|
||||||
|
|
||||||
@@ -100,17 +96,16 @@ def create_app():
|
|||||||
limiter.init_app(app)
|
limiter.init_app(app)
|
||||||
|
|
||||||
# Configure structured logging
|
# Configure structured logging
|
||||||
from app.logging_config import configure_logging
|
from logging_config import configure_logging
|
||||||
configure_logging(app)
|
configure_logging(app)
|
||||||
|
|
||||||
from app.routes.auth import auth_bp
|
from routes.auth import auth_bp
|
||||||
from app.routes.tryouts import tryouts_bp
|
from routes.tryouts import tryouts_bp
|
||||||
from app.routes.evaluations import evaluations_bp
|
from routes.evaluations import evaluations_bp
|
||||||
from app.routes.users import users_bp
|
from routes.users import users_bp
|
||||||
from app.routes.main import main_bp
|
from routes.main import main_bp
|
||||||
from app.routes.teams import teams_bp
|
from routes.teams import teams_bp
|
||||||
from app.routes.matches import matches_bp
|
from routes.matches import matches_bp
|
||||||
from app.routes.team_matches import team_matches_bp
|
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(tryouts_bp)
|
app.register_blueprint(tryouts_bp)
|
||||||
@@ -119,7 +114,6 @@ def create_app():
|
|||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
app.register_blueprint(teams_bp)
|
app.register_blueprint(teams_bp)
|
||||||
app.register_blueprint(matches_bp)
|
app.register_blueprint(matches_bp)
|
||||||
app.register_blueprint(team_matches_bp)
|
|
||||||
|
|
||||||
# Register custom Jinja filters
|
# Register custom Jinja filters
|
||||||
app.jinja_env.filters['nl2br'] = nl2br
|
app.jinja_env.filters['nl2br'] = nl2br
|
||||||
@@ -151,7 +145,7 @@ def create_app():
|
|||||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; "
|
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; "
|
||||||
"font-src 'self' https://cdnjs.cloudflare.com; "
|
"font-src 'self' https://cdnjs.cloudflare.com; "
|
||||||
"img-src 'self' data: https://cdn.discordapp.com; "
|
"img-src 'self' data:; "
|
||||||
"connect-src 'self'; "
|
"connect-src 'self'; "
|
||||||
"frame-ancestors 'none'; "
|
"frame-ancestors 'none'; "
|
||||||
"base-uri 'self'; "
|
"base-uri 'self'; "
|
||||||
@@ -346,13 +340,27 @@ def create_app():
|
|||||||
# Database Initialization
|
# Database Initialization
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
import app.models as models # noqa: F401 — registers all models with SQLAlchemy
|
import models
|
||||||
db.create_all()
|
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'))
|
||||||
|
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
|
# Start the Discord bot for notifications
|
||||||
try:
|
try:
|
||||||
from app.discord_bot import start_bot
|
from discord_bot import start_bot
|
||||||
start_bot(flask_app=app)
|
start_bot()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.warning('Could not start Discord bot: %s', e)
|
app.logger.warning('Could not start Discord bot: %s', e)
|
||||||
|
|
||||||
@@ -368,4 +376,4 @@ if __name__ == '__main__':
|
|||||||
'Running in DEBUG mode with Flask built-in server. '
|
'Running in DEBUG mode with Flask built-in server. '
|
||||||
'This is NOT suitable for production. Use wsgi.py instead.'
|
'This is NOT suitable for production. Use wsgi.py instead.'
|
||||||
)
|
)
|
||||||
app.run(debug=debug_mode, host='0.0.0.0', port=10000)
|
app.run(debug=debug_mode, host='127.0.0.1', port=5000)
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# 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.
|
Before Width: | Height: | Size: 148 KiB |
@@ -1,95 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""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),
|
|
||||||
)
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"""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}',
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
"""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))
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
"""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']
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"""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')
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"""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')
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
"""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'),
|
|
||||||
)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
"""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']
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"""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()]
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""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])
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Organisation team models."""
|
|
||||||
from app.models.org_team.org_team import OrgTeam
|
|
||||||
from app.models.org_team.team_player import TeamPlayer
|
|
||||||
|
|
||||||
__all__ = ['OrgTeam', 'TeamPlayer']
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"""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]
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""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'),
|
|
||||||
)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
"""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']
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"""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')
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
"""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')
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""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])
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Tryout-specific temporary team models."""
|
|
||||||
from app.models.team.team import Team
|
|
||||||
from app.models.team.team_member import TeamMember
|
|
||||||
|
|
||||||
__all__ = ['Team', 'TeamMember']
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""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')
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""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])
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Tryout models."""
|
|
||||||
from app.models.tryout.tryout import Tryout
|
|
||||||
from app.models.tryout.tryout_registration import TryoutRegistration
|
|
||||||
|
|
||||||
__all__ = ['Tryout', 'TryoutRegistration']
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
"""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']
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
"""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]
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"""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 []
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
"""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',
|
|
||||||
})
|
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
"""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'))
|
|
||||||
-1270
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 148 KiB |
@@ -1 +0,0 @@
|
|||||||
# supporting scripts package
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
{% 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()">×</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()">×</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,278 +0,0 @@
|
|||||||
{% 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,607 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
{% extends "layouts/base.html" %}
|
|
||||||
{% block title %}Team Matches - UdeS team manager{% endblock %}
|
|
||||||
{% block page_title %}Team Matches{% endblock %}
|
|
||||||
{% block breadcrumb %}<span class="breadcrumb">Home / Team Matches</span>{% endblock %}
|
|
||||||
|
|
||||||
{% block header_actions %}
|
|
||||||
{% if teams %}
|
|
||||||
<div class="header-actions">
|
|
||||||
<select id="teamSelect" class="form-select" style="width:200px;" onchange="window.location.href='/team-matches/' + this.value + '/create'">
|
|
||||||
<option value="">+ Schedule Match</option>
|
|
||||||
{% for t in teams %}
|
|
||||||
<option value="{{ t.id }}">{{ t.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% if match_data %}
|
|
||||||
<div class="table-container">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Match</th>
|
|
||||||
<th>Team</th>
|
|
||||||
<th>Opponent</th>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Time</th>
|
|
||||||
<th>Location</th>
|
|
||||||
<th>Presence</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for item in match_data %}
|
|
||||||
{% set m = item.match %}
|
|
||||||
<tr>
|
|
||||||
<td class="cell-title">{{ m.title }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge badge-info">{{ m.org_team.name }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if m.opponent %}
|
|
||||||
{{ m.opponent }}
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-info">Practice</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ m.date.strftime('%m/%d/%Y') }}</td>
|
|
||||||
<td>
|
|
||||||
{% if m.start_time and m.end_time %}
|
|
||||||
{{ m.start_time.strftime('%H:%M') }} - {{ m.end_time.strftime('%H:%M') }}
|
|
||||||
{% else %}
|
|
||||||
TBD
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ m.location or '—' }}</td>
|
|
||||||
<td>
|
|
||||||
{% if item.total_count > 0 %}
|
|
||||||
<div class="presence-bar" title="{{ item.confirmed_count }} of {{ item.total_count }} confirmed">
|
|
||||||
<div class="presence-progress">
|
|
||||||
{% set pct = (item.confirmed_count / item.total_count * 100) | int %}
|
|
||||||
<div class="presence-fill" style="width: {{ pct }}%;"></div>
|
|
||||||
</div>
|
|
||||||
<span class="presence-text">
|
|
||||||
{% if item.confirmed_count == item.total_count and item.total_count > 0 %}
|
|
||||||
✅ {{ item.confirmed_count }}/{{ item.total_count }}
|
|
||||||
{% elif item.confirmed_count > 0 %}
|
|
||||||
⏳ {{ item.confirmed_count }}/{{ item.total_count }}
|
|
||||||
{% else %}
|
|
||||||
❌ 0/{{ item.total_count }}
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- Player presence details -->
|
|
||||||
<div class="presence-players">
|
|
||||||
{% for p in item.participants %}
|
|
||||||
<span class="presence-player-tag {% if p.is_confirmed %}confirmed{% else %}pending{% endif %}"
|
|
||||||
title="{{ p.player.username }}{% if p.is_confirmed %} - Confirmed{% else %} - Pending{% endif %}">
|
|
||||||
{{ p.player.username[:2] | upper }} {{ p.player.username }}
|
|
||||||
{% if p.is_confirmed %}✅{% else %}⏳{% endif %}
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge badge-{{ m.status }}">{{ m.status }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="eval-actions">
|
|
||||||
{% set can_manage_this = (current_user.role in ['admin', 'manager']) or (current_user.role == 'coach' and m.org_team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and m.org_team.coach_id == current_user.id) %}
|
|
||||||
{% if can_manage_this %}
|
|
||||||
<a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="Edit Match">
|
|
||||||
<i class="fas fa-edit"></i>
|
|
||||||
</a>
|
|
||||||
<form method="POST" action="{{ url_for('team_matches.delete_match', match_id=m.id) }}" class="inline-form" onsubmit="return confirm('Delete this match?')">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
|
||||||
<button type="submit" class="btn btn-sm btn-danger" title="Delete Match">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
<!-- Toggle presence for each participant if user is player -->
|
|
||||||
{% if current_user.role == 'player' %}
|
|
||||||
{% for p in item.participants %}
|
|
||||||
{% if p.player.id == current_user.id %}
|
|
||||||
<button class="btn btn-sm {% if p.is_confirmed %}btn-success{% else %}btn-outline{% endif %} presence-toggle-btn"
|
|
||||||
data-match-id="{{ m.id }}"
|
|
||||||
data-participant-id="{{ p.id }}"
|
|
||||||
onclick="togglePresence(this)">
|
|
||||||
{% if p.is_confirmed %}✅ Confirmed{% else %}Confirm{% endif %}
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body text-center py-5">
|
|
||||||
<div class="empty-state">
|
|
||||||
<i class="fas fa-futbol fa-3x text-muted mb-3"></i>
|
|
||||||
<h3>No Team Matches</h3>
|
|
||||||
<p class="text-muted">Regular season matches have not been scheduled yet.</p>
|
|
||||||
{% if teams %}
|
|
||||||
<div class="mt-3">
|
|
||||||
<select id="teamSelectEmpty" class="form-select" style="width:220px; display:inline;" onchange="window.location.href='/team-matches/' + this.value + '/create'">
|
|
||||||
<option value="">-- Schedule a Match --</option>
|
|
||||||
{% for t in teams %}
|
|
||||||
<option value="{{ t.id }}">{{ t.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</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';
|
|
||||||
}
|
|
||||||
// Reload to update presence bar
|
|
||||||
location.reload();
|
|
||||||
})
|
|
||||||
.catch(function(error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.presence-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.presence-progress {
|
|
||||||
width: 60px;
|
|
||||||
height: 6px;
|
|
||||||
background: #e5e7eb;
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.presence-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: #10b981;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.presence-text {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.presence-players {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 3px;
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
.presence-player-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.presence-player-tag.confirmed {
|
|
||||||
background: #d1fae5;
|
|
||||||
color: #065f46;
|
|
||||||
}
|
|
||||||
.presence-player-tag.pending {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
.presence-toggle-btn {
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
{% extends "layouts/base.html" %}
|
|
||||||
{% block title %}{{ profile_user.username }} - UdeS team manager{% endblock %}
|
|
||||||
{% block page_title %}{{ profile_user.username }}{% endblock %}
|
|
||||||
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="#" onclick="history.back()">Back</a> / {{ profile_user.username }}</span>{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<div class="user-mini" style="gap: 12px;">
|
|
||||||
<div class="avatar-sm" style="width: 48px; height: 48px; font-size: 1.2rem;">
|
|
||||||
{{ profile_user.username[:2] | upper }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 style="margin: 0;">{{ profile_user.username }}</h3>
|
|
||||||
<span class="badge badge-{{ 'info' if profile_user.role == 'player' else 'primary' }}">{{ profile_user.role | capitalize }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="detail-grid">
|
|
||||||
<div class="detail-item">
|
|
||||||
<span class="detail-label">Username</span>
|
|
||||||
<span class="detail-value">{{ profile_user.username }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<span class="detail-label">Role</span>
|
|
||||||
<span class="detail-value">
|
|
||||||
<span class="badge badge-{{ 'info' if profile_user.role == 'player' else 'primary' }}">{{ profile_user.role | capitalize }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% if profile_user.discord_username %}
|
|
||||||
<div class="detail-item">
|
|
||||||
<span class="detail-label">Discord</span>
|
|
||||||
<span class="detail-value">{{ profile_user.discord_username }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if profile_user.league_os_profile %}
|
|
||||||
<div class="detail-item">
|
|
||||||
<span class="detail-label"><i class="fas fa-link"></i> League OS</span>
|
|
||||||
<span class="detail-value">
|
|
||||||
<a href="{{ profile_user.league_os_profile }}" target="_blank" rel="noopener noreferrer" class="trn-link">
|
|
||||||
<i class="fas fa-external-link-alt"></i> {{ profile_user.league_os_profile }}
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% set gamertags = profile_user.gamertags %}
|
|
||||||
{% if gamertags %}
|
|
||||||
<hr class="my-4">
|
|
||||||
<h4 class="mb-3"><i class="fas fa-gamepad"></i> Gamertags</h4>
|
|
||||||
<div class="table-container">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Game</th>
|
|
||||||
<th>Gamertag</th>
|
|
||||||
<th>Platform</th>
|
|
||||||
<th>Profile</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for gt in gamertags %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ gt.game }}</td>
|
|
||||||
<td>{{ gt.gamertag }}</td>
|
|
||||||
<td>{{ gt.platform or '-' }}</td>
|
|
||||||
<td>
|
|
||||||
{% if gt.get_trn_url() %}
|
|
||||||
<a href="{{ gt.get_trn_url() }}" target="_blank" class="btn btn-sm btn-outline trn-link">
|
|
||||||
<i class="fas fa-external-link-alt"></i> View Profile
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
-56
@@ -1,56 +0,0 @@
|
|||||||
"""Database seeding script for Team Tryouts application.
|
|
||||||
|
|
||||||
Wipes all data and creates an admin user. Run manually with:
|
|
||||||
python -m app.supporting_scripts.seed
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app.extensions import db, hash_password
|
|
||||||
from app.models import Admin
|
|
||||||
|
|
||||||
|
|
||||||
def seed_database():
|
|
||||||
"""Delete all existing data and create a single admin account."""
|
|
||||||
|
|
||||||
# Delete all data in FK-safe order
|
|
||||||
print("Deleting existing data...")
|
|
||||||
tables = [
|
|
||||||
'one_on_one_requests',
|
|
||||||
'player_disponibilities', 'coach_availabilities',
|
|
||||||
'match_participants', 'team_match_participants',
|
|
||||||
'matches', 'team_matches',
|
|
||||||
'team_players', 'team_members', 'teams',
|
|
||||||
'evaluations', 'tryout_registrations', 'tryouts',
|
|
||||||
'org_team_coaches', 'org_team_managers',
|
|
||||||
'team_notes', 'personal_notes',
|
|
||||||
'user_gamertags',
|
|
||||||
'org_teams',
|
|
||||||
'users',
|
|
||||||
]
|
|
||||||
for table in tables:
|
|
||||||
db.session.execute(db.text(f'DELETE FROM {table}'))
|
|
||||||
db.session.commit()
|
|
||||||
print("[OK] All data deleted.")
|
|
||||||
|
|
||||||
# Create the admin user
|
|
||||||
admin = Admin(
|
|
||||||
username='admin',
|
|
||||||
password_hash=hash_password('password'),
|
|
||||||
role='admin',
|
|
||||||
full_name='Admin',
|
|
||||||
email='[email protected]',
|
|
||||||
)
|
|
||||||
db.session.add(admin)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
print("[OK] Created admin user")
|
|
||||||
print("\n=== Admin Credentials ===")
|
|
||||||
print("Username: admin")
|
|
||||||
print("Password: password")
|
|
||||||
print("\n[SUCCESS] Database reset and seeded!")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
from app.app import create_app
|
|
||||||
app = create_app()
|
|
||||||
with app.app_context():
|
|
||||||
seed_database()
|
|
||||||
@@ -7,11 +7,9 @@ This module provides a persistent bot that handles:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
from queue import Queue, Empty
|
from queue import Queue, Empty
|
||||||
@@ -23,13 +21,11 @@ from dotenv import load_dotenv
|
|||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
|
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
|
||||||
|
print(DISCORD_BOT_TOKEN or 'FAILED TO PRINT BOT TOKEN')
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# Emoji constants
|
||||||
CHECK_EMOJI = '✅' # Green checkmark
|
CHECK_EMOJI = '✅' # Green checkmark
|
||||||
CROSS_EMOJI = '❌' # Red X
|
CROSS_EMOJI = '❌' # Red X
|
||||||
@@ -41,56 +37,27 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
Handles One on One requests, schedule additions, and daily reminders.
|
Handles One on One requests, schedule additions, and daily reminders.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, flask_app=None):
|
def __init__(self):
|
||||||
intents = Intents.default()
|
intents = Intents.default()
|
||||||
intents.message_content = True
|
intents.message_content = True
|
||||||
intents.dm_messages = True
|
intents.dm_messages = True
|
||||||
intents.dm_reactions = True
|
intents.dm_reactions = True
|
||||||
intents.reactions = True
|
intents.reactions = True
|
||||||
intents.guilds = True
|
intents.guilds = True
|
||||||
intents.members = True
|
|
||||||
|
|
||||||
super().__init__(command_prefix='!', intents=intents)
|
super().__init__(command_prefix='!', intents=intents)
|
||||||
self.flask_app = flask_app
|
|
||||||
self.pending_requests = {} # Maps message_id to {type, id} for reaction handling
|
self.pending_requests = {} # Maps message_id to {type, id} for reaction handling
|
||||||
self.message_queue = Queue() # Thread-safe queue for messages from Flask
|
self.message_queue = Queue() # Thread-safe queue for messages from Flask
|
||||||
self.scheduler = AsyncIOScheduler()
|
self.scheduler = AsyncIOScheduler()
|
||||||
self.timezone = ZoneInfo('America/Toronto') # EDT timezone
|
self.timezone = ZoneInfo('America/Toronto') # EDT timezone
|
||||||
|
|
||||||
def _load_pending(self):
|
|
||||||
"""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):
|
async def setup_hook(self):
|
||||||
"""Called when the bot is ready."""
|
"""Called when the bot is ready."""
|
||||||
self._load_pending()
|
|
||||||
logger.info(f'TeamTryoutsBot logged in as {self.user}')
|
logger.info(f'TeamTryoutsBot logged in as {self.user}')
|
||||||
|
|
||||||
async def on_ready(self):
|
async def on_ready(self):
|
||||||
"""Log when the bot is ready and start background tasks."""
|
"""Log when the bot is ready and start background tasks."""
|
||||||
try:
|
logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user}')
|
||||||
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
|
# Start the queue processing task
|
||||||
self.loop.create_task(self.process_queue())
|
self.loop.create_task(self.process_queue())
|
||||||
@@ -125,82 +92,45 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
if item.get('type') == 'one_on_one_request':
|
if item.get('type') == 'one_on_one_request':
|
||||||
await self._send_one_on_one_dm(**item['data'])
|
await self._send_one_on_one_dm(**item['data'])
|
||||||
elif item.get('type') == 'schedule_addition':
|
elif item.get('type') == 'schedule_addition':
|
||||||
if self.flask_app:
|
await self._send_schedule_notification(**item['data'])
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"Error processing queue: {e}\n{traceback.format_exc()}")
|
logger.error(f"Error processing queue: {e}")
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
def is_dm_channel(self, channel) -> bool:
|
def is_dm_channel(self, channel) -> bool:
|
||||||
"""Check if a channel is a DM channel."""
|
"""Check if a channel is a DM channel."""
|
||||||
return hasattr(channel, 'recipient') or hasattr(channel, 'recipients')
|
return hasattr(channel, 'recipient') or hasattr(channel, 'recipients')
|
||||||
|
|
||||||
async def on_raw_reaction_add(self, payload):
|
async def on_reaction_add(self, reaction, user):
|
||||||
"""Handle when a reaction is added to a message (works even after bot restart)."""
|
"""Handle when a reaction is added to a message."""
|
||||||
# Ignore bot's own reactions
|
if user.bot:
|
||||||
if payload.user_id == self.user.id:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if this is a pending request we're tracking
|
if not self.is_dm_channel(reaction.message.channel):
|
||||||
if payload.message_id not in self.pending_requests:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Fetch the channel and check if it's a DM
|
message_id = reaction.message.id
|
||||||
try:
|
|
||||||
channel = await self.fetch_channel(payload.channel_id)
|
if message_id not in self.pending_requests:
|
||||||
except Exception:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.is_dm_channel(channel):
|
request_info = self.pending_requests[message_id]
|
||||||
return
|
emoji_str = str(reaction.emoji)
|
||||||
|
|
||||||
# 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')
|
handler_type = request_info.get('type')
|
||||||
request_id = request_info.get('id')
|
request_id = request_info.get('id')
|
||||||
|
|
||||||
if emoji_str == CHECK_EMOJI:
|
if emoji_str == CHECK_EMOJI:
|
||||||
if handler_type == 'one_on_one':
|
if handler_type == 'one_on_one':
|
||||||
if self.flask_app:
|
await self.handle_one_on_one_approve(user, message_id, request_id, reaction.message)
|
||||||
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':
|
elif handler_type == 'schedule_addition':
|
||||||
if self.flask_app:
|
await self.handle_attendance_confirm(user, message_id, request_id, reaction.message)
|
||||||
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:
|
elif emoji_str == CROSS_EMOJI:
|
||||||
if handler_type == 'one_on_one':
|
if handler_type == 'one_on_one':
|
||||||
if self.flask_app:
|
await self.handle_one_on_one_reject(user, message_id, request_id, reaction.message)
|
||||||
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':
|
elif handler_type == 'schedule_addition':
|
||||||
if self.flask_app:
|
await self.handle_attendance_decline(user, message_id, request_id, reaction.message)
|
||||||
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,
|
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,
|
team_name: str, date_str: str, start_time: str, end_time: str,
|
||||||
@@ -235,7 +165,6 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
|
|
||||||
# Track this pending request
|
# Track this pending request
|
||||||
self.pending_requests[msg.id] = {'type': 'one_on_one', 'id': request_id}
|
self.pending_requests[msg.id] = {'type': 'one_on_one', 'id': request_id}
|
||||||
self._save_pending()
|
|
||||||
|
|
||||||
logger.info(f"Sent One on One DM with reactions, message_id={msg.id}")
|
logger.info(f"Sent One on One DM with reactions, message_id={msg.id}")
|
||||||
return msg.id
|
return msg.id
|
||||||
@@ -247,32 +176,10 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
async def _send_schedule_notification(self, user_id: int, event_type: str,
|
async def _send_schedule_notification(self, user_id: int, event_type: str,
|
||||||
event_title: str, event_date: str,
|
event_title: str, event_date: str,
|
||||||
event_time: str, reference_id: int) -> int:
|
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:
|
try:
|
||||||
# Look up the DB user to get their Discord user ID
|
user = await self.fetch_user(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:
|
if not user:
|
||||||
logger.warning(f"Could not fetch Discord user {discord_uid}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
event_name = "Match" if event_type == 'match' else "Tryout"
|
event_name = "Match" if event_type == 'match' else "Tryout"
|
||||||
@@ -294,84 +201,65 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
|
|
||||||
# Track this pending request
|
# Track this pending request
|
||||||
self.pending_requests[msg.id] = {'type': 'schedule_addition', 'id': reference_id, 'event_type': event_type}
|
self.pending_requests[msg.id] = {'type': 'schedule_addition', 'id': reference_id, 'event_type': event_type}
|
||||||
self._save_pending()
|
|
||||||
|
|
||||||
logger.info(f"Sent {event_type} schedule notification to {db_user.username}, message_id={msg.id}")
|
logger.info(f"Sent {event_type} schedule notification, message_id={msg.id}")
|
||||||
return msg.id
|
return msg.id
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending schedule notification: {e}")
|
logger.error(f"Error sending schedule notification: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def handle_one_on_one_approve(self, coach, message_id, request_id, channel):
|
async def handle_one_on_one_approve(self, coach, message_id, request_id, original_message):
|
||||||
"""Handle coach approving a One on One request."""
|
"""Handle coach approving a One on One request."""
|
||||||
try:
|
try:
|
||||||
from app.models import OneOnOneRequest
|
from models import OneOnOneRequest, db
|
||||||
from app.extensions import db
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
request = OneOnOneRequest.query.get(request_id)
|
request = OneOnOneRequest.query.options(
|
||||||
|
joinedload(OneOnOneRequest.player),
|
||||||
|
joinedload(OneOnOneRequest.coach)
|
||||||
|
).get(request_id)
|
||||||
if not request:
|
if not request:
|
||||||
return
|
return
|
||||||
|
|
||||||
if request.coach.discord_user_id != str(coach.id):
|
if request.coach.discord_user_id != str(coach.id):
|
||||||
await channel.send("⚠️ You are not the intended recipient.")
|
await original_message.channel.send("⚠️ You are not the intended recipient.")
|
||||||
return
|
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.status = 'approved'
|
||||||
request.responded_at = datetime.utcnow()
|
request.responded_at = datetime.utcnow()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
await channel.send(
|
await original_message.channel.send(
|
||||||
f"✅ You have **approved** the One on One session with {player_full_name}."
|
f"✅ You have **approved** the One on One session with {request.player.full_name}."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notify player via Discord
|
await self.notify_player_about_one_on_one(request, approved=True)
|
||||||
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]
|
del self.pending_requests[message_id]
|
||||||
self._save_pending()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling approval: {e}\n{traceback.format_exc()}")
|
logger.error(f"Error handling approval: {e}")
|
||||||
|
|
||||||
async def handle_one_on_one_reject(self, coach, message_id, request_id, channel):
|
async def handle_one_on_one_reject(self, coach, message_id, request_id, original_message):
|
||||||
"""Handle coach rejecting a One on One request."""
|
"""Handle coach rejecting a One on One request."""
|
||||||
try:
|
try:
|
||||||
from app.models import OneOnOneRequest
|
from models import OneOnOneRequest, db
|
||||||
from app.extensions import db
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
request = OneOnOneRequest.query.get(request_id)
|
request = OneOnOneRequest.query.options(
|
||||||
|
joinedload(OneOnOneRequest.player),
|
||||||
|
joinedload(OneOnOneRequest.coach)
|
||||||
|
).get(request_id)
|
||||||
if not request:
|
if not request:
|
||||||
return
|
return
|
||||||
|
|
||||||
if request.coach.discord_user_id != str(coach.id):
|
if request.coach.discord_user_id != str(coach.id):
|
||||||
await channel.send("⚠️ You are not the intended recipient.")
|
await original_message.channel.send("⚠️ You are not the intended recipient.")
|
||||||
return
|
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
|
refusal_note = None
|
||||||
try:
|
try:
|
||||||
async for reply in channel.history(limit=20):
|
async for reply in original_message.channel.history(limit=20):
|
||||||
if reply.author.id == coach.id and reply.reference and reply.reference.message_id == message_id:
|
if reply.author.id == coach.id and reply.reference and reply.reference.message_id == message_id:
|
||||||
refusal_note = reply.content
|
refusal_note = reply.content
|
||||||
break
|
break
|
||||||
@@ -384,33 +272,23 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
request.coach_rejection_message = refusal_note
|
request.coach_rejection_message = refusal_note
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
rejection_msg = f"❌ You have **rejected** the One on One session with {player_full_name}."
|
rejection_msg = f"❌ You have **rejected** the One on One session with {request.player.full_name}."
|
||||||
if refusal_note:
|
if refusal_note:
|
||||||
rejection_msg += f"\n**Reason:** {refusal_note}"
|
rejection_msg += f"\n**Reason:** {refusal_note}"
|
||||||
else:
|
else:
|
||||||
rejection_msg += "\n\nℹ️ The player has been notified that you are not available."
|
rejection_msg += "\n\nℹ️ The player has been notified that you are not available."
|
||||||
|
|
||||||
await channel.send(rejection_msg)
|
await original_message.channel.send(rejection_msg)
|
||||||
if player_discord_id:
|
await self.notify_player_about_one_on_one(request, approved=False, refusal_note=refusal_note)
|
||||||
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]
|
del self.pending_requests[message_id]
|
||||||
self._save_pending()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling rejection: {e}\n{traceback.format_exc()}")
|
logger.error(f"Error handling rejection: {e}")
|
||||||
|
|
||||||
async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
|
async def handle_attendance_confirm(self, player, message_id, reference_id, original_message):
|
||||||
"""Handle player confirming attendance for a match/tryout."""
|
"""Handle player confirming attendance for a match/tryout."""
|
||||||
try:
|
try:
|
||||||
from app.models import MatchParticipant, TryoutRegistration, Match, Tryout
|
from models import MatchParticipant, TryoutRegistration, Match, Tryout, db
|
||||||
from app.extensions import db
|
|
||||||
|
|
||||||
request_info = self.pending_requests[message_id]
|
request_info = self.pending_requests[message_id]
|
||||||
event_type = request_info.get('event_type')
|
event_type = request_info.get('event_type')
|
||||||
@@ -418,28 +296,24 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
if event_type == 'match':
|
if event_type == 'match':
|
||||||
participant = MatchParticipant.query.get(reference_id)
|
participant = MatchParticipant.query.get(reference_id)
|
||||||
if participant:
|
if participant:
|
||||||
participant = db.session.merge(participant)
|
|
||||||
participant.attendance_confirmed = True
|
participant.attendance_confirmed = True
|
||||||
elif event_type == 'tryout':
|
elif event_type == 'tryout':
|
||||||
registration = TryoutRegistration.query.get(reference_id)
|
registration = TryoutRegistration.query.get(reference_id)
|
||||||
if registration:
|
if registration:
|
||||||
registration = db.session.merge(registration)
|
|
||||||
registration.attendance_confirmed = True
|
registration.attendance_confirmed = True
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
await channel.send("✅ Your attendance has been confirmed!")
|
await original_message.channel.send("✅ Your attendance has been confirmed!")
|
||||||
del self.pending_requests[message_id]
|
del self.pending_requests[message_id]
|
||||||
self._save_pending()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling attendance confirmation: {e}\n{traceback.format_exc()}")
|
logger.error(f"Error handling attendance confirmation: {e}")
|
||||||
|
|
||||||
async def handle_attendance_decline(self, player, message_id, reference_id, channel):
|
async def handle_attendance_decline(self, player, message_id, reference_id, original_message):
|
||||||
"""Handle player declining attendance for a match/tryout."""
|
"""Handle player declining attendance for a match/tryout."""
|
||||||
try:
|
try:
|
||||||
from app.models import MatchParticipant, TryoutRegistration, Match, Tryout
|
from models import MatchParticipant, TryoutRegistration, Match, Tryout, db
|
||||||
from app.extensions import db
|
|
||||||
|
|
||||||
request_info = self.pending_requests[message_id]
|
request_info = self.pending_requests[message_id]
|
||||||
event_type = request_info.get('event_type')
|
event_type = request_info.get('event_type')
|
||||||
@@ -447,82 +321,47 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
if event_type == 'match':
|
if event_type == 'match':
|
||||||
participant = MatchParticipant.query.get(reference_id)
|
participant = MatchParticipant.query.get(reference_id)
|
||||||
if participant:
|
if participant:
|
||||||
participant = db.session.merge(participant)
|
|
||||||
db.session.delete(participant)
|
db.session.delete(participant)
|
||||||
elif event_type == 'tryout':
|
elif event_type == 'tryout':
|
||||||
registration = TryoutRegistration.query.get(reference_id)
|
registration = TryoutRegistration.query.get(reference_id)
|
||||||
if registration:
|
if registration:
|
||||||
registration = db.session.merge(registration)
|
|
||||||
registration.status = 'no_show'
|
registration.status = 'no_show'
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
await channel.send("❌ Your attendance has been declined.")
|
await original_message.channel.send("❌ Your attendance has been declined.")
|
||||||
del self.pending_requests[message_id]
|
del self.pending_requests[message_id]
|
||||||
self._save_pending()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling attendance decline: {e}\n{traceback.format_exc()}")
|
logger.error(f"Error handling attendance decline: {e}")
|
||||||
|
|
||||||
async def notify_player_about_one_on_one(self, request, approved=True, refusal_note=None):
|
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:
|
try:
|
||||||
|
# Ensure player and coach relationships are loaded
|
||||||
player = request.player
|
player = request.player
|
||||||
coach = request.coach
|
coach = request.coach
|
||||||
|
|
||||||
if not player or not player.discord_user_id:
|
if not player:
|
||||||
logger.warning(f"Player has no Discord user ID for request {request.id}")
|
logger.warning(f"Player not found for request {request.id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not coach:
|
if not coach:
|
||||||
logger.warning(f"Coach not found for request {request.id}")
|
logger.warning(f"Coach not found for request {request.id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
await self.notify_player_about_one_on_one_direct(
|
if not player.discord_user_id:
|
||||||
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}")
|
logger.warning(f"Player has no Discord user ID for request {request.id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
player_user = await self.fetch_user(int(player_discord_id))
|
player_user = await self.fetch_user(int(player.discord_user_id))
|
||||||
if not player_user:
|
if not player_user:
|
||||||
logger.warning(f"Could not fetch Discord user {player_discord_id}")
|
logger.warning(f"Could not fetch Discord user for player {player.id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if approved:
|
if approved:
|
||||||
message = (
|
message = (
|
||||||
"🎉 **One on One Session Confirmed!**\n\n"
|
"🎉 **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"**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"**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"
|
f"**Discussion Points:** {request.points or 'No specific points provided'}\n\n"
|
||||||
@@ -532,39 +371,27 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
if refusal_note:
|
if refusal_note:
|
||||||
message = (
|
message = (
|
||||||
"😞 **One on One Session Rejected**\n\n"
|
"😞 **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"
|
f"**Reason:** {refusal_note}\n\n"
|
||||||
"Please try selecting a different time slot."
|
"Please try selecting a different time slot."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
message = (
|
message = (
|
||||||
"😞 **One on One Session Unavailable**\n\n"
|
"😞 **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."
|
"Please try selecting a different time slot."
|
||||||
)
|
)
|
||||||
|
|
||||||
await player_user.send(message)
|
await player_user.send(message)
|
||||||
logger.info(f"Sent One on One notification to player {player_full_name} (request {request.id})")
|
logger.info(f"Sent One on One notification to player {player.full_name} (request {request.id})")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in direct One on One notification: {e}")
|
logger.error(f"Error notifying player about One on One: {e}")
|
||||||
|
|
||||||
async def send_daily_reminders(self):
|
async def send_daily_reminders(self):
|
||||||
"""Send daily reminders at 18:00 EDT for events in 24-48 hours."""
|
"""Send daily reminders at 18:00 EDT for events in 24-48 hours."""
|
||||||
try:
|
try:
|
||||||
if self.flask_app:
|
from models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest, db
|
||||||
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
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
now = datetime.now(self.timezone)
|
now = datetime.now(self.timezone)
|
||||||
@@ -633,56 +460,6 @@ class TeamTryoutsBot(commands.Bot):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending tryout reminder: {e}")
|
logger.error(f"Error sending tryout reminder: {e}")
|
||||||
|
|
||||||
async def _send_one_on_one_response_dm(self, player_discord_id: str, player_full_name: str,
|
|
||||||
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):
|
async def send_one_on_one_reminder(self, player, session):
|
||||||
"""Send One on One reminder to player."""
|
"""Send One on One reminder to player."""
|
||||||
try:
|
try:
|
||||||
@@ -705,13 +482,11 @@ bot_instance = None
|
|||||||
bot_thread = None
|
bot_thread = None
|
||||||
|
|
||||||
|
|
||||||
def get_bot(flask_app=None):
|
def get_bot():
|
||||||
"""Get or create the bot instance."""
|
"""Get or create the bot instance."""
|
||||||
global bot_instance
|
global bot_instance
|
||||||
if bot_instance is None:
|
if bot_instance is None:
|
||||||
bot_instance = TeamTryoutsBot(flask_app=flask_app)
|
bot_instance = TeamTryoutsBot()
|
||||||
elif flask_app is not None and bot_instance.flask_app is None:
|
|
||||||
bot_instance.flask_app = flask_app
|
|
||||||
return bot_instance
|
return bot_instance
|
||||||
|
|
||||||
|
|
||||||
@@ -763,41 +538,11 @@ def send_schedule_notification(user_id: int, event_type: str, event_title: str,
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def send_one_on_one_response(player_discord_id: str, player_full_name: str,
|
def start_bot():
|
||||||
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."""
|
"""Start the Discord bot in the background."""
|
||||||
global bot_thread
|
global bot_thread
|
||||||
|
|
||||||
bot = get_bot(flask_app=flask_app)
|
bot = get_bot()
|
||||||
if DISCORD_BOT_TOKEN and bot_thread is None:
|
if DISCORD_BOT_TOKEN and bot_thread is None:
|
||||||
def run_bot():
|
def run_bot():
|
||||||
try:
|
try:
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,799 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Team Tryouts — Architecture v3 (Refactored)</title>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #1a1a2e;
|
|
||||||
--surface: #16213e;
|
|
||||||
--primary: #0f3460;
|
|
||||||
--accent: #e94560;
|
|
||||||
--text: #eee;
|
|
||||||
--text-muted: #aaa;
|
|
||||||
--border: #2a2a4a;
|
|
||||||
--card-bg: #1e2a3a;
|
|
||||||
}
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
header {
|
|
||||||
background: var(--surface);
|
|
||||||
border-bottom: 2px solid var(--accent);
|
|
||||||
padding: 1.5rem 2rem;
|
|
||||||
text-align: center;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: 0 2px 20px rgba(0,0,0,0.5);
|
|
||||||
}
|
|
||||||
header h1 {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
color: var(--accent);
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
header p {
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
nav {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
nav a {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 0.5rem 1.5rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: background 0.2s, transform 0.2s;
|
|
||||||
}
|
|
||||||
nav a:hover {
|
|
||||||
background: var(--accent);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
max-width: 1500px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
section {
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
section h2 {
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
section h2 .icon { font-size: 1.4rem; }
|
|
||||||
.mermaid-wrapper {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
.legend {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
.legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.legend-dot {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 3px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.legend-dot.model { background: #4fc3f7; }
|
|
||||||
.legend-dot.subpackage { background: #aed581; }
|
|
||||||
.legend-dot.blueprint { background: #ffb74d; }
|
|
||||||
.legend-dot.extension { background: #81c784; }
|
|
||||||
.legend-dot.utility { background: #ba68c8; }
|
|
||||||
.legend-dot.external { background: #e57373; }
|
|
||||||
.legend-dot.middleware { background: #4dd0e1; }
|
|
||||||
|
|
||||||
footer {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
.stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
.stat-card {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.stat-card .stat-num {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
.stat-card .stat-label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
main { padding: 1rem; }
|
|
||||||
header h1 { font-size: 1.3rem; }
|
|
||||||
.mermaid-wrapper { padding: 1rem; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<h1>🏆 Team Tryouts — Architecture v3 (Refactored)</h1>
|
|
||||||
<p>UML Class Diagram & Functional Graph — 7 Subpackages + 6 Standalone Models</p>
|
|
||||||
<nav>
|
|
||||||
<a href="#stats">Overview</a>
|
|
||||||
<a href="#uml">UML Class Diagram</a>
|
|
||||||
<a href="#packages">Package Structure</a>
|
|
||||||
<a href="#functional">Functional Graph</a>
|
|
||||||
<a href="#legend">Legend</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
|
|
||||||
<!-- ============ STATS ============ -->
|
|
||||||
<section id="stats">
|
|
||||||
<h2><span class="icon">📊</span> Project Statistics</h2>
|
|
||||||
<div class="stats">
|
|
||||||
<div class="stat-card"><div class="stat-num">31</div><div class="stat-label">Python files in models/</div></div>
|
|
||||||
<div class="stat-card"><div class="stat-num">7</div><div class="stat-label">Subpackages</div></div>
|
|
||||||
<div class="stat-card"><div class="stat-num">24</div><div class="stat-label">Classes (incl. abstract)</div></div>
|
|
||||||
<div class="stat-card"><div class="stat-num">8</div><div class="stat-label">Route Blueprints</div></div>
|
|
||||||
<div class="stat-card"><div class="stat-num">4</div><div class="stat-label">Flask Extensions</div></div>
|
|
||||||
<div class="stat-card"><div class="stat-num">8</div><div class="stat-label">Utility Modules</div></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ============ UML CLASS DIAGRAM ============ -->
|
|
||||||
<section id="uml">
|
|
||||||
<h2><span class="icon">📐</span> UML Class Diagram — All 24 Classes, Relationships & Inheritance</h2>
|
|
||||||
<div class="mermaid-wrapper">
|
|
||||||
|
|
||||||
<div class="mermaid">
|
|
||||||
classDiagram
|
|
||||||
class BaseAvailability {
|
|
||||||
+int day_of_week
|
|
||||||
+time start_time
|
|
||||||
+time end_time
|
|
||||||
+datetime created_at
|
|
||||||
+datetime updated_at
|
|
||||||
}
|
|
||||||
class PlayerDisponibility {
|
|
||||||
+int id
|
|
||||||
+int player_id
|
|
||||||
}
|
|
||||||
class CoachAvailability {
|
|
||||||
+int id
|
|
||||||
+int coach_id
|
|
||||||
}
|
|
||||||
BaseAvailability <|-- PlayerDisponibility : extends
|
|
||||||
BaseAvailability <|-- CoachAvailability : extends
|
|
||||||
|
|
||||||
class BaseMatch {
|
|
||||||
+str title
|
|
||||||
+str description
|
|
||||||
+date date
|
|
||||||
+time start_time
|
|
||||||
+time end_time
|
|
||||||
+str location
|
|
||||||
+str status
|
|
||||||
+int created_by
|
|
||||||
+datetime created_at
|
|
||||||
}
|
|
||||||
class Match {
|
|
||||||
+int id
|
|
||||||
+int tryout_id
|
|
||||||
+str match_type
|
|
||||||
+int team1_id
|
|
||||||
+int team2_id
|
|
||||||
+get_participating_players()
|
|
||||||
}
|
|
||||||
class TeamMatch {
|
|
||||||
+int id
|
|
||||||
+int org_team_id
|
|
||||||
+str opponent
|
|
||||||
+get_confirmed_count()
|
|
||||||
}
|
|
||||||
BaseMatch <|-- Match : extends
|
|
||||||
BaseMatch <|-- TeamMatch : extends
|
|
||||||
|
|
||||||
class BaseParticipant {
|
|
||||||
+int player_id
|
|
||||||
+datetime added_at
|
|
||||||
}
|
|
||||||
class MatchParticipant {
|
|
||||||
+int id
|
|
||||||
+int match_id
|
|
||||||
+int team_side
|
|
||||||
+str position
|
|
||||||
+bool attendance_confirmed
|
|
||||||
}
|
|
||||||
class TeamMatchParticipant {
|
|
||||||
+int id
|
|
||||||
+int team_match_id
|
|
||||||
+bool is_confirmed
|
|
||||||
}
|
|
||||||
BaseParticipant <|-- MatchParticipant : extends
|
|
||||||
BaseParticipant <|-- TeamMatchParticipant : extends
|
|
||||||
|
|
||||||
class User {
|
|
||||||
+int id
|
|
||||||
+str username
|
|
||||||
+str password_hash
|
|
||||||
+str role
|
|
||||||
+str full_name
|
|
||||||
+str email
|
|
||||||
+str phone
|
|
||||||
+bool is_active_account
|
|
||||||
+datetime created_at
|
|
||||||
+int failed_login_attempts
|
|
||||||
+datetime locked_until
|
|
||||||
+str games
|
|
||||||
+str discord_username
|
|
||||||
+str discord_user_id
|
|
||||||
+str league_os_profile
|
|
||||||
+get_games_list()
|
|
||||||
+get_gamertags()
|
|
||||||
+get_org_teams()
|
|
||||||
+can_evaluate()
|
|
||||||
+can_manage_users()
|
|
||||||
+can_manage_teams()
|
|
||||||
+can_manage_tryouts()
|
|
||||||
+can_schedule_matches()
|
|
||||||
+can_manage_this_tryout()
|
|
||||||
+can_manage_this_org_team()
|
|
||||||
+get_visible_tryouts()
|
|
||||||
}
|
|
||||||
class Admin {
|
|
||||||
+all permissions = True
|
|
||||||
}
|
|
||||||
class Manager {
|
|
||||||
+can_manage_teams()
|
|
||||||
+can_manage_tryouts()
|
|
||||||
}
|
|
||||||
class Coach {
|
|
||||||
+can_evaluate()
|
|
||||||
+can_schedule_matches()
|
|
||||||
+can_manage_this_tryout()
|
|
||||||
+can_manage_this_org_team()
|
|
||||||
}
|
|
||||||
class Player {
|
|
||||||
+get_visible_tryouts()
|
|
||||||
}
|
|
||||||
class Scout {
|
|
||||||
+can_evaluate()
|
|
||||||
+get_visible_tryouts()
|
|
||||||
}
|
|
||||||
User <|-- Admin : polymorphic
|
|
||||||
User <|-- Manager : polymorphic
|
|
||||||
User <|-- Coach : polymorphic
|
|
||||||
User <|-- Player : polymorphic
|
|
||||||
User <|-- Scout : polymorphic
|
|
||||||
|
|
||||||
class UserGamertag {
|
|
||||||
+int id
|
|
||||||
+int user_id
|
|
||||||
+str game
|
|
||||||
+str gamertag
|
|
||||||
+str platform
|
|
||||||
+get_trn_url()
|
|
||||||
}
|
|
||||||
class OrgTeam {
|
|
||||||
+int id
|
|
||||||
+str name
|
|
||||||
+int created_by
|
|
||||||
+datetime created_at
|
|
||||||
+int coach_id
|
|
||||||
+int manager_id
|
|
||||||
+get_coaches()
|
|
||||||
+get_managers()
|
|
||||||
+players()
|
|
||||||
+get_players_with_status()
|
|
||||||
}
|
|
||||||
class TeamPlayer {
|
|
||||||
+int id
|
|
||||||
+int player_id
|
|
||||||
+int org_team_id
|
|
||||||
+str status
|
|
||||||
+str position
|
|
||||||
+datetime added_at
|
|
||||||
}
|
|
||||||
class Tryout {
|
|
||||||
+int id
|
|
||||||
+str title
|
|
||||||
+str description
|
|
||||||
+str game
|
|
||||||
+date date
|
|
||||||
+str location
|
|
||||||
+str status
|
|
||||||
+int max_players
|
|
||||||
+int created_by
|
|
||||||
+int target_org_team_id
|
|
||||||
+int manager_id
|
|
||||||
+int coach_id
|
|
||||||
+datetime created_at
|
|
||||||
}
|
|
||||||
class TryoutRegistration {
|
|
||||||
+int id
|
|
||||||
+int tryout_id
|
|
||||||
+int player_id
|
|
||||||
+datetime registered_at
|
|
||||||
+str status
|
|
||||||
+str notes
|
|
||||||
}
|
|
||||||
class Evaluation {
|
|
||||||
+int id
|
|
||||||
+int tryout_id
|
|
||||||
+int player_id
|
|
||||||
+int evaluator_id
|
|
||||||
+int mecanics_score
|
|
||||||
+int cohesion_score
|
|
||||||
+int communication_score
|
|
||||||
+int gamesense_score
|
|
||||||
+int versatility_score
|
|
||||||
+int discipline_score
|
|
||||||
+int analysis_score
|
|
||||||
+int sport_ethics_score
|
|
||||||
+int mental_score
|
|
||||||
+float overall_score
|
|
||||||
+str comments
|
|
||||||
+str position_recommendation
|
|
||||||
+datetime created_at
|
|
||||||
+datetime updated_at
|
|
||||||
}
|
|
||||||
class Team {
|
|
||||||
+int id
|
|
||||||
+int tryout_id
|
|
||||||
+str name
|
|
||||||
+int created_by
|
|
||||||
+datetime created_at
|
|
||||||
}
|
|
||||||
class TeamMember {
|
|
||||||
+int id
|
|
||||||
+int team_id
|
|
||||||
+int player_id
|
|
||||||
+str position
|
|
||||||
+datetime added_at
|
|
||||||
}
|
|
||||||
class Contract {
|
|
||||||
+int id
|
|
||||||
+int player_id
|
|
||||||
+int team_id
|
|
||||||
+int uploaded_by_id
|
|
||||||
+str original_filename
|
|
||||||
+str stored_filename
|
|
||||||
+str file_path
|
|
||||||
+str signed_filename
|
|
||||||
+str signed_file_path
|
|
||||||
+str status
|
|
||||||
+str notes
|
|
||||||
+datetime uploaded_at
|
|
||||||
+datetime signed_at
|
|
||||||
+can_view()
|
|
||||||
+can_upload_signed()
|
|
||||||
}
|
|
||||||
class TeamNote {
|
|
||||||
+int id
|
|
||||||
+int org_team_id
|
|
||||||
+int coach_id
|
|
||||||
+str content
|
|
||||||
+datetime created_at
|
|
||||||
+datetime updated_at
|
|
||||||
}
|
|
||||||
class PersonalNote {
|
|
||||||
+int id
|
|
||||||
+int player_id
|
|
||||||
+int coach_id
|
|
||||||
+str content
|
|
||||||
+datetime created_at
|
|
||||||
+datetime updated_at
|
|
||||||
+int match_id
|
|
||||||
+int team_id
|
|
||||||
+int tryout_id
|
|
||||||
}
|
|
||||||
class OneOnOneRequest {
|
|
||||||
+int id
|
|
||||||
+int player_id
|
|
||||||
+int coach_id
|
|
||||||
+int org_team_id
|
|
||||||
+date date
|
|
||||||
+time start_time
|
|
||||||
+time end_time
|
|
||||||
+str points
|
|
||||||
+str status
|
|
||||||
+datetime created_at
|
|
||||||
+datetime responded_at
|
|
||||||
+bigint discord_message_id
|
|
||||||
+str coach_rejection_message
|
|
||||||
}
|
|
||||||
class load_user {
|
|
||||||
+load_user(user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
User "1" --> "*" UserGamertag : gamertags
|
|
||||||
User "1" --> "*" Tryout : created_tryouts
|
|
||||||
User "1" --> "*" Tryout : managed_tryouts
|
|
||||||
User "1" --> "*" Tryout : coached_tryouts
|
|
||||||
User "1" --> "*" Evaluation : evaluations_given
|
|
||||||
User "1" --> "*" Evaluation : evaluations_received
|
|
||||||
User "1" --> "*" TryoutRegistration : registrations
|
|
||||||
User "1" --> "*" TeamMember : team_assignments
|
|
||||||
User "1" --> "*" TeamPlayer : team_placements
|
|
||||||
User "1" --> "*" Match : created_matches
|
|
||||||
User "1" --> "*" TeamMatch : created_team_matches
|
|
||||||
User "1" --> "*" PlayerDisponibility : disponibilities
|
|
||||||
User "1" --> "*" CoachAvailability : availabilities
|
|
||||||
User "1" --> "*" Team : created_teams
|
|
||||||
User "1" --> "*" Contract : contracts
|
|
||||||
User "1" --> "*" PersonalNote : personal_notes
|
|
||||||
User "1" --> "*" OneOnOneRequest : one_on_one_requests
|
|
||||||
|
|
||||||
OrgTeam "1" --> "*" TeamPlayer : team_players
|
|
||||||
OrgTeam "1" --> "*" Tryout : tryouts
|
|
||||||
OrgTeam "1" --> "*" TeamNote : team_notes
|
|
||||||
OrgTeam "1" --> "*" TeamMatch : team_matches
|
|
||||||
OrgTeam "1" --> "*" OneOnOneRequest : requests
|
|
||||||
OrgTeam "1" --> "*" Contract : contracts
|
|
||||||
|
|
||||||
Tryout "1" --> "*" TryoutRegistration : registrations
|
|
||||||
Tryout "1" --> "*" Evaluation : evaluations
|
|
||||||
Tryout "1" --> "*" Team : teams
|
|
||||||
Tryout "1" --> "*" Match : matches
|
|
||||||
Tryout "1" --> "*" PersonalNote : notes
|
|
||||||
|
|
||||||
Team "1" --> "*" TeamMember : members
|
|
||||||
Team "1" --> "*" Match : as_team1
|
|
||||||
Team "1" --> "*" Match : as_team2
|
|
||||||
Match "1" --> "*" MatchParticipant : participants
|
|
||||||
Match "1" --> "*" PersonalNote : notes
|
|
||||||
|
|
||||||
TeamMatch "1" --> "*" TeamMatchParticipant : participants
|
|
||||||
|
|
||||||
User .. load_user : loads
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ============ PACKAGE STRUCTURE ============ -->
|
|
||||||
<section id="packages">
|
|
||||||
<h2><span class="icon">📦</span> Model Package Structure — 7 Subpackages</h2>
|
|
||||||
<div class="mermaid-wrapper">
|
|
||||||
|
|
||||||
<div class="mermaid">
|
|
||||||
graph TD
|
|
||||||
subgraph MODELS["app/models/ — 31 files"]
|
|
||||||
direction TB
|
|
||||||
INIT["__init__.py — master re-exporter"]
|
|
||||||
CONST["_constants.py — USER_TYPES, ESPORT_GAMES, GAME_POSITIONS, etc."]
|
|
||||||
LOADERS["_loaders.py — Flask-Login load_user()"]
|
|
||||||
ASSOC["_associations.py — M2M association tables"]
|
|
||||||
|
|
||||||
subgraph USER_PKG["user_model/ (7 files)"]
|
|
||||||
USER["user.py — User base (polymorphic)"]
|
|
||||||
ADMIN["admin.py — Admin(User)"]
|
|
||||||
MGR["manager.py — Manager(User)"]
|
|
||||||
COACH["coach.py — Coach(User)"]
|
|
||||||
PLAYER["player.py — Player(User)"]
|
|
||||||
SCOUT["scout.py — Scout(User)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph AVAIL_PKG["availability/ (4 files)"]
|
|
||||||
BASE_AVAIL["base.py — BaseAvailability (abstract)"]
|
|
||||||
PD["player_disponibility.py — PlayerDisponibility"]
|
|
||||||
CA["coach_availability.py — CoachAvailability"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph MATCH_PKG["match_model/ (4 files)"]
|
|
||||||
BASE_M["base.py — BaseMatch (abstract)"]
|
|
||||||
MATCH["match.py — Match (tryout-scoped)"]
|
|
||||||
TM["team_match.py — TeamMatch (regular season)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph PARTIC_PKG["participant/ (4 files)"]
|
|
||||||
BASE_P["base.py — BaseParticipant (abstract)"]
|
|
||||||
MP["match_participant.py — MatchParticipant"]
|
|
||||||
TMP["team_match_participant.py — TeamMatchParticipant"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph ORG_PKG["org_team/ (3 files)"]
|
|
||||||
ORG["org_team.py — OrgTeam"]
|
|
||||||
TP["team_player.py — TeamPlayer"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph TRYOUT_PKG["tryout/ (3 files)"]
|
|
||||||
TRY["tryout.py — Tryout"]
|
|
||||||
TR["tryout_registration.py — TryoutRegistration"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph TEAM_PKG["team/ (3 files)"]
|
|
||||||
TEAM["team.py — Team (tryout-specific)"]
|
|
||||||
TMEMBER["team_member.py — TeamMember"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph STANDALONE["6 Standalone Files"]
|
|
||||||
EVAL["evaluation.py"]
|
|
||||||
CONTRACT["contract.py"]
|
|
||||||
GT["user_gamertag.py"]
|
|
||||||
TN["team_note.py"]
|
|
||||||
PN["personal_note.py"]
|
|
||||||
OOO["one_on_one_request.py"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
INIT --> USER_PKG
|
|
||||||
INIT --> AVAIL_PKG
|
|
||||||
INIT --> MATCH_PKG
|
|
||||||
INIT --> PARTIC_PKG
|
|
||||||
INIT --> ORG_PKG
|
|
||||||
INIT --> TRYOUT_PKG
|
|
||||||
INIT --> TEAM_PKG
|
|
||||||
INIT --> STANDALONE
|
|
||||||
INIT --> CONST
|
|
||||||
INIT --> LOADERS
|
|
||||||
INIT --> ASSOC
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ============ FUNCTIONAL ARCHITECTURE ============ -->
|
|
||||||
<section id="functional">
|
|
||||||
<h2><span class="icon">🔀</span> Functional Architecture — Request Flow & Component Graph</h2>
|
|
||||||
<div class="mermaid-wrapper">
|
|
||||||
|
|
||||||
<div class="mermaid">
|
|
||||||
graph TD
|
|
||||||
subgraph CLIENT["Client Layer"]
|
|
||||||
BROWSER["Browser / User"]
|
|
||||||
DISCORD_APP["Discord App"]
|
|
||||||
MONITOR["Monitoring / LB"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph PROXY["Reverse Proxy"]
|
|
||||||
NGINX["Nginx<br/>TLS termination<br/>Static files<br/>Rate limiting"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph APP["Flask Application — create_app()"]
|
|
||||||
subgraph INGRESS["Incoming Middleware"]
|
|
||||||
BEFORE_REQ["before_request<br/>force_https()"]
|
|
||||||
CSRF_CHECK["CSRF Validation"]
|
|
||||||
LIMITER_CHECK["Rate Limiter<br/>200/day | 50/hr"]
|
|
||||||
LOGIN_CHECK["login_required<br/>Permission checks"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph BLUEPRINTS["Route Blueprints (8)"]
|
|
||||||
AUTH_BP["auth_bp /auth<br/>login, register, logout"]
|
|
||||||
MAIN_BP["main_bp /<br/>dashboard, privacy, terms"]
|
|
||||||
TRYOUTS_BP["tryouts_bp /tryouts<br/>CRUD + register"]
|
|
||||||
TEAMS_BP["teams_bp /teams<br/>OrgTeam CRUD"]
|
|
||||||
MATCHES_BP["matches_bp /matches<br/>Tryout match CRUD"]
|
|
||||||
TEAM_MATCHES_BP["team_matches_bp /team-matches<br/>Season match CRUD"]
|
|
||||||
USERS_BP["users_bp /users<br/>User CRUD, contracts<br/>disponibilities, one-on-one"]
|
|
||||||
EVALS_BP["evaluations_bp /evaluations<br/>9-score evaluations"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph EGRESS["Outgoing Middleware"]
|
|
||||||
AFTER_REQ["after_request<br/>Security headers"]
|
|
||||||
ERRORS["Error Handlers<br/>400 401 403 404 429 500"]
|
|
||||||
HEALTH["GET /health"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph EXTENSIONS["Flask Extensions"]
|
|
||||||
DB_EXT["SQLAlchemy db"]
|
|
||||||
LOGIN_EXT["LoginManager"]
|
|
||||||
CSRF_EXT["CSRFProtect"]
|
|
||||||
LIMITER_EXT["Flask-Limiter"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph MODELS["SQLAlchemy Models (7 subpackages)"]
|
|
||||||
USER_M["user_model/ — User hierarchy<br/>User, Admin, Manager, Coach, Player, Scout"]
|
|
||||||
AVAIL_M["availability/ — BaseAvailability<br/>PlayerDisponibility, CoachAvailability"]
|
|
||||||
MATCH_M["match_model/ — BaseMatch<br/>Match, TeamMatch"]
|
|
||||||
PARTIC_M["participant/ — BaseParticipant<br/>MatchParticipant, TeamMatchParticipant"]
|
|
||||||
ORG_M["org_team/ — OrgTeam, TeamPlayer"]
|
|
||||||
TRYOUT_M["tryout/ — Tryout, TryoutRegistration"]
|
|
||||||
TEAM_M["team/ — Team, TeamMember"]
|
|
||||||
STANDALONE_M["Standalone: Evaluation, Contract<br/>UserGamertag, TeamNote, PersonalNote<br/>OneOnOneRequest"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph DB["Database"]
|
|
||||||
SQLITE["SQLite / PostgreSQL<br/>DATABASE_URL"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph UTILS["Utility Modules"]
|
|
||||||
VALIDATORS["validators.py<br/>Input validation schemas"]
|
|
||||||
LOGGING["logging_config.py<br/>Structured logging"]
|
|
||||||
DISCORD_BOT["discord_bot.py<br/>Discord notifications<br/>Reaction handling"]
|
|
||||||
SECURITY["security_scan.py<br/>Security audit"]
|
|
||||||
SEED["seed.py<br/>Database seeding"]
|
|
||||||
BACKUP["backup.py<br/>Database backup"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph TEMPLATES["Jinja2 Templates"]
|
|
||||||
PAGES["templates/pages/<br/>~30 HTML pages"]
|
|
||||||
LAYOUTS["templates/layouts/<br/>base, nav"]
|
|
||||||
ERRORS_TPL["templates/errors/<br/>400-500 errors"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph STATIC["Static Assets"]
|
|
||||||
CSS_F["static/css/"]
|
|
||||||
JS_F["static/js/"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph EXTERNAL["External APIs"]
|
|
||||||
TRN_API["TRN / Tracker.gg<br/>Gamertag profiles"]
|
|
||||||
DISCORD_API["Discord API<br/>Bot notifications"]
|
|
||||||
end
|
|
||||||
|
|
||||||
BROWSER --> NGINX
|
|
||||||
NGINX --> BEFORE_REQ
|
|
||||||
BEFORE_REQ --> CSRF_CHECK
|
|
||||||
CSRF_CHECK --> LIMITER_CHECK
|
|
||||||
LIMITER_CHECK --> LOGIN_CHECK
|
|
||||||
LOGIN_CHECK --> BLUEPRINTS
|
|
||||||
BLUEPRINTS --> AFTER_REQ
|
|
||||||
AFTER_REQ --> BROWSER
|
|
||||||
|
|
||||||
AUTH_BP -.-> USER_M
|
|
||||||
TRYOUTS_BP -.-> TRYOUT_M
|
|
||||||
EVALS_BP -.-> STANDALONE_M
|
|
||||||
TEAMS_BP -.-> ORG_M
|
|
||||||
MATCHES_BP -.-> MATCH_M
|
|
||||||
TEAM_MATCHES_BP -.-> MATCH_M
|
|
||||||
USERS_BP -.-> USER_M
|
|
||||||
|
|
||||||
MODELS --> DB
|
|
||||||
|
|
||||||
DB_EXT --> MODELS
|
|
||||||
LOGIN_EXT --> USER_M
|
|
||||||
CSRF_EXT --> BLUEPRINTS
|
|
||||||
LIMITER_EXT --> BLUEPRINTS
|
|
||||||
|
|
||||||
VALIDATORS -.-> AUTH_BP
|
|
||||||
VALIDATORS -.-> USERS_BP
|
|
||||||
LOGGING -.-> APP
|
|
||||||
DISCORD_BOT --> DISCORD_API
|
|
||||||
DISCORD_BOT -.-> MATCHES_BP
|
|
||||||
DISCORD_BOT -.-> TEAM_MATCHES_BP
|
|
||||||
DISCORD_BOT -.-> USERS_BP
|
|
||||||
SEED -.-> DB
|
|
||||||
BACKUP -.-> DB
|
|
||||||
|
|
||||||
BLUEPRINTS --> TEMPLATES
|
|
||||||
TEMPLATES --> STATIC
|
|
||||||
|
|
||||||
USER_M -.-> TRN_API
|
|
||||||
MONITOR --> HEALTH
|
|
||||||
DISCORD_APP --> DISCORD_BOT
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ============ LEGEND ============ -->
|
|
||||||
<section id="legend">
|
|
||||||
<h2><span class="icon">🗺️</span> Legend</h2>
|
|
||||||
<div class="legend">
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-dot model"></span>
|
|
||||||
<span><strong>Models (abstract)</strong> — BaseAvailability, BaseMatch, BaseParticipant</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-dot subpackage"></span>
|
|
||||||
<span><strong>Subpackages</strong> — user_model, availability, match_model, participant, org_team, tryout, team</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-dot blueprint"></span>
|
|
||||||
<span><strong>Blueprints</strong> — Flask route groups</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-dot extension"></span>
|
|
||||||
<span><strong>Extensions</strong> — SQLAlchemy, Login, CSRF, Limiter</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-dot utility"></span>
|
|
||||||
<span><strong>Utilities</strong> — Logging, validators, seed, backup, bot</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-dot external"></span>
|
|
||||||
<span><strong>External APIs</strong> — Discord API, TRN/Tracker.gg</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-dot middleware"></span>
|
|
||||||
<span><strong>Middleware</strong> — HTTPS redirect, security headers, errors</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span style="font-weight:bold;color:#ccc;">━━ Solid</span>
|
|
||||||
<span>= HTTP / data flow</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span style="font-weight:bold;color:#999;">┅┅ Dashed</span>
|
|
||||||
<span>= Logical dependency</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<p>Team Tryouts © 2026 — Generated from refactored codebase (commit fc1bdc5)</p>
|
|
||||||
<p>31 files · 7 subpackages · 24 classes · 8 blueprints · 4 extensions · 8 utilities</p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
mermaid.initialize({
|
|
||||||
startOnLoad: true,
|
|
||||||
theme: 'dark',
|
|
||||||
themeVariables: {
|
|
||||||
primaryColor: '#0f3460',
|
|
||||||
primaryTextColor: '#eee',
|
|
||||||
primaryBorderColor: '#4fc3f7',
|
|
||||||
lineColor: '#4fc3f7',
|
|
||||||
secondaryColor: '#1e2a3a',
|
|
||||||
tertiaryColor: '#16213e',
|
|
||||||
noteBkgColor: '#1a1a2e',
|
|
||||||
noteTextColor: '#aaa',
|
|
||||||
fontFamily: 'Segoe UI, system-ui, -apple-system, sans-serif',
|
|
||||||
fontSize: '13px',
|
|
||||||
},
|
|
||||||
class: { useMaxWidth: false },
|
|
||||||
flowchart: {
|
|
||||||
useMaxWidth: false,
|
|
||||||
htmlLabels: true,
|
|
||||||
curve: 'basis',
|
|
||||||
padding: 20,
|
|
||||||
nodeSpacing: 30,
|
|
||||||
rankSpacing: 60,
|
|
||||||
},
|
|
||||||
securityLevel: 'loose',
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,747 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Team Tryouts — Architecture Documentation</title>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #1a1a2e;
|
|
||||||
--surface: #16213e;
|
|
||||||
--primary: #0f3460;
|
|
||||||
--accent: #e94560;
|
|
||||||
--text: #eee;
|
|
||||||
--text-muted: #aaa;
|
|
||||||
--border: #2a2a4a;
|
|
||||||
--card-bg: #1e2a3a;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
background: var(--surface);
|
|
||||||
border-bottom: 2px solid var(--accent);
|
|
||||||
padding: 1.5rem 2rem;
|
|
||||||
text-align: center;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: 0 2px 20px rgba(0,0,0,0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
header h1 {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
color: var(--accent);
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header p {
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 0.5rem 1.5rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: background 0.2s, transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a:hover {
|
|
||||||
background: var(--accent);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
section h2 {
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
section h2 .icon {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-wrapper {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-wrapper.full-width {
|
|
||||||
/* full bleed for wide diagrams */
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-dot {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 3px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-dot.model { background: #4fc3f7; }
|
|
||||||
.legend-dot.blueprint { background: #ffb74d; }
|
|
||||||
.legend-dot.extension { background: #81c784; }
|
|
||||||
.legend-dot.utility { background: #ba68c8; }
|
|
||||||
.legend-dot.external { background: #e57373; }
|
|
||||||
.legend-dot.middleware { background: #4dd0e1; }
|
|
||||||
|
|
||||||
footer {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card .stat-num {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card .stat-label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
main { padding: 1rem; }
|
|
||||||
header h1 { font-size: 1.3rem; }
|
|
||||||
.mermaid-wrapper { padding: 1rem; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<h1>🏆 Team Tryouts — Architecture Documentation</h1>
|
|
||||||
<p>UML Class Diagram & Functional System Architecture</p>
|
|
||||||
<nav>
|
|
||||||
<a href="#stats">Overview</a>
|
|
||||||
<a href="#uml">UML Class Diagram</a>
|
|
||||||
<a href="#functional">Functional Graph</a>
|
|
||||||
<a href="#legend">Legend</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
|
|
||||||
<!-- ============ STATS ============ -->
|
|
||||||
<section id="stats">
|
|
||||||
<h2><span class="icon">📊</span> Project Statistics</h2>
|
|
||||||
<div class="stats">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-num">19</div>
|
|
||||||
<div class="stat-label">SQLAlchemy Model Classes</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-num">8</div>
|
|
||||||
<div class="stat-label">Route Blueprints</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-num">4</div>
|
|
||||||
<div class="stat-label">Flask Extensions</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-num">2</div>
|
|
||||||
<div class="stat-label">Association Tables (M2M)</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-num">50+</div>
|
|
||||||
<div class="stat-label">Route Endpoints</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-num">8</div>
|
|
||||||
<div class="stat-label">Utility Modules</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ============ UML CLASS DIAGRAM ============ -->
|
|
||||||
<section id="uml">
|
|
||||||
<h2><span class="icon">📐</span> UML Class Diagram — All 19 Model Classes</h2>
|
|
||||||
<div class="mermaid-wrapper">
|
|
||||||
|
|
||||||
<div class="mermaid">
|
|
||||||
classDiagram
|
|
||||||
class User {
|
|
||||||
+int id
|
|
||||||
+str username
|
|
||||||
+str password_hash
|
|
||||||
+str role
|
|
||||||
+str full_name
|
|
||||||
+str email
|
|
||||||
+str phone
|
|
||||||
+bool is_active_account
|
|
||||||
+datetime created_at
|
|
||||||
+int failed_login_attempts
|
|
||||||
+datetime locked_until
|
|
||||||
+str games
|
|
||||||
+str discord_username
|
|
||||||
+str discord_user_id
|
|
||||||
+str league_os_profile
|
|
||||||
+get_games_list()
|
|
||||||
+has_role()
|
|
||||||
+can_evaluate()
|
|
||||||
+can_manage_users()
|
|
||||||
+can_manage_tryouts()
|
|
||||||
+can_manage_teams()
|
|
||||||
+can_schedule_matches()
|
|
||||||
+can_manage_this_tryout()
|
|
||||||
+can_manage_this_org_team()
|
|
||||||
+get_gamertags()
|
|
||||||
+get_org_teams()
|
|
||||||
}
|
|
||||||
class UserGamertag {
|
|
||||||
+int id
|
|
||||||
+int user_id
|
|
||||||
+str game
|
|
||||||
+str gamertag
|
|
||||||
+str platform
|
|
||||||
+get_trn_url()
|
|
||||||
}
|
|
||||||
class OrgTeam {
|
|
||||||
+int id
|
|
||||||
+str name
|
|
||||||
+int created_by
|
|
||||||
+datetime created_at
|
|
||||||
+int coach_id
|
|
||||||
+int manager_id
|
|
||||||
+get_coaches()
|
|
||||||
+get_managers()
|
|
||||||
+players()
|
|
||||||
+get_players_with_status()
|
|
||||||
}
|
|
||||||
class TeamPlayer {
|
|
||||||
+int id
|
|
||||||
+int player_id
|
|
||||||
+int org_team_id
|
|
||||||
+str status
|
|
||||||
+str position
|
|
||||||
+datetime added_at
|
|
||||||
}
|
|
||||||
class Tryout {
|
|
||||||
+int id
|
|
||||||
+str title
|
|
||||||
+str description
|
|
||||||
+str game
|
|
||||||
+date date
|
|
||||||
+str location
|
|
||||||
+str status
|
|
||||||
+int max_players
|
|
||||||
+int created_by
|
|
||||||
+int target_org_team_id
|
|
||||||
+int manager_id
|
|
||||||
+int coach_id
|
|
||||||
+datetime created_at
|
|
||||||
}
|
|
||||||
class TryoutRegistration {
|
|
||||||
+int id
|
|
||||||
+int tryout_id
|
|
||||||
+int player_id
|
|
||||||
+datetime registered_at
|
|
||||||
+str status
|
|
||||||
+str notes
|
|
||||||
}
|
|
||||||
class Evaluation {
|
|
||||||
+int id
|
|
||||||
+int tryout_id
|
|
||||||
+int player_id
|
|
||||||
+int evaluator_id
|
|
||||||
+int mecanics_score
|
|
||||||
+int cohesion_score
|
|
||||||
+int communication_score
|
|
||||||
+int gamesense_score
|
|
||||||
+int versatility_score
|
|
||||||
+int discipline_score
|
|
||||||
+int analysis_score
|
|
||||||
+int sport_ethics_score
|
|
||||||
+int mental_score
|
|
||||||
+float overall_score
|
|
||||||
+str comments
|
|
||||||
+str position_recommendation
|
|
||||||
+datetime created_at
|
|
||||||
+datetime updated_at
|
|
||||||
}
|
|
||||||
class Team {
|
|
||||||
+int id
|
|
||||||
+int tryout_id
|
|
||||||
+str name
|
|
||||||
+int created_by
|
|
||||||
+datetime created_at
|
|
||||||
}
|
|
||||||
class TeamMember {
|
|
||||||
+int id
|
|
||||||
+int team_id
|
|
||||||
+int player_id
|
|
||||||
+str position
|
|
||||||
+datetime added_at
|
|
||||||
}
|
|
||||||
class Match {
|
|
||||||
+int id
|
|
||||||
+int tryout_id
|
|
||||||
+str title
|
|
||||||
+str description
|
|
||||||
+date date
|
|
||||||
+time start_time
|
|
||||||
+time end_time
|
|
||||||
+str location
|
|
||||||
+str status
|
|
||||||
+str match_type
|
|
||||||
+int created_by
|
|
||||||
+datetime created_at
|
|
||||||
+int team1_id
|
|
||||||
+int team2_id
|
|
||||||
+get_participating_players()
|
|
||||||
}
|
|
||||||
class PlayerDisponibility {
|
|
||||||
+int id
|
|
||||||
+int player_id
|
|
||||||
+int day_of_week
|
|
||||||
+time start_time
|
|
||||||
+time end_time
|
|
||||||
+datetime created_at
|
|
||||||
+datetime updated_at
|
|
||||||
}
|
|
||||||
class MatchParticipant {
|
|
||||||
+int id
|
|
||||||
+int match_id
|
|
||||||
+int player_id
|
|
||||||
+int team_side
|
|
||||||
+str position
|
|
||||||
+bool attendance_confirmed
|
|
||||||
+datetime added_at
|
|
||||||
}
|
|
||||||
class Contract {
|
|
||||||
+int id
|
|
||||||
+int player_id
|
|
||||||
+int team_id
|
|
||||||
+int uploaded_by_id
|
|
||||||
+str original_filename
|
|
||||||
+str stored_filename
|
|
||||||
+str file_path
|
|
||||||
+str signed_filename
|
|
||||||
+str signed_file_path
|
|
||||||
+str status
|
|
||||||
+str notes
|
|
||||||
+datetime uploaded_at
|
|
||||||
+datetime signed_at
|
|
||||||
+can_view()
|
|
||||||
+can_upload_signed()
|
|
||||||
}
|
|
||||||
class CoachAvailability {
|
|
||||||
+int id
|
|
||||||
+int coach_id
|
|
||||||
+int day_of_week
|
|
||||||
+time start_time
|
|
||||||
+time end_time
|
|
||||||
+datetime created_at
|
|
||||||
+datetime updated_at
|
|
||||||
}
|
|
||||||
class TeamNote {
|
|
||||||
+int id
|
|
||||||
+int org_team_id
|
|
||||||
+int coach_id
|
|
||||||
+str content
|
|
||||||
+datetime created_at
|
|
||||||
+datetime updated_at
|
|
||||||
}
|
|
||||||
class PersonalNote {
|
|
||||||
+int id
|
|
||||||
+int player_id
|
|
||||||
+int coach_id
|
|
||||||
+str content
|
|
||||||
+datetime created_at
|
|
||||||
+datetime updated_at
|
|
||||||
+int match_id
|
|
||||||
+int team_id
|
|
||||||
+int tryout_id
|
|
||||||
}
|
|
||||||
class OneOnOneRequest {
|
|
||||||
+int id
|
|
||||||
+int player_id
|
|
||||||
+int coach_id
|
|
||||||
+int org_team_id
|
|
||||||
+date date
|
|
||||||
+time start_time
|
|
||||||
+time end_time
|
|
||||||
+str points
|
|
||||||
+str status
|
|
||||||
+datetime created_at
|
|
||||||
+datetime responded_at
|
|
||||||
+bigint discord_message_id
|
|
||||||
+str coach_rejection_message
|
|
||||||
}
|
|
||||||
class TeamMatch {
|
|
||||||
+int id
|
|
||||||
+int org_team_id
|
|
||||||
+str title
|
|
||||||
+str description
|
|
||||||
+str opponent
|
|
||||||
+date date
|
|
||||||
+time start_time
|
|
||||||
+time end_time
|
|
||||||
+str location
|
|
||||||
+str status
|
|
||||||
+int created_by
|
|
||||||
+datetime created_at
|
|
||||||
+get_confirmed_count()
|
|
||||||
}
|
|
||||||
class TeamMatchParticipant {
|
|
||||||
+int id
|
|
||||||
+int team_match_id
|
|
||||||
+int player_id
|
|
||||||
+bool is_confirmed
|
|
||||||
+datetime added_at
|
|
||||||
}
|
|
||||||
class load_user {
|
|
||||||
+load_user(user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
User "1" --> "*" UserGamertag : gamertags
|
|
||||||
User "1" --> "*" Tryout : created_by
|
|
||||||
User "1" --> "*" Tryout : managed_tryouts
|
|
||||||
User "1" --> "*" Tryout : coached_tryouts
|
|
||||||
User "1" --> "*" Evaluation : evaluations_given
|
|
||||||
User "1" --> "*" Evaluation : evaluations_received
|
|
||||||
User "1" --> "*" TryoutRegistration : tryout_registrations
|
|
||||||
User "1" --> "*" TeamMember : team_assignments
|
|
||||||
User "1" --> "*" TeamPlayer : team_placements
|
|
||||||
User "1" --> "*" Match : created_matches
|
|
||||||
User "1" --> "*" TeamMatch : created_team_matches
|
|
||||||
User "1" --> "*" PlayerDisponibility : disponibilities
|
|
||||||
User "1" --> "*" CoachAvailability : coach_availabilities
|
|
||||||
User "1" --> "*" Team : created_teams
|
|
||||||
User "1" --> "*" Contract : contracts
|
|
||||||
User "1" --> "*" PersonalNote : personal_notes
|
|
||||||
User "1" --> "*" OneOnOneRequest : one_on_one_requests
|
|
||||||
|
|
||||||
OrgTeam "1" --> "*" TeamPlayer : team_players
|
|
||||||
OrgTeam "1" --> "*" Tryout : tryouts
|
|
||||||
OrgTeam "1" --> "*" TeamNote : team_notes
|
|
||||||
OrgTeam "1" --> "*" TeamMatch : team_matches
|
|
||||||
OrgTeam "1" --> "*" OneOnOneRequest : requests
|
|
||||||
OrgTeam "1" --> "*" Contract : contracts
|
|
||||||
|
|
||||||
Tryout "1" --> "*" TryoutRegistration : registrations
|
|
||||||
Tryout "1" --> "*" Evaluation : evaluations
|
|
||||||
Tryout "1" --> "*" Team : teams
|
|
||||||
Tryout "1" --> "*" Match : matches
|
|
||||||
Tryout "1" --> "*" PersonalNote : notes
|
|
||||||
|
|
||||||
Team "1" --> "*" TeamMember : members
|
|
||||||
Team "1" --> "*" Match : matches_as_team1
|
|
||||||
Team "1" --> "*" Match : matches_as_team2
|
|
||||||
Match "1" --> "*" MatchParticipant : participants
|
|
||||||
Match "1" --> "*" PersonalNote : notes
|
|
||||||
|
|
||||||
TeamMatch "1" --> "*" TeamMatchParticipant : participants
|
|
||||||
|
|
||||||
User .. load_user : loads
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ============ FUNCTIONAL ARCHITECTURE GRAPH ============ -->
|
|
||||||
<section id="functional">
|
|
||||||
<h2><span class="icon">🔀</span> Functional Architecture — Request Flow & Component Graph</h2>
|
|
||||||
<div class="mermaid-wrapper">
|
|
||||||
|
|
||||||
<div class="mermaid">
|
|
||||||
graph TD
|
|
||||||
subgraph CLIENT["🌐 Client Layer"]
|
|
||||||
BROWSER["Browser / User"]
|
|
||||||
DISCORD_APP["Discord App"]
|
|
||||||
MONITOR["Monitoring / LB"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph PROXY["🔄 Reverse Proxy"]
|
|
||||||
NGINX["Nginx<br/>(TLS termination,<br/>static files,<br/>rate limiting)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph APP["🐍 Flask Application (create_app)"]
|
|
||||||
direction TB
|
|
||||||
|
|
||||||
subgraph INGRESS["Incoming"]
|
|
||||||
BEFORE_REQ["@before_request<br/>force_https()"]
|
|
||||||
CSRF_CHECK["CSRF Validate"]
|
|
||||||
LIMITER_CHECK["Rate Limiter<br/>200/day · 50/hr"]
|
|
||||||
LOGIN_CHECK["@login_required"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph BLUEPRINTS["📦 Route Blueprints (8 total)"]
|
|
||||||
AUTH_BP["auth_bp<br/>━━━━━━━<br/>⁕ /auth/login<br/>⁕ /auth/register<br/>⁕ /auth/logout<br/>⁕ /auth/profile<br/>⁕ /auth/change-password<br/>⁕ /auth/delete-account"]
|
|
||||||
MAIN_BP["main_bp<br/>━━━━━━━<br/>⁕ /<br/>⁕ /dashboard<br/>⁕ /privacy<br/>⁕ /terms"]
|
|
||||||
TRYOUTS_BP["tryouts_bp<br/>━━━━━━━<br/>⁕ /tryouts/<br/>⁕ /tryouts/create<br/>⁕ /tryouts/{id}<br/>⁕ /tryouts/{id}/register<br/>⁕ /tryouts/{id}/edit<br/>⁕ /tryouts/{id}/delete"]
|
|
||||||
TEAMS_BP["teams_bp<br/>━━━━━━━<br/>⁕ /teams/<br/>⁕ /teams/create<br/>⁕ /teams/{id}<br/>⁕ /teams/{id}/edit<br/>⁕ /teams/{id}/delete<br/>⁕ /teams/{id}/add-player"]
|
|
||||||
MATCHES_BP["matches_bp<br/>━━━━━━━<br/>⁕ /matches/<br/>⁕ /matches/create<br/>⁕ /matches/{id}<br/>⁕ /matches/{id}/edit"]
|
|
||||||
TEAM_MATCHES_BP["team_matches_bp<br/>━━━━━━━<br/>⁕ /team-matches/<br/>⁕ /team-matches/create<br/>⁕ /team-matches/{id}<br/>⁕ /team-matches/{id}/edit<br/>⁕ /team-matches/{id}/delete"]
|
|
||||||
USERS_BP["users_bp<br/>━━━━━━━<br/>⁕ /users/<br/>⁕ /users/create<br/>⁕ /users/{id}<br/>⁕ /users/{id}/delete<br/>⁕ /users/disponibilities/<br/>⁕ /users/coach-availability/<br/>⁕ /users/api/gamertags"]
|
|
||||||
EVALS_BP["evaluations_bp<br/>━━━━━━━<br/>⁕ /evaluations/<br/>⁕ /evaluations/create<br/>⁕ /evaluations/{id}<br/>⁕ /evaluations/{id}/edit<br/>⁕ /evaluations/{id}/delete"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph EGRESS["Outgoing"]
|
|
||||||
AFTER_REQ["@after_request<br/>add_security_headers()"]
|
|
||||||
ERROR_HANDLERS["Error Handlers<br/>400 · 401 · 403 · 404<br/>429 · 500 · HTTPException"]
|
|
||||||
HEALTH["GET /health"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph EXTENSIONS["🔌 Flask Extensions"]
|
|
||||||
DB_EXT["SQLAlchemy<br/>(db)"]
|
|
||||||
LOGIN_EXT["Flask-Login<br/>(login_manager)"]
|
|
||||||
CSRF_EXT["CSRFProtect<br/>(csrf)"]
|
|
||||||
LIMITER_EXT["Flask-Limiter<br/>(limiter)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph MODELS["🗄️ SQLAlchemy Models (models.py)"]
|
|
||||||
direction LR
|
|
||||||
USER_M["User"]
|
|
||||||
TRYOUT_M["Tryout"]
|
|
||||||
EVAL_M["Evaluation"]
|
|
||||||
ORGTEAM_M["OrgTeam"]
|
|
||||||
TEAM_M["Team"]
|
|
||||||
MATCH_M["Match"]
|
|
||||||
TEAMMATCH_M["TeamMatch"]
|
|
||||||
OTHERS_M["...15 more classes"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph DB["💾 Database"]
|
|
||||||
SQLITE["SQLite / PostgreSQL<br/>(SQLALCHEMY_DATABASE_URI)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph UTILS["🛠️ Utility Modules"]
|
|
||||||
VALIDATORS["validators.py<br/>Password/username validation"]
|
|
||||||
LOGGING["logging_config.py<br/>Structured logging"]
|
|
||||||
DISCORD_BOT["discord_bot.py<br/>Discord notifications<br/>+ reaction handling"]
|
|
||||||
SECURITY["security_scan.py<br/>Security audit tool"]
|
|
||||||
SEED["seed.py<br/>Database seeding"]
|
|
||||||
BACKUP["backup.py<br/>Database backup"]
|
|
||||||
MIGRATE["migrate_usernames.py<br/>Username migration"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph TEMPLATES["🖼️ Jinja2 Templates"]
|
|
||||||
PAGES["templates/pages/<br/>(~30 HTML pages)"]
|
|
||||||
LAYOUTS["templates/layouts/<br/>(base, nav)"]
|
|
||||||
ERRORS_TPL["templates/errors/<br/>(400, 401, 403, 404, 429, 500)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph STATIC["📁 Static Assets"]
|
|
||||||
CSS_F["static/css/"]
|
|
||||||
JS_F["static/js/"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph EXTERNAL["🌍 External APIs"]
|
|
||||||
TRN_API["TRN / Tracker.gg<br/>(gamertag profiles)"]
|
|
||||||
DISCORD_API["Discord API<br/>(bot notifications)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
%% ========== FLOW ==========
|
|
||||||
BROWSER --> NGINX
|
|
||||||
NGINX --> BEFORE_REQ
|
|
||||||
BEFORE_REQ --> CSRF_CHECK
|
|
||||||
CSRF_CHECK --> LIMITER_CHECK
|
|
||||||
LIMITER_CHECK --> LOGIN_CHECK
|
|
||||||
LOGIN_CHECK --> BLUEPRINTS
|
|
||||||
BLUEPRINTS --> AFTER_REQ
|
|
||||||
AFTER_REQ --> BROWSER
|
|
||||||
|
|
||||||
%% Blueprint to models
|
|
||||||
AUTH_BP -.-> USER_M
|
|
||||||
TRYOUTS_BP -.-> TRYOUT_M
|
|
||||||
EVALS_BP -.-> EVAL_M
|
|
||||||
TEAMS_BP -.-> ORGTEAM_M
|
|
||||||
MATCHES_BP -.-> MATCH_M
|
|
||||||
TEAM_MATCHES_BP -.-> TEAMMATCH_M
|
|
||||||
USERS_BP -.-> USER_M
|
|
||||||
|
|
||||||
%% Models to DB
|
|
||||||
MODELS --> DB
|
|
||||||
|
|
||||||
%% Extensions
|
|
||||||
DB_EXT --> MODELS
|
|
||||||
LOGIN_EXT --> USER_M
|
|
||||||
CSRF_EXT --> BLUEPRINTS
|
|
||||||
LIMITER_EXT --> BLUEPRINTS
|
|
||||||
|
|
||||||
%% Utilities
|
|
||||||
VALIDATORS -.-> AUTH_BP
|
|
||||||
VALIDATORS -.-> USERS_BP
|
|
||||||
LOGGING -.-> APP
|
|
||||||
DISCORD_BOT --> DISCORD_API
|
|
||||||
DISCORD_BOT -.-> MATCHES_BP
|
|
||||||
DISCORD_BOT -.-> TEAM_MATCHES_BP
|
|
||||||
DISCORD_BOT -.-> USERS_BP
|
|
||||||
SEED -.-> DB
|
|
||||||
BACKUP -.-> DB
|
|
||||||
|
|
||||||
%% Templates & Static
|
|
||||||
BLUEPRINTS --> TEMPLATES
|
|
||||||
TEMPLATES --> STATIC
|
|
||||||
|
|
||||||
%% External
|
|
||||||
USER_M -.-> TRN_API
|
|
||||||
MONITOR --> HEALTH
|
|
||||||
|
|
||||||
%% Discord
|
|
||||||
DISCORD_APP --> DISCORD_BOT
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ============ LEGEND ============ -->
|
|
||||||
<section id="legend">
|
|
||||||
<h2><span class="icon">🗺️</span> Legend</h2>
|
|
||||||
<div class="legend">
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-dot model"></span>
|
|
||||||
<span><strong>SQLAlchemy Models</strong> — Database entity classes</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-dot blueprint"></span>
|
|
||||||
<span><strong>Route Blueprints</strong> — Flask route groups</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-dot extension"></span>
|
|
||||||
<span><strong>Flask Extensions</strong> — SQLAlchemy, Login, CSRF, Limiter</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-dot utility"></span>
|
|
||||||
<span><strong>Utility Modules</strong> — Logging, validators, seeding, backups</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-dot external"></span>
|
|
||||||
<span><strong>External APIs</strong> — Discord, TRN/Tracker.gg</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-dot middleware"></span>
|
|
||||||
<span><strong>Middleware</strong> — Security headers, HTTPS redirect, error handlers</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span style="font-weight:bold;">─── Solid arrow</span>
|
|
||||||
<span>= HTTP request flow</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span style="font-weight:bold; color:#999;">- - - Dashed arrow</span>
|
|
||||||
<span>= Data access / logical dependency</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<p>Team Tryouts © 2026 — Generated from codebase analysis</p>
|
|
||||||
<p>19 SQLAlchemy models · 8 blueprints · 4 Flask extensions · 8 utility modules</p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
mermaid.initialize({
|
|
||||||
startOnLoad: true,
|
|
||||||
theme: 'dark',
|
|
||||||
themeVariables: {
|
|
||||||
primaryColor: '#0f3460',
|
|
||||||
primaryTextColor: '#eee',
|
|
||||||
primaryBorderColor: '#4fc3f7',
|
|
||||||
lineColor: '#4fc3f7',
|
|
||||||
secondaryColor: '#1e2a3a',
|
|
||||||
tertiaryColor: '#16213e',
|
|
||||||
noteBkgColor: '#1a1a2e',
|
|
||||||
noteTextColor: '#aaa',
|
|
||||||
fontFamily: 'Segoe UI, system-ui, -apple-system, sans-serif',
|
|
||||||
fontSize: '13px',
|
|
||||||
},
|
|
||||||
class: {
|
|
||||||
useMaxWidth: false,
|
|
||||||
},
|
|
||||||
flowchart: {
|
|
||||||
useMaxWidth: false,
|
|
||||||
htmlLabels: true,
|
|
||||||
curve: 'basis',
|
|
||||||
padding: 20,
|
|
||||||
nodeSpacing: 30,
|
|
||||||
rankSpacing: 60,
|
|
||||||
defaultRenderer: 'dagre-wrapper',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
@@ -128,13 +128,14 @@ def configure_logging(app):
|
|||||||
app.logger.addHandler(app_handler)
|
app.logger.addHandler(app_handler)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# 4. Console Handler (always enabled for debugging in both dev and production)
|
# 4. Console Handler (for development)
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
console_handler = logging.StreamHandler()
|
if os.getenv('FLASK_DEBUG', 'false').lower() == 'true':
|
||||||
console_handler.setLevel(log_level)
|
console_handler = logging.StreamHandler()
|
||||||
console_handler.setFormatter(formatter)
|
console_handler.setLevel(logging.DEBUG)
|
||||||
console_handler.addFilter(sensitive_filter)
|
console_handler.setFormatter(formatter)
|
||||||
app.logger.addHandler(console_handler)
|
console_handler.addFilter(sensitive_filter)
|
||||||
|
app.logger.addHandler(console_handler)
|
||||||
|
|
||||||
# Log startup information
|
# Log startup information
|
||||||
app.logger.info('Logging configured - Level: %s, Log directory: %s', log_level_name, log_dir)
|
app.logger.info('Logging configured - Level: %s, Log directory: %s', log_level_name, log_dir)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Data migration script to fix corrupted username fields.
|
||||||
|
|
||||||
|
This script fixes the bug where user.username was incorrectly set to full_name
|
||||||
|
instead of preserving the actual username. It migrates existing users by:
|
||||||
|
1. Setting username to a slug version of full_name (e.g., 'sarah-johnson')
|
||||||
|
2. Clearing full_name to empty string (will be collected via profile edit)
|
||||||
|
|
||||||
|
Run this script once to fix existing database records.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from extensions import db
|
||||||
|
from models import User
|
||||||
|
|
||||||
|
def slugify(name):
|
||||||
|
"""Convert a name to a username-friendly slug.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Full name to convert.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Slugified username.
|
||||||
|
"""
|
||||||
|
return name.lower().replace(' ', '-').replace("'", '')
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
"""Migrate existing users to fix corrupted username/full_name fields."""
|
||||||
|
with app.app_context():
|
||||||
|
users = User.query.all()
|
||||||
|
migrated = 0
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
# If username looks like a full name (contains spaces), migrate it
|
||||||
|
if ' ' in user.username:
|
||||||
|
# Save the current username (which is actually the full name)
|
||||||
|
actual_full_name = user.username
|
||||||
|
# Generate a username from the full name
|
||||||
|
new_username = slugify(actual_full_name)
|
||||||
|
# Ensure uniqueness
|
||||||
|
base_username = new_username
|
||||||
|
counter = 1
|
||||||
|
while User.query.filter_by(username=new_username).first() and User.query.get(user.id).username != new_username:
|
||||||
|
new_username = f"{base_username}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
user.username = new_username
|
||||||
|
user.full_name = actual_full_name
|
||||||
|
migrated += 1
|
||||||
|
print(f"Migrated: '{actual_full_name}' -> username='{new_username}', full_name='{actual_full_name}'")
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
print(f"\n[MIGRATION] Migrated {migrated} users")
|
||||||
|
print("Done! Usernames are now properly stored.")
|
||||||
|
print("Users should edit their profile to set a proper username and full name.")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app = create_app()
|
||||||
|
migrate()
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""Migration: Create tryout_coaches association table and migrate existing data.
|
|
||||||
|
|
||||||
Run this script to create the many-to-many relationship between tryouts and coaches.
|
|
||||||
Usage: python migrations/add_tryout_coaches.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
from app.app import create_app
|
|
||||||
from app.extensions import db
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
|
|
||||||
with app.app_context():
|
|
||||||
# Check if table already exists
|
|
||||||
result = db.session.execute(text(
|
|
||||||
"SELECT COUNT(*) FROM information_schema.tables "
|
|
||||||
"WHERE table_name = 'tryout_coaches'"
|
|
||||||
))
|
|
||||||
exists = result.scalar() > 0
|
|
||||||
|
|
||||||
if exists:
|
|
||||||
print("Table 'tryout_coaches' already exists. Skipping creation.")
|
|
||||||
else:
|
|
||||||
db.session.execute(text("""
|
|
||||||
CREATE TABLE tryout_coaches (
|
|
||||||
tryout_id INTEGER NOT NULL,
|
|
||||||
coach_id INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (tryout_id, coach_id),
|
|
||||||
FOREIGN KEY (tryout_id) REFERENCES tryouts (id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (coach_id) REFERENCES users (id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
db.session.commit()
|
|
||||||
print("Created 'tryout_coaches' association table.")
|
|
||||||
|
|
||||||
# Migrate existing coach_id data into the new table
|
|
||||||
result = db.session.execute(text(
|
|
||||||
"SELECT COUNT(*) FROM tryouts WHERE coach_id IS NOT NULL"
|
|
||||||
))
|
|
||||||
count = result.scalar()
|
|
||||||
|
|
||||||
if count > 0:
|
|
||||||
# Check how many already migrated
|
|
||||||
migrated = db.session.execute(text(
|
|
||||||
"SELECT COUNT(*) FROM tryout_coaches"
|
|
||||||
)).scalar()
|
|
||||||
|
|
||||||
if migrated == 0:
|
|
||||||
db.session.execute(text("""
|
|
||||||
INSERT INTO tryout_coaches (tryout_id, coach_id)
|
|
||||||
SELECT id, coach_id FROM tryouts WHERE coach_id IS NOT NULL
|
|
||||||
"""))
|
|
||||||
db.session.commit()
|
|
||||||
print(f"Migrated {count} existing coach assignments to tryout_coaches.")
|
|
||||||
else:
|
|
||||||
print(f"Skipping data migration — {migrated} rows already exist in tryout_coaches.")
|
|
||||||
else:
|
|
||||||
print("No existing coach assignments to migrate.")
|
|
||||||
|
|
||||||
print("Migration complete.")
|
|
||||||
@@ -0,0 +1,865 @@
|
|||||||
|
"""Database models for the Team Tryouts application.
|
||||||
|
|
||||||
|
This module defines all SQLAlchemy models including User, Tryout, Evaluation,
|
||||||
|
Team, Match, and Contract entities with their relationships and helper methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from extensions import db, login_manager
|
||||||
|
from flask_login import UserMixin
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
|
# Available user roles in the system
|
||||||
|
ROLES = ['president', 'manager', 'coach', 'player', 'scout']
|
||||||
|
|
||||||
|
# Popular E-Sports games list for player profiles
|
||||||
|
ESPORT_GAMES = [
|
||||||
|
'Valorant',
|
||||||
|
'League of Legends',
|
||||||
|
'Counter-Strike 2',
|
||||||
|
'Apex Legends',
|
||||||
|
'Overwatch 2',
|
||||||
|
'Rainbow Six Siege',
|
||||||
|
'Rocket League',
|
||||||
|
'Super Smash Bros.'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Game-specific positions for tryouts
|
||||||
|
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.': []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
"""Load a user by ID for Flask-Login session management.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id (int): The user's unique identifier.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User: The User object if found, None otherwise.
|
||||||
|
"""
|
||||||
|
return User.query.get(int(user_id))
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserMixin, db.Model):
|
||||||
|
"""User model representing all users in the system.
|
||||||
|
|
||||||
|
Users can have different roles: president, manager, coach, player, or scout.
|
||||||
|
Each user has E-Sports specific fields for competitive gaming profiles.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier for the user.
|
||||||
|
username: Unique username for login.
|
||||||
|
password_hash: Hashed password for authentication.
|
||||||
|
role: User role determining permissions.
|
||||||
|
full_name: User's display name.
|
||||||
|
email: User's email address.
|
||||||
|
phone: Optional phone number.
|
||||||
|
is_active_account: Whether the account is active.
|
||||||
|
games: Comma-separated list of games the user plays.
|
||||||
|
discord_username: User's Discord handle.
|
||||||
|
league_os_profile: Link to League OS profile.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'users'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||||
|
password_hash = db.Column(db.String(128), nullable=False)
|
||||||
|
role = db.Column(db.String(20), nullable=False, default='player')
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Account lockout fields for brute-force protection
|
||||||
|
failed_login_attempts = db.Column(db.Integer, default=0)
|
||||||
|
locked_until = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
# E-Sports specific fields
|
||||||
|
games = db.Column(db.Text, nullable=True) # Comma-separated list of games
|
||||||
|
discord_username = db.Column(db.String(128), nullable=True) # Discord handle (e.g., Username#1234)
|
||||||
|
discord_user_id = db.Column(db.String(64), nullable=True) # Discord User ID for DMs (numeric, e.g., 123456789012345678)
|
||||||
|
league_os_profile = db.Column(db.String(256), nullable=True) # League OS profile URL or ID
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
def get_games_list(self):
|
||||||
|
"""Return the user's games as a list.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of game names the user plays, or empty list if none.
|
||||||
|
"""
|
||||||
|
if self.games:
|
||||||
|
return [g.strip() for g in self.games.split(',') if g.strip()]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def has_role(self, *roles):
|
||||||
|
"""Check if the user has one of the specified roles.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
*roles: Variable number of role names to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if user has any of the specified roles.
|
||||||
|
"""
|
||||||
|
return self.role in roles
|
||||||
|
|
||||||
|
def can_evaluate(self):
|
||||||
|
"""Check if user can evaluate other players.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if user is president, manager, or coach.
|
||||||
|
"""
|
||||||
|
return self.role in ['president', 'manager', 'coach']
|
||||||
|
|
||||||
|
def can_manage_users(self):
|
||||||
|
"""Check if user can manage (create/delete) other users.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if user is president.
|
||||||
|
"""
|
||||||
|
return self.role == 'president'
|
||||||
|
|
||||||
|
def can_manage_tryouts(self):
|
||||||
|
"""Check if user can manage tryouts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if user is president, manager or coach.
|
||||||
|
"""
|
||||||
|
return self.role in ['president', 'manager', 'coach']
|
||||||
|
|
||||||
|
def can_manage_teams(self):
|
||||||
|
"""Check if user can manage organization teams.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if user is president or manager.
|
||||||
|
"""
|
||||||
|
return self.role in ['president', 'manager']
|
||||||
|
|
||||||
|
def can_schedule_matches(self):
|
||||||
|
"""Check if user can schedule matches.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if user is president, manager, or coach.
|
||||||
|
"""
|
||||||
|
return self.role in ['president', 'manager', 'coach']
|
||||||
|
|
||||||
|
def can_manage_this_tryout(self, tryout):
|
||||||
|
"""Check if user can manage a specific tryout.
|
||||||
|
|
||||||
|
Presidents can manage all tryouts. Managers can manage their own tryouts
|
||||||
|
or tryouts where they are assigned as manager. Coaches can manage tryouts
|
||||||
|
targeting their coached team or where they are assigned as coach.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tryout: The Tryout object to check permissions for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if user has permission to manage the tryout.
|
||||||
|
"""
|
||||||
|
if self.role == 'president':
|
||||||
|
return True
|
||||||
|
if self.role == 'manager' and (tryout.created_by == self.id or tryout.manager_id == self.id):
|
||||||
|
return True
|
||||||
|
if self.role == 'coach':
|
||||||
|
org_team = OrgTeam.query.filter_by(coach_id=self.id).first()
|
||||||
|
if org_team and tryout.target_org_team_id == org_team.id:
|
||||||
|
return True
|
||||||
|
if tryout.coach_id == self.id:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def can_manage_this_org_team(self, org_team):
|
||||||
|
"""Check if user can manage a specific org team.
|
||||||
|
|
||||||
|
Presidents can manage all org teams. Managers can manage all org teams.
|
||||||
|
Coaches can only manage their own coached team.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_team: The OrgTeam object to check permissions for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if user has permission to manage the org team.
|
||||||
|
"""
|
||||||
|
if self.role == 'president':
|
||||||
|
return True
|
||||||
|
if self.role == 'manager':
|
||||||
|
return True # Managers can manage all org teams (create/edit/delete)
|
||||||
|
if self.role == 'coach' and org_team.coach_id == self.id:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_gamertags(self):
|
||||||
|
"""Return gamertags as a dictionary keyed by game.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Dictionary with game names as keys and gamertag info as values.
|
||||||
|
"""
|
||||||
|
return {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in self.gamertags}
|
||||||
|
|
||||||
|
def get_org_teams(self):
|
||||||
|
"""Return all organization teams this player belongs to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of OrgTeam objects the player is assigned to.
|
||||||
|
"""
|
||||||
|
return [tp.org_team for tp in self.team_placements]
|
||||||
|
|
||||||
|
|
||||||
|
# Platform options for games that require platform specification
|
||||||
|
GAME_PLATFORMS = {
|
||||||
|
'Valorant': [], # No platform needed
|
||||||
|
'League of Legends': [],
|
||||||
|
'Counter-Strike 2': [],
|
||||||
|
'Apex Legends': ['PC', 'PlayStation', 'Xbox', 'Nintendo Switch'],
|
||||||
|
'Overwatch 2': [],
|
||||||
|
'Rainbow Six Siege': ['Ubisoft', 'PlayStation', 'Xbox'], # Ubisoft = Uplay/Steam
|
||||||
|
'Rocket League': ['Epic', 'PlayStation', 'Xbox', 'Nintendo Switch'], # Epic uses EpicID, others use username
|
||||||
|
'Super Smash Bros.': ['Nintendo Switch'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Platform code mapping for TRN URLs (platform -> TRN code)
|
||||||
|
PLATFORM_CODES = {
|
||||||
|
'Ubisoft': 'ubi',
|
||||||
|
'PlayStation': 'psn',
|
||||||
|
'Xbox': 'xbl',
|
||||||
|
'Nintendo Switch': 'switch',
|
||||||
|
'PC': 'pc',
|
||||||
|
'Steam': 'steam',
|
||||||
|
'Epic': 'epic',
|
||||||
|
}
|
||||||
|
|
||||||
|
# TRN URL mapping for each game
|
||||||
|
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}',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UserGamertag(db.Model):
|
||||||
|
"""Store gamertag per game for each user.
|
||||||
|
|
||||||
|
Allows users to link their gaming profiles for different games with optional
|
||||||
|
platform specification for cross-platform games.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
user_id: Foreign key to the user.
|
||||||
|
game: Name of the game.
|
||||||
|
gamertag: Player's gamertag/username for the game.
|
||||||
|
platform: Platform for cross-platform games (optional).
|
||||||
|
"""
|
||||||
|
__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) # For games that need platform (e.g., PSN, Xbox Live)
|
||||||
|
|
||||||
|
user = db.relationship('User', backref='gamertags')
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('user_id', 'game', name='unique_user_game'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_trn_url(self):
|
||||||
|
"""Generate the TRN (Tracker Network) URL for this gamertag.
|
||||||
|
|
||||||
|
Builds the appropriate URL based on the game, handling platform codes
|
||||||
|
and URL encoding for special characters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The Tracker Network URL for the gamertag, or None if game not supported.
|
||||||
|
"""
|
||||||
|
if self.game not in TRN_URLS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = TRN_URLS[self.game]
|
||||||
|
# URL-encode the gamertag to handle special characters like #, spaces, etc.
|
||||||
|
encoded_gamertag = quote(self.gamertag, safe='')
|
||||||
|
|
||||||
|
# Check for platform_code placeholder (used for R6 and Rocket League)
|
||||||
|
if '{platform_code}' in url and '{username}' in url:
|
||||||
|
platform_code = PLATFORM_CODES.get(self.platform, self.platform.lower().replace(' ', '-')) if self.platform else ''
|
||||||
|
return url.format(platform_code=platform_code, username=encoded_gamertag)
|
||||||
|
elif '{platform}' in url and '{username}' in url:
|
||||||
|
return url.format(platform=self.platform.lower().replace(' ', '-'), username=encoded_gamertag)
|
||||||
|
elif '{username}' in url:
|
||||||
|
return url.format(username=encoded_gamertag)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
class TeamPlayer(db.Model):
|
||||||
|
"""Many-to-many relationship between players and organization teams.
|
||||||
|
|
||||||
|
Allows a player to be in multiple teams with a status of starter or substitute.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
player_id: Foreign key to the player.
|
||||||
|
org_team_id: Foreign key to the organization team.
|
||||||
|
status: Player status on the team ('starter' or 'substitute').
|
||||||
|
position: Player's position on the team (optional).
|
||||||
|
added_at: Timestamp when player was added.
|
||||||
|
"""
|
||||||
|
__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') # 'starter' or 'substitute'
|
||||||
|
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'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrgTeam(db.Model):
|
||||||
|
"""Persistent organization teams (e.g., Varsity, JV) that exist across tryouts.
|
||||||
|
|
||||||
|
These teams are long-term organizational structures that persist beyond
|
||||||
|
individual tryouts, unlike tryout-specific Team entities.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
name: Team name (e.g., "Varsity", "Junior Varsity").
|
||||||
|
coach_id: Foreign key to the assigned coach.
|
||||||
|
manager_id: Foreign key to the assigned manager.
|
||||||
|
created_by: Foreign key to the user who created the team.
|
||||||
|
created_at: Timestamp of team creation.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'org_teams'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||||
|
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||||
|
manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||||
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
coach = db.relationship('User', foreign_keys=[coach_id], backref='coached_org_team', uselist=False)
|
||||||
|
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_org_team', uselist=False)
|
||||||
|
creator = db.relationship('User', foreign_keys=[created_by])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def players(self):
|
||||||
|
"""Get all players assigned to this team.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of User objects assigned to this team.
|
||||||
|
"""
|
||||||
|
return [tp.player for tp in self.team_players]
|
||||||
|
|
||||||
|
def get_players_with_status(self):
|
||||||
|
"""Get all players with their status on this team.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of dicts with 'player' and 'status' keys.
|
||||||
|
"""
|
||||||
|
return [{'player': tp.player, 'status': tp.status, 'position': tp.position} for tp in self.team_players]
|
||||||
|
|
||||||
|
|
||||||
|
class Tryout(db.Model):
|
||||||
|
"""Tryout event for player evaluations and team formation.
|
||||||
|
|
||||||
|
Represents a scheduled tryout session where players can register
|
||||||
|
and be evaluated by coaches/managers.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
title: Tryout title/name.
|
||||||
|
description: Optional description of the tryout.
|
||||||
|
game: The game this tryout is for.
|
||||||
|
date: Date of the tryout.
|
||||||
|
location: Physical or virtual location.
|
||||||
|
status: Current status (upcoming, in_progress, completed).
|
||||||
|
max_players: Maximum number of players allowed.
|
||||||
|
created_by: Foreign key to the creating manager/president.
|
||||||
|
target_org_team_id: Foreign key to target organization team.
|
||||||
|
manager_id: Foreign key to the assigned manager.
|
||||||
|
coach_id: Foreign key to the assigned coach.
|
||||||
|
created_at: Timestamp of creation.
|
||||||
|
"""
|
||||||
|
__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)
|
||||||
|
location = db.Column(db.String(200), nullable=True)
|
||||||
|
status = db.Column(db.String(20), default='upcoming') # upcoming, in_progress, completed
|
||||||
|
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)
|
||||||
|
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='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])
|
||||||
|
|
||||||
|
|
||||||
|
class TryoutRegistration(db.Model):
|
||||||
|
"""Registration linking a player to a tryout.
|
||||||
|
|
||||||
|
Tracks which players have registered for which tryouts and their
|
||||||
|
attendance status.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
tryout_id: Foreign key to the tryout.
|
||||||
|
player_id: Foreign key to the registered player.
|
||||||
|
registered_at: Timestamp of registration.
|
||||||
|
status: Registration status (registered, attended, no_show).
|
||||||
|
notes: Optional notes about the registration.
|
||||||
|
"""
|
||||||
|
__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') # registered, attended, no_show
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Evaluation(db.Model):
|
||||||
|
"""Player evaluation record.
|
||||||
|
|
||||||
|
Contains scored evaluations from coaches/managers for players
|
||||||
|
during tryouts.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
tryout_id: Foreign key to the tryout.
|
||||||
|
player_id: Foreign key to the evaluated player.
|
||||||
|
evaluator_id: Foreign key to the evaluating coach/manager.
|
||||||
|
speed_score: Speed rating (1-10).
|
||||||
|
agility_score: Agility rating (1-10).
|
||||||
|
technique_score: Technique rating (1-10).
|
||||||
|
teamwork_score: Teamwork rating (1-10).
|
||||||
|
attitude_score: Attitude rating (1-10).
|
||||||
|
overall_score: Average of all scores.
|
||||||
|
comments: Optional evaluator comments.
|
||||||
|
position_recommendation: Recommended position.
|
||||||
|
created_at: Timestamp of creation.
|
||||||
|
updated_at: Timestamp of last update.
|
||||||
|
"""
|
||||||
|
__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'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Team(db.Model):
|
||||||
|
"""Tryout-specific team (e.g., Alpha, Bravo within a single tryout).
|
||||||
|
|
||||||
|
Teams created during a tryout for match scheduling purposes.
|
||||||
|
Different from OrgTeam which is a long-term organizational entity.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
tryout_id: Foreign key to the parent tryout.
|
||||||
|
name: Team name.
|
||||||
|
created_by: Foreign key to the creator.
|
||||||
|
created_at: Timestamp of creation.
|
||||||
|
"""
|
||||||
|
__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')
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMember(db.Model):
|
||||||
|
"""Link between a player and a tryout-specific team.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
team_id: Foreign key to the team.
|
||||||
|
player_id: Foreign key to the player.
|
||||||
|
position: Player's position on the team.
|
||||||
|
added_at: Timestamp when player was added.
|
||||||
|
"""
|
||||||
|
__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") # Many-to-one, no dynamic loader
|
||||||
|
|
||||||
|
|
||||||
|
class Match(db.Model):
|
||||||
|
"""Match/scrimmage scheduled within a tryout.
|
||||||
|
|
||||||
|
Can be team vs team or player scrim type.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
tryout_id: Foreign key to the parent tryout.
|
||||||
|
title: Match title/name.
|
||||||
|
description: Optional description.
|
||||||
|
date: Match date.
|
||||||
|
start_time: Match start time.
|
||||||
|
end_time: Match end time.
|
||||||
|
location: Match location.
|
||||||
|
status: Match status (scheduled, completed, cancelled).
|
||||||
|
match_type: Type of match (team_vs_team, player_vs_player, player_scrim).
|
||||||
|
created_by: Foreign key to the creator.
|
||||||
|
created_at: Timestamp of creation.
|
||||||
|
team1_id: Foreign key to first team (for team_vs_team).
|
||||||
|
team2_id: Foreign key to second team (for team_vs_team).
|
||||||
|
"""
|
||||||
|
__tablename__ = 'matches'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
|
||||||
|
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') # scheduled, completed, cancelled
|
||||||
|
match_type = db.Column(db.String(20), nullable=False) # team_vs_team, player_scrim
|
||||||
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# For team vs team matches
|
||||||
|
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 list of player IDs participating in this match.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of unique player IDs.
|
||||||
|
"""
|
||||||
|
return [p.player_id for p in self.participants.all()]
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerDisponibility(db.Model):
|
||||||
|
"""Player availability in 30-minute time blocks for scheduling matches.
|
||||||
|
|
||||||
|
Allows players to specify when they're available for matches.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
player_id: Foreign key to the player.
|
||||||
|
day_of_week: Day of week (0=Monday, 6=Sunday).
|
||||||
|
start_time: Start time of availability block.
|
||||||
|
end_time: End time of availability block (always 30 min after start).
|
||||||
|
created_at: Timestamp of creation.
|
||||||
|
updated_at: Timestamp of last update.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'player_disponibilities'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
day_of_week = db.Column(db.Integer, nullable=False) # 0=Monday, 6=Sunday
|
||||||
|
start_time = db.Column(db.Time, nullable=False)
|
||||||
|
end_time = db.Column(db.Time, nullable=False) # Always 30 minutes after start_time
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
player = db.relationship('User', backref='disponibilities')
|
||||||
|
|
||||||
|
|
||||||
|
class MatchParticipant(db.Model):
|
||||||
|
"""Players participating in player scrimmage matches.
|
||||||
|
|
||||||
|
Links players to matches for player_scrim and player_vs_player match types.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
match_id: Foreign key to the match.
|
||||||
|
player_id: Foreign key to the player.
|
||||||
|
team_side: Team side (1 or 2) for player_vs_player matches.
|
||||||
|
position: Player's position for this match.
|
||||||
|
added_at: Timestamp when added.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'match_participants'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False)
|
||||||
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
team_side = db.Column(db.Integer, nullable=True) # 1 for team 1, 2 for team 2 (for player_vs_player matches)
|
||||||
|
position = db.Column(db.String(50), nullable=True) # Position for this match
|
||||||
|
attendance_confirmed = db.Column(db.Boolean, default=False) # Whether the player confirmed via Discord or manual toggle
|
||||||
|
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
player = db.relationship('User')
|
||||||
|
|
||||||
|
|
||||||
|
class Contract(db.Model):
|
||||||
|
"""Contract documents for players to sign.
|
||||||
|
|
||||||
|
Tracks contract uploads and signed documents for players.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
player_id: Foreign key to the player.
|
||||||
|
team_id: Foreign key to the player's team.
|
||||||
|
uploaded_by_id: Foreign key to the uploader.
|
||||||
|
original_filename: Original uploaded file name.
|
||||||
|
stored_filename: Stored file name on disk.
|
||||||
|
file_path: Full path to the contract file.
|
||||||
|
signed_filename: Signed file name (if signed).
|
||||||
|
signed_file_path: Full path to signed contract (if signed).
|
||||||
|
status: Contract status (pending, signed).
|
||||||
|
notes: Optional notes.
|
||||||
|
uploaded_at: Timestamp of upload.
|
||||||
|
signed_at: Timestamp of signing (if signed).
|
||||||
|
"""
|
||||||
|
__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)
|
||||||
|
|
||||||
|
# File information
|
||||||
|
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 contract information
|
||||||
|
signed_filename = db.Column(db.String(255), nullable=True)
|
||||||
|
signed_file_path = db.Column(db.String(500), nullable=True)
|
||||||
|
|
||||||
|
# Status and metadata
|
||||||
|
status = db.Column(db.String(20), default='pending') # pending, signed
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
signed_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
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):
|
||||||
|
"""Check if a user can view this contract.
|
||||||
|
|
||||||
|
Players can always view their own contracts. Presidents and managers
|
||||||
|
have full access. Coaches can view contracts for players on their teams.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The User object requesting access.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if user has permission to view the contract.
|
||||||
|
"""
|
||||||
|
# Player can always view their own contracts
|
||||||
|
if user.id == self.player_id:
|
||||||
|
return True
|
||||||
|
# President can view all contracts
|
||||||
|
if user.role == 'president':
|
||||||
|
return True
|
||||||
|
# Manager can view contracts for players on their teams
|
||||||
|
if user.role == 'manager':
|
||||||
|
player = User.query.get(self.player_id)
|
||||||
|
if player and player.get_org_teams():
|
||||||
|
return True
|
||||||
|
# Coach can view contracts for players on their team
|
||||||
|
if user.role == '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):
|
||||||
|
"""Check if a user can upload a signed contract.
|
||||||
|
|
||||||
|
Only the player themselves can upload their signed contract.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The User object requesting to upload.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if user is the player associated with the contract.
|
||||||
|
"""
|
||||||
|
# Only the player can upload their signed contract
|
||||||
|
return user.id == self.player_id
|
||||||
|
|
||||||
|
|
||||||
|
class CoachAvailability(db.Model):
|
||||||
|
"""Coach availability in 30-minute time blocks for One on One sessions.
|
||||||
|
|
||||||
|
Allows coaches to specify when they're available for individual coaching sessions.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
coach_id: Foreign key to the coach.
|
||||||
|
day_of_week: Day of week (0=Monday, 6=Sunday).
|
||||||
|
start_time: Start time of availability block.
|
||||||
|
end_time: End time of availability block (always 30 min after start).
|
||||||
|
created_at: Timestamp of creation.
|
||||||
|
updated_at: Timestamp of last update.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'coach_availabilities'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
day_of_week = db.Column(db.Integer, nullable=False) # 0=Monday, 6=Sunday
|
||||||
|
start_time = db.Column(db.Time, nullable=False)
|
||||||
|
end_time = db.Column(db.Time, nullable=False) # Always 30 minutes after start_time
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
coach = db.relationship('User', backref='coach_availabilities')
|
||||||
|
|
||||||
|
|
||||||
|
class TeamNote(db.Model):
|
||||||
|
"""Team improvement notes from coach.
|
||||||
|
|
||||||
|
Contains coaching notes and suggestions for team improvement.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
org_team_id: Foreign key to the organization team.
|
||||||
|
coach_id: Foreign key to the coach who wrote the notes.
|
||||||
|
content: The note content.
|
||||||
|
created_at: Timestamp of creation.
|
||||||
|
updated_at: Timestamp of last update.
|
||||||
|
"""
|
||||||
|
__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])
|
||||||
|
|
||||||
|
|
||||||
|
class PersonalNote(db.Model):
|
||||||
|
"""Personal notes from coach to individual player.
|
||||||
|
|
||||||
|
Contains individual feedback and coaching tips for players.
|
||||||
|
Can be linked to specific contexts: match, team, or tryout.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
player_id: Foreign key to the player.
|
||||||
|
coach_id: Foreign key to the coach who wrote the notes.
|
||||||
|
content: The note content.
|
||||||
|
created_at: Timestamp of creation.
|
||||||
|
updated_at: Timestamp of last update.
|
||||||
|
match_id: Optional foreign key to the match context.
|
||||||
|
team_id: Optional foreign key to the team context.
|
||||||
|
tryout_id: Optional foreign key to the tryout context.
|
||||||
|
"""
|
||||||
|
__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)
|
||||||
|
|
||||||
|
# Optional context linking
|
||||||
|
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])
|
||||||
|
|
||||||
|
|
||||||
|
class OneOnOneRequest(db.Model):
|
||||||
|
"""Request from player to coach for a One on One session.
|
||||||
|
|
||||||
|
Tracks requests for individual coaching sessions with time slot selection.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique identifier.
|
||||||
|
player_id: Foreign key to the player requesting.
|
||||||
|
coach_id: Foreign key to the coach.
|
||||||
|
org_team_id: Foreign key to the player's team.
|
||||||
|
date: Requested date for the session.
|
||||||
|
start_time: Requested start time.
|
||||||
|
end_time: Requested end time.
|
||||||
|
points: What the player wants to discuss.
|
||||||
|
status: Request status (pending, approved, rejected, scheduled).
|
||||||
|
created_at: Timestamp of creation.
|
||||||
|
responded_at: Timestamp when coach responded.
|
||||||
|
discord_message_id: Discord message ID for reaction handling.
|
||||||
|
coach_rejection_message: Optional justification from coach when rejecting.
|
||||||
|
"""
|
||||||
|
__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') # pending, approved, rejected, scheduled
|
||||||
|
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) # Discord message ID for reaction handling
|
||||||
|
coach_rejection_message = db.Column(db.Text, nullable=True) # Optional justification when rejecting
|
||||||
|
|
||||||
|
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])
|
||||||
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.
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.
Binary file not shown.
@@ -6,36 +6,19 @@ password policy enforcement and CAPTCHA verification.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
|
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 flask_login import login_user, logout_user, login_required, current_user
|
||||||
from app.extensions import db, hash_password, check_password, limiter
|
from extensions import db, hash_password, check_password, limiter
|
||||||
from app.models import User, Player, ESPORT_GAMES
|
from models import User, ESPORT_GAMES
|
||||||
from app.validators import RegisterSchema, LoginSchema
|
from validators import RegisterSchema, LoginSchema
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
import requests
|
|
||||||
|
|
||||||
# Account lockout settings
|
# Account lockout settings
|
||||||
MAX_LOGIN_ATTEMPTS = 5
|
MAX_LOGIN_ATTEMPTS = 5
|
||||||
LOCKOUT_DURATION_MINUTES = 15
|
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):
|
def is_safe_url(url):
|
||||||
"""Validate that a URL is safe for redirection (same origin).
|
"""Validate that a URL is safe for redirection (same origin).
|
||||||
@@ -187,7 +170,7 @@ def login():
|
|||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||||
@limiter.limit("20 per hour")
|
@limiter.limit("3 per hour")
|
||||||
def register():
|
def register():
|
||||||
"""Handle new player registration with CAPTCHA and password policy.
|
"""Handle new player registration with CAPTCHA and password policy.
|
||||||
|
|
||||||
@@ -204,43 +187,34 @@ def register():
|
|||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
if request.method == 'POST':
|
# Generate CAPTCHA for GET requests
|
||||||
# Build form data from request to preserve state across re-renders
|
captcha = generate_captcha()
|
||||||
form_data = dict(request.form)
|
|
||||||
form_data['games'] = request.form.getlist('games')
|
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
# Validate CAPTCHA first
|
# Validate CAPTCHA first
|
||||||
captcha_answer = request.form.get('captcha_answer', '')
|
captcha_answer = request.form.get('captcha_answer', '')
|
||||||
if not verify_captcha(captcha_answer):
|
if not verify_captcha(captcha_answer):
|
||||||
flash('Incorrect CAPTCHA answer. Please try again.', 'danger')
|
flash('Incorrect CAPTCHA answer. Please try again.', 'danger')
|
||||||
captcha = generate_captcha()
|
captcha = generate_captcha() # Generate new captcha
|
||||||
# Clear password fields only on CAPTCHA failure
|
|
||||||
form_data.pop('password', None)
|
|
||||||
form_data.pop('confirm_password', None)
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'pages/register.html',
|
'pages/register.html',
|
||||||
esport_games=ESPORT_GAMES,
|
esport_games=ESPORT_GAMES,
|
||||||
captcha=captcha,
|
captcha=captcha
|
||||||
form_data=form_data,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate input with marshmallow schema
|
# Validate input with marshmallow schema
|
||||||
register_schema = RegisterSchema()
|
register_schema = RegisterSchema()
|
||||||
try:
|
try:
|
||||||
validated = register_schema.load(form_data)
|
validated = register_schema.load(request.form)
|
||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
for field, messages in err.messages.items():
|
for field, messages in err.messages.items():
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
flash(f'{field}: {msg}', 'danger')
|
flash(f'{field}: {msg}', 'danger')
|
||||||
captcha = generate_captcha()
|
captcha = generate_captcha()
|
||||||
# Clear password fields on validation failure
|
|
||||||
form_data.pop('password', None)
|
|
||||||
form_data.pop('confirm_password', None)
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'pages/register.html',
|
'pages/register.html',
|
||||||
esport_games=ESPORT_GAMES,
|
esport_games=ESPORT_GAMES,
|
||||||
captcha=captcha,
|
captcha=captcha
|
||||||
form_data=form_data,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
username = validated['username']
|
username = validated['username']
|
||||||
@@ -249,36 +223,30 @@ def register():
|
|||||||
full_name = validated['full_name']
|
full_name = validated['full_name']
|
||||||
phone = validated.get('phone')
|
phone = validated.get('phone')
|
||||||
selected_games = validated.get('games', [])
|
selected_games = validated.get('games', [])
|
||||||
|
trn_username = request.form.get('trn_username', '').strip() or None
|
||||||
discord_username = validated.get('discord_username')
|
discord_username = validated.get('discord_username')
|
||||||
discord_user_id = validated.get('discord_user_id')
|
|
||||||
league_os_profile = validated.get('league_os_profile')
|
league_os_profile = validated.get('league_os_profile')
|
||||||
|
|
||||||
if User.query.filter_by(username=username).first():
|
if User.query.filter_by(username=username).first():
|
||||||
flash('Username already exists.', 'danger')
|
flash('Username already exists.', 'danger')
|
||||||
captcha = generate_captcha()
|
captcha = generate_captcha()
|
||||||
form_data.pop('password', None)
|
|
||||||
form_data.pop('confirm_password', None)
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'pages/register.html',
|
'pages/register.html',
|
||||||
esport_games=ESPORT_GAMES,
|
esport_games=ESPORT_GAMES,
|
||||||
captcha=captcha,
|
captcha=captcha
|
||||||
form_data=form_data,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if User.query.filter_by(email=email).first():
|
if User.query.filter_by(email=email).first():
|
||||||
flash('Email already registered.', 'danger')
|
flash('Email already registered.', 'danger')
|
||||||
captcha = generate_captcha()
|
captcha = generate_captcha()
|
||||||
form_data.pop('password', None)
|
|
||||||
form_data.pop('confirm_password', None)
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'pages/register.html',
|
'pages/register.html',
|
||||||
esport_games=ESPORT_GAMES,
|
esport_games=ESPORT_GAMES,
|
||||||
captcha=captcha,
|
captcha=captcha
|
||||||
form_data=form_data,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
hashed_password = hash_password(password)
|
hashed_password = hash_password(password)
|
||||||
user = Player(
|
user = User(
|
||||||
username=username,
|
username=username,
|
||||||
password_hash=hashed_password,
|
password_hash=hashed_password,
|
||||||
role='player',
|
role='player',
|
||||||
@@ -287,173 +255,15 @@ def register():
|
|||||||
phone=phone,
|
phone=phone,
|
||||||
games=','.join(selected_games) if selected_games else None,
|
games=','.join(selected_games) if selected_games else None,
|
||||||
discord_username=discord_username,
|
discord_username=discord_username,
|
||||||
discord_user_id=discord_user_id,
|
league_os_profile=league_os_profile
|
||||||
league_os_profile=league_os_profile,
|
|
||||||
)
|
)
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
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')
|
flash('Your account has been created! You can now log in.', 'success')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
# GET request — render empty form
|
return render_template('pages/register.html', esport_games=ESPORT_GAMES, captcha=captcha)
|
||||||
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')
|
@auth_bp.route('/logout')
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
"""Evaluation routes for assessing player performance during tryouts.
|
"""Evaluation routes for assessing player performance during tryouts.
|
||||||
|
|
||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
This module handles player evaluation creation, management, and viewing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app.extensions import db
|
from extensions import db
|
||||||
from app.models import (
|
from models import User, Tryout, Evaluation, TryoutRegistration, GAME_POSITIONS, OrgTeam
|
||||||
Admin, Coach, Manager, Player,
|
|
||||||
User, Tryout, Evaluation, TryoutRegistration,
|
|
||||||
OrgTeam, GAME_POSITIONS,
|
|
||||||
)
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
@@ -18,7 +14,14 @@ evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
|||||||
|
|
||||||
|
|
||||||
def validate_score(score_value):
|
def validate_score(score_value):
|
||||||
"""Validate that a score is between 1 and 10."""
|
"""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.
|
||||||
|
"""
|
||||||
if score_value is None:
|
if score_value is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -33,18 +36,32 @@ def validate_score(score_value):
|
|||||||
@evaluations_bp.route('')
|
@evaluations_bp.route('')
|
||||||
@login_required
|
@login_required
|
||||||
def list_evaluations():
|
def list_evaluations():
|
||||||
"""List all evaluations accessible to the current user."""
|
"""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.
|
||||||
|
"""
|
||||||
user = current_user
|
user = current_user
|
||||||
|
|
||||||
if isinstance(user, Player):
|
# Players are not allowed to view evaluations
|
||||||
|
if user.role == 'player':
|
||||||
flash('You do not have permission to view evaluations.', 'danger')
|
flash('You do not have permission to view evaluations.', 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
# Get sort parameters
|
||||||
sort_column = request.args.get('sort', 'created_at')
|
sort_column = request.args.get('sort', 'created_at')
|
||||||
sort_order = request.args.get('order', 'desc')
|
sort_order = request.args.get('order', 'desc')
|
||||||
|
|
||||||
|
# Validate sort_order
|
||||||
if sort_order not in ('asc', 'desc'):
|
if sort_order not in ('asc', 'desc'):
|
||||||
sort_order = 'desc'
|
sort_order = 'desc'
|
||||||
|
|
||||||
|
# Map sort columns to SQLAlchemy expressions using aliased User models for relationship sorting
|
||||||
player_alias = aliased(User, name='eval_player')
|
player_alias = aliased(User, name='eval_player')
|
||||||
evaluator_alias = aliased(User, name='eval_evaluator')
|
evaluator_alias = aliased(User, name='eval_evaluator')
|
||||||
|
|
||||||
@@ -72,7 +89,7 @@ def list_evaluations():
|
|||||||
else:
|
else:
|
||||||
sort_expr = sort_expr.desc()
|
sort_expr = sort_expr.desc()
|
||||||
|
|
||||||
if isinstance(user, Admin):
|
if user.role == 'president':
|
||||||
evaluations = Evaluation.query \
|
evaluations = Evaluation.query \
|
||||||
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
|
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
|
||||||
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
|
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
|
||||||
@@ -81,16 +98,14 @@ def list_evaluations():
|
|||||||
avg_scores = db.session.query(
|
avg_scores = db.session.query(
|
||||||
Evaluation.player_id,
|
Evaluation.player_id,
|
||||||
func.count(Evaluation.id).label('eval_count'),
|
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()
|
).group_by(Evaluation.player_id).all()
|
||||||
player_scores = {}
|
player_scores = {}
|
||||||
for row in avg_scores:
|
for row in avg_scores:
|
||||||
p = User.query.get(row.player_id)
|
p = User.query.get(row.player_id)
|
||||||
if p:
|
if p:
|
||||||
player_scores[p.id] = {
|
player_scores[p.id] = {'player': p, 'count': row.eval_count, 'avg': round(row.avg_score, 1) if row.avg_score else 0}
|
||||||
'player': p, 'count': row.eval_count,
|
|
||||||
'avg': round(row.avg_score, 1) if row.avg_score else 0,
|
|
||||||
}
|
|
||||||
elif user.can_evaluate():
|
elif user.can_evaluate():
|
||||||
evaluations = Evaluation.query \
|
evaluations = Evaluation.query \
|
||||||
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
|
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
|
||||||
@@ -108,38 +123,53 @@ def list_evaluations():
|
|||||||
.order_by(sort_expr).all()
|
.order_by(sort_expr).all()
|
||||||
player_scores = {}
|
player_scores = {}
|
||||||
|
|
||||||
return render_template('pages/evaluations.html',
|
return render_template('pages/evaluations.html', evaluations=evaluations, player_scores=player_scores, sort_column=sort_column, sort_order=sort_order)
|
||||||
evaluations=evaluations, player_scores=player_scores,
|
|
||||||
sort_column=sort_column, sort_order=sort_order)
|
|
||||||
|
|
||||||
|
|
||||||
@evaluations_bp.route('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST'])
|
@evaluations_bp.route('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def evaluate_player(tryout_id, player_id):
|
def evaluate_player(tryout_id, player_id):
|
||||||
"""Evaluate a specific player in a tryout."""
|
"""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.
|
||||||
|
"""
|
||||||
if not current_user.can_evaluate():
|
if not current_user.can_evaluate():
|
||||||
flash('You do not have permission to evaluate players.', 'danger')
|
flash('You do not have permission to evaluate players.', 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
tryout = Tryout.query.get_or_404(tryout_id)
|
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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash('You do not have permission to evaluate players in this tryout.', 'danger')
|
flash('You do not have permission to evaluate players in this tryout.', 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
|
|
||||||
|
# Check if player is registered for this tryout
|
||||||
is_registered = TryoutRegistration.query.filter_by(
|
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
|
).first() is not None
|
||||||
if not is_registered:
|
if not is_registered:
|
||||||
flash('Player is not registered for this tryout.', 'danger')
|
flash('Player is not registered for this tryout.', 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
player = User.query.get_or_404(player_id)
|
player = User.query.get_or_404(player_id)
|
||||||
if not isinstance(player, Player):
|
|
||||||
|
if player.role != 'player':
|
||||||
flash('Can only evaluate players.', 'danger')
|
flash('Can only evaluate players.', 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
existing_eval = Evaluation.query.filter_by(
|
existing_eval = Evaluation.query.filter_by(
|
||||||
tryout_id=tryout_id, player_id=player_id, evaluator_id=current_user.id,
|
tryout_id=tryout_id,
|
||||||
|
player_id=player_id,
|
||||||
|
evaluator_id=current_user.id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@@ -155,9 +185,7 @@ def evaluate_player(tryout_id, player_id):
|
|||||||
comments = request.form.get('comments')
|
comments = request.form.get('comments')
|
||||||
position = request.form.get('position_recommendation')
|
position = request.form.get('position_recommendation')
|
||||||
|
|
||||||
scores = [s for s in [mecanics, cohesion, communication, gamesense,
|
scores = [s for s in [mecanics, cohesion, communication, gamesense, versatility, discipline, analysis, sport_ethics, mental] if s is not None]
|
||||||
versatility, discipline, analysis, sport_ethics, mental]
|
|
||||||
if s is not None]
|
|
||||||
overall = sum(scores) / len(scores) if scores else None
|
overall = sum(scores) / len(scores) if scores else None
|
||||||
|
|
||||||
if existing_eval:
|
if existing_eval:
|
||||||
@@ -176,14 +204,21 @@ def evaluate_player(tryout_id, player_id):
|
|||||||
flash('Evaluation updated!', 'success')
|
flash('Evaluation updated!', 'success')
|
||||||
else:
|
else:
|
||||||
evaluation = Evaluation(
|
evaluation = Evaluation(
|
||||||
tryout_id=tryout_id, player_id=player_id,
|
tryout_id=tryout_id,
|
||||||
|
player_id=player_id,
|
||||||
evaluator_id=current_user.id,
|
evaluator_id=current_user.id,
|
||||||
mecanics_score=mecanics, cohesion_score=cohesion,
|
mecanics_score=mecanics,
|
||||||
communication_score=communication, gamesense_score=gamesense,
|
cohesion_score=cohesion,
|
||||||
versatility_score=versatility, discipline_score=discipline,
|
communication_score=communication,
|
||||||
analysis_score=analysis, sport_ethics_score=sport_ethics,
|
gamesense_score=gamesense,
|
||||||
mental_score=mental, overall_score=overall,
|
versatility_score=versatility,
|
||||||
comments=comments, position_recommendation=position,
|
discipline_score=discipline,
|
||||||
|
analysis_score=analysis,
|
||||||
|
sport_ethics_score=sport_ethics,
|
||||||
|
mental_score=mental,
|
||||||
|
overall_score=overall,
|
||||||
|
comments=comments,
|
||||||
|
position_recommendation=position
|
||||||
)
|
)
|
||||||
db.session.add(evaluation)
|
db.session.add(evaluation)
|
||||||
flash('Evaluation submitted successfully!', 'success')
|
flash('Evaluation submitted successfully!', 'success')
|
||||||
@@ -192,42 +227,53 @@ def evaluate_player(tryout_id, player_id):
|
|||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
evaluators = None
|
evaluators = None
|
||||||
if isinstance(current_user, Admin):
|
if current_user.role == 'president':
|
||||||
all_evaluations = Evaluation.query.filter_by(
|
all_evaluations = Evaluation.query.filter_by(tryout_id=tryout_id, player_id=player_id).all()
|
||||||
tryout_id=tryout_id, player_id=player_id,
|
evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations]
|
||||||
).all()
|
|
||||||
evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e}
|
|
||||||
for e in all_evaluations]
|
|
||||||
|
|
||||||
return render_template('pages/evaluate_player.html',
|
return render_template('pages/evaluate_player.html',
|
||||||
tryout=tryout, player=player,
|
tryout=tryout,
|
||||||
existing_eval=existing_eval,
|
player=player,
|
||||||
evaluators=evaluators,
|
existing_eval=existing_eval,
|
||||||
game_positions=GAME_POSITIONS)
|
evaluators=evaluators,
|
||||||
|
game_positions=GAME_POSITIONS)
|
||||||
|
|
||||||
|
|
||||||
@evaluations_bp.route('/<int:tryout_id>/players')
|
@evaluations_bp.route('/<int:tryout_id>/players')
|
||||||
@login_required
|
@login_required
|
||||||
def players_to_evaluate(tryout_id):
|
def players_to_evaluate(tryout_id):
|
||||||
"""List players that need evaluation in a specific tryout."""
|
"""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.
|
||||||
|
"""
|
||||||
if not current_user.can_evaluate():
|
if not current_user.can_evaluate():
|
||||||
flash('Permission denied.', 'danger')
|
flash('Permission denied.', 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
tryout = Tryout.query.get_or_404(tryout_id)
|
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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash('You do not have permission to evaluate players in this tryout.', 'danger')
|
flash('You do not have permission to evaluate players in this tryout.', 'danger')
|
||||||
return redirect(url_for('tryouts.list_tryouts'))
|
return redirect(url_for('tryouts.list_tryouts'))
|
||||||
|
|
||||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||||
players = []
|
players = []
|
||||||
for reg in registrations:
|
for reg in registrations:
|
||||||
p = User.query.get(reg.player_id)
|
p = User.query.get(reg.player_id)
|
||||||
if p and isinstance(p, Player):
|
if p and p.role == 'player':
|
||||||
existing = Evaluation.query.filter_by(
|
existing = Evaluation.query.filter_by(
|
||||||
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
|
tryout_id=tryout_id,
|
||||||
|
player_id=p.id,
|
||||||
|
evaluator_id=current_user.id
|
||||||
).first()
|
).first()
|
||||||
players.append({'player': p, 'evaluated': existing is not None,
|
players.append({'player': p, 'evaluated': existing is not None, 'registration': reg})
|
||||||
'registration': reg})
|
|
||||||
|
|
||||||
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players)
|
||||||
@@ -1,25 +1,27 @@
|
|||||||
"""Main dashboard routes for the Team Tryouts application.
|
"""Main dashboard routes for the Team Tryouts application.
|
||||||
|
|
||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
This module provides the main dashboard view with role-specific statistics.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, render_template, redirect, url_for, flash
|
from flask import Blueprint, render_template, redirect, url_for, flash
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app.extensions import db
|
from extensions import db
|
||||||
from app.models import (
|
from models import User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember, Match, MatchParticipant, OrgTeam
|
||||||
Admin, Manager, Coach, Player, Scout,
|
|
||||||
User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember,
|
|
||||||
Match, MatchParticipant, OrgTeam,
|
|
||||||
)
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from datetime import date
|
from datetime import datetime, date
|
||||||
|
|
||||||
main_bp = Blueprint('main', __name__)
|
main_bp = Blueprint('main', __name__)
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route('/')
|
@main_bp.route('/')
|
||||||
def index():
|
def index():
|
||||||
"""Redirect root URL to login page."""
|
"""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.
|
||||||
|
"""
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
|
||||||
@@ -27,13 +29,21 @@ def index():
|
|||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
"""Render the main dashboard with role-specific statistics.
|
"""Render the main dashboard with role-specific statistics.
|
||||||
|
|
||||||
Each User subclass provides its own stats view.
|
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.
|
||||||
"""
|
"""
|
||||||
user = current_user
|
user = current_user
|
||||||
stats = {}
|
stats = {}
|
||||||
|
|
||||||
if isinstance(user, Admin):
|
if user.role == 'president':
|
||||||
stats['total_users'] = User.query.count()
|
stats['total_users'] = User.query.count()
|
||||||
stats['total_players'] = User.query.filter_by(role='player').count()
|
stats['total_players'] = User.query.filter_by(role='player').count()
|
||||||
stats['total_tryouts'] = Tryout.query.count()
|
stats['total_tryouts'] = Tryout.query.count()
|
||||||
@@ -44,92 +54,101 @@ def dashboard():
|
|||||||
stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
|
stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
|
||||||
today = date.today()
|
today = date.today()
|
||||||
stats['upcoming_matches'] = Match.query.filter(
|
stats['upcoming_matches'] = Match.query.filter(
|
||||||
Match.status == 'scheduled', Match.date >= today,
|
Match.status == 'scheduled',
|
||||||
|
Match.date >= today
|
||||||
).order_by(Match.date, Match.start_time).limit(5).all()
|
).order_by(Match.date, Match.start_time).limit(5).all()
|
||||||
|
|
||||||
elif isinstance(user, Manager):
|
elif user.role == 'manager':
|
||||||
stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count()
|
stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count()
|
||||||
stats['active_tryouts'] = Tryout.query.filter_by(
|
stats['active_tryouts'] = Tryout.query.filter_by(created_by=user.id, status='in_progress').count()
|
||||||
created_by=user.id, status='in_progress').count()
|
|
||||||
stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
||||||
stats['my_tryouts'] = Tryout.query.filter_by(
|
stats['my_tryouts'] = Tryout.query.filter_by(created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
|
||||||
created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
|
|
||||||
today = date.today()
|
today = date.today()
|
||||||
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
|
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
|
||||||
stats['upcoming_matches'] = Match.query.filter(
|
stats['upcoming_matches'] = Match.query.filter(
|
||||||
Match.tryout_id.in_(manager_tryout_ids),
|
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 []
|
).order_by(Match.date, Match.start_time).limit(5).all() if manager_tryout_ids else []
|
||||||
|
|
||||||
elif isinstance(user, Coach):
|
elif user.role == 'coach':
|
||||||
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
||||||
registrations = TryoutRegistration.query.filter(
|
stats['pending_evaluations'] = 0
|
||||||
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]
|
registered_player_ids = [r.player_id for r in registrations]
|
||||||
evaluated_player_ids = [e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()]
|
evaluated_player_ids = [e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()]
|
||||||
stats['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids))
|
stats['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids))
|
||||||
stats['my_recent_evaluations'] = Evaluation.query.filter_by(
|
stats['my_recent_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all()
|
||||||
evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all()
|
|
||||||
today = date.today()
|
today = date.today()
|
||||||
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
||||||
coach_tryout_ids = [t.id for t in Tryout.query.filter_by(
|
coach_tryout_ids = [t.id for t in Tryout.query.filter_by(target_org_team_id=org_team.id).all()] if org_team else []
|
||||||
target_org_team_id=org_team.id).all()] if org_team else []
|
|
||||||
stats['upcoming_matches'] = Match.query.filter(
|
stats['upcoming_matches'] = Match.query.filter(
|
||||||
Match.tryout_id.in_(coach_tryout_ids),
|
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 []
|
).order_by(Match.date, Match.start_time).limit(5).all() if coach_tryout_ids else []
|
||||||
|
|
||||||
elif isinstance(user, Player):
|
elif user.role == 'player':
|
||||||
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
|
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
|
||||||
stats['my_registrations'] = TryoutRegistration.query.filter_by(
|
stats['my_registrations'] = TryoutRegistration.query.filter_by(player_id=user.id).order_by(TryoutRegistration.registered_at.desc()).limit(5).all()
|
||||||
player_id=user.id).order_by(TryoutRegistration.registered_at.desc()).limit(5).all()
|
|
||||||
|
# Get upcoming matches for the player
|
||||||
today = date.today()
|
today = date.today()
|
||||||
next_matches = []
|
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()
|
all_registrations = TryoutRegistration.query.filter_by(player_id=user.id).all()
|
||||||
registered_tryout_ids = [r.tryout_id for r in all_registrations]
|
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_participant_matches = MatchParticipant.query.filter_by(player_id=user.id).all()
|
||||||
player_match_ids = [p.match_id for p in player_participant_matches]
|
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_memberships = TeamMember.query.filter_by(player_id=user.id).all()
|
||||||
player_team_ids = [tm.team_id for tm in player_team_memberships]
|
player_team_ids = [tm.team_id for tm in player_team_memberships]
|
||||||
|
|
||||||
|
# Find all scheduled matches in registered tryouts
|
||||||
upcoming_matches = Match.query.filter(
|
upcoming_matches = Match.query.filter(
|
||||||
Match.tryout_id.in_(registered_tryout_ids),
|
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()
|
).order_by(Match.date, Match.start_time).all()
|
||||||
|
|
||||||
for match in upcoming_matches:
|
for match in upcoming_matches:
|
||||||
is_participant = False
|
is_participant = False
|
||||||
team = None
|
team = None
|
||||||
|
|
||||||
if match.match_type == 'team_vs_team':
|
if match.match_type == 'team_vs_team':
|
||||||
|
# Check if player is on either team
|
||||||
if match.team1_id in player_team_ids:
|
if match.team1_id in player_team_ids:
|
||||||
is_participant = True
|
is_participant = True
|
||||||
team = next((tm for tm in player_team_memberships
|
team = next((tm for tm in player_team_memberships if tm.team_id == match.team1_id), None)
|
||||||
if tm.team_id == match.team1_id), None)
|
|
||||||
elif match.team2_id in player_team_ids:
|
elif match.team2_id in player_team_ids:
|
||||||
is_participant = True
|
is_participant = True
|
||||||
team = next((tm for tm in player_team_memberships
|
team = next((tm for tm in player_team_memberships if tm.team_id == match.team2_id), None)
|
||||||
if tm.team_id == match.team2_id), None)
|
|
||||||
else:
|
else:
|
||||||
|
# For player_vs_player and player_scrim, check MatchParticipant
|
||||||
if match.id in player_match_ids:
|
if match.id in player_match_ids:
|
||||||
is_participant = True
|
is_participant = True
|
||||||
|
|
||||||
if is_participant:
|
if is_participant:
|
||||||
|
tryout = match.tryout
|
||||||
next_matches.append({
|
next_matches.append({
|
||||||
'tryout': match.tryout, 'match': match,
|
'tryout': tryout,
|
||||||
'team': team.team if team else None,
|
'match': match,
|
||||||
|
'team': team.team if team else None
|
||||||
})
|
})
|
||||||
|
|
||||||
stats['next_matches'] = next_matches
|
stats['next_matches'] = next_matches
|
||||||
|
|
||||||
elif isinstance(user, Scout):
|
elif user.role == 'scout':
|
||||||
stats['total_players'] = User.query.filter_by(role='player').count()
|
stats['total_players'] = User.query.filter_by(role='player').count()
|
||||||
stats['total_evaluations'] = Evaluation.query.count()
|
stats['total_evaluations'] = Evaluation.query.count()
|
||||||
stats['avg_scores'] = db.session.query(
|
stats['avg_scores'] = db.session.query(
|
||||||
Evaluation.player_id,
|
Evaluation.player_id,
|
||||||
func.avg(Evaluation.overall_score).label('avg_score'),
|
func.avg(Evaluation.overall_score).label('avg_score')
|
||||||
).group_by(Evaluation.player_id).order_by(
|
).group_by(Evaluation.player_id).order_by(func.avg(Evaluation.overall_score).desc()).limit(5).all()
|
||||||
func.avg(Evaluation.overall_score).desc()).limit(5).all()
|
|
||||||
stats['top_players'] = []
|
stats['top_players'] = []
|
||||||
for row in stats['avg_scores']:
|
for row in stats['avg_scores']:
|
||||||
p = User.query.get(row.player_id)
|
p = User.query.get(row.player_id)
|
||||||
@@ -1,53 +1,73 @@
|
|||||||
"""Match scheduling routes for managing scrimmages and matches within tryouts.
|
"""Match scheduling routes for managing scrimmages and matches within tryouts.
|
||||||
|
|
||||||
Uses polymorphic isinstance checks instead of role-string comparisons.
|
This module handles calendar views, match creation, and player availability.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app.extensions import db
|
from extensions import db
|
||||||
from app.models import (
|
from models import User, Tryout, Match, MatchParticipant, Team, TeamMember, OrgTeam, TryoutRegistration, PlayerDisponibility
|
||||||
Admin, Manager, Coach, Player, Scout,
|
|
||||||
User, Tryout, Match, MatchParticipant, Team, TeamMember,
|
|
||||||
OrgTeam, TryoutRegistration, PlayerDisponibility,
|
|
||||||
OneOnOneRequest,
|
|
||||||
)
|
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
from app.discord_bot import send_schedule_notification
|
from discord_bot import send_schedule_notification
|
||||||
|
|
||||||
matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
|
matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
|
||||||
|
|
||||||
|
|
||||||
def can_schedule_match():
|
def can_schedule_match():
|
||||||
"""Check if user can schedule matches (Admin, Manager, Coach, Scout)."""
|
"""Check if user can schedule matches (coaches and above).
|
||||||
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.get_visible_tryouts()
|
return current_user.role in ['president', 'manager', 'coach', 'scout']
|
||||||
|
|
||||||
|
|
||||||
@matches_bp.route('/calendar')
|
@matches_bp.route('/calendar')
|
||||||
@login_required
|
@login_required
|
||||||
def calendar():
|
def calendar():
|
||||||
"""Render the calendar view."""
|
"""Render the calendar view showing all tryouts and matches.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: Rendered calendar template.
|
||||||
|
"""
|
||||||
return render_template('pages/calendar.html')
|
return render_template('pages/calendar.html')
|
||||||
|
|
||||||
|
|
||||||
@matches_bp.route('/api/events')
|
@matches_bp.route('/api/events')
|
||||||
@login_required
|
@login_required
|
||||||
def api_events():
|
def api_events():
|
||||||
"""API endpoint returning calendar events for FullCalendar."""
|
"""API endpoint returning calendar events for FullCalendar.
|
||||||
|
|
||||||
|
Returns tryout events and match events with participant information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: JSON array of calendar events.
|
||||||
|
"""
|
||||||
events = []
|
events = []
|
||||||
|
|
||||||
|
# Get tryouts based on user permissions (this already filters by user's role)
|
||||||
tryouts = get_visible_tryouts_for_user()
|
tryouts = get_visible_tryouts_for_user()
|
||||||
|
|
||||||
for tryout in tryouts:
|
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:
|
for match in tryout.matches:
|
||||||
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
|
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
|
||||||
|
|
||||||
|
# Build match description with participants
|
||||||
match_desc = match.description or ''
|
match_desc = match.description or ''
|
||||||
participants_str = ''
|
participants_str = ''
|
||||||
if match.match_type == 'team_vs_team':
|
if match.match_type == 'team_vs_team':
|
||||||
@@ -59,96 +79,100 @@ def api_events():
|
|||||||
participants_str = f"{' vs '.join(teams)}"
|
participants_str = f"{' vs '.join(teams)}"
|
||||||
match_desc = participants_str + (f"<br>{match.description}" if match.description else '')
|
match_desc = participants_str + (f"<br>{match.description}" if match.description else '')
|
||||||
else:
|
else:
|
||||||
|
# Player scrim - show all participants
|
||||||
player_names = []
|
player_names = []
|
||||||
for p in match.participants.all():
|
for p in match.participants.all():
|
||||||
player_names.append(p.player.username if p.player else 'Unknown Player')
|
player_name = p.player.username if p.player else 'Unknown Player'
|
||||||
|
player_names.append(player_name)
|
||||||
participants_str = ', '.join(player_names) if player_names else 'No players'
|
participants_str = ', '.join(player_names) if player_names else 'No players'
|
||||||
match_desc = participants_str + (f"<br>{match.description}" if match.description else '')
|
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
|
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
|
||||||
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
||||||
|
|
||||||
user_participant = MatchParticipant.query.filter_by(
|
|
||||||
match_id=match.id, player_id=current_user.id,
|
|
||||||
).first()
|
|
||||||
|
|
||||||
events.append({
|
events.append({
|
||||||
'id': f'match_{match.id}',
|
'id': f'match_{match.id}',
|
||||||
'title': match.title,
|
'title': match.title,
|
||||||
'date': match.date.strftime('%Y-%m-%d'),
|
'date': match.date.strftime('%Y-%m-%d'),
|
||||||
'type': 'match', 'color': match_color,
|
'type': 'match',
|
||||||
|
'color': match_color,
|
||||||
'extendedProps': {
|
'extendedProps': {
|
||||||
'location': match.location or tryout.location or 'TBD',
|
'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,
|
'match_type': match.match_type,
|
||||||
'tryout_id': tryout.id, 'match_id': match.id,
|
'tryout_id': tryout.id,
|
||||||
'start_time': start_time_str, 'end_time': end_time_str,
|
'match_id': match.id,
|
||||||
'participants': participants_str,
|
'start_time': start_time_str,
|
||||||
'user_participant_id': user_participant.id if user_participant else None,
|
'end_time': end_time_str,
|
||||||
'user_attendance_confirmed': user_participant.attendance_confirmed if user_participant else False,
|
'participants': participants_str
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
# 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)
|
return jsonify(events)
|
||||||
|
|
||||||
|
|
||||||
@matches_bp.route('/api/events/<int:tryout_id>')
|
@matches_bp.route('/api/events/<int:tryout_id>')
|
||||||
@login_required
|
@login_required
|
||||||
def api_events_for_tryout(tryout_id):
|
def api_events_for_tryout(tryout_id):
|
||||||
"""API endpoint returning calendar events for a specific tryout."""
|
"""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.
|
||||||
|
"""
|
||||||
tryout = Tryout.query.get_or_404(tryout_id)
|
tryout = Tryout.query.get_or_404(tryout_id)
|
||||||
|
|
||||||
|
# Check if user can view this tryout
|
||||||
can_view = current_user.can_manage_this_tryout(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
|
is_registered = False
|
||||||
player_in_match = False
|
player_in_match = False
|
||||||
if isinstance(current_user, Player):
|
if current_user.role == 'player':
|
||||||
is_registered = TryoutRegistration.query.filter_by(
|
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
|
).first() is not None
|
||||||
|
|
||||||
|
# Check if player is participating in any matches for this tryout
|
||||||
player_matches = Match.query.join(MatchParticipant).filter(
|
player_matches = Match.query.join(MatchParticipant).filter(
|
||||||
MatchParticipant.player_id == current_user.id,
|
MatchParticipant.player_id == current_user.id,
|
||||||
Match.tryout_id == tryout_id,
|
Match.tryout_id == tryout_id
|
||||||
).all()
|
).all()
|
||||||
player_in_match = len(player_matches) > 0
|
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:
|
if not can_view and not is_registered and not player_in_match:
|
||||||
return jsonify([])
|
return jsonify([])
|
||||||
|
|
||||||
events = []
|
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:
|
for match in tryout.matches:
|
||||||
match_color = '#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
|
# 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
|
||||||
participants_str = ''
|
participants_str = ''
|
||||||
if match.match_type == 'team_vs_team':
|
if match.match_type == 'team_vs_team':
|
||||||
teams = []
|
teams = []
|
||||||
@@ -158,55 +182,121 @@ def api_events_for_tryout(tryout_id):
|
|||||||
teams.append(match.team2.name)
|
teams.append(match.team2.name)
|
||||||
participants_str = f"{' vs '.join(teams)}"
|
participants_str = f"{' vs '.join(teams)}"
|
||||||
elif match.match_type == 'player_vs_player':
|
elif match.match_type == 'player_vs_player':
|
||||||
team1_players = [p.player.username for p in match.participants.filter_by(team_side=1).all() if p.player]
|
# Get players grouped by team side
|
||||||
team2_players = [p.player.username for p in match.participants.filter_by(team_side=2).all() if p.player]
|
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)
|
||||||
if team1_players and team2_players:
|
if team1_players and team2_players:
|
||||||
participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}"
|
participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}"
|
||||||
else:
|
else:
|
||||||
participants_str = 'TBD vs TBD'
|
participants_str = 'TBD vs TBD'
|
||||||
else:
|
else:
|
||||||
player_names = [p.player.username for p in match.participants.all() if p.player]
|
player_names = []
|
||||||
|
for p in match.participants.all():
|
||||||
|
player_name = p.player.username if p.player else 'Unknown Player'
|
||||||
|
player_names.append(player_name)
|
||||||
participants_str = ', '.join(player_names) if player_names else 'No players'
|
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
|
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
|
||||||
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
|
||||||
|
|
||||||
events.append({
|
events.append({
|
||||||
'id': f'match_{match.id}',
|
'id': f'match_{match.id}',
|
||||||
'title': match.title,
|
'title': match.title,
|
||||||
'date': match.date.strftime('%Y-%m-%d'),
|
'date': match.date.strftime('%Y-%m-%d'),
|
||||||
'type': 'match', 'color': match_color,
|
'type': 'match',
|
||||||
|
'color': match_color,
|
||||||
'extendedProps': {
|
'extendedProps': {
|
||||||
'location': match.location or tryout.location or 'TBD',
|
'location': match.location or tryout.location or 'TBD',
|
||||||
'status': match.status, 'match_type': match.match_type,
|
'status': match.status,
|
||||||
'tryout_id': tryout.id, 'match_id': match.id,
|
'match_type': match.match_type,
|
||||||
|
'tryout_id': tryout.id,
|
||||||
|
'match_id': match.id,
|
||||||
'participants': participants_str,
|
'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)
|
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'])
|
@matches_bp.route('/create/<int:tryout_id>', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def create_match(tryout_id):
|
def create_match(tryout_id):
|
||||||
"""Create a new match / scrimmage within a tryout."""
|
"""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.
|
||||||
|
"""
|
||||||
tryout = Tryout.query.get_or_404(tryout_id)
|
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):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash('You do not have permission to schedule matches for this tryout.', 'danger')
|
flash('You do not have permission to schedule matches for this tryout.', 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
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()
|
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
||||||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
|
||||||
all_players = [User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)]
|
all_players = [User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)]
|
||||||
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
|
all_players = sorted([p for p in all_players if p], key=lambda x: x.username)
|
||||||
prefill_date = request.args.get('date', '')
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
title = request.form.get('title')
|
title = request.form.get('title')
|
||||||
description = request.form.get('description')
|
description = request.form.get('description')
|
||||||
@@ -215,49 +305,59 @@ def create_match(tryout_id):
|
|||||||
end_time_str = request.form.get('end_time')
|
end_time_str = request.form.get('end_time')
|
||||||
location = request.form.get('location')
|
location = request.form.get('location')
|
||||||
match_type = request.form.get('match_type')
|
match_type = request.form.get('match_type')
|
||||||
|
|
||||||
|
# Start time is now mandatory
|
||||||
if not start_time_str:
|
if not start_time_str:
|
||||||
flash('Start time is required. Please select a time slot.', 'danger')
|
flash('Start time is required. Please select a time slot.', 'danger')
|
||||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
|
return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players)
|
||||||
all_players=all_players, prefill_date=prefill_date)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date
|
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
flash('Invalid date format.', 'danger')
|
flash('Invalid date format.', 'danger')
|
||||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
|
return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players)
|
||||||
all_players=all_players, prefill_date=prefill_date)
|
|
||||||
|
|
||||||
start_time = None
|
start_time = None
|
||||||
end_time = None
|
end_time = None
|
||||||
try:
|
try:
|
||||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||||
|
# Auto-calculate end time if not provided (start + 30 minutes)
|
||||||
if end_time_str:
|
if end_time_str:
|
||||||
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||||
else:
|
else:
|
||||||
|
# Auto-calculate end time as start + 30 minutes
|
||||||
start_dt = datetime.combine(date_obj, start_time)
|
start_dt = datetime.combine(date_obj, start_time)
|
||||||
end_dt = start_dt + timedelta(minutes=30)
|
end_dt = start_dt + timedelta(minutes=30)
|
||||||
end_time = end_dt.time()
|
end_time = end_dt.time()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
flash('Invalid time format.', 'danger')
|
flash('Invalid time format.', 'danger')
|
||||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players)
|
return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players)
|
||||||
|
|
||||||
match = Match(
|
match = Match(
|
||||||
tryout_id=tryout_id, title=title, description=description,
|
tryout_id=tryout_id,
|
||||||
date=date_obj, start_time=start_time, end_time=end_time,
|
title=title,
|
||||||
location=location, match_type=match_type, created_by=current_user.id,
|
description=description,
|
||||||
|
date=date_obj,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
location=location,
|
||||||
|
match_type=match_type,
|
||||||
|
created_by=current_user.id
|
||||||
)
|
)
|
||||||
db.session.add(match)
|
db.session.add(match)
|
||||||
db.session.flush()
|
db.session.flush() # Get match.id before commit
|
||||||
|
|
||||||
|
# Collect player IDs for Discord notifications
|
||||||
notified_player_ids = []
|
notified_player_ids = []
|
||||||
notified_participant_ids = []
|
|
||||||
|
# Handle team vs team matches
|
||||||
if match_type == 'team_vs_team':
|
if match_type == 'team_vs_team':
|
||||||
team1_id = request.form.get('team1_id')
|
team1_id = request.form.get('team1_id')
|
||||||
team2_id = request.form.get('team2_id')
|
team2_id = request.form.get('team2_id')
|
||||||
match.team1_id = int(team1_id) if team1_id else None
|
match.team1_id = int(team1_id) if team1_id else None
|
||||||
match.team2_id = int(team2_id) if team2_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:
|
if match.team1_id:
|
||||||
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
|
for m in TeamMember.query.filter_by(team_id=match.team1_id).all():
|
||||||
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1)
|
participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1)
|
||||||
@@ -272,11 +372,14 @@ def create_match(tryout_id):
|
|||||||
db.session.flush()
|
db.session.flush()
|
||||||
notified_participant_ids.append(participant.id)
|
notified_participant_ids.append(participant.id)
|
||||||
notified_player_ids.append(m.player_id)
|
notified_player_ids.append(m.player_id)
|
||||||
|
|
||||||
|
# Handle player vs player matches
|
||||||
elif match_type == 'player_vs_player':
|
elif match_type == 'player_vs_player':
|
||||||
team1_player_ids = request.form.get('team1_player_ids', '')
|
team1_player_ids = request.form.get('team1_player_ids', '')
|
||||||
team2_player_ids = request.form.get('team2_player_ids', '')
|
team2_player_ids = request.form.get('team2_player_ids', '')
|
||||||
team1_ids = [int(p) for p in team1_player_ids.split(',') if p] if team1_player_ids else []
|
team1_ids = [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 []
|
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:
|
for pid in team1_ids:
|
||||||
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1)
|
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1)
|
||||||
db.session.add(participant)
|
db.session.add(participant)
|
||||||
@@ -288,58 +391,85 @@ def create_match(tryout_id):
|
|||||||
db.session.flush()
|
db.session.flush()
|
||||||
notified_participant_ids.append(participant.id)
|
notified_participant_ids.append(participant.id)
|
||||||
notified_player_ids = team1_ids + team2_ids
|
notified_player_ids = team1_ids + team2_ids
|
||||||
|
|
||||||
|
# Handle player scrim matches
|
||||||
elif match_type == 'player_scrim':
|
elif match_type == 'player_scrim':
|
||||||
player_ids = request.form.getlist('player_ids')
|
player_ids = request.form.getlist('player_ids')
|
||||||
|
notified_participant_ids = []
|
||||||
for pid in player_ids:
|
for pid in player_ids:
|
||||||
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
|
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
|
||||||
db.session.add(participant)
|
db.session.add(participant)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
notified_participant_ids.append(participant.id)
|
notified_participant_ids.append(participant.id)
|
||||||
notified_player_ids = [int(p) for p in player_ids]
|
notified_player_ids = [int(p) for p in player_ids]
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Discord notifications
|
# Send Discord notifications to players (one per participant for proper attendance tracking)
|
||||||
event_date_str = date_obj.strftime('%Y-%m-%d')
|
event_date_str = date_obj.strftime('%Y-%m-%d')
|
||||||
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
|
event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
|
||||||
for i, player_id in enumerate(notified_player_ids):
|
|
||||||
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
|
# Send Discord notifications with proper participant reference IDs
|
||||||
send_schedule_notification(
|
if match_type == 'team_vs_team':
|
||||||
user_id=player_id, event_type='match', event_title=match.title,
|
for i, player_id in enumerate(notified_player_ids):
|
||||||
event_date=event_date_str, event_time=event_time_str,
|
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
|
||||||
reference_id=reference_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
|
||||||
|
)
|
||||||
|
|
||||||
flash('Match scheduled successfully!', 'success')
|
flash('Match scheduled successfully!', 'success')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||||
|
|
||||||
return render_template('pages/match_form.html', tryout=tryout, teams=teams,
|
return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players)
|
||||||
all_players=all_players, prefill_date=prefill_date)
|
|
||||||
|
|
||||||
|
|
||||||
@matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
|
@matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def edit_match(match_id):
|
def edit_match(match_id):
|
||||||
"""Edit an existing match."""
|
"""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.
|
||||||
|
"""
|
||||||
match = Match.query.get_or_404(match_id)
|
match = Match.query.get_or_404(match_id)
|
||||||
tryout = match.tryout
|
tryout = match.tryout
|
||||||
|
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash('You do not have permission to edit this match.', 'danger')
|
flash('You do not have permission to edit this match.', 'danger')
|
||||||
return redirect(url_for('matches.calendar'))
|
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()
|
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()
|
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 = [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)
|
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()]
|
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()]
|
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()]
|
team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()]
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
match.title = request.form.get('title')
|
match.title = request.form.get('title')
|
||||||
match.description = request.form.get('description')
|
match.description = request.form.get('description')
|
||||||
@@ -348,45 +478,47 @@ def edit_match(match_id):
|
|||||||
end_time_str = request.form.get('end_time')
|
end_time_str = request.form.get('end_time')
|
||||||
location = request.form.get('location')
|
location = request.form.get('location')
|
||||||
status = request.form.get('status')
|
status = request.form.get('status')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
flash('Invalid date format.', 'danger')
|
flash('Invalid date format.', 'danger')
|
||||||
return render_template('pages/match_form.html', match=match, tryout=tryout,
|
return render_template('pages/match_form.html', match=match, tryout=tryout, teams=teams, all_players=all_players, current_player_ids=current_player_ids)
|
||||||
teams=teams, all_players=all_players,
|
|
||||||
current_player_ids=current_player_ids)
|
# Start time is now mandatory
|
||||||
|
|
||||||
if not start_time_str:
|
if not start_time_str:
|
||||||
flash('Start time is required.', 'danger')
|
flash('Start time is required. Please select a time slot.', 'danger')
|
||||||
return render_template('pages/match_form.html', match=match, tryout=tryout,
|
return render_template('pages/match_form.html', match=match, tryout=tryout, teams=teams, all_players=all_players, current_player_ids=current_player_ids)
|
||||||
teams=teams, all_players=all_players,
|
|
||||||
current_player_ids=current_player_ids)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||||
|
# Auto-calculate end time if not provided (start + 30 minutes)
|
||||||
if end_time_str:
|
if end_time_str:
|
||||||
match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||||
else:
|
else:
|
||||||
|
# Auto-calculate end time as start + 30 minutes
|
||||||
start_dt = datetime.combine(match.date, match.start_time)
|
start_dt = datetime.combine(match.date, match.start_time)
|
||||||
end_dt = start_dt + timedelta(minutes=30)
|
end_dt = start_dt + timedelta(minutes=30)
|
||||||
match.end_time = end_dt.time()
|
match.end_time = end_dt.time()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
match.start_time = None
|
match.start_time = None
|
||||||
|
|
||||||
match.location = location
|
match.location = location
|
||||||
if status in ['scheduled', 'completed', 'cancelled']:
|
if status in ['scheduled', 'completed', 'cancelled']:
|
||||||
match.status = status
|
match.status = status
|
||||||
|
|
||||||
|
# Collect player IDs for Discord notifications
|
||||||
notified_player_ids = []
|
notified_player_ids = []
|
||||||
|
|
||||||
|
# Handle team vs team matches
|
||||||
notified_participant_ids = []
|
notified_participant_ids = []
|
||||||
|
|
||||||
if match.match_type == 'team_vs_team':
|
if match.match_type == 'team_vs_team':
|
||||||
team1_id = request.form.get('team1_id')
|
team1_id = request.form.get('team1_id')
|
||||||
team2_id = request.form.get('team2_id')
|
team2_id = request.form.get('team2_id')
|
||||||
new_team1_id = int(team1_id) if team1_id else None
|
new_team1_id = int(team1_id) if team1_id else None
|
||||||
new_team2_id = int(team2_id) if team2_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:
|
if new_team1_id != match.team1_id or new_team2_id != match.team2_id:
|
||||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||||
match.team1_id = new_team1_id
|
match.team1_id = new_team1_id
|
||||||
@@ -406,104 +538,124 @@ def edit_match(match_id):
|
|||||||
notified_participant_ids.append(participant.id)
|
notified_participant_ids.append(participant.id)
|
||||||
notified_player_ids.append(m.player_id)
|
notified_player_ids.append(m.player_id)
|
||||||
else:
|
else:
|
||||||
|
# Teams didn't change, still get notified player IDs
|
||||||
if match.team1_id:
|
if match.team1_id:
|
||||||
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team1_id).all()])
|
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team1_id).all()])
|
||||||
if match.team2_id:
|
if match.team2_id:
|
||||||
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team2_id).all()])
|
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team2_id).all()])
|
||||||
|
|
||||||
|
# Handle player vs player matches - update participants
|
||||||
elif match.match_type == 'player_vs_player':
|
elif match.match_type == 'player_vs_player':
|
||||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||||
team1_str = request.form.get('team1_player_ids', '')
|
team1_player_ids = request.form.getlist('team1_player_ids')
|
||||||
team2_str = request.form.get('team2_player_ids', '')
|
team2_player_ids = request.form.getlist('team2_player_ids')
|
||||||
t1_ids = [p for p in team1_str.split(',') if p.strip()] if team1_str else []
|
notified_participant_ids = []
|
||||||
t2_ids = [p for p in team2_str.split(',') if p.strip()] if team2_str else []
|
for pid in team1_player_ids:
|
||||||
for pid in t1_ids:
|
|
||||||
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=1)
|
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=1)
|
||||||
db.session.add(participant)
|
db.session.add(participant)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
notified_participant_ids.append(participant.id)
|
notified_participant_ids.append(participant.id)
|
||||||
for pid in t2_ids:
|
for pid in team2_player_ids:
|
||||||
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=2)
|
participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=2)
|
||||||
db.session.add(participant)
|
db.session.add(participant)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
notified_participant_ids.append(participant.id)
|
notified_participant_ids.append(participant.id)
|
||||||
notified_player_ids = [int(p) for p in t1_ids] + [int(p) for p in t2_ids]
|
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
|
||||||
elif match.match_type == 'player_scrim':
|
elif match.match_type == 'player_scrim':
|
||||||
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
||||||
player_ids = request.form.getlist('player_ids')
|
player_ids = request.form.getlist('player_ids')
|
||||||
|
notified_participant_ids = []
|
||||||
for pid in player_ids:
|
for pid in player_ids:
|
||||||
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
|
participant = MatchParticipant(match_id=match.id, player_id=int(pid))
|
||||||
db.session.add(participant)
|
db.session.add(participant)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
notified_participant_ids.append(participant.id)
|
notified_participant_ids.append(participant.id)
|
||||||
notified_player_ids = [int(p) for p in player_ids]
|
notified_player_ids = [int(p) for p in player_ids]
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Discord notifications
|
# Send Discord notifications to players
|
||||||
end_time_val = match.end_time or (match.start_time if match.start_time else None)
|
if match.match_type in ['player_vs_player', 'player_scrim']:
|
||||||
if match.start_time and end_time_val:
|
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:
|
event_time_str = f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}"
|
||||||
event_time_str = 'TBD'
|
else:
|
||||||
event_date_str = match.date.strftime('%Y-%m-%d')
|
event_time_str = 'TBD'
|
||||||
for i, player_id in enumerate(notified_player_ids):
|
event_date_str = match.date.strftime('%Y-%m-%d')
|
||||||
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
|
for i, player_id in enumerate(notified_player_ids):
|
||||||
send_schedule_notification(
|
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
|
||||||
user_id=player_id, event_type='match', event_title=match.title,
|
send_schedule_notification(
|
||||||
event_date=event_date_str, event_time=event_time_str,
|
user_id=player_id,
|
||||||
reference_id=reference_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
|
||||||
|
)
|
||||||
|
|
||||||
flash('Match updated successfully!', 'success')
|
flash('Match updated successfully!', 'success')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||||
|
|
||||||
|
# Build participant attendance map for the template
|
||||||
participants_map = {}
|
participants_map = {}
|
||||||
for p in match.participants.all():
|
for p in match.participants.all():
|
||||||
participants_map[p.player_id] = {
|
participants_map[p.player_id] = {
|
||||||
'participant_id': p.id,
|
'participant_id': p.id,
|
||||||
'attendance_confirmed': p.attendance_confirmed,
|
'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,
|
return render_template('pages/match_form.html', match=match, tryout=tryout, teams=teams,
|
||||||
teams=teams, all_players=all_players,
|
all_players=all_players, current_player_ids=current_player_ids,
|
||||||
current_player_ids=current_player_ids,
|
team1_player_ids=team1_player_ids, team2_player_ids=team2_player_ids,
|
||||||
team1_player_ids=team1_player_ids,
|
|
||||||
team2_player_ids=team2_player_ids,
|
|
||||||
participants_map=participants_map)
|
participants_map=participants_map)
|
||||||
|
|
||||||
|
|
||||||
@matches_bp.route('/api/manageable-tryouts')
|
|
||||||
@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'])
|
@matches_bp.route('/<int:match_id>/delete', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_match(match_id):
|
def delete_match(match_id):
|
||||||
"""Delete a match."""
|
"""Delete a match.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
match_id: The ID of the match to delete.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: Redirect to tryout view with status message.
|
||||||
|
"""
|
||||||
match = Match.query.get_or_404(match_id)
|
match = Match.query.get_or_404(match_id)
|
||||||
tryout = match.tryout
|
tryout = match.tryout
|
||||||
|
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
if not current_user.can_manage_this_tryout(tryout):
|
||||||
flash('You do not have permission to delete this match.', 'danger')
|
flash('You do not have permission to delete this match.', 'danger')
|
||||||
return redirect(url_for('matches.calendar'))
|
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.delete(match)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Match deleted successfully.', 'success')
|
flash('Match deleted successfully.', 'success')
|
||||||
@@ -511,38 +663,72 @@ def delete_match(match_id):
|
|||||||
|
|
||||||
|
|
||||||
def get_players_available_at_time(date_str, time_str):
|
def get_players_available_at_time(date_str, time_str):
|
||||||
"""Get list of player IDs available at a specific date and time."""
|
"""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.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||||
time_obj = datetime.strptime(time_str, '%H:%M').time()
|
time_obj = datetime.strptime(time_str, '%H:%M').time()
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
date_for_day = datetime.strptime(date_str, '%Y-%m-%d')
|
# Calculate day of week (Python: 0=Monday, 6=Sunday)
|
||||||
day_of_week = date_for_day.weekday()
|
# 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()
|
||||||
|
|
||||||
|
# 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()
|
players = User.query.filter_by(role='player', is_active_account=True).all()
|
||||||
|
|
||||||
available_players = []
|
available_players = []
|
||||||
for player in players:
|
for player in players:
|
||||||
|
# Check if player has disponibility at this time
|
||||||
disponibilities = PlayerDisponibility.query.filter_by(
|
disponibilities = PlayerDisponibility.query.filter_by(
|
||||||
player_id=player.id, day_of_week=day_of_week,
|
player_id=player.id,
|
||||||
|
day_of_week=day_of_week
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
for disp in disponibilities:
|
for disp in disponibilities:
|
||||||
|
# Check if time falls within disponibility block
|
||||||
disp_start = disp.start_time.hour * 60 + disp.start_time.minute
|
disp_start = disp.start_time.hour * 60 + disp.start_time.minute
|
||||||
disp_end = disp.end_time.hour * 60 + disp.end_time.minute
|
disp_end = disp.end_time.hour * 60 + disp.end_time.minute
|
||||||
match_time = time_obj.hour * 60 + time_obj.minute
|
match_time = time_obj.hour * 60 + time_obj.minute
|
||||||
|
|
||||||
if disp_start <= match_time < disp_end:
|
if disp_start <= match_time < disp_end:
|
||||||
available_players.append(player.id)
|
available_players.append(player.id)
|
||||||
break
|
break
|
||||||
|
|
||||||
return available_players
|
return available_players
|
||||||
|
|
||||||
|
|
||||||
@matches_bp.route('/api/available_players/<date>/<time>')
|
@matches_bp.route('/api/available_players/<date>/<time>')
|
||||||
@login_required
|
@login_required
|
||||||
def api_available_players(date, time):
|
def api_available_players(date, time):
|
||||||
"""API endpoint to get players available at a specific date/time slot."""
|
"""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.
|
||||||
|
"""
|
||||||
if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
|
if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
|
||||||
return jsonify({'error': 'Unauthorized'}), 403
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
player_ids = get_players_available_at_time(date, time)
|
player_ids = get_players_available_at_time(date, time)
|
||||||
return jsonify({'available_player_ids': player_ids})
|
return jsonify({'available_player_ids': player_ids})
|
||||||
|
|
||||||
@@ -550,22 +736,32 @@ def api_available_players(date, time):
|
|||||||
@matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
@matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def toggle_presence(match_id, participant_id):
|
def toggle_presence(match_id, participant_id):
|
||||||
"""Toggle attendance_confirmed for a match participant."""
|
"""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.
|
||||||
|
"""
|
||||||
match = Match.query.get_or_404(match_id)
|
match = Match.query.get_or_404(match_id)
|
||||||
tryout = match.tryout
|
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)
|
participant = MatchParticipant.query.get_or_404(participant_id)
|
||||||
if participant.match_id != match_id:
|
if participant.match_id != match_id:
|
||||||
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
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
|
participant.attendance_confirmed = not participant.attendance_confirmed
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'participant_id': participant.id,
|
'participant_id': participant.id,
|
||||||
'attendance_confirmed': participant.attendance_confirmed,
|
'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'
|
||||||
})
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user