This commit is contained in:
cedrick2711
2026-07-28 15:45:48 -04:00
19 changed files with 2132 additions and 225 deletions
+1
View File
@@ -0,0 +1 @@
1. Ajouter l'option d'enlever des joueurs dans les tryouts.
+3 -1
View File
@@ -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)
app.run(debug=debug_mode, host='0.0.0.0', port=10000)
+99 -19
View File
@@ -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."""
+159 -13
View File
@@ -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])
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'),
)
+45 -7
View File
@@ -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('/<int:match_id>/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()
+388
View File
@@ -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('/<int:team_id>/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('/<int:match_id>/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('/<int:match_id>/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('/<int:match_id>/toggle-presence/<int:participant_id>', 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'
})
+192 -110
View File
@@ -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('/<int:team_id>/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('/<int:team_id>/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('/<int:team_id>/remove_coach', methods=['POST'])
@teams_bp.route('/<int:team_id>/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('/<int:team_id>/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('/<int:team_id>/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('/<int:team_id>/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('/<int:team_id>/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('/<int:team_id>/remove_player/<int:player_id>', 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('/<int:team_id>/toggle_status/<int:player_id>', 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('/<int:team_id>/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('/<int:team_id>/add-player-note/<int:player_id>', 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')
+50
View File
@@ -457,6 +457,56 @@ def register_player(tryout_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', 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('/<int:tryout_id>/team/create', methods=['POST'])
@login_required
def create_team(tryout_id):
+18
View File
@@ -270,6 +270,24 @@ def create_user():
return render_template('pages/create_user.html', roles=ROLES)
@users_bp.route('/<int:user_id>/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():
+19 -16
View File
@@ -53,12 +53,21 @@
</a>
</li>
{% endif %}
{% if current_user.role == 'player' %}
<li>
<a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint %}active{% endif %}">
<i class="fas fa-users-cog"></i>
<span>Teams</span>
<a href="{{ url_for('teams.my_teams') }}" class="{% if request.endpoint == 'teams.my_teams' %}active{% endif %}">
<i class="fas fa-users"></i>
<span>My Team(s)</span>
</a>
</li>
{% else %}
<li>
<a href="{{ url_for('teams.list_teams') }}" class="{% if request.endpoint and 'teams' in request.endpoint and request.endpoint != 'teams.my_teams' %}active{% endif %}">
<i class="fas fa-users-cog"></i>
<span>Manage Teams</span>
</a>
</li>
{% endif %}
{% if current_user.can_manage_users() %}
<li>
<a href="{{ url_for('users.list_users') }}" class="{% if request.endpoint and 'users' in request.endpoint and request.endpoint != 'users.profile' %}active{% endif %}">
@@ -67,19 +76,7 @@
</a>
</li>
{% endif %}
<li>
<a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
<i class="fas fa-user"></i>
<span>My Profile</span>
</a>
</li>
{% if current_user.role == 'player' %}
<li>
<a href="{{ url_for('users.one_on_one') }}" class="{% if request.endpoint == 'users.one_on_one' %}active{% endif %}">
<i class="fas fa-calendar-check"></i>
<span>One on One</span>
</a>
</li>
<li>
<a href="{{ url_for('users.my_notes') }}" class="{% if request.endpoint == 'users.my_notes' %}active{% endif %}">
<i class="fas fa-sticky-note"></i>
@@ -97,7 +94,7 @@
<li>
<a href="{{ url_for('users.notes_dashboard') }}" class="{% if request.endpoint == 'users.notes_dashboard' or request.endpoint == 'users.manage_team_notes' or request.endpoint == 'users.manage_personal_notes' %}active{% endif %}">
<i class="fas fa-sticky-note"></i>
<span>Notes</span>
<span>Notes & One on One</span>
</a>
</li>
{% endif %}
@@ -108,6 +105,12 @@
</a>
</li>
<li class="nav-divider"></li>
<li>
<a href="{{ url_for('users.profile') }}" class="{% if request.endpoint == 'users.profile' %}active{% endif %}">
<i class="fas fa-user"></i>
<span>My Profile</span>
</a>
</li>
<li>
<a href="{{ url_for('auth.logout') }}" class="logout-link">
<i class="fas fa-sign-out-alt"></i>
+180 -1
View File
@@ -29,6 +29,61 @@
</div>
</div>
<!-- Create Event Modal (for clicking empty days) -->
<div id="createEventModal" class="modal hidden">
<div class="modal-backdrop" onclick="hideCreateEventModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-plus-circle"></i> Create New Event</h3>
<button class="modal-close" onclick="hideCreateEventModal()">&times;</button>
</div>
<div class="modal-body">
<p class="mb-3"><strong>Date:</strong> <span id="createEventDate"></span></p>
<input type="hidden" id="createEventDateInput"/>
<div class="card mb-3">
<div class="card-body">
<h5><i class="fas fa-futbol"></i> Schedule Tryout Match</h5>
<p class="text-muted small">Add a scrim/match inside an existing tryout</p>
<div class="form-inline">
<select id="createTryoutSelect" class="form-select" style="flex:1;">
<option value="">-- Select a tryout --</option>
</select>
<button class="btn btn-sm btn-primary ml-2" onclick="goToTryoutMatch()">
<i class="fas fa-arrow-right"></i> Go
</button>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h5><i class="fas fa-users"></i> Schedule Team Match</h5>
<p class="text-muted small">Regular season match for an org team</p>
<div class="form-inline">
<select id="createTeamSelect" class="form-select" style="flex:1;">
<option value="">-- Select a team --</option>
</select>
<button class="btn btn-sm btn-success ml-2" onclick="goToTeamMatch()">
<i class="fas fa-arrow-right"></i> Go
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<h5><i class="fas fa-calendar-plus"></i> Create New Tryout</h5>
<p class="text-muted small">Create a brand new tryout event</p>
<button class="btn btn-sm btn-info" onclick="goToCreateTryout()">
<i class="fas fa-plus"></i> Create Tryout
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Event Details Modal -->
<div id="eventModal" class="modal hidden">
<div class="modal-backdrop" onclick="hideEventModal()"></div>
@@ -39,6 +94,9 @@
</div>
<div class="modal-body">
<div id="modalContent"></div>
<div id="modalPresenceToggle" class="form-actions mt-3" style="display: none; justify-content: center;">
<button class="btn btn-sm btn-outline" id="calPresenceBtn">Confirm</button>
</div>
<div id="modalActions" class="form-actions mt-3" style="display: none;">
<button class="btn btn-sm btn-danger" id="deleteMatchBtn" style="display: none;">
<i class="fas fa-trash"></i> Delete Match
@@ -74,13 +132,32 @@ document.addEventListener('DOMContentLoaded', function() {
eventClick: function(info) {
showEventModal(info.event);
},
dateClick: function(info) {
if (canScheduleMatches) {
var dateStr = info.dateStr;
showCreateEventModal(dateStr);
}
},
selectable: true,
select: function(info) {
if (canScheduleMatches) {
var dateStr = info.startStr;
showCreateEventModal(dateStr);
calendar.unselect();
}
},
slotMinTime: '12:00:00',
slotMaxTime: '24:00:00'
});
calendar.render();
// Store calendar in window for access
window.fcCalendar = calendar;
// Pre-load tryout and team options for the create modal
if (canScheduleMatches) {
fetchTryoutOptions();
fetchTeamOptions();
}
});
function changeView(viewName) {
@@ -89,6 +166,68 @@ function changeView(viewName) {
}
}
// --- Create Event Modal ---
function showCreateEventModal(dateStr) {
document.getElementById('createEventDate').textContent = dateStr;
document.getElementById('createEventDateInput').value = dateStr;
// Reset dropdowns
document.getElementById('createTryoutSelect').value = '';
document.getElementById('createTeamSelect').value = '';
document.getElementById('createEventModal').classList.remove('hidden');
}
function hideCreateEventModal() {
document.getElementById('createEventModal').classList.add('hidden');
}
function goToTryoutMatch() {
var tryoutId = document.getElementById('createTryoutSelect').value;
if (!tryoutId) { alert('Please select a tryout.'); return; }
var date = document.getElementById('createEventDateInput').value;
window.location.href = '/matches/create/' + tryoutId + '?date=' + date;
}
function goToTeamMatch() {
var teamId = document.getElementById('createTeamSelect').value;
if (!teamId) { alert('Please select a team.'); return; }
var date = document.getElementById('createEventDateInput').value;
window.location.href = '/team-matches/' + teamId + '/create?date=' + date;
}
function goToCreateTryout() {
// Tryout creation doesn't support pre-filling date easily, just navigate
window.location.href = '/tryouts/create';
}
// Pre-fetch data for dropdowns
function fetchTryoutOptions() {
fetch('/matches/api/manageable-tryouts')
.then(function(r) { return r.json(); })
.then(function(data) {
var sel = document.getElementById('createTryoutSelect');
sel.innerHTML = '<option value="">-- Select a tryout --</option>';
data.forEach(function(t) {
sel.innerHTML += '<option value="' + t.id + '">' + t.title + ' (' + t.date + ')</option>';
});
})
.catch(function() {});
}
function fetchTeamOptions() {
fetch('/team-matches/api/manageable-teams')
.then(function(r) { return r.json(); })
.then(function(data) {
var sel = document.getElementById('createTeamSelect');
sel.innerHTML = '<option value="">-- Select a team --</option>';
data.forEach(function(t) {
sel.innerHTML += '<option value="' + t.id + '">' + t.name + '</option>';
});
})
.catch(function() {});
}
function showEventModal(event) {
var props = event.extendedProps;
var title = event.title;
@@ -172,9 +311,49 @@ function showEventModal(event) {
document.getElementById('modalActions').style.display = 'none';
}
// Show presence toggle for matches where user is a participant
var presenceDiv = document.getElementById('modalPresenceToggle');
if (type === 'match' && props.user_participant_id) {
presenceDiv.style.display = 'flex';
var confirmed = props.user_attendance_confirmed || false;
var toggleBtn = document.getElementById('calPresenceBtn');
toggleBtn.textContent = confirmed ? '✅ Confirmed' : 'Confirm';
toggleBtn.className = 'btn btn-sm ' + (confirmed ? 'btn-success' : 'btn-outline');
toggleBtn.onclick = function() {
toggleCalendarPresence(props.match_id, props.user_participant_id, toggleBtn);
};
} else {
presenceDiv.style.display = 'none';
}
document.getElementById('eventModal').classList.remove('hidden');
}
function toggleCalendarPresence(matchId, participantId, btn) {
fetch('/matches/' + matchId + '/toggle-presence/' + participantId, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.attendance_confirmed) {
btn.classList.add('btn-success');
btn.classList.remove('btn-outline');
btn.textContent = '✅ Confirmed';
} else {
btn.classList.remove('btn-success');
btn.classList.add('btn-outline');
btn.textContent = 'Confirm';
}
})
.catch(function(err) {
console.error('Error toggling presence:', err);
});
}
function hideEventModal() {
document.getElementById('eventModal').classList.add('hidden');
}
+17 -3
View File
@@ -16,15 +16,19 @@
{% block content %}
<div class="card">
<div class="card-header">
<h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% else %}Schedule Match for {{ tryout.title }}{% endif %}</h3>
<h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% elif is_practice %}Schedule Practice — {{ team.name }}{% else %}Schedule Match for {{ tryout.title }}{% endif %}</h3>
{% if match %}
<p class="text-muted small">Match Type: <strong>{{ match.match_type.replace('_', ' ') | title }}</strong></p>
{% endif %}
</div>
<div class="card-body">
<form method="POST" action="{% if match %}{{ url_for('matches.edit_match', match_id=match.id) }}{% else %}{{ url_for('matches.create_match', tryout_id=tryout.id) }}{% endif %}" class="form" id="matchForm">
<form method="POST" action="{% if match %}{{ url_for('matches.edit_match', match_id=match.id) }}{% elif is_practice %}{{ url_for('team_matches.create_match', team_id=team_id) }}{% else %}{{ url_for('matches.create_match', tryout_id=tryout.id) }}{% endif %}" class="form" id="matchForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% if is_practice %}
<input type="hidden" name="title" value="Practice">
{% endif %}
{% if not is_practice %}
{% if not match %}
<div class="form-group">
<label for="match_type">Match Type</label>
@@ -37,16 +41,19 @@
{% else %}
<input type="hidden" name="match_type" value="{{ match.match_type }}">
{% endif %}
{% endif %}
{% if not is_practice %}
<div class="form-group">
<label for="title">Match Title</label>
<input type="text" name="title" id="title" class="form-input" value="{{ match.title if match else '' }}" placeholder="e.g., Alpha vs Bravo Scrimmage" required>
</div>
{% endif %}
<div class="form-row">
<div class="form-group">
<label for="date">Date</label>
<input type="date" name="date" id="date" class="form-input" value="{{ match.date.strftime('%Y-%m-%d') if match else tryout.date.strftime('%Y-%m-%d') }}" required>
<input type="date" name="date" id="date" class="form-input" value="{{ match.date.strftime('%Y-%m-%d') if match else (prefill_date if is_practice else tryout.date.strftime('%Y-%m-%d')) }}" required>
</div>
{% if match %}
<div class="form-group">
@@ -568,10 +575,17 @@ document.addEventListener('DOMContentLoaded', function() {
{% endif %}
{% if not match %}
{% if is_practice %}
// Practices default to player_scrim mode
document.getElementById('player-scrim-section').classList.remove('hidden');
document.getElementById('team-vs-team-section').classList.add('hidden');
document.getElementById('player-vs-player-section').classList.add('hidden');
{% else %}
toggleMatchType();
// Show all players initially
updatePlayerPool();
{% endif %}
{% endif %}
});
function allDisponibilitiesInitialized() {
+278
View File
@@ -0,0 +1,278 @@
{% extends "layouts/base.html" %}
{% block title %}My Team(s) - TryoutPro{% endblock %}
{% block page_title %}My Team(s){% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / My Team(s)</span>{% endblock %}
{% block content %}
{% if team_data %}
{% for item in team_data %}
{% set team = item.team %}
<div class="card mb-4">
<div class="card-header">
<h3><i class="fas fa-users"></i> {{ team.name }}</h3>
</div>
<!-- Staff bar -->
<div class="team-staff-bar">
<div class="staff-row">
<div class="staff-group">
<span class="staff-label"><i class="fas fa-chalkboard-teacher"></i> Coaches</span>
<div class="staff-items">
{% if item.coaches %}
{% for c in item.coaches %}
<span class="staff-tag">{{ c.username }}</span>
{% endfor %}
{% else %}
<span class="text-muted text-sm">None</span>
{% endif %}
</div>
</div>
<div class="staff-group">
<span class="staff-label"><i class="fas fa-user-tie"></i> Managers</span>
<div class="staff-items">
{% if item.managers %}
{% for m in item.managers %}
<span class="staff-tag manager-tag">{{ m.username }}</span>
{% endfor %}
{% else %}
<span class="text-muted text-sm">None</span>
{% endif %}
</div>
</div>
</div>
</div>
<div class="card-body">
<!-- Team Roster -->
<h4 class="mb-3"><i class="fas fa-users"></i> Team Roster</h4>
{% set roster = team.get_players_with_status() %}
{% if roster %}
<div class="table-container mb-4">
<table class="table">
<thead>
<tr>
<th>Player</th>
<th>Status</th>
<th>Position</th>
</tr>
</thead>
<tbody>
{% for entry in roster %}
<tr>
<td>
<div class="user-mini">
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
</div>
</td>
<td>
<span class="badge {% if entry.status == 'starter' %}badge-success{% else %}badge-warning{% endif %}">
{{ entry.status | capitalize }}
</span>
</td>
<td>{{ entry.position or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-3 text-muted">
<i class="fas fa-users-slash"></i> No players on this team.
</div>
{% endif %}
<!-- Team Matches -->
<h4 class="mb-3"><i class="fas fa-futbol"></i> Upcoming Matches</h4>
{% if item.matches %}
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Match</th>
<th>Opponent</th>
<th>Date</th>
<th>Time</th>
<th>Location</th>
<th>Presence</th>
<th>My Status</th>
</tr>
</thead>
<tbody>
{% for mdata in item.matches %}
{% set tm = mdata.match %}
<tr>
<td class="cell-title">{{ tm.title }}</td>
<td>
{% if tm.opponent %}
{{ tm.opponent }}
{% else %}
<span class="badge badge-info">Practice</span>
{% endif %}
</td>
<td>{{ tm.date.strftime('%m/%d/%Y') }}</td>
<td>
{% if tm.start_time and tm.end_time %}
{{ tm.start_time.strftime('%H:%M') }} - {{ tm.end_time.strftime('%H:%M') }}
{% else %}
TBD
{% endif %}
</td>
<td>{{ tm.location or '—' }}</td>
<td>
{% if mdata.total_count > 0 %}
<span title="{{ mdata.confirmed_count }} of {{ mdata.total_count }} confirmed">
{% if mdata.confirmed_count == mdata.total_count and mdata.total_count > 0 %}
✅ {{ mdata.confirmed_count }}/{{ mdata.total_count }}
{% elif mdata.confirmed_count > 0 %}
⏳ {{ mdata.confirmed_count }}/{{ mdata.total_count }}
{% else %}
❌ 0/{{ mdata.total_count }}
{% endif %}
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
{% if mdata.participant_id %}
<button class="btn btn-sm {% if mdata.is_confirmed %}btn-success{% else %}btn-outline{% endif %} presence-toggle-btn"
data-match-id="{{ tm.id }}"
data-participant-id="{{ mdata.participant_id }}"
onclick="togglePresence(this)">
{% if mdata.is_confirmed %}✅ Confirmed{% else %}Confirm{% endif %}
</button>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-3 text-muted">
<i class="fas fa-calendar-alt"></i> No upcoming matches scheduled.
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="card">
<div class="card-body text-center py-5">
<div class="empty-state">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h3>No Teams</h3>
<p class="text-muted">You are not currently assigned to any team.</p>
</div>
</div>
</div>
{% endif %}
<script>
function togglePresence(btn) {
var matchId = btn.getAttribute('data-match-id');
var participantId = btn.getAttribute('data-participant-id');
fetch('/team-matches/' + matchId + '/toggle-presence/' + participantId, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.is_confirmed) {
btn.classList.add('btn-success');
btn.classList.remove('btn-outline');
btn.innerHTML = '✅ Confirmed';
} else {
btn.classList.remove('btn-success');
btn.classList.add('btn-outline');
btn.innerHTML = 'Confirm';
}
location.reload();
})
.catch(function(error) {
console.error('Error:', error);
});
}
</script>
<style>
.team-staff-bar {
padding: 10px 20px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.staff-row {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.staff-group {
display: flex;
align-items: center;
gap: 8px;
}
.staff-label {
font-size: 0.8rem;
color: #64748b;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
.staff-label i {
margin-right: 3px;
font-size: 0.75rem;
}
.staff-items {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.staff-tag {
display: inline-flex;
align-items: center;
gap: 5px;
background: #e0e7ff;
color: #3730a3;
padding: 3px 10px;
border-radius: 14px;
font-size: 0.82rem;
font-weight: 500;
line-height: 1.3;
}
.staff-tag.manager-tag {
background: #fef3c7;
color: #92400e;
}
.text-sm {
font-size: 0.82rem;
}
.presence-toggle-btn {
min-width: 100px;
}
/* Dark mode */
[data-theme="dark"] .team-staff-bar {
background: var(--bg-tertiary);
border-bottom-color: var(--border-color);
}
[data-theme="dark"] .staff-label {
color: var(--text-muted);
}
[data-theme="dark"] .staff-tag {
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
}
[data-theme="dark"] .staff-tag.manager-tag {
background: rgba(229, 169, 57, 0.2);
color: #fcd34d;
}
</style>
{% endblock %}
+1 -14
View File
@@ -65,20 +65,7 @@
<div class="card-body">
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label"><i class="fas fa-headset"></i> Games</span>
<span class="detail-value">
{% set games_list = user.get_games_list() %}
{% if games_list %}
{% for game in games_list %}
<span class="badge badge-esport">{{ game }}</span>
{% endfor %}
{% else %}
<span class="text-muted">Not specified</span>
{% endif %}
</span>
</div>
<div class="detail-item">
<span class="detail-label"><i class="fas fa-chart-line"></i> Games & TRN</span>
<span class="detail-label"><i class="fas fa-gamepad"></i> Gamertags</span>
<span class="detail-value">
{% set games_list = user.get_games_list() %}
{% if games_list %}
+149
View File
@@ -0,0 +1,149 @@
{% extends "layouts/base.html" %}
{% block title %}{% if match %}Edit{% else %}Schedule{% endif %} Team Match - TryoutPro{% endblock %}
{% block page_title %}{% if match %}Edit{% else %}Schedule{% endif %} Team Match{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('team_matches.list_matches') }}">Team Matches</a> / {% if match %}Edit{% else %}New{% endif %}</span>{% endblock %}
{% block content %}
<div class="card mb-4">
<div class="card-header">
<h3><i class="fas fa-futbol"></i> {% if match %}Edit Match{% else %}Schedule New Match{% endif %} — {{ team.name }}</h3>
</div>
<div class="card-body">
<form method="POST" action="{% if match %}{{ url_for('team_matches.edit_match', match_id=match.id) }}{% else %}{{ url_for('team_matches.create_match', team_id=team.id) }}{% endif %}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-row">
<div class="form-group col-6">
<label for="title">Match Title</label>
<input type="text" id="title" name="title" class="form-input"
value="{{ match.title if match else 'Team Match — ' + team.name }}"
placeholder="e.g., Regular Season vs Opponent" required>
</div>
{% if not is_practice %}
<div class="form-group col-6">
<label for="opponent">Opponent (optional)</label>
<input type="text" id="opponent" name="opponent" class="form-input"
value="{{ match.opponent if match else '' }}"
placeholder="e.g., University of Toronto">
</div>
{% endif %}
</div>
<div class="form-row">
<div class="form-group col-4">
<label for="date">Date</label>
<input type="date" id="date" name="date" class="form-input"
value="{{ match.date.strftime('%Y-%m-%d') if match else '' }}"
required>
</div>
<div class="form-group col-4">
<label for="start_time">Start Time</label>
<input type="time" id="start_time" name="start_time" class="form-input"
value="{{ match.start_time.strftime('%H:%M') if match and match.start_time else '' }}">
</div>
<div class="form-group col-4">
<label for="end_time">End Time</label>
<input type="time" id="end_time" name="end_time" class="form-input"
value="{{ match.end_time.strftime('%H:%M') if match and match.end_time else '' }}">
<small class="text-muted">Auto-calculated if left empty (start + 30 min)</small>
</div>
</div>
<div class="form-row">
<div class="form-group col-6">
<label for="location">Location</label>
<input type="text" id="location" name="location" class="form-input"
value="{{ match.location if match else '' }}"
placeholder="e.g., Online, Gym A">
</div>
{% if match %}
<div class="form-group col-6">
<label for="status">Status</label>
<select id="status" name="status" class="form-select">
<option value="scheduled" {% if match.status == 'scheduled' %}selected{% endif %}>Scheduled</option>
<option value="completed" {% if match.status == 'completed' %}selected{% endif %}>Completed</option>
<option value="cancelled" {% if match.status == 'cancelled' %}selected{% endif %}>Cancelled</option>
</select>
</div>
{% endif %}
</div>
<div class="form-group">
<label for="description">Description (optional)</label>
<textarea id="description" name="description" class="form-input" rows="3"
placeholder="Additional details about the match...">{{ match.description if match else '' }}</textarea>
</div>
{% if not match %}
<!-- Player roster (pre-filled, read-only display) -->
<div class="card mt-4">
<div class="card-header">
<h4><i class="fas fa-users"></i> Team Roster (auto-included)</h4>
<span class="text-muted" style="font-size: 0.85rem;">All {{ team_players | length }} player(s) will be added automatically</span>
</div>
<div class="card-body">
{% if team_players %}
<div class="roster-list" style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for tp in team_players %}
<span class="staff-tag">
{{ tp.player.username }}
{% if tp.status == 'substitute' %}
<span class="text-muted" style="font-size: 0.7rem;">(sub)</span>
{% endif %}
</span>
{% endfor %}
</div>
{% else %}
<p class="text-muted text-center py-3">
<i class="fas fa-users-slash"></i> No players on this team. Add players in the Teams page first.
</p>
{% endif %}
</div>
</div>
{% else %}
<!-- Edit mode: show participants with presence status -->
<div class="card mt-4">
<div class="card-header">
<h4><i class="fas fa-users"></i> Participants</h4>
</div>
<div class="card-body">
{% if match.participants %}
<table class="table">
<thead>
<tr>
<th>Player</th>
<th>Presence</th>
</tr>
</thead>
<tbody>
{% for p in match.participants.all() %}
<tr>
<td>{{ p.player.username if p.player else 'Unknown' }}</td>
<td>
{% if p.is_confirmed %}
<span class="badge badge-success">✅ Confirmed</span>
{% else %}
<span class="badge badge-warning">⏳ Pending</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted text-center">No participants recorded.</p>
{% endif %}
</div>
</div>
{% endif %}
<div class="form-actions mt-4">
<a href="{{ url_for('team_matches.list_matches') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> {% if match %}Save Changes{% else %}Schedule Match{% endif %}
</button>
</div>
</form>
</div>
</div>
{% endblock %}
+231
View File
@@ -0,0 +1,231 @@
{% extends "layouts/base.html" %}
{% block title %}Team Matches - TryoutPro{% endblock %}
{% block page_title %}Team Matches{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / Team Matches</span>{% endblock %}
{% block header_actions %}
{% if teams %}
<div class="header-actions">
<select id="teamSelect" class="form-select" style="width:200px;" onchange="window.location.href='/team-matches/' + this.value + '/create'">
<option value="">+ Schedule Match</option>
{% for t in teams %}
<option value="{{ t.id }}">{{ t.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% endblock %}
{% block content %}
{% if match_data %}
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Match</th>
<th>Team</th>
<th>Opponent</th>
<th>Date</th>
<th>Time</th>
<th>Location</th>
<th>Presence</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in match_data %}
{% set m = item.match %}
<tr>
<td class="cell-title">{{ m.title }}</td>
<td>
<span class="badge badge-info">{{ m.org_team.name }}</span>
</td>
<td>
{% if m.opponent %}
{{ m.opponent }}
{% else %}
<span class="badge badge-info">Practice</span>
{% endif %}
</td>
<td>{{ m.date.strftime('%m/%d/%Y') }}</td>
<td>
{% if m.start_time and m.end_time %}
{{ m.start_time.strftime('%H:%M') }} - {{ m.end_time.strftime('%H:%M') }}
{% else %}
TBD
{% endif %}
</td>
<td>{{ m.location or '—' }}</td>
<td>
{% if item.total_count > 0 %}
<div class="presence-bar" title="{{ item.confirmed_count }} of {{ item.total_count }} confirmed">
<div class="presence-progress">
{% set pct = (item.confirmed_count / item.total_count * 100) | int %}
<div class="presence-fill" style="width: {{ pct }}%;"></div>
</div>
<span class="presence-text">
{% 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 %}
</span>
</div>
<!-- Player presence details -->
<div class="presence-players">
{% for p in item.participants %}
<a href="{{ url_for('users.view_user', user_id=p.player.id) }}" class="presence-player-tag {% if p.is_confirmed %}confirmed{% else %}pending{% endif %}"
title="{{ p.player.username }}{% if p.is_confirmed %} - Confirmed{% else %} - Pending{% endif %}"
style="text-decoration: none;">
{{ p.player.username[:2] | upper }} {{ p.player.username }}
{% if p.is_confirmed %}✅{% else %}⏳{% endif %}
</a>
{% endfor %}
</div>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<span class="badge badge-{{ m.status }}">{{ m.status }}</span>
</td>
<td class="eval-actions">
{% 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 %}
<a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="Edit Match">
<i class="fas fa-edit"></i>
</a>
<form method="POST" action="{{ url_for('team_matches.delete_match', match_id=m.id) }}" class="inline-form" onsubmit="return confirm('Delete this match?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="Delete Match">
<i class="fas fa-trash"></i>
</button>
</form>
{% endif %}
<!-- Toggle presence for each participant if user is player -->
{% if current_user.role == 'player' %}
{% for p in item.participants %}
{% if p.player.id == current_user.id %}
<button class="btn btn-sm {% if p.is_confirmed %}btn-success{% else %}btn-outline{% endif %} presence-toggle-btn"
data-match-id="{{ m.id }}"
data-participant-id="{{ p.id }}"
onclick="togglePresence(this)">
{% if p.is_confirmed %}✅ Confirmed{% else %}Confirm{% endif %}
</button>
{% endif %}
{% endfor %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card">
<div class="card-body text-center py-5">
<div class="empty-state">
<i class="fas fa-futbol fa-3x text-muted mb-3"></i>
<h3>No Team Matches</h3>
<p class="text-muted">Regular season matches have not been scheduled yet.</p>
{% if teams %}
<div class="mt-3">
<select id="teamSelectEmpty" class="form-select" style="width:220px; display:inline;" onchange="window.location.href='/team-matches/' + this.value + '/create'">
<option value="">-- Schedule a Match --</option>
{% for t in teams %}
<option value="{{ t.id }}">{{ t.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
<script>
function togglePresence(btn) {
var matchId = btn.getAttribute('data-match-id');
var participantId = btn.getAttribute('data-participant-id');
fetch('/team-matches/' + matchId + '/toggle-presence/' + participantId, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.is_confirmed) {
btn.classList.add('btn-success');
btn.classList.remove('btn-outline');
btn.innerHTML = '✅ Confirmed';
} else {
btn.classList.remove('btn-success');
btn.classList.add('btn-outline');
btn.innerHTML = 'Confirm';
}
// Reload to update presence bar
location.reload();
})
.catch(function(error) {
console.error('Error:', error);
});
}
</script>
<style>
.presence-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.presence-progress {
width: 60px;
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
}
.presence-fill {
height: 100%;
background: #10b981;
border-radius: 3px;
}
.presence-text {
font-size: 0.8rem;
font-weight: 600;
}
.presence-players {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 3px;
}
.presence-player-tag {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 5px;
border-radius: 10px;
font-size: 0.7rem;
font-weight: 600;
}
.presence-player-tag.confirmed {
background: #d1fae5;
color: #065f46;
}
.presence-player-tag.pending {
background: #fef3c7;
color: #92400e;
}
.presence-toggle-btn {
margin-left: 4px;
}
</style>
{% endblock %}
+204 -31
View File
@@ -59,34 +59,6 @@
<div class="card-header">
<h3><i class="fas fa-users-cog"></i> {{ team.name }}</h3>
<div class="card-actions">
<div class="team-staff">
{% if team.coach %}
<span class="text-muted">Coach: {{ team.coach.username }}</span>
{% if can_manage %}
<form method="POST" action="{{ url_for('teams.remove_coach', team_id=team.id) }}" class="inline-form" onsubmit="return confirm('Remove coach {{ team.coach.username }} from {{ team.name }}?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-outline-danger" title="Remove Coach">
<i class="fas fa-user-minus"></i>
</button>
</form>
{% endif %}
{% else %}
<span class="text-muted">No coach assigned</span>
{% endif %}
{% if team.manager %}
<span class="text-muted ml-2">Manager: {{ team.manager.username }}</span>
{% if can_manage %}
<form method="POST" action="{{ url_for('teams.remove_manager', team_id=team.id) }}" class="inline-form" onsubmit="return confirm('Remove manager {{ team.manager.username }} from {{ team.name }}?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-outline-danger" title="Remove Manager">
<i class="fas fa-user-minus"></i>
</button>
</form>
{% endif %}
{% else %}
<span class="text-muted ml-2">No manager assigned</span>
{% endif %}
</div>
{% if can_manage %}
<button class="btn btn-sm btn-outline edit-team-btn" data-team-id="{{ team.id }}" data-team-name="{{ team.name }}" data-coach-id="{{ team.coach_id or '' }}" data-manager-id="{{ team.manager_id or '' }}">
<i class="fas fa-edit"></i> Edit
@@ -105,6 +77,92 @@
{% endif %}
</div>
</div>
<!-- Full-width staff bar for coaches and managers -->
<div class="team-staff-bar">
<div class="staff-row">
<div class="staff-group">
<span class="staff-label"><i class="fas fa-chalkboard-teacher"></i> Coaches</span>
<div class="staff-items">
{% set team_coaches = team.get_coaches() %}
{% if team_coaches %}
{% for c in team_coaches %}
<span class="staff-tag">
{{ c.username }}
{% if can_manage %}
<form method="POST" action="{{ url_for('teams.remove_coach', team_id=team.id) }}" class="inline-form" onsubmit="return confirm('Remove coach {{ c.username }} from {{ team.name }}?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="coach_id" value="{{ c.id }}"/>
<button type="submit" class="btn-icon-sm" title="Remove Coach">&times;</button>
</form>
{% endif %}
</span>
{% endfor %}
{% else %}
<span class="text-muted text-sm">None assigned</span>
{% endif %}
</div>
</div>
<div class="staff-group">
<span class="staff-label"><i class="fas fa-user-tie"></i> Managers</span>
<div class="staff-items">
{% set team_managers = team.get_managers() %}
{% if team_managers %}
{% for m in team_managers %}
<span class="staff-tag manager-tag">
{{ m.username }}
{% if can_manage %}
<form method="POST" action="{{ url_for('teams.remove_manager', team_id=team.id) }}" class="inline-form" onsubmit="return confirm('Remove manager {{ m.username }} from {{ team.name }}?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="manager_id" value="{{ m.id }}"/>
<button type="submit" class="btn-icon-sm" title="Remove Manager">&times;</button>
</form>
{% endif %}
</span>
{% endfor %}
{% else %}
<span class="text-muted text-sm">None assigned</span>
{% endif %}
</div>
</div>
{% if can_manage %}
<div class="staff-add-forms">
<form method="POST" action="{{ url_for('teams.add_coach', team_id=team.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="coach_id" class="form-select form-select-sm" onchange="if(this.value) this.form.submit()">
<option value="">+ Add Coach</option>
{% for c in coaches %}
{% if c.id not in (team_coaches | map(attribute='id') | list) %}
<option value="{{ c.id }}">{{ c.username }}</option>
{% endif %}
{% endfor %}
</select>
</form>
<form method="POST" action="{{ url_for('teams.add_manager', team_id=team.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="manager_id" class="form-select form-select-sm" onchange="if(this.value) this.form.submit()">
<option value="">+ Add Manager</option>
{% for m in managers %}
{% if m.id not in (team_managers | map(attribute='id') | list) %}
<option value="{{ m.id }}">{{ m.username }}</option>
{% endif %}
{% endfor %}
</select>
</form>
</div>
{% endif %}
<a href="{{ url_for('team_matches.list_matches') }}?team_id={{ team.id }}" class="btn btn-sm btn-outline" title="View Team Matches">
<i class="fas fa-futbol"></i> Matches
</a>
{% 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)) %}
<a href="{{ url_for('team_matches.create_match', team_id=team.id) }}" class="btn btn-sm btn-success" title="Schedule Team Match">
<i class="fas fa-plus"></i> Match
</a>
<a href="{{ url_for('team_matches.create_match', team_id=team.id, type='practice') }}" class="btn btn-sm btn-info" title="Schedule Practice">
<i class="fas fa-dumbbell"></i> Practice
</a>
{% endif %}
</div>
</div>
<div class="card-body">
<div class="table-container">
<table class="table">
@@ -126,7 +184,7 @@
<td>
<div class="user-mini">
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
<span>{{ entry.player.username }}</span>
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
</div>
</td>
<td>
@@ -326,14 +384,93 @@ function hideEditForm() {
}
</script>
<style>
.team-staff {
/* === Full-width staff bar layout === */
.team-staff-bar {
padding: 10px 20px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.staff-row {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.staff-group {
display: flex;
align-items: center;
gap: 8px;
}
.team-staff .text-muted {
.staff-label {
font-size: 0.8rem;
color: #64748b;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
.staff-label i {
margin-right: 3px;
font-size: 0.75rem;
}
.staff-items {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.staff-tag {
display: inline-flex;
align-items: center;
gap: 5px;
background: #e0e7ff;
color: #3730a3;
padding: 3px 10px;
border-radius: 14px;
font-size: 0.82rem;
font-weight: 500;
line-height: 1.3;
}
.staff-tag.manager-tag {
background: #fef3c7;
color: #92400e;
}
.staff-tag .btn-icon-sm {
background: none;
border: none;
color: #ef4444;
cursor: pointer;
padding: 0;
font-size: 0.85rem;
font-weight: 700;
line-height: 1;
margin-left: 1px;
}
.staff-tag .btn-icon-sm:hover {
color: #dc2626;
}
.staff-add-forms {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.staff-add-forms .form-select-sm {
font-size: 0.8rem;
padding: 4px 28px 4px 10px;
height: auto;
border-radius: 6px;
border: 1px dashed #cbd5e1;
background: #fff;
min-width: 150px;
}
.staff-add-forms .form-select-sm:focus {
border-color: #6366f1;
outline: none;
box-shadow: 0 0 0 2px rgba(99,102,241,0.15);
}
.text-sm {
font-size: 0.82rem;
}
.ml-2 {
margin-left: 8px;
@@ -342,5 +479,41 @@ function hideEditForm() {
cursor: pointer;
min-width: 90px;
}
/* === Dark mode overrides for staff bar === */
[data-theme="dark"] .team-staff-bar {
background: var(--bg-tertiary);
border-bottom-color: var(--border-color);
}
[data-theme="dark"] .staff-label {
color: var(--text-muted);
}
[data-theme="dark"] .staff-tag {
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
}
[data-theme="dark"] .staff-tag.manager-tag {
background: rgba(229, 169, 57, 0.2);
color: #fcd34d;
}
[data-theme="dark"] .staff-tag .btn-icon-sm {
color: var(--text-muted);
}
[data-theme="dark"] .staff-tag .btn-icon-sm:hover {
color: var(--danger);
}
[data-theme="dark"] .staff-add-forms .form-select-sm {
background: var(--input-bg);
border-color: var(--border-color);
color: var(--text-secondary);
}
[data-theme="dark"] .staff-add-forms .form-select-sm:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(0, 152, 76, 0.25);
}
[data-theme="dark"] .staff-add-forms .form-select-sm option {
background: var(--bg-secondary);
color: var(--text-primary);
}
</style>
{% endblock %}
+22 -10
View File
@@ -127,6 +127,8 @@
<th>Status</th>
{% if current_user.can_evaluate() %}
<th>Evaluation</th>
{% endif %}
{% if can_edit %}
<th>Actions</th>
{% endif %}
</tr>
@@ -138,7 +140,7 @@
<td>
<div class="user-mini">
<div class="avatar-sm">{{ p.username[:2] | upper }}</div>
<span>{{ p.username }}</span>
<a href="{{ url_for('users.view_user', user_id=p.id) }}">{{ p.username }}</a>
</div>
</td>
<td>
@@ -163,19 +165,29 @@
<span class="badge badge-warning">Pending</span>
{% endif %}
</td>
<td class="eval-actions">
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=p.id) }}" class="btn btn-sm btn-primary" title="Evaluate player">
<i class="fas fa-edit"></i> {% if player_eval_status.get(p.id) %}Edit{% else %}Evaluate{% endif %}
</a>
<a href="{{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }}?player_id={{ p.id }}" class="btn btn-sm btn-outline" title="Add note for {{ p.username }}">
<i class="fas fa-sticky-note"></i>
</a>
</td>
{% endif %}
{% if can_edit %}
<td class="eval-actions">
{% if current_user.can_evaluate() %}
<a href="{{ url_for('evaluations.evaluate_player', tryout_id=tryout.id, player_id=p.id) }}" class="btn btn-sm btn-primary" title="Evaluate player">
<i class="fas fa-edit"></i> {% if player_eval_status.get(p.id) %}Edit{% else %}Evaluate{% endif %}
</a>
<a href="{{ url_for('users.add_note_from_tryout', tryout_id=tryout.id) }}?player_id={{ p.id }}" class="btn btn-sm btn-outline" title="Add note for {{ p.username }}">
<i class="fas fa-sticky-note"></i>
</a>
{% endif %}
<form method="POST" action="{{ url_for('tryouts.remove_player', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form" onsubmit="return confirm('Remove {{ p.username }} from this tryout? This will also remove them from all teams and matches within this tryout.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="Remove player from tryout">
<i class="fas fa-user-minus"></i> Remove
</button>
</form>
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="{% if current_user.can_evaluate() %}4{% else %}2{% endif %}" class="text-center">
<td colspan="{% if can_edit %}{% if current_user.can_evaluate() %}5{% else %}4{% endif %}{% else %}{% if current_user.can_evaluate() %}4{% else %}2{% endif %}{% endif %}" class="text-center">
No players registered yet.
</td>
</tr>
+76
View File
@@ -0,0 +1,76 @@
{% extends "layouts/base.html" %}
{% block title %}{{ profile_user.username }} - TryoutPro{% endblock %}
{% block page_title %}{{ profile_user.username }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="#" onclick="history.back()">Back</a> / {{ profile_user.username }}</span>{% endblock %}
{% block content %}
<div class="card mb-4">
<div class="card-header">
<div class="user-mini" style="gap: 12px;">
<div class="avatar-sm" style="width: 48px; height: 48px; font-size: 1.2rem;">
{{ profile_user.username[:2] | upper }}
</div>
<div>
<h3 style="margin: 0;">{{ profile_user.username }}</h3>
<span class="badge badge-{{ 'info' if profile_user.role == 'player' else 'primary' }}">{{ profile_user.role | capitalize }}</span>
</div>
</div>
</div>
<div class="card-body">
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Username</span>
<span class="detail-value">{{ profile_user.username }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Role</span>
<span class="detail-value">
<span class="badge badge-{{ 'info' if profile_user.role == 'player' else 'primary' }}">{{ profile_user.role | capitalize }}</span>
</span>
</div>
{% if profile_user.discord_username %}
<div class="detail-item">
<span class="detail-label">Discord</span>
<span class="detail-value">{{ profile_user.discord_username }}</span>
</div>
{% endif %}
</div>
{% set gamertags = profile_user.gamertags %}
{% if gamertags %}
<hr class="my-4">
<h4 class="mb-3"><i class="fas fa-gamepad"></i> Gamertags</h4>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Game</th>
<th>Gamertag</th>
<th>Platform</th>
<th>Profile</th>
</tr>
</thead>
<tbody>
{% for gt in gamertags %}
<tr>
<td>{{ gt.game }}</td>
<td>{{ gt.gamertag }}</td>
<td>{{ gt.platform or '-' }}</td>
<td>
{% if gt.get_trn_url() %}
<a href="{{ gt.get_trn_url() }}" target="_blank" class="btn btn-sm btn-outline trn-link">
<i class="fas fa-external-link-alt"></i> View Profile
</a>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
{% endblock %}