diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc
new file mode 100644
index 0000000..6b5d54a
Binary files /dev/null and b/__pycache__/app.cpython-313.pyc differ
diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc
index 2f07596..2a51998 100644
Binary files a/__pycache__/models.cpython-313.pyc and b/__pycache__/models.cpython-313.pyc differ
diff --git a/app.py b/app.py
index 61f6a94..736775d 100644
--- a/app.py
+++ b/app.py
@@ -20,6 +20,7 @@ def create_app():
from routes.users import users_bp
from routes.main import main_bp
from routes.teams import teams_bp
+ from routes.matches import matches_bp
app.register_blueprint(auth_bp)
app.register_blueprint(tryouts_bp)
@@ -27,6 +28,7 @@ def create_app():
app.register_blueprint(users_bp)
app.register_blueprint(main_bp)
app.register_blueprint(teams_bp)
+ app.register_blueprint(matches_bp)
with app.app_context():
import models
diff --git a/instance/team_tryouts.db b/instance/team_tryouts.db
index 91d116b..926066b 100644
Binary files a/instance/team_tryouts.db and b/instance/team_tryouts.db differ
diff --git a/models.py b/models.py
index 6eb2296..93228a7 100644
--- a/models.py
+++ b/models.py
@@ -60,10 +60,13 @@ class User(UserMixin, db.Model):
return self.role == 'president'
def can_manage_tryouts(self):
- return self.role in ['president', 'manager']
+ return self.role in ['president', 'manager', 'coach', 'scout']
def can_manage_teams(self):
return self.role in ['president', 'manager']
+
+ def can_schedule_matches(self):
+ return self.role in ['president', 'manager', 'coach']
def can_manage_this_tryout(self, tryout):
"""Check if user can manage a specific tryout (president, or manager/coach in charge of it)."""
@@ -181,3 +184,45 @@ class TeamMember(db.Model):
added_at = db.Column(db.DateTime, default=datetime.utcnow)
player = db.relationship('User', overlaps="player_ref,team_assignments") # Many-to-one, no dynamic loader
+
+
+class Match(db.Model):
+ """Matches/scrimmages scheduled within tryouts."""
+ __tablename__ = 'matches'
+ id = db.Column(db.Integer, primary_key=True)
+ tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False)
+ title = db.Column(db.String(200), nullable=False)
+ description = db.Column(db.Text, 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
+ match_type = db.Column(db.String(20), nullable=False) # team_vs_team, player_scrim
+ created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
+
+ # For team vs team matches
+ team1_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
+ team2_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True)
+
+ creator = db.relationship('User', backref='created_matches')
+ tryout = db.relationship('Tryout', backref='matches')
+ team1 = db.relationship('Team', foreign_keys=[team1_id], backref='matches_as_team1')
+ team2 = db.relationship('Team', foreign_keys=[team2_id], backref='matches_as_team2')
+ participants = db.relationship('MatchParticipant', backref='match', lazy='dynamic')
+
+ def get_participating_players(self):
+ """Return list of players participating in this match."""
+ return [p.player_id for p in self.participants.all()]
+
+
+class MatchParticipant(db.Model):
+ """Players participating in player scrimmage matches."""
+ __tablename__ = 'match_participants'
+ id = db.Column(db.Integer, primary_key=True)
+ match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False)
+ player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+ added_at = db.Column(db.DateTime, default=datetime.utcnow)
+
+ player = db.relationship('User')
diff --git a/routes/__pycache__/matches.cpython-313.pyc b/routes/__pycache__/matches.cpython-313.pyc
new file mode 100644
index 0000000..83accc4
Binary files /dev/null and b/routes/__pycache__/matches.cpython-313.pyc differ
diff --git a/routes/__pycache__/tryouts.cpython-313.pyc b/routes/__pycache__/tryouts.cpython-313.pyc
index 86171b9..ecbf1a6 100644
Binary files a/routes/__pycache__/tryouts.cpython-313.pyc and b/routes/__pycache__/tryouts.cpython-313.pyc differ
diff --git a/routes/matches.py b/routes/matches.py
new file mode 100644
index 0000000..480f9d7
--- /dev/null
+++ b/routes/matches.py
@@ -0,0 +1,340 @@
+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 User, Tryout, Match, MatchParticipant, Team, TeamMember, OrgTeam
+from datetime import datetime, time
+
+matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
+
+
+def can_schedule_match():
+ """Check if user can schedule matches (coaches and above)."""
+ return current_user.role in ['president', 'manager', 'coach', 'scout']
+
+
+@matches_bp.route('/calendar')
+@login_required
+def calendar():
+ """Calendar view showing tryouts and matches."""
+ return render_template('pages/calendar.html')
+
+
+@matches_bp.route('/api/events')
+@login_required
+def api_events():
+ """API endpoint returning calendar events."""
+ events = []
+
+ # Get tryouts based on user permissions
+ tryouts = get_visible_tryouts_for_user()
+
+ for tryout in tryouts:
+ events.append({
+ 'id': f'tryout_{tryout.id}',
+ 'title': tryout.title,
+ 'date': tryout.date.strftime('%Y-%m-%d'),
+ 'type': 'tryout',
+ 'color': '#3b82f6', # Blue for tryouts
+ 'extendedProps': {
+ 'location': tryout.location or 'TBD',
+ 'status': tryout.status,
+ 'description': tryout.description or '',
+ 'tryout_id': tryout.id
+ }
+ })
+
+ # Add matches for this tryout
+ for match in tryout.matches:
+ match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
+
+ # Build match description with participants
+ match_desc = match.description or ''
+ participants_str = ''
+ if match.match_type == 'team_vs_team':
+ teams = []
+ if match.team1:
+ teams.append(match.team1.name)
+ if match.team2:
+ teams.append(match.team2.name)
+ participants_str = f"{' vs '.join(teams)}"
+ match_desc = participants_str + (f"
{match.description}" if match.description else '')
+ else:
+ # Player scrim - show all participants
+ player_names = [p.player.full_name for p in match.participants.all()]
+ participants_str = ', '.join(player_names) if player_names else 'No players'
+ match_desc = participants_str + (f"
{match.description}" if match.description else '')
+
+ # Include time for calendar display
+ 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
+
+ events.append({
+ 'id': f'match_{match.id}',
+ 'title': match.title + ' (' + participants_str + ')',
+ 'date': match.date.strftime('%Y-%m-%d'),
+ 'type': 'match',
+ 'color': match_color,
+ 'extendedProps': {
+ 'location': match.location or tryout.location or 'TBD',
+ 'status': match.status,
+ 'description': match_desc,
+ 'match_type': match.match_type,
+ 'tryout_id': tryout.id,
+ 'match_id': match.id,
+ 'start_time': start_time_str,
+ 'end_time': end_time_str,
+ 'participants': participants_str
+ }
+ })
+
+ return jsonify(events)
+
+
+@matches_bp.route('/api/events/')
+@login_required
+def api_events_for_tryout(tryout_id):
+ """API endpoint returning calendar events for a specific tryout."""
+ tryout = Tryout.query.get_or_404(tryout_id)
+
+ # Check if user can view this tryout
+ if not current_user.can_manage_this_tryout(tryout):
+ # For players, check if they're registered or participating in the match
+ if current_user.role == 'player':
+ # Check if player is registered for this tryout
+ is_registered = TryoutRegistration.query.filter_by(
+ tryout_id=tryout_id, player_id=current_user.id
+ ).first() is not None
+
+ # Check if player is participating in any matches for this tryout
+ match_participation = MatchParticipant.query.filter(
+ MatchParticipant.match_id == MatchParticipant.match_id
+ ).join(Match).filter(Match.tryout_id == tryout_id).all()
+
+ player_in_match = any(mp.player_id == current_user.id for mp in match_participation)
+
+ if not is_registered and not player_in_match:
+ return jsonify([])
+
+ events = []
+
+ for match in tryout.matches:
+ match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
+
+ # Build participant string
+ participants_str = ''
+ if match.match_type == 'team_vs_team':
+ teams = []
+ if match.team1:
+ teams.append(match.team1.name)
+ if match.team2:
+ teams.append(match.team2.name)
+ participants_str = f"{' vs '.join(teams)}"
+ else:
+ player_names = [p.player.full_name for p in match.participants.all()]
+ participants_str = ', '.join(player_names) if player_names else 'No players'
+
+ events.append({
+ 'id': f'match_{match.id}',
+ 'title': match.title + ' (' + participants_str + ')',
+ 'date': match.date.strftime('%Y-%m-%d'),
+ 'type': 'match',
+ 'color': match_color,
+ 'extendedProps': {
+ 'location': match.location or tryout.location or 'TBD',
+ 'status': match.status,
+ 'match_type': match.match_type,
+ 'tryout_id': tryout.id,
+ 'match_id': match.id
+ }
+ })
+
+ return jsonify(events)
+
+
+def get_visible_tryouts_for_user():
+ """Get tryouts that the current user can see based on their role."""
+ if current_user.role == 'president':
+ return Tryout.query.order_by(Tryout.date).all()
+ elif current_user.role == 'manager':
+ return Tryout.query.filter_by(created_by=current_user.id).order_by(Tryout.date).all()
+ elif current_user.role == 'coach':
+ org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
+ if org_team:
+ return Tryout.query.filter_by(target_org_team_id=org_team.id).order_by(Tryout.date).all()
+ return []
+ elif current_user.role == 'player':
+ # Get tryouts player is registered for
+ player_tryout_ids = [r.tryout_id for r in current_user.tryout_registrations.all()]
+ tryouts = Tryout.query.filter(Tryout.id.in_(player_tryout_ids)).order_by(Tryout.date).all() if player_tryout_ids else []
+
+ # Also include matches where player is participating
+ player_matches = Match.query.join(MatchParticipant).filter(
+ MatchParticipant.player_id == current_user.id
+ ).all()
+
+ player_match_tryout_ids = list(set(m.tryout_id for m in player_matches))
+ additional_tryouts = Tryout.query.filter(
+ Tryout.id.in_(player_match_tryout_ids)
+ ).order_by(Tryout.date).all() if player_match_tryout_ids else []
+
+ # Combine and deduplicate
+ all_tryouts = tryouts + [t for t in additional_tryouts if t.id not in player_tryout_ids]
+ return all_tryouts
+ else: # scout
+ return Tryout.query.order_by(Tryout.date).all()
+
+
+@matches_bp.route('/create/', methods=['GET', 'POST'])
+@login_required
+def create_match(tryout_id):
+ """Create a new match/scrimmage within a tryout."""
+ tryout = Tryout.query.get_or_404(tryout_id)
+
+ # Check if user can manage this tryout (president, manager, or coach)
+ if not current_user.can_manage_this_tryout(tryout):
+ flash('You do not have permission to schedule matches for this tryout.', 'danger')
+ return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
+
+ teams = Team.query.filter_by(tryout_id=tryout_id).all()
+ all_players = User.query.filter_by(role='player').order_by(User.full_name).all()
+
+ if request.method == 'POST':
+ title = request.form.get('title')
+ 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')
+ match_type = request.form.get('match_type')
+
+ 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/create_match.html', tryout=tryout, teams=teams, all_players=all_players)
+
+ start_time = None
+ end_time = None
+ try:
+ if start_time_str:
+ start_time = datetime.strptime(start_time_str, '%H:%M').time()
+ if end_time_str:
+ end_time = datetime.strptime(end_time_str, '%H:%M').time()
+ except ValueError:
+ flash('Invalid time format.', 'danger')
+ return render_template('pages/create_match.html', tryout=tryout, teams=teams, all_players=all_players)
+
+ match = Match(
+ tryout_id=tryout_id,
+ title=title,
+ description=description,
+ date=date_obj,
+ start_time=start_time,
+ end_time=end_time,
+ location=location,
+ match_type=match_type,
+ created_by=current_user.id
+ )
+ db.session.add(match)
+ db.session.flush() # Get match.id before commit
+
+ # Handle team vs team matches
+ if match_type == 'team_vs_team':
+ team1_id = request.form.get('team1_id')
+ team2_id = request.form.get('team2_id')
+ match.team1_id = int(team1_id) if team1_id else None
+ match.team2_id = int(team2_id) if team2_id else None
+
+ # Handle player scrim matches
+ elif match_type == 'player_scrim':
+ player_ids = request.form.getlist('player_ids')
+ for pid in player_ids:
+ participant = MatchParticipant(match_id=match.id, player_id=int(pid))
+ db.session.add(participant)
+
+ db.session.commit()
+ flash('Match scheduled successfully!', 'success')
+ return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
+
+ return render_template('pages/create_match.html', tryout=tryout, teams=teams, all_players=all_players)
+
+
+@matches_bp.route('//edit', methods=['GET', 'POST'])
+@login_required
+def edit_match(match_id):
+ """Edit an existing match."""
+ match = Match.query.get_or_404(match_id)
+ tryout = match.tryout
+
+ if not current_user.can_manage_this_tryout(tryout):
+ flash('You do not have permission to edit this match.', 'danger')
+ return redirect(url_for('matches.calendar'))
+
+ teams = Team.query.filter_by(tryout_id=tryout.id).all()
+ all_players = User.query.filter_by(role='player').order_by(User.full_name).all()
+ current_player_ids = [p.player_id for p in match.participants.all()]
+
+ if request.method == 'POST':
+ match.title = request.form.get('title')
+ match.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')
+ status = request.form.get('status')
+
+ try:
+ match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
+ except (ValueError, TypeError):
+ flash('Invalid date format.', 'danger')
+ return render_template('pages/edit_match.html', match=match, tryout=tryout, teams=teams, all_players=all_players, current_player_ids=current_player_ids)
+
+ try:
+ if start_time_str:
+ match.start_time = datetime.strptime(start_time_str, '%H:%M').time()
+ if end_time_str:
+ match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
+ except ValueError:
+ pass
+
+ match.location = location
+ if status in ['scheduled', 'completed', 'cancelled']:
+ match.status = status
+
+ # Handle team vs team matches
+ if match.match_type == 'team_vs_team':
+ team1_id = request.form.get('team1_id')
+ team2_id = request.form.get('team2_id')
+ match.team1_id = int(team1_id) if team1_id else None
+ match.team2_id = int(team2_id) if team2_id else None
+
+ # Handle player scrim matches - update participants
+ elif match.match_type == 'player_scrim':
+ MatchParticipant.query.filter_by(match_id=match.id).delete()
+ player_ids = request.form.getlist('player_ids')
+ for pid in player_ids:
+ participant = MatchParticipant(match_id=match.id, player_id=int(pid))
+ db.session.add(participant)
+
+ db.session.commit()
+ flash('Match updated successfully!', 'success')
+ return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
+
+ return render_template('pages/edit_match.html', match=match, tryout=tryout, teams=teams, all_players=all_players, current_player_ids=current_player_ids)
+
+
+@matches_bp.route('//delete', methods=['POST'])
+@login_required
+def delete_match(match_id):
+ """Delete a match."""
+ match = Match.query.get_or_404(match_id)
+ tryout = match.tryout
+
+ if not current_user.can_manage_this_tryout(tryout):
+ flash('You do not have permission to delete this match.', 'danger')
+ return redirect(url_for('matches.calendar'))
+
+ db.session.delete(match)
+ db.session.commit()
+ flash('Match deleted successfully.', 'success')
+ return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
\ No newline at end of file
diff --git a/routes/tryouts.py b/routes/tryouts.py
index b7911a9..1142606 100644
--- a/routes/tryouts.py
+++ b/routes/tryouts.py
@@ -1,7 +1,7 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from extensions import db
-from models import User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember, OrgTeam
+from models import User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember, OrgTeam, Match, MatchParticipant
from datetime import datetime
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
@@ -148,6 +148,22 @@ def view_tryout(tryout_id):
# Get all players (for manager registration dropdown)
all_players = User.query.filter_by(role='player').order_by(User.full_name).all()
+ # Get matches for this tryout with participant info
+ matches = Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date).all()
+ match_data = []
+ for match in matches:
+ if match.match_type == 'team_vs_team':
+ participants = {
+ 'team1': match.team1.name if match.team1 else 'TBD',
+ 'team2': match.team2.name if match.team2 else 'TBD'
+ }
+ else:
+ participants = [p.player.full_name for p in match.participants.all()]
+ match_data.append({
+ 'match': match,
+ 'participants': participants
+ })
+
return render_template('pages/view_tryout.html',
tryout=tryout,
registered_players=registered_players,
@@ -158,6 +174,8 @@ def view_tryout(tryout_id):
team_data=team_data,
can_edit=can_edit,
all_players=all_players,
+ matches=matches,
+ match_data=match_data,
now=datetime.utcnow())
@tryouts_bp.route('//register', methods=['POST'])
diff --git a/static/css/style.css b/static/css/style.css
index 089f508..a978463 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -358,6 +358,8 @@ a:hover { color: var(--primary-dark); }
.badge-upcoming { background: var(--info-light); color: var(--info); }
.badge-in_progress { background: var(--warning-light); color: var(--warning); }
.badge-completed { background: var(--success-light); color: var(--success); }
+.badge-scheduled { background: var(--info-light); color: var(--info); }
+.badge-cancelled { background: var(--danger-light); color: var(--danger); }
.badge-president { background: #eef2ff; color: #4338ca; }
.badge-manager { background: #ecfdf5; color: #059669; }
.badge-coach { background: #fffbeb; color: #d97706; }
@@ -888,7 +890,65 @@ a:hover { color: var(--primary-dark); }
.mr-2 { margin-right: 8px; }
.mx-2 { margin-left: 8px; margin-right: 8px; }
-/* Responsive */
+/* Calendar Styles */
+#calendar {
+ min-height: 600px;
+}
+
+.fc {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+}
+
+.fc-toolbar {
+ margin-bottom: 16px;
+}
+
+.fc-toolbar-title {
+ font-size: 1.3rem;
+ font-weight: 600;
+ color: var(--gray-900);
+}
+
+.fc-button {
+ background: var(--primary) !important;
+ border: none !important;
+ border-radius: var(--radius-sm) !important;
+ padding: 6px 12px !important;
+ font-size: 0.85rem !important;
+}
+
+.fc-button:hover {
+ background: var(--primary-dark) !important;
+}
+
+.fc-button-primary:not(:disabled).fc-button-active,
+.fc-button-primary:not(:disabled):active {
+ background: var(--primary-dark) !important;
+}
+
+.fc-daygrid-day {
+ transition: var(--transition);
+}
+
+.fc-daygrid-day:hover {
+ background: var(--gray-50);
+}
+
+.fc-event {
+ border-radius: var(--radius-sm);
+ padding: 2px 4px;
+ font-size: 0.8rem;
+}
+
+.fc-event-title {
+ font-weight: 600;
+}
+
+.btn-group {
+ display: flex;
+ gap: 4px;
+}
+
@media (max-width: 768px) {
.sidebar {
width: var(--sidebar-collapsed);
@@ -910,4 +970,4 @@ a:hover { color: var(--primary-dark); }
.content { padding: 16px; }
.top-bar { padding: 12px 16px; }
.flash-messages { padding: 0 16px; }
-}
\ No newline at end of file
+}
diff --git a/templates/layouts/base.html b/templates/layouts/base.html
index 50963cd..136598f 100644
--- a/templates/layouts/base.html
+++ b/templates/layouts/base.html
@@ -39,6 +39,12 @@
Tryouts
+
+
+
+ Calendar
+
+
{% if current_user.can_evaluate() %}
@@ -130,5 +136,6 @@
{% endif %}
+ {% block scripts %}{% endblock %}