fix probleme de match dans les calendriers et ajout de boutton delete pour les matchs
CI - Security, Lint & Tests / validate (push) Failing after 1m0s

This commit is contained in:
cedrick2711
2026-09-05 12:50:48 -04:00
parent cf551867c9
commit 742995d3cd
4 changed files with 242 additions and 64 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)
+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));
} }
+6
View File
@@ -458,6 +458,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') }}
+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'