diff --git a/README.md b/README.md index ceb0414..91c27d9 100644 --- a/README.md +++ b/README.md @@ -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 \ No newline at end of file +- Link to the application for approval/rejection +- Two provided reactions to accept or refuse via discord \ No newline at end of file diff --git a/app/.env.exemple b/app/.env.exemple new file mode 100644 index 0000000..ad55377 --- /dev/null +++ b/app/.env.exemple @@ -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 \ No newline at end of file diff --git a/app/app.py b/app/app.py index 79147c9..4f5cffb 100644 --- a/app/app.py +++ b/app/app.py @@ -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) diff --git a/app/discord_bot.py b/app/discord_bot.py index 964960a..e497b38 100644 --- a/app/discord_bot.py +++ b/app/discord_bot.py @@ -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: diff --git a/app/models/user_model/coach.py b/app/models/user_model/coach.py index ccd9aa0..e836e0f 100644 --- a/app/models/user_model/coach.py +++ b/app/models/user_model/coach.py @@ -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() diff --git a/app/routes/matches.py b/app/routes/matches.py index 2f8f81e..649183f 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -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) diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index f313104..c5f4a3a 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -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 diff --git a/app/routes/users.py b/app/routes/users.py index a2dfd33..9a18a3e 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -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//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//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) diff --git a/app/templates/pages/notes.html b/app/templates/pages/notes.html index 4237bff..75146de 100644 --- a/app/templates/pages/notes.html +++ b/app/templates/pages/notes.html @@ -105,6 +105,94 @@ + +
+
+

One on One Requests

+ {% if org_team %} + {{ org_team.name }} + {% endif %} +
+
+ {% if one_on_one_requests %} +
+ + + + + + + + + + + + + {% for req in one_on_one_requests %} + + + + + + + + + {% endfor %} + +
PlayerDateTimeDiscussion PointsStatusActions
{{ req.player.username if req.player else 'Unknown' }}{{ req.date.strftime('%b %d, %Y') }}{{ req.start_time.strftime('%I:%M %p') }} - {{ req.end_time.strftime('%I:%M %p') }}{{ req.points or 'N/A' }} + {% if req.status == 'pending' %} + Pending + {% elif req.status == 'approved' %} + Approved + {% elif req.status == 'rejected' %} + Rejected + {% endif %} + + {% if req.status == 'pending' %} +
+ + +
+ + {% elif req.status == 'rejected' and req.coach_rejection_message %} + Reason: {{ req.coach_rejection_message[:50] }}{% if req.coach_rejection_message|length > 50 %}...{% endif %} + {% endif %} +
+
+ {% else %} +

No One on One requests from your players yet.

+ {% endif %} +
+
+ + + +
{% if org_team and team_notes %} @@ -169,4 +257,20 @@
{% endif %} +{% endblock %} + +{% block scripts %} + {% endblock %} \ No newline at end of file diff --git a/app/templates/pages/one_on_one.html b/app/templates/pages/one_on_one.html index 9fdff6e..177c3ba 100644 --- a/app/templates/pages/one_on_one.html +++ b/app/templates/pages/one_on_one.html @@ -54,7 +54,66 @@ {% endif %} + + +
+
+

My One on One Requests

+
+
+ {% if my_requests %} +
+ + + + + + + + + + + + {% for req in my_requests %} + + + + + + + + {% endfor %} + +
DateTimeDiscussion PointsStatusCoach Response
{{ req.date.strftime('%b %d, %Y') }}{{ req.start_time.strftime('%I:%M %p') }} - {{ req.end_time.strftime('%I:%M %p') }}{{ req.points or 'N/A' }} + {% if req.status == 'pending' %} + Pending + {% elif req.status == 'approved' %} + Approved + {% elif req.status == 'rejected' %} + Rejected + {% endif %} + + {% if req.status == 'approved' %} + Session confirmed! + {% elif req.status == 'rejected' %} + {% if req.coach_rejection_message %} + {{ req.coach_rejection_message }} + {% else %} + Coach is unavailable + {% endif %} + {% else %} + Awaiting coach response... + {% endif %} +
+
+ {% else %} +

You haven't made any One on One requests yet.

+ {% endif %} +
+
+ +