Author SHA1 Message Date
cedrick2711 e4a271cfd8 changer gestion des team intra tryout
CI - Security, Lint & Tests / validate (push) Failing after 1m4s
2026-09-05 14:38:31 -04:00
cedrick2711 742995d3cd fix probleme de match dans les calendriers et ajout de boutton delete pour les matchs
CI - Security, Lint & Tests / validate (push) Failing after 1m0s
2026-09-05 12:50:48 -04:00
6 changed files with 597 additions and 105 deletions
+125 -61
View File
@@ -26,11 +26,13 @@ from app.models import (
PlayerDisponibility, PlayerDisponibility,
Scout, Scout,
Team, Team,
TeamMatch,
TeamMember, TeamMember,
Tryout, Tryout,
TryoutRegistration, TryoutRegistration,
User, User,
) )
from app.permissions import visible_org_teams
from app.services.scheduling import notify_participants, zip_participants from app.services.scheduling import notify_participants, zip_participants
from app.validators import MatchEditSchema, MatchSchema 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() 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): def create_participants(match, data):
"""Attach participants to a match, per its type. """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 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 out whether the viewer was in it. The calendar's cost grew with the
whole history, on every navigation. 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 = [] events = []
tryouts = get_visible_tryouts_for_user() tryouts = get_visible_tryouts_for_user()
window_start, window_end = calendar_window(request.args)
tryouts_by_id = {tryout.id: tryout for tryout in tryouts} tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
if tryouts_by_id: if tryouts_by_id:
window_start, window_end = calendar_window(request.args)
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id)) query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
if window_start: if window_start:
query = query.filter(Match.date >= window_start) query = query.filter(Match.date >= window_start)
@@ -225,30 +250,71 @@ def api_events():
user_participant = mine_by_match.get(match.id) user_participant = mine_by_match.get(match.id)
events.append( event = {
{ 'id': f'match_{match.id}',
'id': f'match_{match.id}', 'title': match.title,
'title': match.title, 'type': 'match',
'date': match.date.strftime('%Y-%m-%d'), 'color': match_color,
'type': 'match', 'extendedProps': {
'color': match_color, 'location': match.location or tryout.location or 'TBD',
'extendedProps': { 'status': match.status,
'location': match.location or tryout.location or 'TBD', 'description': match.description or '',
'status': match.status, 'match_type': match.match_type,
'description': match.description or '', 'tryout_id': tryout.id,
'match_type': match.match_type, 'match_id': match.id,
'tryout_id': tryout.id, 'start_time': start_time_str,
'match_id': match.id, 'end_time': end_time_str,
'start_time': start_time_str, 'participants': participants_str,
'end_time': end_time_str, 'user_participant_id': user_participant.id if user_participant else None,
'participants': participants_str, 'user_attendance_confirmed': user_participant.attendance_confirmed
'user_participant_id': user_participant.id if user_participant else None, if user_participant
'user_attendance_confirmed': user_participant.attendance_confirmed else False,
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) # Add approved One on One sessions for the current user (player or coach)
if isinstance(current_user, Player): if isinstance(current_user, Player):
@@ -263,23 +329,22 @@ def api_events():
one_on_ones = [] one_on_ones = []
for ooo in one_on_ones: for ooo in one_on_ones:
events.append( event = {
{ 'id': f'one_on_one_{ooo.id}',
'id': f'one_on_one_{ooo.id}', 'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}', 'type': 'one_on_one',
'date': ooo.date.strftime('%Y-%m-%d'), 'color': '#8b5cf6',
'type': 'one_on_one', 'extendedProps': {
'color': '#8b5cf6', 'location': 'Discord / Voice Chat',
'extendedProps': { 'status': 'approved',
'location': 'Discord / Voice Chat', 'description': ooo.points or 'One on One session',
'status': 'approved', 'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None,
'description': ooo.points or 'One on One session', 'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None,
'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None, 'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}",
'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) 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 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 end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
events.append( event = {
{ 'id': f'match_{match.id}',
'id': f'match_{match.id}', 'title': match.title,
'title': match.title, 'type': 'match',
'date': match.date.strftime('%Y-%m-%d'), 'color': match_color,
'type': 'match', 'extendedProps': {
'color': match_color, 'location': match.location or tryout.location or 'TBD',
'extendedProps': { 'status': match.status,
'location': match.location or tryout.location or 'TBD', 'match_type': match.match_type,
'status': match.status, 'tryout_id': tryout.id,
'match_type': match.match_type, 'match_id': match.id,
'tryout_id': tryout.id, 'participants': participants_str,
'match_id': match.id, 'start_time': start_time_str,
'participants': participants_str, 'end_time': end_time_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) return jsonify(events)
+191 -14
View File
@@ -16,6 +16,7 @@ from app.models import (
ESPORT_GAMES, ESPORT_GAMES,
GAME_POSITIONS, GAME_POSITIONS,
Admin, Admin,
AppSettings,
Coach, Coach,
Evaluation, Evaluation,
Manager, Manager,
@@ -30,7 +31,6 @@ from app.models import (
Tryout, Tryout,
TryoutRegistration, TryoutRegistration,
User, User,
AppSettings,
) )
from app.time_utils import utc_now_naive from app.time_utils import utc_now_naive
from app.validators import ( from app.validators import (
@@ -38,6 +38,7 @@ from app.validators import (
TryoutRegistrationStatusSchema, TryoutRegistrationStatusSchema,
TryoutSchema, TryoutSchema,
TryoutStatusSchema, TryoutStatusSchema,
TryoutTeamMemberPositionSchema,
TryoutTeamMemberSchema, TryoutTeamMemberSchema,
TryoutTeamSchema, TryoutTeamSchema,
) )
@@ -140,7 +141,10 @@ def list_tryouts():
def create_tryout(): def create_tryout():
"""Create a new tryout event. Requires Admin or Manager.""" """Create a new tryout event. Requires Admin or Manager."""
if tryouts_locked(): 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')) return redirect(url_for('tryouts.list_tryouts'))
if not can_manage(): if not can_manage():
@@ -204,7 +208,10 @@ def edit_tryout(tryout_id):
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked(): 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
@@ -273,9 +280,7 @@ def view_tryout(tryout_id):
can_view = False can_view = False
if isinstance(current_user, Admin): if isinstance(current_user, Admin):
can_view = True can_view = True
elif isinstance(current_user, Manager): elif isinstance(current_user, (Manager, Coach)):
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Coach):
can_view = current_user.can_manage_this_tryout(tryout) can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player): elif isinstance(current_user, Player):
is_registered = ( 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]) member_players = _users_by_id([m.player_id for m in member_rows if m.player_id])
for row in member_rows: for row in member_rows:
members_by_team.setdefault(row.team_id, []).append( 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] 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.""" """Update the status of a tryout."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked(): 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): 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.""" """Update a registration's attendance status."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked(): 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): 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).""" """Manually register a player for a tryout (by managers/coaches)."""
tryout = locked_tryout_or_404(tryout_id) tryout = locked_tryout_or_404(tryout_id)
if tryouts_locked(): 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): 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).""" """Remove a registered player from a tryout (cascades to teams/matches)."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked(): 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
@@ -643,7 +665,10 @@ def create_team(tryout_id):
"""Create a tryout-specific team.""" """Create a tryout-specific team."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked(): 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): 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) team = db.get_or_404(Team, team_id)
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked(): 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): 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)) 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']) @tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
@login_required @login_required
def delete_tryout(tryout_id): def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations).""" """Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = db.get_or_404(Tryout, tryout_id) tryout = db.get_or_404(Tryout, tryout_id)
if tryouts_locked(): 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)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
+8
View File
@@ -346,6 +346,14 @@ function showEventModal(event) {
grid.appendChild(detailItem('Teams', buildTeamsNode(props), true)); 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) { if (props.description) {
grid.appendChild(detailItem('Description', multilineNode(props.description), true)); grid.appendChild(detailItem('Description', multilineNode(props.description), true));
} }
+157 -26
View File
@@ -249,7 +249,14 @@
{% for team in team_data %} {% for team in team_data %}
<div class="team-card mb-3"> <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"> <ul class="team-members">
{% for member in team.members %} {% for member in team.members %}
<li> <li>
@@ -263,32 +270,96 @@
<li class="text-muted">{{ _('No players assigned yet.') }}</li> <li class="text-muted">{{ _('No players assigned yet.') }}</li>
{% endfor %} {% endfor %}
</ul> </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> </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 }}">&times;</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 %} {% else %}
<p class="text-muted text-center">{{ _('No teams created yet.') }}</p> <p class="text-muted text-center">{{ _('No teams created yet.') }}</p>
{% endfor %} {% endfor %}
@@ -458,6 +529,12 @@
<a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline"> <a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline">
<i class="fas fa-edit"></i> {{ _('Edit') }} <i class="fas fa-edit"></i> {{ _('Edit') }}
</a> </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 %} {% 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') }}"> <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') }} <i class="fas fa-sticky-note"></i> {{ _('Note') }}
@@ -568,8 +645,56 @@
transform: scale(1.08); transform: scale(1.08);
box-shadow: 0 1px 3px rgba(0,0,0,0.12); 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> </style>
<script nonce="{{ csp_nonce }}"> <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 showCreateTeam() { document.getElementById('createTeamForm').classList.remove('hidden'); }
function hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); } function hideCreateTeam() { document.getElementById('createTeamForm').classList.add('hidden'); }
@@ -627,6 +752,12 @@ document.addEventListener('DOMContentLoaded', function() {
registerActions({ registerActions({
'show-create-team': showCreateTeam, 'show-create-team': showCreateTeam,
'hide-create-team': hideCreateTeam, '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) { 'toggle-tryout-presence': function (element) {
toggleTryoutPresence( toggleTryoutPresence(
element.getAttribute('data-match-id'), element.getAttribute('data-match-id'),
+12
View File
@@ -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): class NoteContentSchema(StripMixin):
"""Bounded text stored as a team or personal coaching note.""" """Bounded text stored as a team or personal coaching note."""
+103 -3
View File
@@ -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' '/matches/api/events?start=2030-05-01T00:00:00-04:00&end=2030-05-31T00:00:00-04:00'
).get_json() ).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( def test_a_match_outside_the_window_is_not(
self, app, client, as_role, make_user, match_factory self, app, client, as_role, make_user, match_factory
@@ -192,7 +192,7 @@ class TestTheWindow:
payload = client.get('/matches/api/events').get_json() 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( def test_unparseable_bounds_are_ignored_rather_than_rejected(
self, app, client, as_role, make_user, match_factory self, app, client, as_role, make_user, match_factory
@@ -206,7 +206,7 @@ class TestTheWindow:
response = client.get('/matches/api/events?start=whenever&end=') response = client.get('/matches/api/events?start=whenever&end=')
assert response.status_code == 200 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( def test_the_viewers_own_participation_still_comes_back(
self, app, client, as_role, make_user, match_factory self, app, client, as_role, make_user, match_factory
@@ -258,3 +258,103 @@ class TestTheWindowParser:
from flask import request from flask import request
assert calendar_window(request.args) == (None, None) 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'