Merge branch 'dev' of https://github.com/cedrick2711/team-tryouts
This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+184
-56
@@ -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,7 +60,11 @@ 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."""
|
||||||
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
|
# Start the queue processing task
|
||||||
self.loop.create_task(self.process_queue())
|
self.loop.create_task(self.process_queue())
|
||||||
@@ -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':
|
||||||
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:
|
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,14 +136,30 @@ 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':
|
||||||
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':
|
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:
|
elif emoji_str == CROSS_EMOJI:
|
||||||
if handler_type == 'one_on_one':
|
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':
|
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,
|
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,
|
||||||
@@ -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
|
||||||
await self.notify_player_about_one_on_one_direct(
|
if player_discord_id:
|
||||||
player_discord_id=player_discord_id,
|
await self.notify_player_about_one_on_one_direct(
|
||||||
player_full_name=player_full_name,
|
player_discord_id=player_discord_id,
|
||||||
coach_full_name=coach_obj.full_name,
|
player_full_name=player_full_name,
|
||||||
request=request,
|
coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
|
||||||
approved=True
|
request=request,
|
||||||
)
|
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,23 +347,25 @@ 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)
|
||||||
await self.notify_player_about_one_on_one_direct(
|
if player_discord_id:
|
||||||
player_discord_id=player_discord_id,
|
await self.notify_player_about_one_on_one_direct(
|
||||||
player_full_name=player_full_name,
|
player_discord_id=player_discord_id,
|
||||||
coach_full_name=coach_obj.full_name,
|
player_full_name=player_full_name,
|
||||||
request=request,
|
coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
|
||||||
approved=False,
|
request=request,
|
||||||
refusal_note=refusal_note
|
approved=False,
|
||||||
)
|
refusal_note=refusal_note
|
||||||
|
)
|
||||||
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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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()">×</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 %}
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user