This commit is contained in:
cedrick2711
2026-07-30 13:25:02 -04:00
10 changed files with 588 additions and 102 deletions
+45 -34
View File
@@ -1,38 +1,61 @@
### Plateforme centralisée de tryouts
# Plateforme centralisée de tryouts
## Security Configuration
### Required Environment Variables
Before deploying, create a `.env` file with the following:
Before deploying, create a `.env` file which integrates everything in the .env.exemple.
Ensure you follow the comments of the exemple if you are to use this tool in production.
```
# Flask Configuration (REQUIRED)
SECRET_KEY=your-secure-random-secret-key-here
# Production Settings
FLASK_DEBUG=false
FORCE_HTTPS=true
SESSION_COOKIE_SECURE=true
```
### Security Features Implemented
- **Rate Limiting**: Login endpoint limited to 10 requests per minute to prevent brute-force attacks
- **Secure Session Cookies**: HTTPOnly, SameSite=Lax, and Secure flags enabled
- **Secure Session Cookies**: HTTPSOnly, SameSite=Lax, and Secure flags enabled
- **CSRF Protection**: Enabled by default on all forms
- **HTTPS Enforcement**: Automatic redirect to HTTPS in production
- **Security Headers**: X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, HSTS
- **Open Redirect Prevention**: URL validation on login redirect
- **Authorization Checks**: Proper ownership validation on all sensitive operations
- **nginx**: reverse-proxy and load balancer
- **Waitress WSGI**: Production ready WSGI
## Discord Integration for One on One Requests
### When true in .env:
- **Forces HTTPS only**
- **Forcer secure cookies**
The application supports sending Discord direct messages to coaches when players request One on One sessions.
## App details
### Code
- Full python backend using flask
- statics are pure HTML and CSS
- Some js to add logic to styling and showing certain pages/cards
### Functionalities
- **User base with sign-ins**: Forces users to create an account and register pertinent information for tryouts and teams. The admin can attribute them a role.
- **User-Role-Based Permissions**: admin - full acces, coach/manager - access to team management, player - views what he is registered in (no management), scout - view only
- **Tryout Management**: manage internal tryout teams, organise internal tryouts matches (3 formats, team vs team, PvP, scrim). Coaches can Evaluate players based on 10 criteria
- **Team Management**: manage teams for the season, create matches and practices. When planning a practice there will be a calendar showing player availabitlities slots to help chose a time
- **Coach and Player Availabilities**: Allow better planning for the coaches, and for players to book One on Ones with their coach.
- **Player Notes**: Coaches can give notes to their players. The players will see them and there is a history which keeps the most recent notes.
- **Team Notes**: Coaches can give notes to their teams, where all players from that team can see the note.
- **One on One**: Players can request a One on One meeting with their coach. This sends a discord dm to the coach to accept or refuse. The player is then notified of the response.
- **Availabilities**: Allow players and coach to enter the moments they are available. Allows for easier practice setup and One on One planning.
## Discord Integration
The application supports sending Discord direct messages to coaches when players request One on One sessions,
when matches/tryouts/practices are created and a player is in it, and the players get match reminders 24h before a match.
When sending a **One on One** request, the coach can accept via the platform or react to the discord message to answer the booking request.
Same thing with **matches** and **practices**, the players can react or answer on the platform.
### Setup Instructions
#### 1. Create a Discord Bot
#### 1. Create a Discord Bot (Not needed for UdeS user, the bot already exists)
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Create a new application
@@ -40,34 +63,21 @@ The application supports sending Discord direct messages to coaches when players
4. Copy the bot token - this will be your `DISCORD_BOT_TOKEN`
5. Enable the "Message Content Intent" under Privileged Gateway Intents (required for sending messages)
#### 2. Configure Environment Variables
#### 2. Add Bot to your server
Add the following to your `.env` file (create one if it doesn't exist):
```
DISCORD_BOT_TOKEN=your_bot_token_here
DISCORD_WEBHOOK_URL=optional_webhook_url_for_backup
```
- `DISCORD_BOT_TOKEN`: Required for sending direct messages to coaches
- `DISCORD_WEBHOOK_URL`: Optional fallback for webhook-based notifications
#### 3. Add Coaches to the Bot
For the bot to send DMs to coaches:
1. Each coach must have the bot added to their Discord server OR be friends with the bot
2. Coaches need to add their Discord User ID to their profile:
For the bot to send DMs:
1. Each user must have the bot added to their Discord server OR be friends with the bot
2. Users need to add their Discord User ID to their profile:
- Enable Developer Mode in Discord (User Settings → Advanced → Developer Mode)
- Right-click on their profile → Copy ID
- Enter this numeric ID in the "Discord User ID" field in their profile settings
### How It Works
When a player submits a One on One request:
1. The system checks if the coach has a Discord User ID configured
2. If configured, a direct message is sent to the coach via the Discord bot
3. If the bot fails or no Discord User ID is set, the system falls back to the webhook URL (if configured)
4. The message includes player name, team, requested date/time, and discussion points
### Message Format
@@ -76,4 +86,5 @@ The Discord DM includes:
- Team name
- Requested date and time slot
- 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
try:
from app.discord_bot import start_bot
start_bot()
start_bot(flask_app=app)
except Exception as e:
app.logger.warning('Could not start Discord bot: %s', e)
+184 -56
View File
@@ -10,6 +10,7 @@ import os
import logging
import asyncio
import threading
import traceback
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from queue import Queue, Empty
@@ -37,15 +38,17 @@ class TeamTryoutsBot(commands.Bot):
Handles One on One requests, schedule additions, and daily reminders.
"""
def __init__(self):
def __init__(self, flask_app=None):
intents = Intents.default()
intents.message_content = True
intents.dm_messages = True
intents.dm_reactions = True
intents.reactions = True
intents.guilds = True
intents.members = True
super().__init__(command_prefix='!', intents=intents)
self.flask_app = flask_app
self.pending_requests = {} # Maps message_id to {type, id} for reaction handling
self.message_queue = Queue() # Thread-safe queue for messages from Flask
self.scheduler = AsyncIOScheduler()
@@ -57,7 +60,11 @@ class TeamTryoutsBot(commands.Bot):
async def on_ready(self):
"""Log when the bot is ready and start background tasks."""
logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user}')
try:
guilds = [g.name for g in self.guilds]
logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user} | Guilds: {guilds}')
except Exception:
logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user}')
# Start the queue processing task
self.loop.create_task(self.process_queue())
@@ -92,10 +99,16 @@ class TeamTryoutsBot(commands.Bot):
if item.get('type') == 'one_on_one_request':
await self._send_one_on_one_dm(**item['data'])
elif item.get('type') == 'schedule_addition':
await self._send_schedule_notification(**item['data'])
if self.flask_app:
with self.flask_app.app_context():
await self._send_schedule_notification(**item['data'])
else:
await self._send_schedule_notification(**item['data'])
elif item.get('type') == 'one_on_one_response':
await self._send_one_on_one_response_dm(**item['data'])
except Exception as e:
logger.error(f"Error processing queue: {e}")
logger.error(f"Error processing queue: {e}\n{traceback.format_exc()}")
await asyncio.sleep(0.1)
def is_dm_channel(self, channel) -> bool:
@@ -123,14 +136,30 @@ class TeamTryoutsBot(commands.Bot):
if emoji_str == CHECK_EMOJI:
if handler_type == 'one_on_one':
await self.handle_one_on_one_approve(user, message_id, request_id, reaction.message)
if self.flask_app:
with self.flask_app.app_context():
await self.handle_one_on_one_approve(user, message_id, request_id, reaction.message)
else:
await self.handle_one_on_one_approve(user, message_id, request_id, reaction.message)
elif handler_type == 'schedule_addition':
await self.handle_attendance_confirm(user, message_id, request_id, reaction.message)
if self.flask_app:
with self.flask_app.app_context():
await self.handle_attendance_confirm(user, message_id, request_id, reaction.message)
else:
await self.handle_attendance_confirm(user, message_id, request_id, reaction.message)
elif emoji_str == CROSS_EMOJI:
if handler_type == 'one_on_one':
await self.handle_one_on_one_reject(user, message_id, request_id, reaction.message)
if self.flask_app:
with self.flask_app.app_context():
await self.handle_one_on_one_reject(user, message_id, request_id, reaction.message)
else:
await self.handle_one_on_one_reject(user, message_id, request_id, reaction.message)
elif handler_type == 'schedule_addition':
await self.handle_attendance_decline(user, message_id, request_id, reaction.message)
if self.flask_app:
with self.flask_app.app_context():
await self.handle_attendance_decline(user, message_id, request_id, reaction.message)
else:
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,
team_name: str, date_str: str, start_time: str, end_time: str,
@@ -188,7 +217,7 @@ class TeamTryoutsBot(commands.Bot):
"""
try:
# 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)
if not db_user:
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):
"""Handle coach approving a One on One request."""
try:
from app.models.models import OneOnOneRequest, db
from sqlalchemy.orm import joinedload
from app.models import OneOnOneRequest
from app.extensions import db
request = OneOnOneRequest.query.options(
joinedload(OneOnOneRequest.player),
joinedload(OneOnOneRequest.coach)
).get(request_id)
request = OneOnOneRequest.query.get(request_id)
if not request:
return
@@ -248,11 +274,13 @@ class TeamTryoutsBot(commands.Bot):
await original_message.channel.send("⚠️ You are not the intended recipient.")
return
# Capture data before commit (to avoid expired session issues)
player = request.player
# 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
player_full_name = player.full_name
player_discord_id = player.discord_user_id
request.status = 'approved'
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}."
)
# Pass the pre-fetched data to avoid session expiration issues
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,
request=request,
approved=True
)
# Notify player via Discord
if player_discord_id:
await self.notify_player_about_one_on_one_direct(
player_discord_id=player_discord_id,
player_full_name=player_full_name,
coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
request=request,
approved=True
)
del self.pending_requests[message_id]
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):
"""Handle coach rejecting a One on One request."""
try:
from app.models.models import OneOnOneRequest, db
from sqlalchemy.orm import joinedload
from app.models import OneOnOneRequest
from app.extensions import db
request = OneOnOneRequest.query.options(
joinedload(OneOnOneRequest.player),
joinedload(OneOnOneRequest.coach)
).get(request_id)
request = OneOnOneRequest.query.get(request_id)
if not request:
return
@@ -292,11 +318,12 @@ class TeamTryoutsBot(commands.Bot):
await original_message.channel.send("⚠️ You are not the intended recipient.")
return
# Capture data before commit (to avoid expired session issues)
player = request.player
# 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
player_full_name = player.full_name
player_discord_id = player.discord_user_id
refusal_note = None
try:
@@ -320,23 +347,25 @@ class TeamTryoutsBot(commands.Bot):
rejection_msg += "\n\n️ The player has been notified that you are not available."
await original_message.channel.send(rejection_msg)
await self.notify_player_about_one_on_one_direct(
player_discord_id=player_discord_id,
player_full_name=player_full_name,
coach_full_name=coach_obj.full_name,
request=request,
approved=False,
refusal_note=refusal_note
)
if player_discord_id:
await self.notify_player_about_one_on_one_direct(
player_discord_id=player_discord_id,
player_full_name=player_full_name,
coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
request=request,
approved=False,
refusal_note=refusal_note
)
del self.pending_requests[message_id]
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):
"""Handle player confirming attendance for a match/tryout."""
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]
event_type = request_info.get('event_type')
@@ -344,10 +373,12 @@ class TeamTryoutsBot(commands.Bot):
if event_type == 'match':
participant = MatchParticipant.query.get(reference_id)
if participant:
participant = db.session.merge(participant)
participant.attendance_confirmed = True
elif event_type == 'tryout':
registration = TryoutRegistration.query.get(reference_id)
if registration:
registration = db.session.merge(registration)
registration.attendance_confirmed = True
db.session.commit()
@@ -356,12 +387,13 @@ class TeamTryoutsBot(commands.Bot):
del self.pending_requests[message_id]
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):
"""Handle player declining attendance for a match/tryout."""
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]
event_type = request_info.get('event_type')
@@ -369,10 +401,12 @@ class TeamTryoutsBot(commands.Bot):
if event_type == 'match':
participant = MatchParticipant.query.get(reference_id)
if participant:
participant = db.session.merge(participant)
db.session.delete(participant)
elif event_type == 'tryout':
registration = TryoutRegistration.query.get(reference_id)
if registration:
registration = db.session.merge(registration)
registration.status = 'no_show'
db.session.commit()
@@ -381,7 +415,7 @@ class TeamTryoutsBot(commands.Bot):
del self.pending_requests[message_id]
except Exception as e:
logger.error(f"Error handling attendance decline: {e}")
logger.error(f"Error handling attendance decline: {e}\n{traceback.format_exc()}")
async def notify_player_about_one_on_one(self, request, approved=True, refusal_note=None):
"""Send confirmation to player about One on One response.
@@ -471,7 +505,19 @@ class TeamTryoutsBot(commands.Bot):
async def send_daily_reminders(self):
"""Send daily reminders at 18:00 EDT for events in 24-48 hours."""
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
now = datetime.now(self.timezone)
@@ -540,6 +586,56 @@ class TeamTryoutsBot(commands.Bot):
except Exception as e:
logger.error(f"Error sending tryout reminder: {e}")
async def _send_one_on_one_response_dm(self, player_discord_id: str, player_full_name: str,
coach_full_name: str, date_str: str, start_time: str,
end_time: str, points: str, approved: bool,
refusal_note: str = None) -> bool:
"""Send a DM to a player notifying them of their One on One request response.
Called from the message queue when a coach accepts/rejects via the web app.
"""
try:
if not player_discord_id:
logger.warning("Cannot send response DM: no player_discord_id")
return False
player_user = await self.fetch_user(int(player_discord_id))
if not player_user:
logger.warning(f"Could not fetch Discord user {player_discord_id}")
return False
if approved:
message = (
"🎉 **One on One Session Confirmed!**\n\n"
f"Your coach **{coach_full_name}** has approved your request:\n"
f"**Date:** {date_str}\n"
f"**Time:** {start_time} - {end_time}\n"
f"**Discussion Points:** {points or 'No specific points provided'}\n\n"
"Please prepare for your session!"
)
else:
if refusal_note:
message = (
"😞 **One on One Session Rejected**\n\n"
f"Your coach **{coach_full_name}** has declined:\n"
f"**Reason:** {refusal_note}\n\n"
"Please try selecting a different time slot."
)
else:
message = (
"😞 **One on One Session Unavailable**\n\n"
f"Your coach **{coach_full_name}** is not available.\n\n"
"Please try selecting a different time slot."
)
await player_user.send(message)
logger.info(f"Sent One on One response DM to player {player_full_name} (approved={approved})")
return True
except Exception as e:
logger.error(f"Error sending One on One response DM: {e}")
return False
async def send_one_on_one_reminder(self, player, session):
"""Send One on One reminder to player."""
try:
@@ -562,11 +658,13 @@ bot_instance = None
bot_thread = None
def get_bot():
def get_bot(flask_app=None):
"""Get or create the bot instance."""
global bot_instance
if bot_instance is None:
bot_instance = TeamTryoutsBot()
bot_instance = TeamTryoutsBot(flask_app=flask_app)
elif flask_app is not None and bot_instance.flask_app is None:
bot_instance.flask_app = flask_app
return bot_instance
@@ -618,11 +716,41 @@ def send_schedule_notification(user_id: int, event_type: str, event_title: str,
return False
def start_bot():
def send_one_on_one_response(player_discord_id: str, player_full_name: str,
coach_full_name: str, date_str: str, start_time: str,
end_time: str, points: str, approved: bool,
refusal_note: str = None) -> bool:
"""Queue a One on One response DM to be sent to the player by the bot.
Called from Flask routes when a coach accepts/rejects via the web app.
"""
bot = get_bot()
try:
bot.message_queue.put({
'type': 'one_on_one_response',
'data': {
'player_discord_id': player_discord_id,
'player_full_name': player_full_name,
'coach_full_name': coach_full_name,
'date_str': date_str,
'start_time': start_time,
'end_time': end_time,
'points': points,
'approved': approved,
'refusal_note': refusal_note,
}
})
return True
except Exception as e:
logger.error(f"Error queuing One on One response DM: {e}")
return False
def start_bot(flask_app=None):
"""Start the Discord bot in the background."""
global bot_thread
bot = get_bot()
bot = get_bot(flask_app=flask_app)
if DISCORD_BOT_TOKEN and bot_thread is None:
def run_bot():
try:
+6 -5
View File
@@ -1,5 +1,6 @@
"""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):
@@ -38,8 +39,8 @@ class Coach(User):
def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout
team_ids = [t.id for t in self.coached_org_teams.all()]
if not team_ids:
return Tryout.query.filter(Tryout.id == -1).all() # empty
return Tryout.query.filter(
Tryout.target_org_team_id.in_(team_ids)
).order_by(Tryout.date).all()
conditions = []
if team_ids:
conditions.append(Tryout.target_org_team_id.in_(team_ids))
conditions.append(Tryout.coach_id == self.id)
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,
User, Tryout, Match, MatchParticipant, Team, TeamMember,
OrgTeam, TryoutRegistration, PlayerDisponibility,
OneOnOneRequest,
)
from datetime import datetime, time, timedelta
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)
+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:
can_view = True
elif isinstance(current_user, Coach):
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
if org_team and tryout.target_org_team_id == org_team.id:
can_view = True
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player):
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id).first() is not None
+130 -3
View File
@@ -709,7 +709,15 @@ def one_on_one():
coach_availability = []
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':
date_str = request.form.get('date')
@@ -733,7 +741,9 @@ def one_on_one():
day_of_week = check_date.weekday()
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
)
@@ -764,10 +774,118 @@ def one_on_one():
flash('Your One on One request has been submitted!', 'success')
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',
org_team=org_team, coach=coach,
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,
).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
from app.models import Team as MatchTeam
matches = Match.query.filter(
@@ -911,6 +1037,7 @@ def notes_dashboard():
team_notes=team_notes,
latest_team_note=latest_team_note,
personal_notes=personal_notes,
one_on_one_requests=one_on_one_requests,
matches=matches,
tryouts=tryouts,
teams=teams)
+104
View File
@@ -105,6 +105,94 @@
</div>
</div>
<!-- One on One Requests Section -->
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-calendar-check"></i> One on One Requests</h3>
{% if org_team %}
<span class="badge badge-esport">{{ org_team.name }}</span>
{% endif %}
</div>
<div class="card-body">
{% if one_on_one_requests %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Player</th>
<th>Date</th>
<th>Time</th>
<th>Discussion Points</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for req in one_on_one_requests %}
<tr>
<td>{{ req.player.username if req.player else 'Unknown' }}</td>
<td>{{ req.date.strftime('%b %d, %Y') }}</td>
<td>{{ req.start_time.strftime('%I:%M %p') }} - {{ req.end_time.strftime('%I:%M %p') }}</td>
<td>{{ req.points or 'N/A' }}</td>
<td>
{% if req.status == 'pending' %}
<span class="badge badge-warning">Pending</span>
{% elif req.status == 'approved' %}
<span class="badge badge-success">Approved</span>
{% elif req.status == 'rejected' %}
<span class="badge badge-danger">Rejected</span>
{% endif %}
</td>
<td>
{% if req.status == 'pending' %}
<form method="POST" action="{{ url_for('users.accept_one_on_one', request_id=req.id) }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-success" title="Accept">
<i class="fas fa-check"></i> Accept
</button>
</form>
<button type="button" class="btn btn-sm btn-danger" onclick="showRejectModal({{ req.id }})" title="Refuse">
<i class="fas fa-times"></i> Refuse
</button>
{% elif req.status == 'rejected' and req.coach_rejection_message %}
<span class="text-muted small" title="{{ req.coach_rejection_message }}">Reason: {{ req.coach_rejection_message[:50] }}{% if req.coach_rejection_message|length > 50 %}...{% endif %}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No One on One requests from your players yet.</p>
{% endif %}
</div>
</div>
<!-- Reject Modal -->
<div id="rejectModal" class="modal" style="display:none;">
<div class="modal-overlay" onclick="hideRejectModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h4><i class="fas fa-times-circle"></i> Reject One on One Request</h4>
<button type="button" class="modal-close" onclick="hideRejectModal()">&times;</button>
</div>
<form id="rejectForm" method="POST" action="">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="modal-body">
<div class="form-group">
<label for="rejection_reason">Reason for rejection (optional):</label>
<textarea name="rejection_reason" id="rejection_reason" class="form-textarea" rows="3" placeholder="Let the player know why this time doesn't work..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="hideRejectModal()">Cancel</button>
<button type="submit" class="btn btn-danger">Reject Request</button>
</div>
</form>
</div>
</div>
<div class="dashboard-grid mt-4">
<!-- Team Notes History -->
{% if org_team and team_notes %}
@@ -169,4 +257,20 @@
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
function showRejectModal(requestId) {
const modal = document.getElementById('rejectModal');
const form = document.getElementById('rejectForm');
form.action = "{{ url_for('users.reject_one_on_one', request_id=0) }}".replace('0', requestId);
modal.style.display = 'flex';
}
function hideRejectModal() {
document.getElementById('rejectModal').style.display = 'none';
document.getElementById('rejection_reason').value = '';
}
</script>
{% endblock %}
+59
View File
@@ -54,7 +54,66 @@
{% endif %}
</div>
</div>
</div>
<!-- My One on One Requests Tracker -->
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-list-check"></i> My One on One Requests</h3>
</div>
<div class="card-body">
{% if my_requests %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Time</th>
<th>Discussion Points</th>
<th>Status</th>
<th>Coach Response</th>
</tr>
</thead>
<tbody>
{% for req in my_requests %}
<tr>
<td>{{ req.date.strftime('%b %d, %Y') }}</td>
<td>{{ req.start_time.strftime('%I:%M %p') }} - {{ req.end_time.strftime('%I:%M %p') }}</td>
<td>{{ req.points or 'N/A' }}</td>
<td>
{% if req.status == 'pending' %}
<span class="badge badge-warning"><i class="fas fa-clock"></i> Pending</span>
{% elif req.status == 'approved' %}
<span class="badge badge-success"><i class="fas fa-check-circle"></i> Approved</span>
{% elif req.status == 'rejected' %}
<span class="badge badge-danger"><i class="fas fa-times-circle"></i> Rejected</span>
{% endif %}
</td>
<td>
{% if req.status == 'approved' %}
<span class="text-success">Session confirmed!</span>
{% elif req.status == 'rejected' %}
{% if req.coach_rejection_message %}
<span class="text-danger small">{{ req.coach_rejection_message }}</span>
{% else %}
<span class="text-muted small">Coach is unavailable</span>
{% endif %}
{% else %}
<span class="text-muted small">Awaiting coach response...</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">You haven't made any One on One requests yet.</p>
{% endif %}
</div>
</div>
<div class="dashboard-grid mt-4">
<!-- One on One Request Section -->
<div class="card" style="grid-column: 1 / -1;">
<div class="card-header">