This commit is contained in:
cedrick2711
2026-07-30 13:25:02 -04:00
10 changed files with 588 additions and 102 deletions
+44 -33
View File
@@ -1,38 +1,61 @@
### 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 with the following: Before deploying, create a `.env` file which integrates everything in the .env.exemple.
Ensure you follow the comments of the exemple if you are to use this tool in production.
```
# Flask Configuration (REQUIRED)
SECRET_KEY=your-secure-random-secret-key-here
# Production Settings
FLASK_DEBUG=false
FORCE_HTTPS=true
SESSION_COOKIE_SECURE=true
```
### Security Features Implemented ### 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**: HTTPOnly, SameSite=Lax, and Secure flags enabled - **Secure Session Cookies**: HTTPSOnly, SameSite=Lax, and Secure flags enabled
- **CSRF Protection**: Enabled by default on all forms - **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
## Discord Integration for One on One Requests ### When true in .env:
- **Forces HTTPS only**
- **Forcer secure cookies**
The application supports sending Discord direct messages to coaches when players request One on One sessions. ## App details
### Code
- Full python backend using flask
- statics are pure HTML and CSS
- Some js to add logic to styling and showing certain pages/cards
### Functionalities
- **User base with sign-ins**: Forces users to create an account and register pertinent information for tryouts and teams. The admin can attribute them a role.
- **User-Role-Based Permissions**: admin - full acces, coach/manager - access to team management, player - views what he is registered in (no management), scout - view only
- **Tryout Management**: manage internal tryout teams, organise internal tryouts matches (3 formats, team vs team, PvP, scrim). Coaches can Evaluate players based on 10 criteria
- **Team Management**: manage teams for the season, create matches and practices. When planning a practice there will be a calendar showing player availabitlities slots to help chose a time
- **Coach and Player Availabilities**: Allow better planning for the coaches, and for players to book One on Ones with their coach.
- **Player Notes**: Coaches can give notes to their players. The players will see them and there is a history which keeps the most recent notes.
- **Team Notes**: Coaches can give notes to their teams, where all players from that team can see the note.
- **One on One**: Players can request a One on One meeting with their coach. This sends a discord dm to the coach to accept or refuse. The player is then notified of the response.
- **Availabilities**: Allow players and coach to enter the moments they are available. Allows for easier practice setup and One on One planning.
## Discord Integration
The application supports sending Discord direct messages to coaches when players request One on One sessions,
when matches/tryouts/practices are created and a player is in it, and the players get match reminders 24h before a match.
When sending a **One on One** request, the coach can accept via the platform or react to the discord message to answer the booking request.
Same thing with **matches** and **practices**, the players can react or answer on the platform.
### Setup Instructions ### Setup Instructions
#### 1. Create a Discord Bot #### 1. Create a Discord Bot (Not needed for UdeS user, the bot already exists)
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) 1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Create a new application 2. Create a new application
@@ -40,34 +63,21 @@ The application supports sending Discord direct messages to coaches when players
4. Copy the bot token - this will be your `DISCORD_BOT_TOKEN` 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. Configure Environment Variables #### 2. Add Bot to your server
Add the following to your `.env` file (create one if it doesn't exist): For the bot to send DMs:
1. Each user must have the bot added to their Discord server OR be friends with the bot
``` 2. Users need to add their Discord User ID to their profile:
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
@@ -77,3 +87,4 @@ The Discord DM includes:
- 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
+26
View File
@@ -0,0 +1,26 @@
# 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=65476749453935
# 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=MTUyNzY3ODU3NjUyNTA1NDEyNQ.G1gPNQ.LeFVQAdgbeIFEetgXBuT1yyE4owPEP86Z_JBb8
#where to find the db (hosted on render for now)
DATABASE_URL=postgresql://team_tryouts_db_user:0YO038Od2QcQCsCTlNDAMOAOlcPGrIND@dpg-d9l53f9t0dsc73fqeieg-a.virginia-postgres.render.com/team_tryouts_db
#Where the app will be hosted (corresponds to: localhost:5000 in local)
HOST=127.0.0.1
PORT=5000
+1 -1
View File
@@ -358,7 +358,7 @@ def create_app():
# Start the Discord bot for notifications # Start the Discord bot for notifications
try: try:
from app.discord_bot import start_bot from app.discord_bot import start_bot
start_bot() start_bot(flask_app=app)
except Exception as e: except Exception as e:
app.logger.warning('Could not start Discord bot: %s', e) app.logger.warning('Could not start Discord bot: %s', e)
+165 -37
View File
@@ -10,6 +10,7 @@ import os
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
@@ -37,15 +38,17 @@ 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): def __init__(self, flask_app=None):
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()
@@ -57,6 +60,10 @@ class TeamTryoutsBot(commands.Bot):
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:
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}') logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user}')
# Start the queue processing task # Start the queue processing task
@@ -92,10 +99,16 @@ 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:
with self.flask_app.app_context():
await self._send_schedule_notification(**item['data']) 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}") logger.error(f"Error processing queue: {e}\n{traceback.format_exc()}")
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
def is_dm_channel(self, channel) -> bool: def is_dm_channel(self, channel) -> bool:
@@ -123,13 +136,29 @@ class TeamTryoutsBot(commands.Bot):
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:
with self.flask_app.app_context():
await self.handle_one_on_one_approve(user, message_id, request_id, reaction.message)
else:
await self.handle_one_on_one_approve(user, message_id, request_id, reaction.message) await self.handle_one_on_one_approve(user, message_id, request_id, reaction.message)
elif handler_type == 'schedule_addition': elif handler_type == 'schedule_addition':
if self.flask_app:
with self.flask_app.app_context():
await self.handle_attendance_confirm(user, message_id, request_id, reaction.message)
else:
await self.handle_attendance_confirm(user, message_id, request_id, reaction.message) await self.handle_attendance_confirm(user, message_id, request_id, reaction.message)
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:
with self.flask_app.app_context():
await self.handle_one_on_one_reject(user, message_id, request_id, reaction.message)
else:
await self.handle_one_on_one_reject(user, message_id, request_id, reaction.message) await self.handle_one_on_one_reject(user, message_id, request_id, reaction.message)
elif handler_type == 'schedule_addition': elif handler_type == 'schedule_addition':
if self.flask_app:
with self.flask_app.app_context():
await self.handle_attendance_decline(user, message_id, request_id, reaction.message)
else:
await self.handle_attendance_decline(user, message_id, request_id, reaction.message) await self.handle_attendance_decline(user, message_id, request_id, reaction.message)
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,
@@ -188,7 +217,7 @@ class TeamTryoutsBot(commands.Bot):
""" """
try: try:
# Look up the DB user to get their Discord user ID # Look up the DB user to get their Discord user ID
from app.models.models import User as DBUser from app.models import User as DBUser
db_user = DBUser.query.get(user_id) db_user = DBUser.query.get(user_id)
if not db_user: if not db_user:
logger.warning(f"DB user {user_id} not found for schedule notification") logger.warning(f"DB user {user_id} not found for schedule notification")
@@ -234,13 +263,10 @@ class TeamTryoutsBot(commands.Bot):
async def handle_one_on_one_approve(self, coach, message_id, request_id, original_message): async def handle_one_on_one_approve(self, coach, message_id, request_id, original_message):
"""Handle coach approving a One on One request.""" """Handle coach approving a One on One request."""
try: try:
from app.models.models import OneOnOneRequest, db from app.models import OneOnOneRequest
from sqlalchemy.orm import joinedload from app.extensions import db
request = OneOnOneRequest.query.options( request = OneOnOneRequest.query.get(request_id)
joinedload(OneOnOneRequest.player),
joinedload(OneOnOneRequest.coach)
).get(request_id)
if not request: if not request:
return return
@@ -248,11 +274,13 @@ class TeamTryoutsBot(commands.Bot):
await original_message.channel.send("⚠️ You are not the intended recipient.") await original_message.channel.send("⚠️ You are not the intended recipient.")
return return
# Capture data before commit (to avoid expired session issues) # Re-attach to current session (object may be detached across app contexts)
player = request.player 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 coach_obj = request.coach
player_full_name = player.full_name
player_discord_id = player.discord_user_id
request.status = 'approved' request.status = 'approved'
request.responded_at = datetime.utcnow() request.responded_at = datetime.utcnow()
@@ -262,29 +290,27 @@ class TeamTryoutsBot(commands.Bot):
f"✅ You have **approved** the One on One session with {player_full_name}." f"✅ You have **approved** the One on One session with {player_full_name}."
) )
# Pass the pre-fetched data to avoid session expiration issues # Notify player via Discord
if player_discord_id:
await self.notify_player_about_one_on_one_direct( await self.notify_player_about_one_on_one_direct(
player_discord_id=player_discord_id, player_discord_id=player_discord_id,
player_full_name=player_full_name, player_full_name=player_full_name,
coach_full_name=coach_obj.full_name, coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
request=request, request=request,
approved=True approved=True
) )
del self.pending_requests[message_id] del self.pending_requests[message_id]
except Exception as e: except Exception as e:
logger.error(f"Error handling approval: {e}") logger.error(f"Error handling approval: {e}\n{traceback.format_exc()}")
async def handle_one_on_one_reject(self, coach, message_id, request_id, original_message): async def handle_one_on_one_reject(self, coach, message_id, request_id, original_message):
"""Handle coach rejecting a One on One request.""" """Handle coach rejecting a One on One request."""
try: try:
from app.models.models import OneOnOneRequest, db from app.models import OneOnOneRequest
from sqlalchemy.orm import joinedload from app.extensions import db
request = OneOnOneRequest.query.options( request = OneOnOneRequest.query.get(request_id)
joinedload(OneOnOneRequest.player),
joinedload(OneOnOneRequest.coach)
).get(request_id)
if not request: if not request:
return return
@@ -292,11 +318,12 @@ class TeamTryoutsBot(commands.Bot):
await original_message.channel.send("⚠️ You are not the intended recipient.") await original_message.channel.send("⚠️ You are not the intended recipient.")
return return
# Capture data before commit (to avoid expired session issues) # Re-attach to current session (object may be detached across app contexts)
player = request.player 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 coach_obj = request.coach
player_full_name = player.full_name
player_discord_id = player.discord_user_id
refusal_note = None refusal_note = None
try: try:
@@ -320,10 +347,11 @@ class TeamTryoutsBot(commands.Bot):
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 original_message.channel.send(rejection_msg) await original_message.channel.send(rejection_msg)
if player_discord_id:
await self.notify_player_about_one_on_one_direct( await self.notify_player_about_one_on_one_direct(
player_discord_id=player_discord_id, player_discord_id=player_discord_id,
player_full_name=player_full_name, player_full_name=player_full_name,
coach_full_name=coach_obj.full_name, coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
request=request, request=request,
approved=False, approved=False,
refusal_note=refusal_note refusal_note=refusal_note
@@ -331,12 +359,13 @@ class TeamTryoutsBot(commands.Bot):
del self.pending_requests[message_id] del self.pending_requests[message_id]
except Exception as e: except Exception as e:
logger.error(f"Error handling rejection: {e}") logger.error(f"Error handling rejection: {e}\n{traceback.format_exc()}")
async def handle_attendance_confirm(self, player, message_id, reference_id, original_message): async def handle_attendance_confirm(self, player, message_id, reference_id, original_message):
"""Handle player confirming attendance for a match/tryout.""" """Handle player confirming attendance for a match/tryout."""
try: try:
from app.models.models import MatchParticipant, TryoutRegistration, Match, Tryout, db from app.models import MatchParticipant, TryoutRegistration, Match, Tryout
from app.extensions import db
request_info = self.pending_requests[message_id] request_info = self.pending_requests[message_id]
event_type = request_info.get('event_type') event_type = request_info.get('event_type')
@@ -344,10 +373,12 @@ 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()
@@ -356,12 +387,13 @@ class TeamTryoutsBot(commands.Bot):
del self.pending_requests[message_id] del self.pending_requests[message_id]
except Exception as e: except Exception as e:
logger.error(f"Error handling attendance confirmation: {e}") logger.error(f"Error handling attendance confirmation: {e}\n{traceback.format_exc()}")
async def handle_attendance_decline(self, player, message_id, reference_id, original_message): async def handle_attendance_decline(self, player, message_id, reference_id, original_message):
"""Handle player declining attendance for a match/tryout.""" """Handle player declining attendance for a match/tryout."""
try: try:
from app.models.models import MatchParticipant, TryoutRegistration, Match, Tryout, db from app.models import MatchParticipant, TryoutRegistration, Match, Tryout
from app.extensions import db
request_info = self.pending_requests[message_id] request_info = self.pending_requests[message_id]
event_type = request_info.get('event_type') event_type = request_info.get('event_type')
@@ -369,10 +401,12 @@ 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()
@@ -381,7 +415,7 @@ class TeamTryoutsBot(commands.Bot):
del self.pending_requests[message_id] del self.pending_requests[message_id]
except Exception as e: except Exception as e:
logger.error(f"Error handling attendance decline: {e}") logger.error(f"Error handling attendance decline: {e}\n{traceback.format_exc()}")
async def notify_player_about_one_on_one(self, request, approved=True, refusal_note=None): 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.
@@ -471,7 +505,19 @@ class TeamTryoutsBot(commands.Bot):
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:
from app.models.models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest, db if self.flask_app:
with self.flask_app.app_context():
await self._send_daily_reminders_impl()
else:
await self._send_daily_reminders_impl()
except Exception as e:
logger.error(f"Error sending daily reminders: {e}\n{traceback.format_exc()}")
async def _send_daily_reminders_impl(self):
"""Internal implementation of daily reminders with proper app context."""
try:
from app.models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest
from app.extensions import db
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
now = datetime.now(self.timezone) now = datetime.now(self.timezone)
@@ -540,6 +586,56 @@ 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:
@@ -562,11 +658,13 @@ bot_instance = None
bot_thread = None bot_thread = None
def get_bot(): def get_bot(flask_app=None):
"""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() bot_instance = TeamTryoutsBot(flask_app=flask_app)
elif flask_app is not None and bot_instance.flask_app is None:
bot_instance.flask_app = flask_app
return bot_instance return bot_instance
@@ -618,11 +716,41 @@ def send_schedule_notification(user_id: int, event_type: str, event_title: str,
return False return False
def start_bot(): def send_one_on_one_response(player_discord_id: str, player_full_name: str,
coach_full_name: str, date_str: str, start_time: str,
end_time: str, points: str, approved: bool,
refusal_note: str = None) -> bool:
"""Queue a One on One response DM to be sent to the player by the bot.
Called from Flask routes when a coach accepts/rejects via the web app.
"""
bot = get_bot()
try:
bot.message_queue.put({
'type': 'one_on_one_response',
'data': {
'player_discord_id': player_discord_id,
'player_full_name': player_full_name,
'coach_full_name': coach_full_name,
'date_str': date_str,
'start_time': start_time,
'end_time': end_time,
'points': points,
'approved': approved,
'refusal_note': refusal_note,
}
})
return True
except Exception as e:
logger.error(f"Error queuing One on One response DM: {e}")
return False
def start_bot(flask_app=None):
"""Start the Discord bot in the background.""" """Start the Discord bot in the background."""
global bot_thread global bot_thread
bot = get_bot() bot = get_bot(flask_app=flask_app)
if DISCORD_BOT_TOKEN and bot_thread is None: if DISCORD_BOT_TOKEN and bot_thread is None:
def run_bot(): def run_bot():
try: try:
+6 -5
View File
@@ -1,5 +1,6 @@
"""Coach — evaluates, schedules matches, manages their own org team.""" """Coach — evaluates, schedules matches, manages their own org team."""
from app.models.user_model.user import User from app.models.user_model.user import User
from app.extensions import db
class Coach(User): class Coach(User):
@@ -38,8 +39,8 @@ class Coach(User):
def get_visible_tryouts(self): def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout from app.models.tryout.tryout import Tryout
team_ids = [t.id for t in self.coached_org_teams.all()] team_ids = [t.id for t in self.coached_org_teams.all()]
if not team_ids: conditions = []
return Tryout.query.filter(Tryout.id == -1).all() # empty if team_ids:
return Tryout.query.filter( conditions.append(Tryout.target_org_team_id.in_(team_ids))
Tryout.target_org_team_id.in_(team_ids) conditions.append(Tryout.coach_id == self.id)
).order_by(Tryout.date).all() return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all()
+32
View File
@@ -10,6 +10,7 @@ from app.models import (
Admin, Manager, Coach, Player, Scout, Admin, Manager, Coach, Player, Scout,
User, Tryout, Match, MatchParticipant, Team, TeamMember, User, Tryout, Match, MatchParticipant, Team, TeamMember,
OrgTeam, TryoutRegistration, PlayerDisponibility, 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 app.discord_bot import send_schedule_notification
@@ -101,6 +102,37 @@ def api_events():
}, },
}) })
# 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)
+1 -3
View File
@@ -143,9 +143,7 @@ def view_tryout(tryout_id):
elif isinstance(current_user, Manager) and tryout.created_by == current_user.id: elif isinstance(current_user, Manager) and tryout.created_by == current_user.id:
can_view = True can_view = True
elif isinstance(current_user, Coach): elif isinstance(current_user, Coach):
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() can_view = current_user.can_manage_this_tryout(tryout)
if org_team and tryout.target_org_team_id == org_team.id:
can_view = True
elif isinstance(current_user, Player): elif isinstance(current_user, Player):
is_registered = TryoutRegistration.query.filter_by( is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id).first() is not None tryout_id=tryout_id, player_id=current_user.id).first() is not None
+130 -3
View File
@@ -709,7 +709,15 @@ def one_on_one():
coach_availability = [] coach_availability = []
if coach: if coach:
coach_availability = CoachAvailability.query.filter_by(coach_id=coach.id).all() availabilities = CoachAvailability.query.filter_by(coach_id=coach.id).all()
coach_availability = [
{
'day_of_week': av.day_of_week,
'start_time': av.start_time.strftime('%H:%M'),
'end_time': av.end_time.strftime('%H:%M'),
}
for av in availabilities
]
if request.method == 'POST': if request.method == 'POST':
date_str = request.form.get('date') date_str = request.form.get('date')
@@ -733,7 +741,9 @@ def one_on_one():
day_of_week = check_date.weekday() day_of_week = check_date.weekday()
is_available = any( is_available = any(
av.day_of_week == day_of_week and av.start_time <= start_time and av.end_time >= end_time av['day_of_week'] == day_of_week
and av['start_time'] <= start_time_str
and av['end_time'] >= end_time_str
for av in coach_availability for av in coach_availability
) )
@@ -764,10 +774,118 @@ def one_on_one():
flash('Your One on One request has been submitted!', 'success') flash('Your One on One request has been submitted!', 'success')
return redirect(url_for('users.one_on_one')) return redirect(url_for('users.one_on_one'))
# Build list of upcoming dates that have coach availability
from datetime import date as date_cls, timedelta as td
today = date_cls.today()
available_days = {av['day_of_week'] for av in coach_availability}
dates = []
for i in range(14): # Next 14 days
d = today + td(days=i)
if d.weekday() in available_days:
dates.append({
'value': d.strftime('%Y-%m-%d'),
'day_of_week': d.weekday(),
'display': d.strftime('%B %d, %Y (%A)'),
})
# Player's own One on One request history
my_requests = OneOnOneRequest.query.filter_by(
player_id=current_user.id
).order_by(OneOnOneRequest.created_at.desc()).all()
return render_template('pages/one_on_one.html', return render_template('pages/one_on_one.html',
org_team=org_team, coach=coach, org_team=org_team, coach=coach,
team_notes=team_notes, personal_notes=personal_notes, team_notes=team_notes, personal_notes=personal_notes,
coach_availability=coach_availability) coach_availability=coach_availability,
dates=dates,
my_requests=my_requests)
@users_bp.route('/one-on-one/<int:request_id>/accept', methods=['POST'])
@login_required
def accept_one_on_one(request_id):
"""Coach accepts a One on One request."""
if not isinstance(current_user, Coach):
flash('Only coaches can accept One on One requests.', 'danger')
return redirect(url_for('main.dashboard'))
request_obj = OneOnOneRequest.query.get_or_404(request_id)
if request_obj.coach_id != current_user.id:
flash('This request is not for you.', 'danger')
return redirect(url_for('users.notes_dashboard'))
if request_obj.status != 'pending':
flash('This request has already been processed.', 'info')
return redirect(url_for('users.notes_dashboard'))
player = request_obj.player
request_obj.status = 'approved'
request_obj.responded_at = datetime.utcnow()
db.session.commit()
# Notify player via Discord (same message as if approved through Discord reactions)
if player and player.discord_user_id:
from app.discord_bot import send_one_on_one_response
send_one_on_one_response(
player_discord_id=player.discord_user_id,
player_full_name=player.full_name,
coach_full_name=current_user.full_name,
date_str=request_obj.date.strftime('%A, %B %d, %Y'),
start_time=request_obj.start_time.strftime('%I:%M %p') if request_obj.start_time else 'TBD',
end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD',
points=request_obj.points or 'No specific points provided',
approved=True,
)
flash(f'One on One request from {player.username if player else "Unknown"} has been approved!', 'success')
return redirect(url_for('users.notes_dashboard'))
@users_bp.route('/one-on-one/<int:request_id>/reject', methods=['POST'])
@login_required
def reject_one_on_one(request_id):
"""Coach rejects a One on One request."""
if not isinstance(current_user, Coach):
flash('Only coaches can reject One on One requests.', 'danger')
return redirect(url_for('main.dashboard'))
request_obj = OneOnOneRequest.query.get_or_404(request_id)
if request_obj.coach_id != current_user.id:
flash('This request is not for you.', 'danger')
return redirect(url_for('users.notes_dashboard'))
if request_obj.status != 'pending':
flash('This request has already been processed.', 'info')
return redirect(url_for('users.notes_dashboard'))
rejection_reason = request.form.get('rejection_reason', '').strip()
player = request_obj.player
request_obj.status = 'rejected'
request_obj.responded_at = datetime.utcnow()
if rejection_reason:
request_obj.coach_rejection_message = rejection_reason
db.session.commit()
# Notify player via Discord (same message as if rejected through Discord reactions)
if player and player.discord_user_id:
from app.discord_bot import send_one_on_one_response
send_one_on_one_response(
player_discord_id=player.discord_user_id,
player_full_name=player.full_name,
coach_full_name=current_user.full_name,
date_str=request_obj.date.strftime('%A, %B %d, %Y'),
start_time=request_obj.start_time.strftime('%I:%M %p') if request_obj.start_time else 'TBD',
end_time=request_obj.end_time.strftime('%I:%M %p') if request_obj.end_time else 'TBD',
points=request_obj.points or 'No specific points provided',
approved=False,
refusal_note=rejection_reason or None,
)
flash(f'One on One request from {player.username if player else "Unknown"} has been rejected.', 'info')
return redirect(url_for('users.notes_dashboard'))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -895,6 +1013,14 @@ def notes_dashboard():
coach_id=current_user.id, coach_id=current_user.id,
).order_by(PersonalNote.created_at.desc()).all() ).order_by(PersonalNote.created_at.desc()).all()
# One on One requests from team players
one_on_one_requests = []
if org_team and players:
player_ids_list = [p.id for p in players]
one_on_one_requests = OneOnOneRequest.query.filter(
OneOnOneRequest.player_id.in_(player_ids_list)
).order_by(OneOnOneRequest.created_at.desc()).all()
# For context selectors in the form # For context selectors in the form
from app.models import Team as MatchTeam from app.models import Team as MatchTeam
matches = Match.query.filter( matches = Match.query.filter(
@@ -911,6 +1037,7 @@ def notes_dashboard():
team_notes=team_notes, team_notes=team_notes,
latest_team_note=latest_team_note, latest_team_note=latest_team_note,
personal_notes=personal_notes, personal_notes=personal_notes,
one_on_one_requests=one_on_one_requests,
matches=matches, matches=matches,
tryouts=tryouts, tryouts=tryouts,
teams=teams) teams=teams)
+104
View File
@@ -105,6 +105,94 @@
</div> </div>
</div> </div>
<!-- One on One Requests Section -->
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-calendar-check"></i> One on One Requests</h3>
{% if org_team %}
<span class="badge badge-esport">{{ org_team.name }}</span>
{% endif %}
</div>
<div class="card-body">
{% if one_on_one_requests %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Player</th>
<th>Date</th>
<th>Time</th>
<th>Discussion Points</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for req in one_on_one_requests %}
<tr>
<td>{{ req.player.username if req.player else 'Unknown' }}</td>
<td>{{ req.date.strftime('%b %d, %Y') }}</td>
<td>{{ req.start_time.strftime('%I:%M %p') }} - {{ req.end_time.strftime('%I:%M %p') }}</td>
<td>{{ req.points or 'N/A' }}</td>
<td>
{% if req.status == 'pending' %}
<span class="badge badge-warning">Pending</span>
{% elif req.status == 'approved' %}
<span class="badge badge-success">Approved</span>
{% elif req.status == 'rejected' %}
<span class="badge badge-danger">Rejected</span>
{% endif %}
</td>
<td>
{% if req.status == 'pending' %}
<form method="POST" action="{{ url_for('users.accept_one_on_one', request_id=req.id) }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-success" title="Accept">
<i class="fas fa-check"></i> Accept
</button>
</form>
<button type="button" class="btn btn-sm btn-danger" onclick="showRejectModal({{ req.id }})" title="Refuse">
<i class="fas fa-times"></i> Refuse
</button>
{% elif req.status == 'rejected' and req.coach_rejection_message %}
<span class="text-muted small" title="{{ req.coach_rejection_message }}">Reason: {{ req.coach_rejection_message[:50] }}{% if req.coach_rejection_message|length > 50 %}...{% endif %}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No One on One requests from your players yet.</p>
{% endif %}
</div>
</div>
<!-- Reject Modal -->
<div id="rejectModal" class="modal" style="display:none;">
<div class="modal-overlay" onclick="hideRejectModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h4><i class="fas fa-times-circle"></i> Reject One on One Request</h4>
<button type="button" class="modal-close" onclick="hideRejectModal()">&times;</button>
</div>
<form id="rejectForm" method="POST" action="">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="modal-body">
<div class="form-group">
<label for="rejection_reason">Reason for rejection (optional):</label>
<textarea name="rejection_reason" id="rejection_reason" class="form-textarea" rows="3" placeholder="Let the player know why this time doesn't work..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="hideRejectModal()">Cancel</button>
<button type="submit" class="btn btn-danger">Reject Request</button>
</div>
</form>
</div>
</div>
<div class="dashboard-grid mt-4"> <div class="dashboard-grid mt-4">
<!-- Team Notes History --> <!-- Team Notes History -->
{% if org_team and team_notes %} {% if org_team and team_notes %}
@@ -170,3 +258,19 @@
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
function showRejectModal(requestId) {
const modal = document.getElementById('rejectModal');
const form = document.getElementById('rejectForm');
form.action = "{{ url_for('users.reject_one_on_one', request_id=0) }}".replace('0', requestId);
modal.style.display = 'flex';
}
function hideRejectModal() {
document.getElementById('rejectModal').style.display = 'none';
document.getElementById('rejection_reason').value = '';
}
</script>
{% endblock %}
+59
View File
@@ -54,7 +54,66 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
<!-- My One on One Requests Tracker -->
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-list-check"></i> My One on One Requests</h3>
</div>
<div class="card-body">
{% if my_requests %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Time</th>
<th>Discussion Points</th>
<th>Status</th>
<th>Coach Response</th>
</tr>
</thead>
<tbody>
{% for req in my_requests %}
<tr>
<td>{{ req.date.strftime('%b %d, %Y') }}</td>
<td>{{ req.start_time.strftime('%I:%M %p') }} - {{ req.end_time.strftime('%I:%M %p') }}</td>
<td>{{ req.points or 'N/A' }}</td>
<td>
{% if req.status == 'pending' %}
<span class="badge badge-warning"><i class="fas fa-clock"></i> Pending</span>
{% elif req.status == 'approved' %}
<span class="badge badge-success"><i class="fas fa-check-circle"></i> Approved</span>
{% elif req.status == 'rejected' %}
<span class="badge badge-danger"><i class="fas fa-times-circle"></i> Rejected</span>
{% endif %}
</td>
<td>
{% if req.status == 'approved' %}
<span class="text-success">Session confirmed!</span>
{% elif req.status == 'rejected' %}
{% if req.coach_rejection_message %}
<span class="text-danger small">{{ req.coach_rejection_message }}</span>
{% else %}
<span class="text-muted small">Coach is unavailable</span>
{% endif %}
{% else %}
<span class="text-muted small">Awaiting coach response...</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">You haven't made any One on One requests yet.</p>
{% endif %}
</div>
</div>
<div class="dashboard-grid mt-4">
<!-- One on One Request Section --> <!-- One on One Request Section -->
<div class="card" style="grid-column: 1 / -1;"> <div class="card" style="grid-column: 1 / -1;">
<div class="card-header"> <div class="card-header">