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 f02dd4d..7571d95 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='0.0.0.0', port=10000) \ No newline at end of file + app.run(debug=debug_mode, host='0.0.0.0', port=10000) 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/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