From 742995d3cdf12ee80fea0feb87da263a619f7d85 Mon Sep 17 00:00:00 2001 From: cedrick2711 Date: Sat, 5 Sep 2026 12:50:48 -0400 Subject: [PATCH] fix probleme de match dans les calendriers et ajout de boutton delete pour les matchs --- app/routes/matches.py | 186 ++++++++++++++++++--------- app/templates/pages/calendar.html | 8 ++ app/templates/pages/view_tryout.html | 6 + tests/test_calendar_api.py | 106 ++++++++++++++- 4 files changed, 242 insertions(+), 64 deletions(-) diff --git a/app/routes/matches.py b/app/routes/matches.py index 6f6f4ed..7a91ab3 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -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) diff --git a/app/templates/pages/calendar.html b/app/templates/pages/calendar.html index a7025ce..5ba62f7 100644 --- a/app/templates/pages/calendar.html +++ b/app/templates/pages/calendar.html @@ -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)); } diff --git a/app/templates/pages/view_tryout.html b/app/templates/pages/view_tryout.html index 4311875..4d8bbd5 100644 --- a/app/templates/pages/view_tryout.html +++ b/app/templates/pages/view_tryout.html @@ -458,6 +458,12 @@ {{ _('Edit') }} +
+ + +
{% endif %} {{ _('Note') }} diff --git a/tests/test_calendar_api.py b/tests/test_calendar_api.py index e9836e1..13d7e04 100644 --- a/tests/test_calendar_api.py +++ b/tests/test_calendar_api.py @@ -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'