From fb70c9f0897e0e3ac60303b1a3024ed3f7a20118 Mon Sep 17 00:00:00 2001 From: David Jiang Date: Tue, 23 Dec 2025 00:19:38 -0500 Subject: [PATCH] schedules --- backend/tournaments/serializers.py | 17 +- backend/tournaments/views.py | 167 ++++-- .../pages/TournamentDetailPage.tsx | 518 +++++++++++++++++- frontend/src/shared/types/api.ts | 2 + 4 files changed, 652 insertions(+), 52 deletions(-) diff --git a/backend/tournaments/serializers.py b/backend/tournaments/serializers.py index 70d8e16..b27a0a7 100644 --- a/backend/tournaments/serializers.py +++ b/backend/tournaments/serializers.py @@ -109,19 +109,28 @@ class GameSerializer(serializers.ModelSerializer): """Serializer for games.""" team1_name = serializers.CharField(source='team1.name', read_only=True) team2_name = serializers.CharField(source='team2.name', read_only=True) + team1_pool = serializers.CharField(source='team1.pool', read_only=True) + team2_pool = serializers.CharField(source='team2.pool', read_only=True) round_number = serializers.IntegerField(source='round.round_number', read_only=True) room_name = serializers.CharField(source='room.name', read_only=True) winner_name = serializers.SerializerMethodField() - + pool = serializers.SerializerMethodField() + class Meta: model = Game fields = [ - 'id', 'round_number', 'room_name', - 'team1_name', 'team2_name', 'team1_score', 'team2_score', + 'id', 'round_number', 'room', 'room_name', 'pool', + 'team1_name', 'team2_name', 'team1_pool', 'team2_pool', + 'team1_score', 'team2_score', 'current_tossup', 'is_complete', 'winner_name', 'started_at', 'completed_at' ] - + def get_winner_name(self, obj): winner = obj.winner return winner.name if winner else None + + def get_pool(self, obj): + """Get the pool this game belongs to (from either team).""" + # Both teams should be in the same pool for a valid game + return obj.team1.pool if obj.team1.pool else obj.team2.pool diff --git a/backend/tournaments/views.py b/backend/tournaments/views.py index dc58d49..2c5772f 100644 --- a/backend/tournaments/views.py +++ b/backend/tournaments/views.py @@ -72,6 +72,23 @@ def games(self, request, pk=None): serializer = GameSerializer(games, many=True) return Response(serializer.data) + @action(detail=True, methods=['delete']) + def clear_schedule(self, request, pk=None): + """ + Delete all games and rounds for this tournament. + """ + tournament = self.get_object() + + games_count = tournament.games.count() + rounds_count = tournament.rounds.count() + + tournament.games.all().delete() + tournament.rounds.all().delete() + + return Response({ + 'message': f'Deleted {games_count} games and {rounds_count} rounds' + }, status=status.HTTP_200_OK) + @action(detail=True, methods=['post']) def generate_schedule(self, request, pk=None): """ @@ -108,57 +125,108 @@ def generate_schedule(self, request, pk=None): generated_games = [] with transaction.atomic(): - round_number = 1 + # Generate round-robin schedules for each pool using round-robin algorithm + from collections import deque - # For each pool, generate round-robin matchups + pool_round_schedules = {} for pool_name, pool_teams in pools.items(): if pool_name == 'Unassigned' or len(pool_teams) < 2: continue - # Generate all possible matchups (combinations of 2) - matchups = list(combinations(pool_teams, 2)) - - # Create games for each matchup - for idx, (team1, team2) in enumerate(matchups): - # Get or create round - round_obj, _ = Round.objects.get_or_create( - tournament=tournament, - round_number=round_number, - defaults={'name': f'Pool {pool_name} - Round {round_number}'} - ) - - # Assign to room (rotate through available rooms) - room = rooms[idx % len(rooms)] - - # Create game - game = Game.objects.create( - tournament=tournament, - round=round_obj, - room=room, - team1=team1, - team2=team2 - ) - - generated_games.append({ - 'id': game.id, - 'pool': pool_name, - 'round_number': round_number, - 'room_name': room.name, - 'team1_name': team1.name, - 'team2_name': team2.name, - }) - - # Move to next round after filling all rooms - if (idx + 1) % len(rooms) == 0: - round_number += 1 - - # Ensure next pool starts on a new round - round_number += 1 + # Round-robin scheduling algorithm + # For even number of teams, use standard round-robin + # For odd number, add a "bye" team + teams_list = list(pool_teams) + if len(teams_list) % 2 == 1: + teams_list.append(None) # Add bye + + num_teams = len(teams_list) + num_rounds = num_teams - 1 + games_per_round = num_teams // 2 + + pool_round_schedules[pool_name] = [] + + # Use circle method for round-robin scheduling + # Fix first team, rotate others + for round_num in range(num_rounds): + round_games = [] + for game_num in range(games_per_round): + if game_num == 0: + # First game: fixed team vs team at end of rotation + team1 = teams_list[0] + team2 = teams_list[num_teams - 1] + else: + # Other games: pairs from the rotating part + team1 = teams_list[game_num] + team2 = teams_list[num_teams - 1 - game_num] + + # Skip games with bye + if team1 is not None and team2 is not None: + round_games.append((team1, team2)) + + pool_round_schedules[pool_name].append(round_games) + + # Rotate all teams except the first one + teams_list = [teams_list[0]] + [teams_list[-1]] + teams_list[1:-1] + + # Find maximum number of rounds needed (same for all pools in round-robin) + max_rounds = max(len(rounds) for rounds in pool_round_schedules.values()) if pool_round_schedules else 0 + + # Create games round by round + # Each tournament round contains one round of games from EACH pool + room_idx = 0 + for round_num in range(max_rounds): + round_number = round_num + 1 + + # Get or create the round + round_obj, _ = Round.objects.get_or_create( + tournament=tournament, + round_number=round_number, + defaults={'name': f'Round {round_number}'} + ) + + # Create all games for this round across all pools + # Each pool contributes its round_num-th set of games to this tournament round + for pool_name in sorted(pool_round_schedules.keys()): + if round_num < len(pool_round_schedules[pool_name]): + round_games = pool_round_schedules[pool_name][round_num] + + for team1, team2 in round_games: + # Assign to room (rotate through available rooms) + room = rooms[room_idx % len(rooms)] + room_idx += 1 + + # Create game + game = Game.objects.create( + tournament=tournament, + round=round_obj, + room=room, + team1=team1, + team2=team2 + ) + + generated_games.append({ + 'id': game.id, + 'pool': pool_name, + 'round_number': round_number, + 'room_name': room.name, + 'team1_name': team1.name, + 'team2_name': team2.name, + }) + + # Build pool info for response + pool_info = {} + for pool_name, pool_teams in pools.items(): + if pool_name != 'Unassigned': + pool_info[pool_name] = { + 'team_count': len(pool_teams), + 'teams': [t.name for t in pool_teams] + } return Response({ 'message': f'Successfully generated {len(generated_games)} games across {len(pools) - (1 if "Unassigned" in pools else 0)} pools', 'games': generated_games, - 'pools': {pool: len(teams) for pool, teams in pools.items() if pool != 'Unassigned'}, + 'pool_info': pool_info, }, status=status.HTTP_201_CREATED) @@ -208,15 +276,22 @@ class PlayerViewSet(viewsets.ModelViewSet): permission_classes = [permissions.AllowAny] -class RoomViewSet(viewsets.ReadOnlyModelViewSet): - """ViewSet for viewing rooms.""" +class RoomViewSet(viewsets.ModelViewSet): + """ + ViewSet for managing rooms. + Supports full CRUD operations. + """ queryset = Room.objects.all() serializer_class = RoomSerializer permission_classes = [permissions.AllowAny] -class GameViewSet(viewsets.ReadOnlyModelViewSet): - """ViewSet for viewing games.""" +class GameViewSet(viewsets.ModelViewSet): + """ + ViewSet for managing games. + Allows updating room assignments. + """ queryset = Game.objects.all() serializer_class = GameSerializer permission_classes = [permissions.AllowAny] + http_method_names = ['get', 'patch', 'head', 'options'] # Only allow GET and PATCH diff --git a/frontend/src/features/tournaments/pages/TournamentDetailPage.tsx b/frontend/src/features/tournaments/pages/TournamentDetailPage.tsx index ff47c89..2a470c7 100644 --- a/frontend/src/features/tournaments/pages/TournamentDetailPage.tsx +++ b/frontend/src/features/tournaments/pages/TournamentDetailPage.tsx @@ -10,7 +10,7 @@ export function TournamentDetailPage() { const [teams, setTeams] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState<'overview' | 'teams' | 'pools' | 'contact'>('overview'); + const [activeTab, setActiveTab] = useState<'overview' | 'teams' | 'pools' | 'schedule' | 'contact'>('overview'); const [currentUser, setCurrentUser] = useState(null); // Pool editing state @@ -35,6 +35,21 @@ export function TournamentDetailPage() { const [newCoachEmail, setNewCoachEmail] = useState(''); const [newCoachPhone, setNewCoachPhone] = useState(''); + // Add team state + const [showAddTeam, setShowAddTeam] = useState(false); + const [newTeamName, setNewTeamName] = useState(''); + const [newTeamSchool, setNewTeamSchool] = useState(''); + + // Room and schedule state + const [rooms, setRooms] = useState([]); + const [games, setGames] = useState([]); + const [showAddRoom, setShowAddRoom] = useState(false); + const [newRoomName, setNewRoomName] = useState(''); + const [loadingRooms, setLoadingRooms] = useState(false); + const [generatingSchedule, setGeneratingSchedule] = useState(false); + const [editingGame, setEditingGame] = useState(null); + const [editingGameRoom, setEditingGameRoom] = useState(null); + // Check if current user is admin (tournament director) const isAdmin = currentUser && tournament?.director && currentUser.id === tournament.director.id; @@ -45,6 +60,13 @@ export function TournamentDetailPage() { } }, [id]); + useEffect(() => { + if (id && activeTab === 'schedule') { + loadRooms(); + loadGames(); + } + }, [id, activeTab]); + const loadCurrentUser = async () => { try { const token = localStorage.getItem('access_token'); @@ -253,6 +275,43 @@ export function TournamentDetailPage() { } }; + const handleAddTeam = async () => { + if (!tournament || !newTeamName.trim() || !newTeamSchool.trim()) { + alert('Please enter both team name and school'); + return; + } + + try { + const response = await fetch(`http://localhost:8000/api/teams/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('access_token')}`, + }, + body: JSON.stringify({ + name: newTeamName, + school: newTeamSchool, + tournament: tournament.id, + }), + }); + + if (response.ok) { + const newTeam = await response.json(); + setTeams([...teams, newTeam]); + setNewTeamName(''); + setNewTeamSchool(''); + setShowAddTeam(false); + alert('Team added successfully!'); + } else { + const error = await response.json(); + alert(`Failed to add team: ${error.detail || 'Unknown error'}`); + } + } catch (error) { + console.error('Error adding team:', error); + alert('Failed to add team. Please try again.'); + } + }; + const handleDeleteTeam = async (teamId: number) => { if (!confirm('Are you sure you want to delete this team? This cannot be undone.')) { return; @@ -428,6 +487,190 @@ export function TournamentDetailPage() { } }; + // Room management handlers + const loadRooms = async () => { + if (!id) return; + + setLoadingRooms(true); + try { + const response = await fetch(`http://localhost:8000/api/tournaments/${id}/rooms/`); + if (response.ok) { + const data = await response.json(); + setRooms(data); + } + } catch (error) { + console.error('Error loading rooms:', error); + } finally { + setLoadingRooms(false); + } + }; + + const handleAddRoom = async () => { + if (!tournament || !newRoomName.trim()) { + alert('Please enter a room name'); + return; + } + + try { + const response = await fetch(`http://localhost:8000/api/rooms/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('access_token')}`, + }, + body: JSON.stringify({ + name: newRoomName, + tournament: tournament.id, + }), + }); + + if (response.ok) { + const newRoom = await response.json(); + setRooms([...rooms, newRoom]); + setNewRoomName(''); + setShowAddRoom(false); + alert('Room added successfully!'); + } else { + const error = await response.json(); + alert(`Failed to add room: ${error.detail || 'Unknown error'}`); + } + } catch (error) { + console.error('Error adding room:', error); + alert('Failed to add room. Please try again.'); + } + }; + + const handleDeleteRoom = async (roomId: number) => { + if (!confirm('Are you sure you want to delete this room?')) { + return; + } + + try { + const response = await fetch(`http://localhost:8000/api/rooms/${roomId}/`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('access_token')}`, + }, + }); + + if (response.ok) { + setRooms(rooms.filter(r => r.id !== roomId)); + alert('Room deleted successfully!'); + } else { + alert('Failed to delete room'); + } + } catch (error) { + console.error('Error deleting room:', error); + alert('Failed to delete room. Please try again.'); + } + }; + + const handleClearSchedule = async () => { + if (!id) return; + + if (!confirm('Delete all games and rounds? This cannot be undone.')) { + return; + } + + try { + const response = await fetch(`http://localhost:8000/api/tournaments/${id}/clear_schedule/`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('access_token')}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + alert(data.message); + loadGames(); + } else { + alert('Failed to clear schedule'); + } + } catch (error) { + console.error('Error clearing schedule:', error); + alert('Failed to clear schedule. Please try again.'); + } + }; + + const handleGenerateSchedule = async () => { + if (!id) return; + + if (!confirm('Generate round-robin schedule for all pools? This will create games for each team to play every other team in their pool.')) { + return; + } + + setGeneratingSchedule(true); + try { + const response = await fetch(`http://localhost:8000/api/tournaments/${id}/generate_schedule/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('access_token')}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + console.log('Pool info:', data.pool_info); + alert(data.message); + // Reload games + loadGames(); + } else { + const error = await response.json(); + alert(`Failed to generate schedule: ${error.error || 'Unknown error'}`); + } + } catch (error) { + console.error('Error generating schedule:', error); + alert('Failed to generate schedule. Please try again.'); + } finally { + setGeneratingSchedule(false); + } + }; + + const loadGames = async () => { + if (!id) return; + + try { + const response = await fetch(`http://localhost:8000/api/tournaments/${id}/games/`); + if (response.ok) { + const data = await response.json(); + setGames(data); + } + } catch (error) { + console.error('Error loading games:', error); + } + }; + + const handleUpdateGameRoom = async (gameId: number, newRoomId: number) => { + try { + const response = await fetch(`http://localhost:8000/api/games/${gameId}/`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('access_token')}`, + }, + body: JSON.stringify({ + room: newRoomId, + }), + }); + + if (response.ok) { + // Reload games to get updated data + loadGames(); + setEditingGame(null); + setEditingGameRoom(null); + } else { + alert('Failed to update room assignment'); + } + } catch (error) { + console.error('Error updating game room:', error); + alert('Failed to update room assignment. Please try again.'); + } + }; + const formatDate = (dateString: string) => { // Parse date as local date to avoid timezone shifts const [year, month, day] = dateString.split('-').map(Number); @@ -573,6 +816,16 @@ export function TournamentDetailPage() { Pools )} + + )} + + + {/* Add Team Form */} + {isAdmin && showAddTeam && ( +
+

