This commit is contained in:
cedrick2711
2026-08-25 14:03:07 -04:00
191 changed files with 28790 additions and 5066 deletions
+164 -170
View File
@@ -3,32 +3,42 @@
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from marshmallow import ValidationError
from app.api import json_endpoint
from app.extensions import db
from app.forms import flash_validation_errors, form_payload
from app.models import (
Admin, Manager, Coach, Player,
OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer,
Admin,
Coach,
Manager,
OrgTeam,
Player,
TeamMatch,
TeamMatchParticipant,
TeamPlayer,
AppSettings,
)
from datetime import datetime, timedelta
from app.discord_bot import send_schedule_notification
from app.pagination import paginate
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
from app.routes.matches import default_end_time
from app.services.scheduling import notify_participants, zip_participants
from app.time_utils import utc_now_naive
from app.validators import TeamMatchSchema
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."""
if isinstance(current_user, Admin):
return True
if isinstance(current_user, Manager):
return True
if isinstance(current_user, Coach):
if team.coaches.filter_by(id=current_user.id).first():
return True
if team.coach_id == current_user.id:
return True
return False
"""Whether the current user can manage matches for this team.
Same rule as administering the team itself, so it is the same call.
This function used to restate it, and the restatement drifted.
"""
return can_manage_org_team(current_user, team)
def season_locked():
@@ -48,29 +58,21 @@ def list_matches():
"""List all team matches visible to the current user."""
filter_team_id = request.args.get('team_id', type=int)
if isinstance(current_user, Admin):
# A manager administers every team, so the listing shows them all;
# visible_org_teams() only reports the teams they are attached to.
if isinstance(current_user, (Admin, Manager)):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
matches_query = TeamMatch.query
elif isinstance(current_user, Manager):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
matches_query = TeamMatch.query
elif isinstance(current_user, 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()
elif isinstance(current_user, (Coach, Player)):
teams = visible_org_teams(current_user)
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 isinstance(current_user, 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)
matches_query = (
TeamMatch.query.filter(
TeamMatch.org_team_id.in_(team_ids),
)
if team_ids
else TeamMatch.query.filter(TeamMatch.id == -1)
)
else:
teams = []
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
@@ -78,46 +80,57 @@ def list_matches():
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()
# Pagination also bounds the per-match participant loop below, which is
# the N+1 the constat pointed at (MNT-10 combined with MNT-14).
matches_page = paginate(matches_query.order_by(TeamMatch.date.desc(), TeamMatch.id))
matches = matches_page.items
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,
})
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())
return render_template(
'pages/team_matches.html',
teams=teams,
match_data=match_data,
pagination=matches_page,
now=utc_now_naive(),
)
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
@login_required
def create_match(team_id):
"""Create a new regular-season team match."""
team = OrgTeam.query.get_or_404(team_id)
team = db.get_or_404(OrgTeam, team_id)
if not can_manage_team_match(team):
flash('You do not have permission to schedule matches for this team.', 'danger')
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
return redirect(url_for('team_matches.list_matches'))
if season_locked():
flash('The regular season is not active. An admin must begin a season before scheduling matches.', '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()]
team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all()
prefill_date = request.args.get('date', '')
is_practice = request.args.get('type') == 'practice'
default_title = 'Practice' if is_practice else f'Team Match — {team.name}'
if is_practice and request.method == 'GET':
class TryoutProxy:
def __init__(self, team_obj):
self.id = 0
@@ -129,56 +142,49 @@ def create_match(team_id):
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)
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)
payload = form_payload(list_fields=(), optional_blank=())
# A practice has no opponent, whatever the form sent.
payload.setdefault('title', default_title)
if is_practice:
payload.pop('opponent', None)
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)
data = TeamMatchSchema().load(payload)
except ValidationError as err:
flash_validation_errors(err)
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)
start_time = data['start_time']
end_time = data['end_time'] or default_end_time(data['date'], start_time)
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,
org_team_id=team_id,
title=data['title'],
description=data['description'],
opponent=data['opponent'],
date=data['date'],
start_time=start_time,
end_time=end_time,
location=data['location'],
created_by=current_user.id,
)
db.session.add(team_match)
db.session.flush()
@@ -186,7 +192,8 @@ def create_match(team_id):
notified_participant_ids = []
for tp in team_players:
participant = TeamMatchParticipant(
team_match_id=team_match.id, player_id=tp.player_id,
team_match_id=team_match.id,
player_id=tp.player_id,
)
db.session.add(participant)
db.session.flush()
@@ -194,20 +201,20 @@ def create_match(team_id):
db.session.commit()
# Discord notifications
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'
notify_participants(
title=team_match.title,
date=team_match.date,
start_time=start_time,
end_time=end_time,
participants=zip_participants(
[tp.player_id for tp in team_players], notified_participant_ids
),
fallback_id=team_match.id,
)
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')
flash(
_('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success'
)
return redirect(url_for('team_matches.list_matches'))
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
@@ -217,76 +224,65 @@ def create_match(team_id):
@login_required
def edit_match(match_id):
"""Edit an existing team match."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, 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 season_locked():
flash('The regular season is not active. An admin must begin a season before editing matches.', 'danger')
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
# The three date and time fields used to be checked one at a time,
# each flashing and redirecting on its own: a form with two mistakes
# took two round trips to be told about both. One schema now, every
# problem reported at once and in place.
#
# Known limit: the re-render reads the stored record, so what was
# typed is not echoed back. Repopulating the form from the
# submission is a separate change to the template.
try:
data = TeamMatchSchema().load(form_payload(list_fields=(), optional_blank=()))
except ValidationError as err:
flash_validation_errors(err)
return render_template(
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
)
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
team_match.title = data['title']
team_match.description = data['description']
team_match.opponent = data['opponent']
team_match.date = data['date']
team_match.start_time = data['start_time']
team_match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
team_match.location = data['location']
team_match.status = data['status']
db.session.commit()
flash('Match updated successfully!', 'success')
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=[])
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."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, 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'))
if season_locked():
flash('The regular season is not active. An admin must begin a season before deleting matches.', 'danger')
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')
flash(_('Match deleted successfully.'), 'success')
return redirect(url_for('team_matches.list_matches'))
@team_matches_bp.route('/api/manageable-teams')
@json_endpoint
@login_required
def api_manageable_teams():
"""API endpoint returning teams the current user can schedule matches for."""
@@ -296,12 +292,7 @@ def api_manageable_teams():
if isinstance(current_user, (Admin, Manager)):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
elif isinstance(current_user, 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()
teams = coach_org_teams(current_user)
else:
return jsonify([])
@@ -309,13 +300,14 @@ def api_manageable_teams():
@team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
@json_endpoint
@login_required
def toggle_presence(match_id, participant_id):
"""Toggle is_confirmed for a team match participant."""
team_match = TeamMatch.query.get_or_404(match_id)
team_match = db.get_or_404(TeamMatch, match_id)
team = team_match.org_team
participant = TeamMatchParticipant.query.get_or_404(participant_id)
participant = db.get_or_404(TeamMatchParticipant, participant_id)
if participant.team_match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400
@@ -325,8 +317,10 @@ def toggle_presence(match_id, participant_id):
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',
})
return jsonify(
{
'participant_id': participant.id,
'is_confirmed': participant.is_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown',
}
)