From b982cd031840d45c1bec214dc8f09ac82ad0991b Mon Sep 17 00:00:00 2001 From: cedrick2711 Date: Tue, 28 Jul 2026 12:39:53 -0400 Subject: [PATCH] =?UTF-8?q?ajout=20de=20match=20r=C3=A9gulier=20pour=20les?= =?UTF-8?q?=20=C3=A9quipes=20et=20de=20pratiques=20Ajout=20d'un=20profil?= =?UTF-8?q?=20public=20cliquable=20pour=20les=20utilisateurs=20d=C3=A9plac?= =?UTF-8?q?ement=20du=20profil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGES_TODO | 1 + app.py | 4 +- discord_bot.py | 118 ++++++-- instance/team_tryouts.db | Bin 176128 -> 204800 bytes models.py | 172 +++++++++++- routes/matches.py | 52 +++- routes/team_matches.py | 388 +++++++++++++++++++++++++++ routes/teams.py | 302 +++++++++++++-------- routes/tryouts.py | 50 ++++ routes/users.py | 18 ++ templates/layouts/base.html | 35 +-- templates/pages/calendar.html | 181 ++++++++++++- templates/pages/match_form.html | 20 +- templates/pages/my_teams.html | 278 +++++++++++++++++++ templates/pages/profile.html | 15 +- templates/pages/team_match_form.html | 149 ++++++++++ templates/pages/team_matches.html | 231 ++++++++++++++++ templates/pages/teams.html | 235 +++++++++++++--- templates/pages/view_tryout.html | 32 ++- templates/pages/view_user.html | 76 ++++++ 20 files changed, 2132 insertions(+), 225 deletions(-) create mode 100644 CHANGES_TODO create mode 100644 routes/team_matches.py create mode 100644 templates/pages/my_teams.html create mode 100644 templates/pages/team_match_form.html create mode 100644 templates/pages/team_matches.html create mode 100644 templates/pages/view_user.html diff --git a/CHANGES_TODO b/CHANGES_TODO new file mode 100644 index 0000000..5cdfd59 --- /dev/null +++ b/CHANGES_TODO @@ -0,0 +1 @@ +1. Ajouter l'option d'enlever des joueurs dans les tryouts. \ No newline at end of file diff --git a/app.py b/app.py index 9c9cbc8..ad7a101 100644 --- a/app.py +++ b/app.py @@ -106,6 +106,7 @@ def create_app(): from routes.main import main_bp from routes.teams import teams_bp from routes.matches import matches_bp + from routes.team_matches import team_matches_bp app.register_blueprint(auth_bp) app.register_blueprint(tryouts_bp) @@ -114,6 +115,7 @@ def create_app(): app.register_blueprint(main_bp) app.register_blueprint(teams_bp) app.register_blueprint(matches_bp) + app.register_blueprint(team_matches_bp) # Register custom Jinja filters app.jinja_env.filters['nl2br'] = nl2br @@ -376,4 +378,4 @@ if __name__ == '__main__': 'Running in DEBUG mode with Flask built-in server. ' 'This is NOT suitable for production. Use wsgi.py instead.' ) - app.run(debug=debug_mode, host='127.0.0.1', port=5000) \ No newline at end of file + app.run(debug=debug_mode, host='127.0.0.2', port=5000) \ No newline at end of file diff --git a/discord_bot.py b/discord_bot.py index 365664f..6099659 100644 --- a/discord_bot.py +++ b/discord_bot.py @@ -176,10 +176,32 @@ class TeamTryoutsBot(commands.Bot): async def _send_schedule_notification(self, user_id: int, event_type: str, event_title: str, event_date: str, event_time: str, reference_id: int) -> int: - """Send a schedule addition notification to a player.""" + """Send a schedule addition notification to a player. + + Args: + user_id: Database primary key of the User (NOT Discord user ID). + event_type: 'match' or 'tryout'. + event_title: Title of the event. + event_date: Date string. + event_time: Time string. + reference_id: ID of the MatchParticipant or TryoutRegistration record. + """ try: - user = await self.fetch_user(user_id) + # Look up the DB user to get their Discord user ID + from models import User as DBUser + db_user = DBUser.query.get(user_id) + if not db_user: + logger.warning(f"DB user {user_id} not found for schedule notification") + return None + + if not db_user.discord_user_id: + logger.warning(f"User {db_user.username} has no Discord user ID, cannot send DM") + return None + + discord_uid = int(db_user.discord_user_id) + user = await self.fetch_user(discord_uid) if not user: + logger.warning(f"Could not fetch Discord user {discord_uid}") return None event_name = "Match" if event_type == 'match' else "Tryout" @@ -202,7 +224,7 @@ class TeamTryoutsBot(commands.Bot): # Track this pending request self.pending_requests[msg.id] = {'type': 'schedule_addition', 'id': reference_id, 'event_type': event_type} - logger.info(f"Sent {event_type} schedule notification, message_id={msg.id}") + logger.info(f"Sent {event_type} schedule notification to {db_user.username}, message_id={msg.id}") return msg.id except Exception as e: @@ -226,15 +248,28 @@ 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 + 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() db.session.commit() await original_message.channel.send( - f"✅ You have **approved** the One on One session with {request.player.full_name}." + f"✅ You have **approved** the One on One session with {player_full_name}." ) - await self.notify_player_about_one_on_one(request, approved=True) + # 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 + ) del self.pending_requests[message_id] except Exception as e: @@ -257,6 +292,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 + coach_obj = request.coach + player_full_name = player.full_name + player_discord_id = player.discord_user_id + refusal_note = None try: async for reply in original_message.channel.history(limit=20): @@ -272,14 +313,21 @@ class TeamTryoutsBot(commands.Bot): request.coach_rejection_message = refusal_note db.session.commit() - rejection_msg = f"❌ You have **rejected** the One on One session with {request.player.full_name}." + rejection_msg = f"❌ You have **rejected** the One on One session with {player_full_name}." if refusal_note: rejection_msg += f"\n**Reason:** {refusal_note}" else: 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(request, approved=False, refusal_note=refusal_note) + await self.notify_player_about_one_on_one_direct( + player_discord_id=player_discord_id, + player_full_name=player_full_name, + coach_full_name=coach_obj.full_name, + request=request, + approved=False, + refusal_note=refusal_note + ) del self.pending_requests[message_id] except Exception as e: @@ -336,32 +384,64 @@ class TeamTryoutsBot(commands.Bot): logger.error(f"Error handling attendance decline: {e}") async def notify_player_about_one_on_one(self, request, approved=True, refusal_note=None): - """Send confirmation to player about One on One response.""" + """Send confirmation to player about One on One response. + + This is the legacy method kept for backward compatibility with any + callers that pass a fully-loaded request object. + """ try: - # Ensure player and coach relationships are loaded player = request.player coach = request.coach - if not player: - logger.warning(f"Player not found for request {request.id}") + if not player or not player.discord_user_id: + logger.warning(f"Player has no Discord user ID for request {request.id}") return + if not coach: logger.warning(f"Coach not found for request {request.id}") return - if not player.discord_user_id: + await self.notify_player_about_one_on_one_direct( + player_discord_id=player.discord_user_id, + player_full_name=player.full_name, + coach_full_name=coach.full_name, + request=request, + approved=approved, + refusal_note=refusal_note + ) + except Exception as e: + logger.error(f"Error notifying player about One on One: {e}") + + async def notify_player_about_one_on_one_direct(self, player_discord_id, player_full_name, + coach_full_name, request, + approved=True, refusal_note=None): + """Send confirmation to player about One on One response using pre-fetched data. + + This method avoids session expiration issues by using data captured before + the database commit. + + Args: + player_discord_id: The player's Discord user ID string. + player_full_name: The player's full name. + coach_full_name: The coach's full name. + request: The OneOnOneRequest object (for date/time/points data only). + approved: Whether the session was approved. + refusal_note: Optional coach refusal reason. + """ + try: + if not player_discord_id: logger.warning(f"Player has no Discord user ID for request {request.id}") return - player_user = await self.fetch_user(int(player.discord_user_id)) + player_user = await self.fetch_user(int(player_discord_id)) if not player_user: - logger.warning(f"Could not fetch Discord user for player {player.id}") + logger.warning(f"Could not fetch Discord user {player_discord_id}") return if approved: message = ( "🎉 **One on One Session Confirmed!**\n\n" - f"Your coach **{coach.full_name}** has approved your request:\n" + f"Your coach **{coach_full_name}** has approved your request:\n" f"**Date:** {request.date.strftime('%A, %B %d, %Y')}\n" f"**Time:** {request.start_time.strftime('%I:%M %p')} - {request.end_time.strftime('%I:%M %p')}\n" f"**Discussion Points:** {request.points or 'No specific points provided'}\n\n" @@ -371,22 +451,22 @@ class TeamTryoutsBot(commands.Bot): if refusal_note: message = ( "😞 **One on One Session Rejected**\n\n" - f"Your coach **{coach.full_name}** has declined:\n" + f"Your coach **{coach_full_name}** has declined:\n" f"**Reason:** {refusal_note}\n\n" "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" + 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 notification to player {player.full_name} (request {request.id})") + logger.info(f"Sent One on One notification to player {player_full_name} (request {request.id})") except Exception as e: - logger.error(f"Error notifying player about One on One: {e}") + logger.error(f"Error in direct One on One notification: {e}") async def send_daily_reminders(self): """Send daily reminders at 18:00 EDT for events in 24-48 hours.""" diff --git a/instance/team_tryouts.db b/instance/team_tryouts.db index 921c0aa6fcebe20774e1c0a8a902cfe426a146d7..7ecf4ca26e968718c77ee46174b151448d8b4365 100644 GIT binary patch literal 204800 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 literal 176128 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 diff --git a/models.py b/models.py index 1da0e4b..0371b34 100644 --- a/models.py +++ b/models.py @@ -175,9 +175,18 @@ class User(UserMixin, db.Model): if self.role == 'manager' and (tryout.created_by == self.id or tryout.manager_id == self.id): return True if self.role == 'coach': - org_team = OrgTeam.query.filter_by(coach_id=self.id).first() - if org_team and tryout.target_org_team_id == org_team.id: - return True + # Check if coach belongs to the target org team (many-to-many) + if tryout.target_org_team_id: + is_coach_of_target = OrgTeam.query.filter( + OrgTeam.id == tryout.target_org_team_id, + OrgTeam.coaches.any(id=self.id) + ).first() is not None + if is_coach_of_target: + return True + # Fallback to legacy coach_id + org_team = OrgTeam.query.filter_by(coach_id=self.id).first() + if org_team and tryout.target_org_team_id == org_team.id: + return True if tryout.coach_id == self.id: return True return False @@ -186,7 +195,7 @@ class User(UserMixin, db.Model): """Check if user can manage a specific org team. Presidents can manage all org teams. Managers can manage all org teams. - Coaches can only manage their own coached team. + Coaches can only manage their own coached team (via many-to-many or legacy). Args: org_team: The OrgTeam object to check permissions for. @@ -198,8 +207,13 @@ class User(UserMixin, db.Model): return True if self.role == 'manager': return True # Managers can manage all org teams (create/edit/delete) - if self.role == 'coach' and org_team.coach_id == self.id: - return True + if self.role == 'coach': + # Check many-to-many coaches + if org_team.coaches.filter_by(id=self.id).first(): + return True + # Fallback to legacy coach_id + if org_team.coach_id == self.id: + return True return False def get_gamertags(self): @@ -338,31 +352,87 @@ class TeamPlayer(db.Model): ) +# Many-to-many association tables for multiple coaches and managers per team +org_team_coaches = db.Table('org_team_coaches', + db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'), primary_key=True), + db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True) +) + +org_team_managers = db.Table('org_team_managers', + db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'), primary_key=True), + db.Column('manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True) +) + + class OrgTeam(db.Model): """Persistent organization teams (e.g., Varsity, JV) that exist across tryouts. These teams are long-term organizational structures that persist beyond individual tryouts, unlike tryout-specific Team entities. + Supports multiple coaches and managers per team through many-to-many + junction tables (org_team_coaches, org_team_managers). + Attributes: id: Unique identifier. name: Team name (e.g., "Varsity", "Junior Varsity"). - coach_id: Foreign key to the assigned coach. - manager_id: Foreign key to the assigned manager. created_by: Foreign key to the user who created the team. created_at: Timestamp of team creation. + coaches: Many-to-many relationship to User (coaches). + managers: Many-to-many relationship to User (managers). """ __tablename__ = 'org_teams' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False, unique=True) - coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) - manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Legacy single coach/manager columns kept for backward compatibility during migration + # These will be removed in a future migration after existing data is migrated + coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) - coach = db.relationship('User', foreign_keys=[coach_id], backref='coached_org_team', uselist=False) - manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_org_team', uselist=False) creator = db.relationship('User', foreign_keys=[created_by]) + + # New many-to-many relationships for multiple coaches/managers + coaches = db.relationship('User', secondary=org_team_coaches, lazy='dynamic', + backref=db.backref('coached_org_teams', lazy='dynamic')) + managers = db.relationship('User', secondary=org_team_managers, lazy='dynamic', + backref=db.backref('managed_org_teams', lazy='dynamic')) + + # Legacy single relationships — now properties that use the many-to-many lists + coach = db.relationship('User', foreign_keys=[coach_id], + backref=db.backref('coached_org_team_legacy', uselist=False), viewonly=True) + manager = db.relationship('User', foreign_keys=[manager_id], + backref=db.backref('managed_org_team_legacy', uselist=False), viewonly=True) + + def get_coaches(self): + """Get the list of coaches for display/handling. + + Returns coaches from the many-to-many relationship, falling back to + the legacy single coach for backward compatibility. + + Returns: + list: List of User objects who are coaches for this team. + """ + coach_list = self.coaches.all() + if not coach_list and self.coach: + return [self.coach] + return coach_list + + def get_managers(self): + """Get the list of managers for display/handling. + + Returns managers from the many-to-many relationship, falling back to + the legacy single manager for backward compatibility. + + Returns: + list: List of User objects who are managers for this team. + """ + manager_list = self.managers.all() + if not manager_list and self.manager: + return [self.manager] + return manager_list @property def players(self): @@ -862,4 +932,80 @@ class OneOnOneRequest(db.Model): player = db.relationship('User', foreign_keys=[player_id], backref='one_on_one_requests') coach = db.relationship('User', foreign_keys=[coach_id]) - team = db.relationship('OrgTeam', foreign_keys=[org_team_id]) \ No newline at end of file + team = db.relationship('OrgTeam', foreign_keys=[org_team_id]) + + +class TeamMatch(db.Model): + """Regular season match for an organization team (not tied to a tryout). + + These matches are team-specific, not tryout-specific. The team's roster is + automatically pre-filled as participants. The creator (coach, manager, president) + only needs to select date-time and an optional opponent. + + Attributes: + id: Unique identifier. + org_team_id: Foreign key to the organization team. + title: Match title/name. + description: Optional description. + opponent: Optional opponent name. + date: Match date. + start_time: Match start time. + end_time: Match end time. + location: Match location. + status: Match status (scheduled, completed, cancelled). + created_by: Foreign key to the creator. + created_at: Timestamp of creation. + """ + __tablename__ = 'team_matches' + id = db.Column(db.Integer, primary_key=True) + org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, nullable=True) + opponent = db.Column(db.String(200), nullable=True) + date = db.Column(db.Date, nullable=False) + start_time = db.Column(db.Time, nullable=True) + end_time = db.Column(db.Time, nullable=True) + location = db.Column(db.String(200), nullable=True) + status = db.Column(db.String(20), default='scheduled') # scheduled, completed, cancelled + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + org_team = db.relationship('OrgTeam', backref='team_matches') + creator = db.relationship('User', backref='created_team_matches') + participants = db.relationship('TeamMatchParticipant', backref='team_match', lazy='dynamic', cascade='all, delete-orphan') + + def get_confirmed_count(self): + """Get count of confirmed participants. + + Returns: + tuple: (confirmed_count, total_count) + """ + all_p = self.participants.all() + confirmed = sum(1 for p in all_p if p.is_confirmed) + return confirmed, len(all_p) + + +class TeamMatchParticipant(db.Model): + """Participant in a team match (regular season). + + Automatically created for all team players when a TeamMatch is created. + Tracks attendance confirmation per player. + + Attributes: + id: Unique identifier. + team_match_id: Foreign key to the team match. + player_id: Foreign key to the player. + is_confirmed: Whether attendance is confirmed (manual toggle or Discord reaction). + added_at: Timestamp when added. + """ + __tablename__ = 'team_match_participants' + id = db.Column(db.Integer, primary_key=True) + team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + is_confirmed = db.Column(db.Boolean, default=False) + added_at = db.Column(db.DateTime, default=datetime.utcnow) + + player = db.relationship('User') + __table_args__ = ( + db.UniqueConstraint('team_match_id', 'player_id', name='unique_team_match_player'), + ) diff --git a/routes/matches.py b/routes/matches.py index dcdc51d..9a5947e 100644 --- a/routes/matches.py +++ b/routes/matches.py @@ -91,6 +91,12 @@ def api_events(): start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None + # Find current user's participant record for presence toggle + user_participant = MatchParticipant.query.filter_by( + match_id=match.id, + player_id=current_user.id + ).first() + events.append({ 'id': f'match_{match.id}', 'title': match.title, @@ -106,7 +112,9 @@ def api_events(): 'match_id': match.id, 'start_time': start_time_str, 'end_time': end_time_str, - 'participants': participants_str + 'participants': participants_str, + 'user_participant_id': user_participant.id if user_participant else None, + 'user_attendance_confirmed': user_participant.attendance_confirmed if user_participant else False } }) @@ -297,6 +305,9 @@ def create_match(tryout_id): all_players = [User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)] all_players = sorted([p for p in all_players if p], key=lambda x: x.username) + # Allow pre-filling the date from query param (e.g., from calendar click) + prefill_date = request.args.get('date', '') + if request.method == 'POST': title = request.form.get('title') description = request.form.get('description') @@ -309,13 +320,13 @@ def create_match(tryout_id): # Start time is now mandatory if not start_time_str: flash('Start time is required. Please select a time slot.', 'danger') - return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players) + return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players, prefill_date=prefill_date) try: date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date except (ValueError, TypeError): flash('Invalid date format.', 'danger') - return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players) + return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players, prefill_date=prefill_date) start_time = None end_time = None @@ -638,6 +649,31 @@ def edit_match(match_id): participants_map=participants_map) +@matches_bp.route('/api/manageable-tryouts') +@login_required +def api_manageable_tryouts(): + """API endpoint returning tryouts the current user can manage. + + Used by the calendar's "Create Event" modal to populate the tryout dropdown. + + Returns: + Response: JSON array of {id, title, date}. + """ + if not can_schedule_match(): + return jsonify([]) + + tryouts = get_visible_tryouts_for_user() + manageable = [] + for t in tryouts: + if current_user.can_manage_this_tryout(t): + manageable.append({ + 'id': t.id, + 'title': t.title, + 'date': t.date.strftime('%Y-%m-%d') + }) + return jsonify(manageable) + + @matches_bp.route('//delete', methods=['POST']) @login_required def delete_match(match_id): @@ -738,7 +774,7 @@ def api_available_players(date, time): def toggle_presence(match_id, participant_id): """Toggle the attendance_confirmed status for a match participant. - Accessible only to users who can manage the tryout. + Accessible to tryout managers AND the participant themselves. Args: match_id: The ID of the match. @@ -750,13 +786,15 @@ def toggle_presence(match_id, participant_id): match = Match.query.get_or_404(match_id) tryout = match.tryout - if not current_user.can_manage_this_tryout(tryout): - return jsonify({'error': 'Unauthorized'}), 403 - participant = MatchParticipant.query.get_or_404(participant_id) if participant.match_id != match_id: return jsonify({'error': 'Participant does not belong to this match'}), 400 + # Allow the participant themselves OR a tryout manager + is_self = participant.player_id == current_user.id + if not is_self and not current_user.can_manage_this_tryout(tryout): + return jsonify({'error': 'Unauthorized'}), 403 + participant.attendance_confirmed = not participant.attendance_confirmed db.session.commit() diff --git a/routes/team_matches.py b/routes/team_matches.py new file mode 100644 index 0000000..bb7eaf1 --- /dev/null +++ b/routes/team_matches.py @@ -0,0 +1,388 @@ +"""Team match management routes for regular season matches. + +This module handles CRUD operations for team-specific matches that are +not tied to tryouts. Players are pre-filled from the team roster. +""" + +from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify +from flask_login import login_required, current_user +from extensions import db +from models import OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer +from datetime import datetime, timedelta +from discord_bot import send_schedule_notification + +team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches') + + +def can_manage_team_match(team): + """Check if current user can manage matches for this team. + + Returns: + bool: True if user is president, manager, or a coach of this team. + """ + if current_user.role in ['president']: + return True + if current_user.role == 'manager': + return True + if current_user.role == 'coach': + if team.coaches.filter_by(id=current_user.id).first(): + return True + if team.coach_id == current_user.id: + return True + return False + + +@team_matches_bp.route('') +@login_required +def list_matches(): + """List all team matches visible to the current user. + + Supports optional ?team_id= query param to pre-filter by team. + + Returns: + Response: Rendered team matches list template. + """ + # Optional pre-filter by team_id from query param + filter_team_id = request.args.get('team_id', type=int) + + if current_user.role == 'president': + teams = OrgTeam.query.order_by(OrgTeam.name).all() + matches_query = TeamMatch.query + elif current_user.role == 'manager': + teams = OrgTeam.query.order_by(OrgTeam.name).all() + matches_query = TeamMatch.query + elif current_user.role == 'coach': + teams = OrgTeam.query.filter( + db.or_( + OrgTeam.coaches.any(id=current_user.id), + OrgTeam.coach_id == current_user.id + ) + ).order_by(OrgTeam.name).all() + team_ids = [t.id for t in teams] + matches_query = TeamMatch.query.filter( + TeamMatch.org_team_id.in_(team_ids) + ) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1) + elif current_user.role == 'player': + player_team_ids = [tp.org_team_id for tp in current_user.team_placements] + teams = OrgTeam.query.filter(OrgTeam.id.in_(player_team_ids)).all() if player_team_ids else [] + matches_query = TeamMatch.query.filter( + TeamMatch.org_team_id.in_(player_team_ids) + ) if player_team_ids else TeamMatch.query.filter(TeamMatch.id == -1) + else: + teams = [] + matches_query = TeamMatch.query.filter(TeamMatch.id == -1) + + # Apply team_id filter if provided + if filter_team_id: + matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id) + + matches = matches_query.order_by(TeamMatch.date.desc()).all() + + # Build participants map for each match + match_data = [] + for tm in matches: + confirmed, total = tm.get_confirmed_count() + participants = [] + for p in tm.participants.all(): + participants.append({ + 'id': p.id, + 'player': p.player, + 'is_confirmed': p.is_confirmed + }) + match_data.append({ + 'match': tm, + 'participants': participants, + 'confirmed_count': confirmed, + 'total_count': total + }) + + return render_template('pages/team_matches.html', + teams=teams, + match_data=match_data, + now=datetime.utcnow()) + + +@team_matches_bp.route('//create', methods=['GET', 'POST']) +@login_required +def create_match(team_id): + """Create a new team match (regular season). + + GET: Render the match creation form with pre-filled team roster. + POST: Create the match with all team players as participants. + + Args: + team_id: The ID of the org team to create a match for. + + Returns: + Response: Create form or redirect to team matches list. + """ + team = OrgTeam.query.get_or_404(team_id) + + if not can_manage_team_match(team): + flash('You do not have permission to schedule matches for this team.', 'danger') + return redirect(url_for('team_matches.list_matches')) + + team_players = [tp for tp in TeamPlayer.query.filter_by(org_team_id=team_id).all()] + + # Allow pre-filling the date from query param (e.g., from calendar click) + prefill_date = request.args.get('date', '') + + # Check if this is a practice (no opponent) + is_practice = request.args.get('type') == 'practice' + default_title = 'Practice' if is_practice else f'Team Match — {team.name}' + + # For practices, render the tryout match form (with availability calendar) + if is_practice and request.method == 'GET': + # Build a lightweight proxy for the tryout object the template expects + class TryoutProxy: + def __init__(self, team_obj): + self.id = 0 + self.title = team_obj.name + self.date = '' + self.game = '' + self.target_org_team = team_obj + + proxy_tryout = TryoutProxy(team) + all_players = [tp.player for tp in team_players if tp.player] + + return render_template('pages/match_form.html', + tryout=proxy_tryout, + teams=[], + all_players=all_players, + prefill_date=prefill_date, + is_practice=True, + team_id=team_id, + team=team) + + if request.method == 'POST': + title = request.form.get('title', default_title) + opponent = request.form.get('opponent', '').strip() if not is_practice else None + description = request.form.get('description', '') + date_str = request.form.get('date') + start_time_str = request.form.get('start_time') + end_time_str = request.form.get('end_time') + location = request.form.get('location', '') + + if not date_str: + flash('Date is required.', 'danger') + return render_template('pages/team_match_form.html', team=team, team_players=team_players, prefill_date=prefill_date) + + try: + date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() + except (ValueError, TypeError): + flash('Invalid date format.', 'danger') + return render_template('pages/team_match_form.html', team=team, team_players=team_players, prefill_date=prefill_date, is_practice=is_practice) + + start_time = None + end_time = None + if start_time_str: + try: + start_time = datetime.strptime(start_time_str, '%H:%M').time() + if end_time_str: + end_time = datetime.strptime(end_time_str, '%H:%M').time() + else: + start_dt = datetime.combine(date_obj, start_time) + end_dt = start_dt + timedelta(minutes=30) + end_time = end_dt.time() + except ValueError: + flash('Invalid time format.', 'danger') + return render_template('pages/team_match_form.html', team=team, team_players=team_players, prefill_date=prefill_date, is_practice=is_practice) + + team_match = TeamMatch( + org_team_id=team_id, + title=title, + description=description or None, + opponent=opponent or None, + date=date_obj, + start_time=start_time, + end_time=end_time, + location=location or None, + created_by=current_user.id + ) + db.session.add(team_match) + db.session.flush() # Get team_match.id + + # Auto-add all team players as participants + notified_participant_ids = [] + for tp in team_players: + participant = TeamMatchParticipant( + team_match_id=team_match.id, + player_id=tp.player_id + ) + db.session.add(participant) + db.session.flush() + notified_participant_ids.append(participant.id) + + db.session.commit() + + # Send Discord notifications to players + event_date_str = date_obj.strftime('%Y-%m-%d') + event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD' + + for i, tp in enumerate(team_players): + reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else team_match.id + send_schedule_notification( + user_id=tp.player_id, + event_type='match', + event_title=team_match.title, + event_date=event_date_str, + event_time=event_time_str, + reference_id=reference_id + ) + + flash(f'Team match "{title}" scheduled successfully!', 'success') + return redirect(url_for('team_matches.list_matches')) + + return render_template('pages/team_match_form.html', team=team, team_players=team_players) + + +@team_matches_bp.route('//edit', methods=['GET', 'POST']) +@login_required +def edit_match(match_id): + """Edit an existing team match. + + GET: Render the edit form. + POST: Update match details. + + Args: + match_id: The ID of the team match to edit. + + Returns: + Response: Edit form or redirect to team matches list. + """ + team_match = TeamMatch.query.get_or_404(match_id) + team = team_match.org_team + + if not can_manage_team_match(team): + flash('You do not have permission to edit this match.', 'danger') + return redirect(url_for('team_matches.list_matches')) + + if request.method == 'POST': + team_match.title = request.form.get('title', team_match.title) + team_match.description = request.form.get('description', '') or None + team_match.opponent = request.form.get('opponent', '').strip() or None + + date_str = request.form.get('date') + if date_str: + try: + team_match.date = datetime.strptime(date_str, '%Y-%m-%d').date() + except (ValueError, TypeError): + flash('Invalid date format.', 'danger') + return redirect(url_for('team_matches.edit_match', match_id=match_id)) + + start_time_str = request.form.get('start_time') + if start_time_str: + try: + team_match.start_time = datetime.strptime(start_time_str, '%H:%M').time() + except ValueError: + pass + + end_time_str = request.form.get('end_time') + if end_time_str: + try: + team_match.end_time = datetime.strptime(end_time_str, '%H:%M').time() + except ValueError: + pass + + team_match.location = request.form.get('location', '') or None + + status = request.form.get('status') + if status in ['scheduled', 'completed', 'cancelled']: + team_match.status = status + + db.session.commit() + flash('Match updated successfully!', 'success') + return redirect(url_for('team_matches.list_matches')) + + return render_template('pages/team_match_form.html', + match=team_match, + team=team, + team_players=[]) + + +@team_matches_bp.route('//delete', methods=['POST']) +@login_required +def delete_match(match_id): + """Delete a team match. + + Args: + match_id: The ID of the team match to delete. + + Returns: + Response: Redirect to team matches list. + """ + team_match = TeamMatch.query.get_or_404(match_id) + team = team_match.org_team + + if not can_manage_team_match(team): + flash('You do not have permission to delete this match.', 'danger') + return redirect(url_for('team_matches.list_matches')) + + db.session.delete(team_match) + db.session.commit() + flash('Match deleted successfully.', 'success') + return redirect(url_for('team_matches.list_matches')) + + +@team_matches_bp.route('/api/manageable-teams') +@login_required +def api_manageable_teams(): + """API endpoint returning teams the current user can schedule matches for. + + Used by the calendar's "Create Event" modal. + + Returns: + Response: JSON array of {id, name}. + """ + if not current_user.can_schedule_matches(): + return jsonify([]) + + if current_user.role in ['president', 'manager']: + teams = OrgTeam.query.order_by(OrgTeam.name).all() + elif current_user.role == 'coach': + teams = OrgTeam.query.filter( + db.or_( + OrgTeam.coaches.any(id=current_user.id), + OrgTeam.coach_id == current_user.id + ) + ).order_by(OrgTeam.name).all() + else: + return jsonify([]) + + return jsonify([{'id': t.id, 'name': t.name} for t in teams]) + + +@team_matches_bp.route('//toggle-presence/', methods=['POST']) +@login_required +def toggle_presence(match_id, participant_id): + """Toggle the is_confirmed status for a team match participant. + + Accessible to team managers/coaches AND the player themselves. + + Args: + match_id: The ID of the team match. + participant_id: The ID of the TeamMatchParticipant record. + + Returns: + Response: JSON with new status. + """ + team_match = TeamMatch.query.get_or_404(match_id) + team = team_match.org_team + + participant = TeamMatchParticipant.query.get_or_404(participant_id) + if participant.team_match_id != match_id: + return jsonify({'error': 'Participant does not belong to this match'}), 400 + + # Allow manager, coach, president, or the player themselves + can_toggle = can_manage_team_match(team) or participant.player_id == current_user.id + if not can_toggle: + return jsonify({'error': 'Unauthorized'}), 403 + + participant.is_confirmed = not participant.is_confirmed + db.session.commit() + + return jsonify({ + 'participant_id': participant.id, + 'is_confirmed': participant.is_confirmed, + 'player_name': participant.player.username if participant.player else 'Unknown' + }) \ No newline at end of file diff --git a/routes/teams.py b/routes/teams.py index 347cabe..f427a15 100644 --- a/routes/teams.py +++ b/routes/teams.py @@ -16,8 +16,8 @@ teams_bp = Blueprint('teams', __name__, url_prefix='/teams') def list_teams(): """List all organization teams visible to the current user. - Coaches see only their assigned team. Players and scouts see all teams. - Managers and presidents see all teams and can manage them. + Coaches and managers see only their assigned teams. + President sees all teams. Returns: Response: Rendered teams list template. @@ -25,21 +25,87 @@ def list_teams(): can_manage = current_user.can_manage_teams() if current_user.role == 'coach': - org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() - teams = [org_team] if org_team else [] - elif current_user.role in ['player', 'scout'] or can_manage: - # Players and scouts can view all teams; managers/presidents can manage - teams = OrgTeam.query.order_by(OrgTeam.name).all() + teams = OrgTeam.query.filter( + db.or_( + OrgTeam.coaches.any(id=current_user.id), + OrgTeam.coach_id == current_user.id + ) + ).order_by(OrgTeam.name).all() + elif current_user.role == 'manager': + teams = OrgTeam.query.filter( + db.or_( + OrgTeam.managers.any(id=current_user.id), + OrgTeam.manager_id == current_user.id + ) + ).order_by(OrgTeam.name).all() + elif current_user.role in ['player', 'scout']: + flash('Use My Team(s) to view your teams.', 'info') + return redirect(url_for('teams.my_teams')) else: flash('You do not have permission to view teams.', 'danger') return redirect(url_for('main.dashboard')) - coaches = User.query.filter_by(role='coach').order_by(User.username).all() - managers = User.query.filter_by(role='manager').order_by(User.username).all() + coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all() + managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all() all_players = User.query.filter_by(role='player').order_by(User.username).all() return render_template('pages/teams.html', teams=teams, coaches=coaches, managers=managers, all_players=all_players, can_manage=can_manage) +@teams_bp.route('/my-teams') +@login_required +def my_teams(): + """View the player's own teams with upcoming matches. + + Players can see their team rosters, coaches, managers, and + upcoming team matches with presence confirmation toggles. + + Returns: + Response: Rendered my_teams template. + """ + if current_user.role != 'player': + flash('This page is for players.', 'info') + return redirect(url_for('teams.list_teams')) + + from models import TeamMatch, TeamMatchParticipant + from datetime import datetime + + player_teams = current_user.get_org_teams() + + team_data = [] + now = datetime.utcnow() + for org_team in player_teams: + matches = TeamMatch.query.filter( + TeamMatch.org_team_id == org_team.id, + TeamMatch.status == 'scheduled' + ).order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc()).all() + + matches_data = [] + for tm in matches: + confirmed, total = tm.get_confirmed_count() + participant = TeamMatchParticipant.query.filter_by( + team_match_id=tm.id, + player_id=current_user.id + ).first() + matches_data.append({ + 'match': tm, + 'participant_id': participant.id if participant else None, + 'is_confirmed': participant.is_confirmed if participant else False, + 'confirmed_count': confirmed, + 'total_count': total + }) + + team_data.append({ + 'team': org_team, + 'matches': matches_data, + 'coaches': org_team.get_coaches(), + 'managers': org_team.get_managers() + }) + + return render_template('pages/my_teams.html', + team_data=team_data, + now=now) + + @teams_bp.route('/create', methods=['POST']) @login_required def create_team(): @@ -77,6 +143,17 @@ def create_team(): created_by=current_user.id ) db.session.add(team) + db.session.flush() + + if coach_id: + coach_user = User.query.get(int(coach_id)) + if coach_user: + team.coaches.append(coach_user) + if manager_id: + manager_user = User.query.get(int(manager_id)) + if manager_user: + team.managers.append(manager_user) + db.session.commit() flash(f'Team "{name}" created successfully!', 'success') return redirect(url_for('teams.list_teams')) @@ -85,17 +162,7 @@ def create_team(): @teams_bp.route('//edit', methods=['POST']) @login_required def edit_team(team_id): - """Edit an existing organization team. - - Args: - team_id: The ID of the team to edit. - name: New team name from form. - coach_id: New coach assignment from form. - manager_id: New manager assignment from form. - - Returns: - Response: Redirect to teams list with status message. - """ + """Edit an existing organization team.""" team = OrgTeam.query.get_or_404(team_id) if not current_user.can_manage_this_org_team(team): flash('You do not have permission to edit this team.', 'danger') @@ -117,6 +184,16 @@ def edit_team(team_id): team.name = name team.coach_id = int(coach_id) if coach_id else None team.manager_id = int(manager_id) if manager_id else None + + if coach_id: + coach_user = User.query.get(int(coach_id)) + if coach_user and not team.coaches.filter_by(id=coach_user.id).first(): + team.coaches.append(coach_user) + if manager_id: + manager_user = User.query.get(int(manager_id)) + if manager_user and not team.managers.filter_by(id=manager_user.id).first(): + team.managers.append(manager_user) + db.session.commit() flash(f'Team "{name}" updated successfully!', 'success') return redirect(url_for('teams.list_teams')) @@ -125,17 +202,7 @@ def edit_team(team_id): @teams_bp.route('//delete', methods=['POST']) @login_required def delete_team(team_id): - """Delete an organization team. - - Removes the team and clears its target_org_team_id reference from - any linked tryouts before deletion. - - Args: - team_id: The ID of the team to delete. - - Returns: - Response: Redirect to teams list with status message. - """ + """Delete an organization team.""" if not current_user.can_manage_teams(): flash('You do not have permission to delete teams.', 'danger') return redirect(url_for('teams.list_teams')) @@ -143,7 +210,6 @@ def delete_team(team_id): team = OrgTeam.query.get_or_404(team_id) name = team.name - # Check if any tryouts are targeting this team from models import Tryout tryouts = Tryout.query.filter_by(target_org_team_id=team_id).all() if tryouts: @@ -151,7 +217,6 @@ def delete_team(team_id): t.target_org_team_id = None db.session.commit() - # Remove all team_players associations TeamPlayer.query.filter_by(org_team_id=team_id).delete() db.session.commit() @@ -161,23 +226,88 @@ def delete_team(team_id): return redirect(url_for('teams.list_teams')) -@teams_bp.route('//remove_coach', methods=['POST']) +@teams_bp.route('//add_coach', methods=['POST']) @login_required -def remove_coach(team_id): - """Remove the coach from an organization team. - - Args: - team_id: The ID of the team. - - Returns: - Response: Redirect to teams list with status message. - """ +def add_coach(team_id): + """Add a coach to an organization team (many-to-many).""" team = OrgTeam.query.get_or_404(team_id) if not current_user.can_manage_this_org_team(team): flash('Permission denied.', 'danger') return redirect(url_for('teams.list_teams')) + + coach_id = request.form.get('coach_id') + if not coach_id: + flash('Please select a coach.', 'danger') + return redirect(url_for('teams.list_teams')) + + coach = User.query.get_or_404(int(coach_id)) + if coach.role != 'coach': + flash('Only coaches can be assigned as coach.', 'danger') + return redirect(url_for('teams.list_teams')) + + if team.coaches.filter_by(id=coach.id).first(): + flash(f'{coach.username} is already a coach of {team.name}.', 'info') + return redirect(url_for('teams.list_teams')) + + team.coaches.append(coach) + if not team.coach_id: + team.coach_id = coach.id + db.session.commit() + flash(f'{coach.username} added as coach of {team.name}.', 'success') + return redirect(url_for('teams.list_teams')) - team.coach_id = None + +@teams_bp.route('//add_manager', methods=['POST']) +@login_required +def add_manager(team_id): + """Add a manager to an organization team (many-to-many).""" + team = OrgTeam.query.get_or_404(team_id) + if not current_user.can_manage_this_org_team(team): + flash('Permission denied.', 'danger') + return redirect(url_for('teams.list_teams')) + + manager_id = request.form.get('manager_id') + if not manager_id: + flash('Please select a manager.', 'danger') + return redirect(url_for('teams.list_teams')) + + manager = User.query.get_or_404(int(manager_id)) + if manager.role != 'manager': + flash('Only managers can be assigned as manager.', 'danger') + return redirect(url_for('teams.list_teams')) + + if team.managers.filter_by(id=manager.id).first(): + flash(f'{manager.username} is already a manager of {team.name}.', 'info') + return redirect(url_for('teams.list_teams')) + + team.managers.append(manager) + if not team.manager_id: + team.manager_id = manager.id + db.session.commit() + flash(f'{manager.username} added as manager of {team.name}.', 'success') + return redirect(url_for('teams.list_teams')) + + +@teams_bp.route('//remove_coach', methods=['POST']) +@login_required +def remove_coach(team_id): + """Remove a coach from an organization team.""" + team = OrgTeam.query.get_or_404(team_id) + if not current_user.can_manage_this_org_team(team): + flash('Permission denied.', 'danger') + return redirect(url_for('teams.list_teams')) + + coach_id = request.form.get('coach_id') + if coach_id: + coach = User.query.get(int(coach_id)) + if coach and team.coaches.filter_by(id=coach.id).first(): + team.coaches.remove(coach) + if team.coach_id == coach.id: + team.coach_id = None + else: + team.coaches = [] + team.coach_id = None + db.session.commit() flash(f'Coach removed from {team.name}.', 'success') return redirect(url_for('teams.list_teams')) @@ -186,20 +316,23 @@ def remove_coach(team_id): @teams_bp.route('//remove_manager', methods=['POST']) @login_required def remove_manager(team_id): - """Remove the manager from an organization team. - - Args: - team_id: The ID of the team. - - Returns: - Response: Redirect to teams list with status message. - """ + """Remove a manager from an organization team.""" team = OrgTeam.query.get_or_404(team_id) if not current_user.can_manage_this_org_team(team): flash('Permission denied.', 'danger') return redirect(url_for('teams.list_teams')) - - team.manager_id = None + + manager_id = request.form.get('manager_id') + if manager_id: + manager = User.query.get(int(manager_id)) + if manager and team.managers.filter_by(id=manager.id).first(): + team.managers.remove(manager) + if team.manager_id == manager.id: + team.manager_id = None + else: + team.managers = [] + team.manager_id = None + db.session.commit() flash(f'Manager removed from {team.name}.', 'success') return redirect(url_for('teams.list_teams')) @@ -208,18 +341,7 @@ def remove_manager(team_id): @teams_bp.route('//add_player', methods=['POST']) @login_required def add_player(team_id): - """Add a player to an organization team. - - Players can now be in multiple teams. - - Args: - team_id: The ID of the team to add the player to. - player_id: The ID of the player to add. - status: The player's status (starter or substitute). - - Returns: - Response: Redirect to teams list with status message. - """ + """Add a player to an organization team.""" team = OrgTeam.query.get_or_404(team_id) if not current_user.can_manage_this_org_team(team): flash('Permission denied.', 'danger') @@ -236,13 +358,11 @@ def add_player(team_id): flash('Can only assign players to teams.', 'danger') return redirect(url_for('teams.list_teams')) - # Check if player is already on this team existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first() if existing: flash(f'{player.username} is already on {team.name}.', 'info') return redirect(url_for('teams.list_teams')) - # Add player to the team using TeamPlayer model (allows multiple teams) tp = TeamPlayer( player_id=player.id, org_team_id=team.id, @@ -257,15 +377,7 @@ def add_player(team_id): @teams_bp.route('//remove_player/', methods=['POST']) @login_required def remove_player(team_id, player_id): - """Remove a player from an organization team. - - Args: - team_id: The ID of the team. - player_id: The ID of the player to remove. - - Returns: - Response: Redirect to teams list with status message. - """ + """Remove a player from an organization team.""" team = OrgTeam.query.get_or_404(team_id) if not current_user.can_manage_this_org_team(team): flash('Permission denied.', 'danger') @@ -287,15 +399,7 @@ def remove_player(team_id, player_id): @teams_bp.route('//toggle_status/', methods=['POST']) @login_required def toggle_player_status(team_id, player_id): - """Toggle a player's status between starter and substitute. - - Args: - team_id: The ID of the team. - player_id: The ID of the player. - - Returns: - Response: JSON with new status. - """ + """Toggle a player's status between starter and substitute.""" team = OrgTeam.query.get_or_404(team_id) if not current_user.can_manage_this_org_team(team): return jsonify({'error': 'Permission denied'}), 403 @@ -304,7 +408,6 @@ def toggle_player_status(team_id, player_id): if not tp: return jsonify({'error': 'Player not found on this team'}), 404 - # Toggle status tp.status = 'substitute' if tp.status == 'starter' else 'starter' db.session.commit() @@ -316,23 +419,12 @@ def toggle_player_status(team_id, player_id): }) -# Note actions from team page - - @teams_bp.route('//add-team-note', methods=['POST']) @login_required def add_team_note(team_id): - """Add a team improvement note from the team page (for coaches). - - Args: - team_id: The ID of the team to add notes for. - - Returns: - Response: Redirect to teams list with status message. - """ + """Add a team improvement note from the team page (for coaches).""" team = OrgTeam.query.get_or_404(team_id) - # Check if user can manage this team (president, manager, or coach) if not current_user.can_manage_this_org_team(team): flash('You do not have permission to add notes to this team.', 'danger') return redirect(url_for('teams.list_teams')) @@ -355,18 +447,9 @@ def add_team_note(team_id): @teams_bp.route('//add-player-note/', methods=['POST']) @login_required def add_player_note(team_id, player_id): - """Add a personal note for a player from the team page (for coaches). - - Args: - team_id: The ID of the team. - player_id: The ID of the player to add note for. - - Returns: - Response: Redirect to teams list with status message. - """ + """Add a personal note for a player from the team page (for coaches).""" team = OrgTeam.query.get_or_404(team_id) - # Check if user can manage this team (president, manager, or coach) if not current_user.can_manage_this_org_team(team): flash('You do not have permission to add notes to this team.', 'danger') return redirect(url_for('teams.list_teams')) @@ -376,7 +459,6 @@ def add_player_note(team_id, player_id): flash('Can only add notes for players.', 'danger') return redirect(url_for('teams.list_teams')) - # Verify player belongs to this team tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first() if not tp: flash(f'{player.username} is not on {team.name}.', 'danger') diff --git a/routes/tryouts.py b/routes/tryouts.py index a3d207c..fa63f36 100644 --- a/routes/tryouts.py +++ b/routes/tryouts.py @@ -457,6 +457,56 @@ def register_player(tryout_id): return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) +@tryouts_bp.route('//remove_player/', methods=['POST']) +@login_required +def remove_player(tryout_id, player_id): + """Remove a registered player from a tryout. + + Also removes the player from any tryout teams and match participants + within this tryout. + + Args: + tryout_id: The ID of the tryout. + player_id: The ID of the player to remove. + + Returns: + Response: Redirect to tryout view with status message. + """ + tryout = Tryout.query.get_or_404(tryout_id) + if not current_user.can_manage_this_tryout(tryout): + flash('Permission denied.', 'danger') + return redirect(url_for('tryouts.list_tryouts')) + + player = User.query.get_or_404(player_id) + + # Remove the tryout registration + registration = TryoutRegistration.query.filter_by( + tryout_id=tryout_id, player_id=player_id + ).first() + if registration: + db.session.delete(registration) + + # Remove from tryout teams within this tryout + team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()] + if team_ids: + TeamMember.query.filter( + TeamMember.team_id.in_(team_ids), + TeamMember.player_id == player_id + ).delete(synchronize_session=False) + + # Remove from match participants in this tryout + match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()] + if match_ids: + MatchParticipant.query.filter( + MatchParticipant.match_id.in_(match_ids), + MatchParticipant.player_id == player_id + ).delete(synchronize_session=False) + + db.session.commit() + flash(f'{player.username} removed from tryout.', 'success') + return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + + @tryouts_bp.route('//team/create', methods=['POST']) @login_required def create_team(tryout_id): diff --git a/routes/users.py b/routes/users.py index 48c09b3..ecbb82d 100644 --- a/routes/users.py +++ b/routes/users.py @@ -270,6 +270,24 @@ def create_user(): return render_template('pages/create_user.html', roles=ROLES) +@users_bp.route('//view') +@login_required +def view_user(user_id): + """View a public profile for any user. + + Shows username, games, Discord username, and gamertags. + Does NOT expose full_name, phone, or email. + + Args: + user_id: The ID of the user to view. + + Returns: + Response: Rendered public profile template. + """ + user = User.query.get_or_404(user_id) + return render_template('pages/view_user.html', profile_user=user) + + @users_bp.route('/profile') @login_required def profile(): diff --git a/templates/layouts/base.html b/templates/layouts/base.html index b7380b1..22676c3 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -53,12 +53,21 @@ {% endif %} + {% if current_user.role == 'player' %}
  • - - - Teams + + + My Team(s)
  • + {% else %} +
  • + + + Manage Teams + +
  • + {% endif %} {% if current_user.can_manage_users() %}
  • @@ -67,19 +76,7 @@
  • {% endif %} -
  • - - - My Profile - -
  • {% if current_user.role == 'player' %} -
  • - - - One on One - -
  • @@ -97,7 +94,7 @@
  • - Notes + Notes & One on One
  • {% endif %} @@ -108,6 +105,12 @@ +
  • + + + My Profile + +
  • diff --git a/templates/pages/calendar.html b/templates/pages/calendar.html index bf185f3..d434cc0 100644 --- a/templates/pages/calendar.html +++ b/templates/pages/calendar.html @@ -29,6 +29,61 @@ + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/pages/team_matches.html b/templates/pages/team_matches.html new file mode 100644 index 0000000..3c9fba9 --- /dev/null +++ b/templates/pages/team_matches.html @@ -0,0 +1,231 @@ +{% extends "layouts/base.html" %} +{% block title %}Team Matches - TryoutPro{% endblock %} +{% block page_title %}Team Matches{% endblock %} +{% block breadcrumb %}Home / Team Matches{% endblock %} + +{% block header_actions %} +{% if teams %} +
    + +
    +{% endif %} +{% endblock %} + +{% block content %} +{% if match_data %} +
    + + + + + + + + + + + + + + + + {% for item in match_data %} + {% set m = item.match %} + + + + + + + + + + + + {% endfor %} + +
    MatchTeamOpponentDateTimeLocationPresenceStatusActions
    {{ m.title }} + {{ m.org_team.name }} + + {% if m.opponent %} + {{ m.opponent }} + {% else %} + Practice + {% endif %} + {{ m.date.strftime('%m/%d/%Y') }} + {% if m.start_time and m.end_time %} + {{ m.start_time.strftime('%H:%M') }} - {{ m.end_time.strftime('%H:%M') }} + {% else %} + TBD + {% endif %} + {{ m.location or '—' }} + {% if item.total_count > 0 %} +
    +
    + {% set pct = (item.confirmed_count / item.total_count * 100) | int %} +
    +
    + + {% if item.confirmed_count == item.total_count and item.total_count > 0 %} + ✅ {{ item.confirmed_count }}/{{ item.total_count }} + {% elif item.confirmed_count > 0 %} + ⏳ {{ item.confirmed_count }}/{{ item.total_count }} + {% else %} + ❌ 0/{{ item.total_count }} + {% endif %} + +
    + + + {% else %} + + {% endif %} +
    + {{ m.status }} + + {% set can_manage_this = (current_user.role in ['president', 'manager']) or (current_user.role == 'coach' and m.org_team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and m.org_team.coach_id == current_user.id) %} + {% if can_manage_this %} + + + +
    + + +
    + {% endif %} + + {% if current_user.role == 'player' %} + {% for p in item.participants %} + {% if p.player.id == current_user.id %} + + {% endif %} + {% endfor %} + {% endif %} +
    +
    +{% else %} +
    +
    +
    + +

    No Team Matches

    +

    Regular season matches have not been scheduled yet.

    + {% if teams %} +
    + +
    + {% endif %} +
    +
    +
    +{% endif %} + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/pages/teams.html b/templates/pages/teams.html index 58bf03f..7348b1c 100644 --- a/templates/pages/teams.html +++ b/templates/pages/teams.html @@ -59,34 +59,6 @@

    {{ team.name }}

    -
    - {% if team.coach %} - Coach: {{ team.coach.username }} - {% if can_manage %} -
    - - -
    - {% endif %} - {% else %} - No coach assigned - {% endif %} - {% if team.manager %} - Manager: {{ team.manager.username }} - {% if can_manage %} -
    - - -
    - {% endif %} - {% else %} - No manager assigned - {% endif %} -
    {% if can_manage %}
    + +
    +
    +
    + Coaches +
    + {% set team_coaches = team.get_coaches() %} + {% if team_coaches %} + {% for c in team_coaches %} + + {{ c.username }} + {% if can_manage %} +
    + + + +
    + {% endif %} +
    + {% endfor %} + {% else %} + None assigned + {% endif %} +
    +
    +
    + Managers +
    + {% set team_managers = team.get_managers() %} + {% if team_managers %} + {% for m in team_managers %} + + {{ m.username }} + {% if can_manage %} +
    + + + +
    + {% endif %} +
    + {% endfor %} + {% else %} + None assigned + {% endif %} +
    +
    + {% if can_manage %} +
    +
    + + +
    +
    + + +
    +
    + {% endif %} + + Matches + + {% if current_user.can_schedule_matches() and (current_user.role in ['president', 'manager'] or (current_user.role == 'coach' and team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and team.coach_id == current_user.id)) %} + + Match + + + Practice + + {% endif %} +
    +
    @@ -126,7 +184,7 @@ {% if current_user.can_evaluate() %} + {% endif %} + {% if can_edit %} {% endif %} @@ -138,7 +140,7 @@ - + {% endif %} + {% if can_edit %} + {% endif %} {% else %} - diff --git a/templates/pages/view_user.html b/templates/pages/view_user.html new file mode 100644 index 0000000..630d3fa --- /dev/null +++ b/templates/pages/view_user.html @@ -0,0 +1,76 @@ +{% extends "layouts/base.html" %} +{% block title %}{{ profile_user.username }} - TryoutPro{% endblock %} +{% block page_title %}{{ profile_user.username }}{% endblock %} +{% block breadcrumb %}Home / Back / {{ profile_user.username }}{% endblock %} + +{% block content %} +
    +
    +
    +
    + {{ profile_user.username[:2] | upper }} +
    +
    +

    {{ profile_user.username }}

    + {{ profile_user.role | capitalize }} +
    +
    +
    +
    +
    +
    + Username + {{ profile_user.username }} +
    +
    + Role + + {{ profile_user.role | capitalize }} + +
    + {% if profile_user.discord_username %} +
    + Discord + {{ profile_user.discord_username }} +
    + {% endif %} +
    + + {% set gamertags = profile_user.gamertags %} + {% if gamertags %} +
    +

    Gamertags

    +
    +
    {{ entry.player.username[:2] | upper }}
    - {{ entry.player.username }} + {{ entry.player.username }}
    @@ -326,14 +384,93 @@ function hideEditForm() { } {% endblock %} \ No newline at end of file diff --git a/templates/pages/view_tryout.html b/templates/pages/view_tryout.html index 66e57e6..ad39617 100644 --- a/templates/pages/view_tryout.html +++ b/templates/pages/view_tryout.html @@ -127,6 +127,8 @@ StatusEvaluationActions
    {{ p.username[:2] | upper }}
    - {{ p.username }} + {{ p.username }}
    @@ -163,19 +165,29 @@ Pending {% endif %} - - {% if player_eval_status.get(p.id) %}Edit{% else %}Evaluate{% endif %} - - - - - + {% if current_user.can_evaluate() %} + + {% if player_eval_status.get(p.id) %}Edit{% else %}Evaluate{% endif %} + + + + + {% endif %} +
    + + +
    +
    + No players registered yet.
    + + + + + + + + + + {% for gt in gamertags %} + + + + + + + {% endfor %} + +
    GameGamertagPlatformProfile
    {{ gt.game }}{{ gt.gamertag }}{{ gt.platform or '-' }} + {% if gt.get_trn_url() %} + + View Profile + + {% else %} + + {% endif %} +
    +
    + {% endif %} +
    + +{% endblock %} \ No newline at end of file