Add New Team

+
+ setNewTeamName(e.target.value)} + className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-white focus:ring-2 focus:ring-green-500 focus:border-green-500" + /> + setNewTeamSchool(e.target.value)} + className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-white focus:ring-2 focus:ring-green-500 focus:border-green-500" + /> + +
+
+ )} + {teams.length === 0 ? (

No teams registered yet

) : ( @@ -1067,6 +1360,227 @@ export function TournamentDetailPage() { )} + {activeTab === 'schedule' && ( +
+ {/* Rooms Section */} +
+
+

Rooms ({rooms.length})

+ {isAdmin && ( + + )} +
+ + {/* Add Room Form */} + {isAdmin && showAddRoom && ( +
+

Add New Room

+
+ setNewRoomName(e.target.value)} + className="flex-1 px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-white focus:ring-2 focus:ring-green-500 focus:border-green-500" + /> + +
+
+ )} + + {loadingRooms ? ( +
+
+

Loading rooms...

+
+ ) : rooms.length === 0 ? ( +

No rooms configured yet. Add rooms to enable schedule generation.

+ ) : ( +
+ {rooms.map((room) => ( +
+
+
+
{room.name}
+
+ {room.status === 'NOT_STARTED' && 'Not Started'} + {room.status === 'IN_PROGRESS' && 'In Progress'} + {room.status === 'FINISHED' && 'Finished'} +
+
+ {isAdmin && ( + + )} +
+
+ ))} +
+ )} +
+ + {/* Schedule Generation Section */} +
+

Round-Robin Schedule

+ + {isAdmin && ( +
+

Generate Schedule

+

+ This will create round-robin matchups for all teams in each pool. Each team will play every other team in their pool once. +

+
+ + {games.length > 0 && ( + + )} + {rooms.length === 0 && ( +

Add rooms first to generate schedule

+ )} +
+
+ )} + + {/* Games Display */} + {games.length === 0 ? ( +

+ {isAdmin ? 'No schedule generated yet. Click "Generate Schedule" above to create round-robin matchups.' : 'Schedule will be available once the tournament director generates it.'} +

+ ) : ( +
+ {Array.from(new Set(games.map(g => g.round_number))).sort((a, b) => a - b).map((roundNum) => { + const roundGames = games.filter(g => g.round_number === roundNum); + return ( +
+

Round {roundNum}

+
+ {roundGames.map((game) => ( +
+
+
+ {/* Pool Badge */} + {game.pool && game.pool !== 'Unassigned' && ( + + Pool {game.pool} + + )} + + {/* Room Assignment - Editable for admins */} + {isAdmin && editingGame === game.id ? ( + + ) : ( + {game.room_name} + )} + + {/* Edit Room Button */} + {isAdmin && editingGame === game.id ? ( +
+ + +
+ ) : isAdmin && !game.is_complete ? ( + + ) : null} + +
+ {game.team1_name} + vs + {game.team2_name} +
+
+
+ {game.is_complete && ( +
+
+ {game.team1_score} + - + {game.team2_score} +
+ {game.winner_name && ( + Winner: {game.winner_name} + )} +
+ )} +
+ ))} +
+
+ ); + })} +
+ )} +
+
+ )} + {activeTab === 'contact' && (

Tournament Director

diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts index ee4b536..86f6184 100644 --- a/frontend/src/shared/types/api.ts +++ b/frontend/src/shared/types/api.ts @@ -228,7 +228,9 @@ export interface Round { export interface Game { id: number; round_number: number; + room: number; room_name: string; + pool: string; team1_name: string; team2_name: string; team1_score: number;