Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4a271cfd8 | ||
|
|
742995d3cd |
+125
-61
@@ -26,11 +26,13 @@ from app.models import (
|
||||
PlayerDisponibility,
|
||||
Scout,
|
||||
Team,
|
||||
TeamMatch,
|
||||
TeamMember,
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
)
|
||||
from app.permissions import visible_org_teams
|
||||
from app.services.scheduling import notify_participants, zip_participants
|
||||
from app.validators import MatchEditSchema, MatchSchema
|
||||
|
||||
@@ -74,6 +76,25 @@ def default_end_time(date, start_time):
|
||||
return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time()
|
||||
|
||||
|
||||
def calendar_event_time_fields(date, start_time, end_time):
|
||||
"""FullCalendar date/time fields for a match.
|
||||
|
||||
A date-only event renders in the all-day row; a ``start``/``end`` with a
|
||||
time places it on the clock for the same calendar day. Matches, team
|
||||
matches and one-on-one sessions are stored as a date plus optional time
|
||||
columns, so the feed translates that into the shape FullCalendar expects.
|
||||
"""
|
||||
if not start_time:
|
||||
return {'date': date.strftime('%Y-%m-%d'), 'allDay': True}
|
||||
fields = {
|
||||
'start': f'{date.isoformat()}T{start_time.strftime("%H:%M:%S")}',
|
||||
'allDay': False,
|
||||
}
|
||||
if end_time:
|
||||
fields['end'] = f'{date.isoformat()}T{end_time.strftime("%H:%M:%S")}'
|
||||
return fields
|
||||
|
||||
|
||||
def create_participants(match, data):
|
||||
"""Attach participants to a match, per its type.
|
||||
|
||||
@@ -165,13 +186,17 @@ def api_events():
|
||||
president — and then issue one MatchParticipant query per match to find
|
||||
out whether the viewer was in it. The calendar's cost grew with the
|
||||
whole history, on every navigation.
|
||||
|
||||
Tryout matches, regular-season team matches and approved one-on-one
|
||||
sessions share this feed. Timed rows carry `start`/`end` so
|
||||
FullCalendar places them on the clock instead of the all-day row.
|
||||
"""
|
||||
events = []
|
||||
tryouts = get_visible_tryouts_for_user()
|
||||
window_start, window_end = calendar_window(request.args)
|
||||
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
|
||||
|
||||
if tryouts_by_id:
|
||||
window_start, window_end = calendar_window(request.args)
|
||||
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
|
||||
if window_start:
|
||||
query = query.filter(Match.date >= window_start)
|
||||
@@ -225,30 +250,71 @@ def api_events():
|
||||
|
||||
user_participant = mine_by_match.get(match.id)
|
||||
|
||||
events.append(
|
||||
{
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'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.description or '',
|
||||
'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,
|
||||
'user_participant_id': user_participant.id if user_participant else None,
|
||||
'user_attendance_confirmed': user_participant.attendance_confirmed
|
||||
if user_participant
|
||||
else False,
|
||||
},
|
||||
}
|
||||
)
|
||||
event = {
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'type': 'match',
|
||||
'color': match_color,
|
||||
'extendedProps': {
|
||||
'location': match.location or tryout.location or 'TBD',
|
||||
'status': match.status,
|
||||
'description': match.description or '',
|
||||
'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,
|
||||
'user_participant_id': user_participant.id if user_participant else None,
|
||||
'user_attendance_confirmed': user_participant.attendance_confirmed
|
||||
if user_participant
|
||||
else False,
|
||||
},
|
||||
}
|
||||
event.update(calendar_event_time_fields(match.date, match.start_time, match.end_time))
|
||||
events.append(event)
|
||||
|
||||
# Regular-season team matches: a separate table from tryout matches, but
|
||||
# the same calendar. Visible to the roles that can see the org team.
|
||||
if isinstance(current_user, (Admin, Manager)):
|
||||
team_query = TeamMatch.query
|
||||
elif isinstance(current_user, (Coach, Player)):
|
||||
team_ids = [team.id for team in visible_org_teams(current_user)]
|
||||
team_query = (
|
||||
TeamMatch.query.filter(TeamMatch.org_team_id.in_(team_ids))
|
||||
if team_ids
|
||||
else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
)
|
||||
else:
|
||||
team_query = TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
|
||||
if window_start:
|
||||
team_query = team_query.filter(TeamMatch.date >= window_start)
|
||||
if window_end:
|
||||
team_query = team_query.filter(TeamMatch.date <= window_end)
|
||||
|
||||
for team_match in team_query.options(joinedload(TeamMatch.org_team)).all():
|
||||
start_time_str = team_match.start_time.strftime('%H:%M') if team_match.start_time else None
|
||||
end_time_str = team_match.end_time.strftime('%H:%M') if team_match.end_time else None
|
||||
event = {
|
||||
'id': f'team_match_{team_match.id}',
|
||||
'title': team_match.title,
|
||||
'type': 'team_match',
|
||||
'color': '#3b82f6',
|
||||
'extendedProps': {
|
||||
'location': team_match.location or 'TBD',
|
||||
'status': team_match.status,
|
||||
'description': team_match.description or '',
|
||||
'org_team': team_match.org_team.name if team_match.org_team else '',
|
||||
'opponent': team_match.opponent or '',
|
||||
'start_time': start_time_str,
|
||||
'end_time': end_time_str,
|
||||
},
|
||||
}
|
||||
event.update(
|
||||
calendar_event_time_fields(team_match.date, team_match.start_time, team_match.end_time)
|
||||
)
|
||||
events.append(event)
|
||||
|
||||
# Add approved One on One sessions for the current user (player or coach)
|
||||
if isinstance(current_user, Player):
|
||||
@@ -263,23 +329,22 @@ def api_events():
|
||||
one_on_ones = []
|
||||
|
||||
for ooo in one_on_ones:
|
||||
events.append(
|
||||
{
|
||||
'id': f'one_on_one_{ooo.id}',
|
||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||
'date': ooo.date.strftime('%Y-%m-%d'),
|
||||
'type': 'one_on_one',
|
||||
'color': '#8b5cf6',
|
||||
'extendedProps': {
|
||||
'location': 'Discord / Voice Chat',
|
||||
'status': 'approved',
|
||||
'description': ooo.points or 'One on One session',
|
||||
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
|
||||
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
|
||||
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
|
||||
},
|
||||
}
|
||||
)
|
||||
event = {
|
||||
'id': f'one_on_one_{ooo.id}',
|
||||
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
||||
'type': 'one_on_one',
|
||||
'color': '#8b5cf6',
|
||||
'extendedProps': {
|
||||
'location': 'Discord / Voice Chat',
|
||||
'status': 'approved',
|
||||
'description': ooo.points or 'One on One session',
|
||||
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
|
||||
'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
|
||||
'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
|
||||
},
|
||||
}
|
||||
event.update(calendar_event_time_fields(ooo.date, ooo.start_time, ooo.end_time))
|
||||
events.append(event)
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
@@ -351,25 +416,24 @@ def api_events_for_tryout(tryout_id):
|
||||
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,
|
||||
'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,
|
||||
'participants': participants_str,
|
||||
'start_time': start_time_str,
|
||||
'end_time': end_time_str,
|
||||
},
|
||||
}
|
||||
)
|
||||
event = {
|
||||
'id': f'match_{match.id}',
|
||||
'title': match.title,
|
||||
'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,
|
||||
'participants': participants_str,
|
||||
'start_time': start_time_str,
|
||||
'end_time': end_time_str,
|
||||
},
|
||||
}
|
||||
event.update(calendar_event_time_fields(match.date, match.start_time, match.end_time))
|
||||
events.append(event)
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
|
||||
+191
-14
@@ -16,6 +16,7 @@ from app.models import (
|
||||
ESPORT_GAMES,
|
||||
GAME_POSITIONS,
|
||||
Admin,
|
||||
AppSettings,
|
||||
Coach,
|
||||
Evaluation,
|
||||
Manager,
|
||||
@@ -30,7 +31,6 @@ from app.models import (
|
||||
Tryout,
|
||||
TryoutRegistration,
|
||||
User,
|
||||
AppSettings,
|
||||
)
|
||||
from app.time_utils import utc_now_naive
|
||||
from app.validators import (
|
||||
@@ -38,6 +38,7 @@ from app.validators import (
|
||||
TryoutRegistrationStatusSchema,
|
||||
TryoutSchema,
|
||||
TryoutStatusSchema,
|
||||
TryoutTeamMemberPositionSchema,
|
||||
TryoutTeamMemberSchema,
|
||||
TryoutTeamSchema,
|
||||
)
|
||||
@@ -140,7 +141,10 @@ def list_tryouts():
|
||||
def create_tryout():
|
||||
"""Create a new tryout event. Requires Admin or Manager."""
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
flash(
|
||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
if not can_manage():
|
||||
@@ -204,7 +208,10 @@ def edit_tryout(tryout_id):
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
flash(
|
||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
@@ -273,9 +280,7 @@ def view_tryout(tryout_id):
|
||||
can_view = False
|
||||
if isinstance(current_user, Admin):
|
||||
can_view = True
|
||||
elif isinstance(current_user, Manager):
|
||||
can_view = current_user.can_manage_this_tryout(tryout)
|
||||
elif isinstance(current_user, Coach):
|
||||
elif isinstance(current_user, (Manager, Coach)):
|
||||
can_view = current_user.can_manage_this_tryout(tryout)
|
||||
elif isinstance(current_user, Player):
|
||||
is_registered = (
|
||||
@@ -341,7 +346,12 @@ def view_tryout(tryout_id):
|
||||
member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
|
||||
for row in member_rows:
|
||||
members_by_team.setdefault(row.team_id, []).append(
|
||||
{'player': member_players.get(row.player_id), 'position': row.position}
|
||||
{
|
||||
'player': member_players.get(row.player_id),
|
||||
'position': row.position,
|
||||
'member_id': row.id,
|
||||
'player_id': row.player_id,
|
||||
}
|
||||
)
|
||||
team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams]
|
||||
|
||||
@@ -500,7 +510,10 @@ def update_status(tryout_id):
|
||||
"""Update the status of a tryout."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
flash(
|
||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
@@ -524,7 +537,10 @@ def update_registration_status(tryout_id, player_id):
|
||||
"""Update a registration's attendance status."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
flash(
|
||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
@@ -552,7 +568,10 @@ def register_player(tryout_id):
|
||||
"""Manually register a player for a tryout (by managers/coaches)."""
|
||||
tryout = locked_tryout_or_404(tryout_id)
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
flash(
|
||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
@@ -603,7 +622,10 @@ def remove_player(tryout_id, player_id):
|
||||
"""Remove a registered player from a tryout (cascades to teams/matches)."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
flash(
|
||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
@@ -643,7 +665,10 @@ def create_team(tryout_id):
|
||||
"""Create a tryout-specific team."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
flash(
|
||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
@@ -670,7 +695,10 @@ def add_to_team(tryout_id, team_id):
|
||||
team = db.get_or_404(Team, team_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
flash(
|
||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
@@ -710,13 +738,162 @@ def add_to_team(tryout_id, team_id):
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/edit', methods=['POST'])
|
||||
@login_required
|
||||
def edit_team(tryout_id, team_id):
|
||||
"""Rename a tryout-specific team."""
|
||||
team = db.get_or_404(Team, team_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if tryouts_locked():
|
||||
flash(
|
||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if team.tryout_id != tryout_id:
|
||||
abort(404)
|
||||
|
||||
try:
|
||||
data = TryoutTeamSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
team.name = data['team_name']
|
||||
db.session.commit()
|
||||
flash(_('Team renamed to "%(team_name)s".', team_name=data['team_name']), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_team(tryout_id, team_id):
|
||||
"""Delete a tryout-specific team and its memberships."""
|
||||
team = db.get_or_404(Team, team_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if tryouts_locked():
|
||||
flash(
|
||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if team.tryout_id != tryout_id:
|
||||
abort(404)
|
||||
|
||||
name = team.name
|
||||
|
||||
# Personal note references outlive the team.
|
||||
PersonalNote.query.filter_by(team_id=team_id).update(
|
||||
{'team_id': None}, synchronize_session=False
|
||||
)
|
||||
|
||||
# Matches referencing this team: null the FK so the match survives.
|
||||
Match.query.filter_by(team1_id=team_id).update({'team1_id': None}, synchronize_session=False)
|
||||
Match.query.filter_by(team2_id=team_id).update({'team2_id': None}, synchronize_session=False)
|
||||
|
||||
# Clean participants for those matches (they are no longer in that team).
|
||||
for match in Match.query.filter(
|
||||
db.or_(Match.team1_id == team_id, Match.team2_id == team_id)
|
||||
).all():
|
||||
MatchParticipant.query.filter_by(match_id=match.id).delete(synchronize_session=False)
|
||||
|
||||
TeamMember.query.filter_by(team_id=team_id).delete(synchronize_session=False)
|
||||
db.session.delete(team)
|
||||
db.session.commit()
|
||||
flash(_('Team "%(name)s" deleted.', name=name), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/remove/<int:player_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_from_team(tryout_id, team_id, player_id):
|
||||
"""Remove a player from a tryout team."""
|
||||
team = db.get_or_404(Team, team_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if tryouts_locked():
|
||||
flash(
|
||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if team.tryout_id != tryout_id:
|
||||
abort(404)
|
||||
|
||||
TeamMember.query.filter_by(team_id=team_id, player_id=player_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
# Also remove the player from any match participants that were tied to this team.
|
||||
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(_('Player removed from team.'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route(
|
||||
'/<int:tryout_id>/team/<int:team_id>/member/<int:member_id>/position', methods=['POST']
|
||||
)
|
||||
@login_required
|
||||
def update_member_position(tryout_id, team_id, member_id):
|
||||
"""Set or clear a team member's in-game position (role)."""
|
||||
team = db.get_or_404(Team, team_id)
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
member = db.get_or_404(TeamMember, member_id)
|
||||
if tryouts_locked():
|
||||
flash(
|
||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
flash(_('Permission denied.'), 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if team.tryout_id != tryout_id or member.team_id != team_id:
|
||||
abort(404)
|
||||
|
||||
try:
|
||||
data = TryoutTeamMemberPositionSchema().load(form_payload(list_fields=()))
|
||||
except ValidationError as err:
|
||||
flash_validation_errors(err)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
member.position = data['position'] or None
|
||||
db.session.commit()
|
||||
flash(_('Position updated.'), 'success')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
|
||||
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_tryout(tryout_id):
|
||||
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
|
||||
tryout = db.get_or_404(Tryout, tryout_id)
|
||||
if tryouts_locked():
|
||||
flash('Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger')
|
||||
flash(
|
||||
'Tryouts are currently closed. An admin must open tryouts before changes can be made.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
||||
|
||||
if not current_user.can_manage_this_tryout(tryout):
|
||||
|
||||
@@ -346,6 +346,14 @@ function showEventModal(event) {
|
||||
grid.appendChild(detailItem('Teams', buildTeamsNode(props), true));
|
||||
}
|
||||
|
||||
// Team match details
|
||||
if (type === 'team_match') {
|
||||
grid.appendChild(detailItem('Team', document.createTextNode(props.org_team || 'Team')));
|
||||
if (props.opponent) {
|
||||
grid.appendChild(detailItem('Opponent', document.createTextNode(props.opponent)));
|
||||
}
|
||||
}
|
||||
|
||||
if (props.description) {
|
||||
grid.appendChild(detailItem('Description', multilineNode(props.description), true));
|
||||
}
|
||||
|
||||
@@ -249,7 +249,14 @@
|
||||
|
||||
{% for team in team_data %}
|
||||
<div class="team-card mb-3">
|
||||
<h4 class="team-name">{{ team.team.name }}</h4>
|
||||
<div class="team-card-header">
|
||||
<h4 class="team-name">{{ team.team.name }}</h4>
|
||||
{% if can_edit and not tryout.is_ended %}
|
||||
<button class="btn btn-sm btn-outline" data-action="show-modal" data-modal-id="editTeamModal{{ team.team.id }}">
|
||||
<i class="fas fa-edit"></i> {{ _('Edit') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<ul class="team-members">
|
||||
{% for member in team.members %}
|
||||
<li>
|
||||
@@ -263,32 +270,96 @@
|
||||
<li class="text-muted">{{ _('No players assigned yet.') }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if can_edit and not tryout.is_ended and registered_players %}
|
||||
{% set positions = game_positions.get(tryout.game, []) %}
|
||||
<form method="POST" action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mt-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<select name="player_id" class="form-select" required>
|
||||
<option value="">{{ _('Select player...') }}</option>
|
||||
{% for p in registered_players %}
|
||||
{% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %}
|
||||
<option value="{{ p.id }}">{{ p.username }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if positions %}
|
||||
<select name="position" class="form-select mx-2">
|
||||
<option value="">{{ _('-- Select Position --') }}</option>
|
||||
{% for pos in positions %}
|
||||
<option value="{{ pos }}">{{ pos }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="text" name="position" placeholder="{{ _('Position') }}" class="form-input mx-2">
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-sm btn-primary">{{ _('Add') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if can_edit and not tryout.is_ended %}
|
||||
{% set positions = game_positions.get(tryout.game, []) %}
|
||||
<!-- Edit Team Modal -->
|
||||
<div id="editTeamModal{{ team.team.id }}" class="modal hidden">
|
||||
<div class="modal-backdrop" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-users-cog"></i> {{ _('Edit Team: %(name)s', name=team.team.name) }}</h3>
|
||||
<button class="modal-close" data-action="hide-modal" data-modal-id="editTeamModal{{ team.team.id }}">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Rename -->
|
||||
<form method="POST" action="{{ url_for('tryouts.edit_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mb-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<label for="renameInput{{ team.team.id }}" class="mr-2" style="white-space:nowrap;">{{ _('Team name') }}</label>
|
||||
<input type="text" id="renameInput{{ team.team.id }}" name="team_name" value="{{ team.team.name }}" required class="form-input" style="flex:1;">
|
||||
<button type="submit" class="btn btn-sm btn-primary ml-2">{{ _('Rename') }}</button>
|
||||
</form>
|
||||
|
||||
<hr class="section-divider">
|
||||
|
||||
<!-- Roster -->
|
||||
<h5 class="mb-2"><i class="fas fa-users"></i> {{ _('Roster') }}</h5>
|
||||
<div class="roster-rows">
|
||||
{% for member in team.members %}
|
||||
<div class="roster-row">
|
||||
<div class="avatar-sm">{{ member.player.username[:2] | upper }}</div>
|
||||
<span class="roster-player-name">{{ member.player.username }}</span>
|
||||
<form method="POST" action="{{ url_for('tryouts.update_member_position', tryout_id=tryout.id, team_id=team.team.id, member_id=member.member_id) }}" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<select name="position" class="form-select roster-role-select" data-submit-on-change>
|
||||
<option value="">{{ _('No role') }}</option>
|
||||
{% for pos in positions %}
|
||||
<option value="{{ pos }}" {% if member.position == pos %}selected{% endif %}>{{ pos }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('tryouts.remove_from_team', tryout_id=tryout.id, team_id=team.team.id, player_id=member.player_id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from this team?', player=member.player.username) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-danger roster-remove-btn" title="{{ _('Remove from team') }}">
|
||||
<i class="fas fa-user-minus"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted text-center">{{ _('No players assigned yet.') }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if registered_players %}
|
||||
<hr class="section-divider">
|
||||
<h5 class="mb-2"><i class="fas fa-user-plus"></i> {{ _('Add Player') }}</h5>
|
||||
<form method="POST" action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<select name="player_id" class="form-select" required style="flex:1;">
|
||||
<option value="">{{ _('Select player...') }}</option>
|
||||
{% for p in registered_players %}
|
||||
{% if p.id not in team.members | map(attribute='player') | map(attribute='id') | list %}
|
||||
<option value="{{ p.id }}">{{ p.username }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if positions %}
|
||||
<select name="position" class="form-select mx-2">
|
||||
<option value="">{{ _('-- Select Position --') }}</option>
|
||||
{% for pos in positions %}
|
||||
<option value="{{ pos }}">{{ pos }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="text" name="position" placeholder="{{ _('Position') }}" class="form-input mx-2" style="max-width:140px;">
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-sm btn-primary">{{ _('Add') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<hr class="section-divider" style="border-color: var(--danger);">
|
||||
<div class="text-center">
|
||||
<form method="POST" action="{{ url_for('tryouts.delete_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="inline-form" data-confirm="{{ _('Delete this team? Players already in the team will be removed and matches referencing it will be unlinked.') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-danger">{{ _('Delete Team') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="text-muted text-center">{{ _('No teams created yet.') }}</p>
|
||||
{% endfor %}
|
||||
@@ -458,6 +529,12 @@
|
||||
<a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline">
|
||||
<i class="fas fa-edit"></i> {{ _('Edit') }}
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('matches.delete_match', match_id=m.id) }}" class="inline-form" data-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> {{ _('Delete') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('users.add_note_from_match', match_id=m.id) }}" class="btn btn-sm btn-primary" title="{{ _('Add Note for this Match') }}">
|
||||
<i class="fas fa-sticky-note"></i> {{ _('Note') }}
|
||||
@@ -568,8 +645,56 @@
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
||||
}
|
||||
/* Team card header — edit button aligned right */
|
||||
.team-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.team-card-header .team-name {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* Roster rows inside modal */
|
||||
.roster-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.roster-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.roster-player-name {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.roster-role-select {
|
||||
width: auto;
|
||||
font-size: 0.75rem;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
.roster-remove-btn {
|
||||
padding: 1px 5px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
[data-theme="dark"] .roster-row {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
</style>
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
function showModal(modalId) {
|
||||
document.getElementById(modalId).classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideModal(modalId) {
|
||||
document.getElementById(modalId).classList.add('hidden');
|
||||
}
|
||||
|
||||
function showCreateTeam() { document.getElementById('createTeamForm').classList.remove('hidden'); }
|
||||
function hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); }
|
||||
|
||||
@@ -627,6 +752,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
registerActions({
|
||||
'show-create-team': showCreateTeam,
|
||||
'hide-create-team': hideCreateTeam,
|
||||
'show-modal': function (element) {
|
||||
showModal(element.getAttribute('data-modal-id'));
|
||||
},
|
||||
'hide-modal': function (element) {
|
||||
hideModal(element.getAttribute('data-modal-id'));
|
||||
},
|
||||
'toggle-tryout-presence': function (element) {
|
||||
toggleTryoutPresence(
|
||||
element.getAttribute('data-match-id'),
|
||||
|
||||
@@ -605,6 +605,18 @@ class TryoutTeamMemberSchema(StripMixin):
|
||||
)
|
||||
|
||||
|
||||
class TryoutTeamMemberPositionSchema(StripMixin):
|
||||
"""Update a team member's in-game position (role)."""
|
||||
|
||||
position = fields.String(
|
||||
load_default='',
|
||||
validate=validate.Length(
|
||||
max=50,
|
||||
error=_l('Position must be 50 characters or less.'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class NoteContentSchema(StripMixin):
|
||||
"""Bounded text stored as a team or personal coaching note."""
|
||||
|
||||
|
||||
+103
-3
@@ -168,7 +168,7 @@ class TestTheWindow:
|
||||
'/matches/api/events?start=2030-05-01T00:00:00-04:00&end=2030-05-31T00:00:00-04:00'
|
||||
).get_json()
|
||||
|
||||
assert _match_event(payload)['date'] == '2030-05-02'
|
||||
assert _match_event(payload)['start'] == '2030-05-02T18:00:00'
|
||||
|
||||
def test_a_match_outside_the_window_is_not(
|
||||
self, app, client, as_role, make_user, match_factory
|
||||
@@ -192,7 +192,7 @@ class TestTheWindow:
|
||||
|
||||
payload = client.get('/matches/api/events').get_json()
|
||||
|
||||
assert _match_event(payload)['date'] == '2030-05-02'
|
||||
assert _match_event(payload)['start'] == '2030-05-02T18:00:00'
|
||||
|
||||
def test_unparseable_bounds_are_ignored_rather_than_rejected(
|
||||
self, app, client, as_role, make_user, match_factory
|
||||
@@ -206,7 +206,7 @@ class TestTheWindow:
|
||||
response = client.get('/matches/api/events?start=whenever&end=')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert _match_event(response.get_json())['date'] == '2030-05-02'
|
||||
assert _match_event(response.get_json())['start'] == '2030-05-02T18:00:00'
|
||||
|
||||
def test_the_viewers_own_participation_still_comes_back(
|
||||
self, app, client, as_role, make_user, match_factory
|
||||
@@ -258,3 +258,103 @@ class TestTheWindowParser:
|
||||
from flask import request
|
||||
|
||||
assert calendar_window(request.args) == (None, None)
|
||||
|
||||
|
||||
class TestTimedEvents:
|
||||
"""Matches with time fields render on the clock, not as all-day."""
|
||||
|
||||
def test_timed_match_has_start_and_end(self, app, client, as_role, make_user, match_factory):
|
||||
player_id = make_user('player')
|
||||
admin_id = as_role('admin')
|
||||
match_factory(admin_id, player_id)
|
||||
|
||||
ev = _match_event(client.get('/matches/api/events').get_json())
|
||||
|
||||
assert ev['start'] == '2030-05-02T18:00:00'
|
||||
assert ev['end'] == '2030-05-02T19:00:00'
|
||||
assert ev['allDay'] is False
|
||||
|
||||
def test_allday_match_has_date_when_no_time(
|
||||
self, app, client, as_role, make_user, match_factory
|
||||
):
|
||||
from datetime import date
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Match, MatchParticipant, Tryout, TryoutRegistration
|
||||
|
||||
player_id = make_user('player')
|
||||
admin_id = as_role('admin')
|
||||
|
||||
with app.app_context():
|
||||
tryout = Tryout(
|
||||
title='All-day tryout',
|
||||
game='Valorant',
|
||||
date=date(2030, 5, 1),
|
||||
created_by=admin_id,
|
||||
status='upcoming',
|
||||
)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
|
||||
|
||||
match = Match(
|
||||
tryout_id=tryout.id,
|
||||
title='All-day scrim',
|
||||
description=None,
|
||||
date=date(2030, 5, 2),
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
match_type='player_scrim',
|
||||
created_by=admin_id,
|
||||
)
|
||||
db.session.add(match)
|
||||
db.session.flush()
|
||||
db.session.add(MatchParticipant(match_id=match.id, player_id=player_id))
|
||||
db.session.commit()
|
||||
|
||||
payload = client.get('/matches/api/events').get_json()
|
||||
ev = next(e for e in payload if e['title'] == 'All-day scrim')
|
||||
|
||||
assert ev['date'] == '2030-05-02'
|
||||
assert ev['allDay'] is True
|
||||
assert 'start' not in ev
|
||||
|
||||
|
||||
class TestTeamMatchCalendarEvents:
|
||||
"""Regular-season team matches appear on the main calendar."""
|
||||
|
||||
def test_team_matches_appear_for_admin(self, app, client, as_role, make_user):
|
||||
from datetime import date, time
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import OrgTeam, TeamMatch
|
||||
|
||||
admin_id = as_role('admin')
|
||||
with app.app_context():
|
||||
team = OrgTeam(name='Test Org', created_by=admin_id)
|
||||
db.session.add(team)
|
||||
db.session.flush()
|
||||
tm = TeamMatch(
|
||||
org_team_id=team.id,
|
||||
title='Semifinal',
|
||||
opponent='Enemy Team',
|
||||
date=date(2030, 6, 15),
|
||||
start_time=time(20, 0),
|
||||
end_time=time(21, 30),
|
||||
location='Main arena',
|
||||
status='scheduled',
|
||||
created_by=admin_id,
|
||||
)
|
||||
db.session.add(tm)
|
||||
db.session.commit()
|
||||
|
||||
payload = client.get('/matches/api/events').get_json()
|
||||
team_events = [e for e in payload if e['type'] == 'team_match']
|
||||
|
||||
assert len(team_events) == 1
|
||||
ev = team_events[0]
|
||||
assert ev['title'] == 'Semifinal'
|
||||
assert ev['start'] == '2030-06-15T20:00:00'
|
||||
assert ev['allDay'] is False
|
||||
assert ev['extendedProps']['org_team'] == 'Test Org'
|
||||
assert ev['extendedProps']['opponent'] == 'Enemy Team'
|
||||
|
||||
Reference in New Issue
Block a user