Better Team handeling and WO protection. Implemented database migration.

This commit is contained in:
WilliamSoderberg committed 2026-08-29 19:53:11 +02:00 Verified
1 parent 6b46b778be
commit 364895fc84
18 files changed
+1925 -1376

No files matched your search

-79
View File
@@ -1,79 +0,0 @@
stages:
- Test Stage
- Build Stage
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_BRANCH
"Python Tests":
stage: Test Stage
image: python:3.14-alpine
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
key:
files:
- backend/requirements.txt
paths:
- .cache/pip
script:
- echo "Running tests on the Python Backend..."
- cd backend
- pip install -r requirements.txt
- pytest
"React Tests":
stage: Test Stage
image: node:alpine
variables:
npm_config_cache: "$CI_PROJECT_DIR/.npm-cache"
cache:
key:
files:
- frontend/package-lock.json
paths:
- .npm-cache
- frontend/node_modules
script:
- cd frontend
- npm ci --prefer-offline
- npm test -- run --exclude "**/*.live.test.ts"
"Build Backend Image":
stage: Build Stage
image: docker:24.0.5
rules:
- if: $CI_COMMIT_TAG
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- cd backend
- >
docker build
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
--label "org.opencontainers.image.source=$CI_PROJECT_URL"
-t $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_TAG
-t $CI_REGISTRY_IMAGE/backend:latest .
- docker push $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_TAG
- docker push $CI_REGISTRY_IMAGE/backend:latest
"Build Frontend Image":
stage: Build Stage
image: docker:24.0.5
rules:
- if: $CI_COMMIT_TAG
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- cd frontend
- >
docker build
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
--label "org.opencontainers.image.source=$CI_PROJECT_URL"
-t $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_TAG
-t $CI_REGISTRY_IMAGE/frontend:latest .
- docker push $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_TAG
- docker push $CI_REGISTRY_IMAGE/frontend:latest
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1 -1
View File
@@ -14,4 +14,4 @@ COPY ./app ./app
RUN mkdir -p /app/data RUN mkdir -p /app/data
EXPOSE $PORT EXPOSE $PORT
CMD ["sh", "-c", "gunicorn -w 1 -k uvicorn.workers.UvicornWorker app.main:app --bind 0.0.0.0:$PORT"] CMD ["sh", "-c", "alembic upgrade head && gunicorn -w 1 -k uvicorn.workers.UvicornWorker app.main:app --bind 0.0.0.0:$PORT"]
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = sqlite:///./app.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+27
View File
@@ -0,0 +1,27 @@
# backend/alembic/env.py
import sys
from os.path import dirname, abspath
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# 1. Add your backend folder to the Python path so it can find 'app'
sys.path.insert(0, dirname(dirname(abspath(__file__))))
# 2. Import your Base, Models, and dynamic Database URL
from app.database import Base, SQLALCHEMY_DATABASE_URL
import app.models
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 3. OVERRIDE Alembic's config with your app's actual database URL!
config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL)
# 4. Tell Alembic to look at your models for changes
target_metadata = Base.metadata
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,30 @@
# backend/alembic/versions/eb5be3f1f723_initial_baseline.py
"""initial baseline
Revision ID: eb5be3f1f723
Revises:
Create Date: 2026-08-29 19:44:15.681944
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'eb5be3f1f723'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column('teams', sa.Column('dropped_out', sa.Boolean(), server_default='0', nullable=False))
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('teams', 'dropped_out')
+50 -3
View File
@@ -2,6 +2,7 @@
from uuid import uuid4 from uuid import uuid4
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from fastapi import HTTPException # <--- Add this at the top with your imports
from . import logic, models, schemas from . import logic, models, schemas
@@ -71,6 +72,12 @@ def update_tournament_details(
incoming_type = update_data.get("type") incoming_type = update_data.get("type")
type_changed = incoming_type and incoming_type != t.type type_changed = incoming_type and incoming_type != t.type
# SAFETY CHECK: Prevent changing Double/Single Elim mid-tournament
if type_changed:
has_started = any(m.status == models.MatchStatus.FINISHED for m in t.matches)
if has_started:
raise HTTPException(400, "Cannot change tournament format after matches have been played.")
for key, value in update_data.items(): for key, value in update_data.items():
setattr(t, key, value) setattr(t, key, value)
@@ -106,16 +113,56 @@ def create_team(db: Session, tournament_id: str, team_data: schemas.TeamCreate):
return new_team return new_team
def update_tournament_teams(db: Session, tournament_id: str, new_team_names: list[str]): def update_tournament_teams(db: Session, tournament_id: str, new_teams: list[schemas.TeamUpdate]):
t = get_tournament(db, tournament_id, lock=True) t = get_tournament(db, tournament_id, lock=True)
if not t: if not t:
return None return None
t.teams = [models.Team(name=n, tournament_id=t.id) for n in new_team_names] has_started = any(m.status == models.MatchStatus.FINISHED for m in t.matches)
if has_started:
if len(new_teams) != len(t.teams):
from fastapi import HTTPException
raise HTTPException(400, "Cannot add or remove teams after start.")
team_map = {team.id: team for team in t.teams}
for nt in new_teams:
if isinstance(nt.id, int) and nt.id in team_map:
team = team_map[nt.id]
team.name = nt.name
# Did they just drop out?
if nt.dropped_out and not team.dropped_out:
team.dropped_out = True
db.flush()
pending_matches = [
m for m in t.matches
if m.status != models.MatchStatus.FINISHED
and (m.p1_team_id == team.id or m.p2_team_id == team.id)
]
for pm in pending_matches:
logic.check_and_apply_walkover(db, pm)
elif not nt.dropped_out and team.dropped_out:
team.dropped_out = False
db.commit()
logic.update_schedule_times(db, t)
db.commit()
db.refresh(t)
return t
# If hasn't started, rebuild safely
for team in t.teams:
db.delete(team)
db.flush() db.flush()
t.teams = [models.Team(name=nt.name, tournament_id=t.id) for nt in new_teams]
db.flush()
_rebuild_structure(db, t) _rebuild_structure(db, t)
db.commit() db.commit()
db.refresh(t) db.refresh(t)
return t return t
+44 -30
View File
@@ -240,15 +240,7 @@ def update_schedule_times(db: Session, t: models.Tournament):
# 6. Ultra-Fast Smart Referee Assignment # 6. Ultra-Fast Smart Referee Assignment
all_matches.sort(key=lambda x: (x.start_time or start_of_day, x.court_id or 0)) all_matches.sort(key=lambda x: (x.start_time or start_of_day, x.court_id or 0))
duty_counts = defaultdict(int) duty_counts = defaultdict(int)
team_first_match = {}
for m in all_matches:
if m.start_time:
if m.p1_team_id and m.p1_team_id not in team_first_match:
team_first_match[m.p1_team_id] = m.start_time
if m.p2_team_id and m.p2_team_id not in team_first_match:
team_first_match[m.p2_team_id] = m.start_time
active_refs = [] active_refs = []
match_outcome_next_start = {} match_outcome_next_start = {}
@@ -331,27 +323,8 @@ def update_schedule_times(db: Session, t: models.Tournament):
duty_counts[best_outcome] += 1 duty_counts[best_outcome] += 1
active_refs.append((best_outcome, m_start, m_end)) active_refs.append((best_outcome, m_start, m_end))
else: else:
assigned_bye_team = False m.ref_label = None
sorted_teams = sorted( m.ref_team_id = None
m.tournament.teams, key=lambda t: duty_counts[f"TEAM_{t.id}"]
)
for team in sorted_teams:
t_id = team.id
t_first_start = team_first_match.get(t_id)
identifier = f"TEAM_{t_id}"
if t_first_start and t_first_start >= m_end:
if not is_ref_busy(identifier, m_start, m_end):
m.ref_team_id = t_id
m.ref_label = None
duty_counts[identifier] += 1
active_refs.append((identifier, m_start, m_end))
assigned_bye_team = True
break
if not assigned_bye_team:
m.ref_label = "Staff / Volunteers"
m.ref_team_id = None
db.commit() db.commit()
@@ -429,6 +402,12 @@ def advance_winner(db: Session, match: models.Match, winner_id: int):
db.commit() db.commit()
if match.winner_next_match:
check_and_apply_walkover(db, match.winner_next_match)
if match.loser_next_match:
check_and_apply_walkover(db, match.loser_next_match)
def undo_advancement(db: Session, match: models.Match): def undo_advancement(db: Session, match: models.Match):
if not match.winner_team_id: if not match.winner_team_id:
@@ -467,3 +446,38 @@ def undo_advancement(db: Session, match: models.Match):
clear_from_next(match.loser_next_match, match.loser_next_match_slot) clear_from_next(match.loser_next_match, match.loser_next_match_slot)
db.commit() db.commit()
def check_and_apply_walkover(db: Session, m: models.Match):
"""Instantly resolves matches if a participating team has dropped out."""
if m.status == MatchStatus.FINISHED:
return False
if not m.p1_team_id or not m.p2_team_id:
return False
p1 = db.get(models.Team, m.p1_team_id)
p2 = db.get(models.Team, m.p2_team_id)
if p1 and p2:
if not p1.dropped_out and not p2.dropped_out:
return False
# Determine WO Winner
if p1.dropped_out and p2.dropped_out:
winner_id = p1.id
m.sets = [{"p1": 0, "p2": 0}]
elif p1.dropped_out:
winner_id = p2.id
m.sets = [{"p1": 0, "p2": 21},{"p1": 0, "p2": 21}]
else:
winner_id = p1.id
m.sets = [{"p1": 21, "p2": 0},{"p1": 21, "p2": 0}]
m.status = MatchStatus.FINISHED
m.winner_team_id = winner_id
db.add(m)
db.flush()
advance_winner(db, m, winner_id)
return True
else:
return False
+8 -3
View File
@@ -4,7 +4,7 @@ from typing import Optional
from sqlalchemy import JSON, DateTime, Table, Column from sqlalchemy import JSON, DateTime, Table, Column
from sqlalchemy import Enum as SqlEnum from sqlalchemy import Enum as SqlEnum
from sqlalchemy import ForeignKey, Integer, String from sqlalchemy import ForeignKey, Integer, String, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from .constants import MatchStatus, TournamentTypes, BracketTypes from .constants import MatchStatus, TournamentTypes, BracketTypes
@@ -67,16 +67,21 @@ class Tournament(Base):
def court_count(self) -> int: def court_count(self) -> int:
return len(self.courts) return len(self.courts)
# --- NEW: Dynamically check if the tournament has started ---
@property
def has_started(self) -> bool:
return any(m.status == MatchStatus.FINISHED for m in self.matches)
class Team(Base): class Team(Base):
__tablename__ = "teams" __tablename__ = "teams"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False) name: Mapped[str] = mapped_column(String, nullable=False)
dropped_out: Mapped[bool] = mapped_column(Boolean, default=False)
tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id")) tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id"))
tournament: Mapped["Tournament"] = relationship(back_populates="teams") tournament: Mapped["Tournament"] = relationship(back_populates="teams")
class Match(Base): class Match(Base):
__tablename__ = "matches" __tablename__ = "matches"
+2 -2
View File
@@ -33,7 +33,7 @@ async def create_team(
@router.patch("/{id}/teams", response_model=list[schemas.TeamSchema]) @router.patch("/{id}/teams", response_model=list[schemas.TeamSchema])
async def update_teams( async def update_teams(
id: str, id: str,
teams: list[str], teams: list[schemas.TeamUpdate], # <-- CHANGED: Now accepts the TeamUpdate schema!
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: dict = Depends(get_admin_user), user: dict = Depends(get_admin_user),
): ):
@@ -57,4 +57,4 @@ async def delete_team(
raise HTTPException(404, "Team or Tournament not found") raise HTTPException(404, "Team or Tournament not found")
await send_ws_update(id) await send_ws_update(id)
return SUCCESS return SUCCESS
+7
View File
@@ -9,8 +9,14 @@ from .constants import MatchStatus, TournamentTypes, BracketTypes
class TeamSchema(BaseModel): class TeamSchema(BaseModel):
id: int id: int
name: str name: str
dropped_out: bool = False
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class TeamUpdate(BaseModel):
id: int
name: str
dropped_out: bool = False
class CourtSchema(BaseModel): class CourtSchema(BaseModel):
id: int id: int
@@ -109,6 +115,7 @@ class TournamentSettingsResponse(TournamentOut):
code: str code: str
teams: list[TeamSchema] teams: list[TeamSchema]
courts: list[CourtSchema] courts: list[CourtSchema]
has_started: bool
class Token(BaseModel): class Token(BaseModel):
Binary file not shown.
+1245 -1209
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -41,5 +41,8 @@
"vite-plugin-pwa": "^1.2.0", "vite-plugin-pwa": "^1.2.0",
"vite-plugin-svgr": "^5.0.0", "vite-plugin-svgr": "^5.0.0",
"vitest": "^4.0.18" "vitest": "^4.0.18"
},
"allowScripts": {
"esbuild@0.28.2": true
} }
} }
+135 -29
View File
@@ -1,6 +1,6 @@
// frontend/src/components/Forms/TournamentForm.tsx // frontend/src/components/Forms/TournamentForm.tsx
import { Loader2, Plus } from 'lucide-react'; import { Loader2, Plus, Trash2, UserMinus } from 'lucide-react';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import api from '../../services/api'; import api from '../../services/api';
@@ -9,6 +9,12 @@ interface Court {
name: string; name: string;
} }
interface LocalTeam {
id: string | number;
name: string;
dropped_out: boolean;
}
interface TournamentSettings { interface TournamentSettings {
id: string | number; id: string | number;
name: string; name: string;
@@ -17,7 +23,8 @@ interface TournamentSettings {
timestamp: string; timestamp: string;
duration: number; duration: number;
courts: Court[]; courts: Court[];
teams: { name?: string }[]; teams: { id?: string | number; name?: string; dropped_out?: boolean }[];
has_started?: boolean;
} }
interface TournamentFormProps { interface TournamentFormProps {
@@ -32,6 +39,9 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
const [selectedCourts, setSelectedCourts] = useState<number[]>([]); const [selectedCourts, setSelectedCourts] = useState<number[]>([]);
const [newCourtName, setNewCourtName] = useState(''); const [newCourtName, setNewCourtName] = useState('');
const [teams, setTeams] = useState<LocalTeam[]>([]);
const [newTeamInput, setNewTeamInput] = useState('');
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
@@ -46,6 +56,14 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`); const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`);
setInitialData(data); setInitialData(data);
setSelectedCourts(data.courts.map(c => c.id)); setSelectedCourts(data.courts.map(c => c.id));
if (data.teams) {
setTeams(data.teams.map((t, i) => ({
id: t.id || `db-${i}-${Math.random()}`,
name: t.name || '',
dropped_out: (t as any).dropped_out || false
})));
}
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -58,6 +76,29 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
void fetchDependencies(); void fetchDependencies();
}, [tournamentId]); }, [tournamentId]);
const handleAddTeams = () => {
const names = newTeamInput.split('\n').map(n => n.trim()).filter(n => n.length > 0);
if (names.length === 0) return;
// FIX 2: Explicitly provide dropped_out: false to satisfy the LocalTeam interface
const newTeams = names.map(name => ({ id: crypto.randomUUID(), name, dropped_out: false }));
setTeams([...teams, ...newTeams]);
setNewTeamInput('');
};
// FIX 3 & 4: Change type from 'string' to 'string | number'
const removeTeam = (id: string | number) => {
setTeams(teams.filter(t => t.id !== id));
};
const updateTeamName = (id: string | number, newName: string) => {
setTeams(teams.map(t => t.id === id ? { ...t, name: newName } : t));
};
const toggleDropOut = (id: string | number) => {
setTeams(teams.map(t => t.id === id ? { ...t, dropped_out: !t.dropped_out } : t));
};
const handleAddCourt = async () => { const handleAddCourt = async () => {
if (!newCourtName.trim()) return; if (!newCourtName.trim()) return;
try { try {
@@ -82,8 +123,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
setError(null); setError(null);
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const rawTeams = formData.get('teams') as string;
const date = formData.get('date') as string; const date = formData.get('date') as string;
const startTime = formData.get('start_time') as string; const startTime = formData.get('start_time') as string;
const typeRaw = formData.get('type') as string; const typeRaw = formData.get('type') as string;
@@ -91,9 +130,11 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
const name = formData.get('name') as string; const name = formData.get('name') as string;
const code = formData.get('code') as string; const code = formData.get('code') as string;
const teams = rawTeams.split('\n').map(t => t.trim()).filter(t => t.length > 0); const finalTeams = teams
.filter(t => t.name.trim().length > 0)
.map(t => ({ id: t.id, name: t.name.trim(), dropped_out: t.dropped_out }));
if (teams.length < 2) { if (finalTeams.length < 2) {
setError("At least 2 teams required."); setError("At least 2 teams required.");
setIsSubmitting(false); setIsSubmitting(false);
return; return;
@@ -115,22 +156,21 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
name, code, type: formattedType, timestamp, duration: parsedDuration name, code, type: formattedType, timestamp, duration: parsedDuration
}; };
await Promise.all([ await api.patch(`/tournaments/${tournamentId}`, basePayload);
api.patch(`/tournaments/${tournamentId}`, basePayload), await api.patch(`/tournaments/${tournamentId}/teams`, finalTeams);
api.patch(`/tournaments/${tournamentId}/teams`, teams), await api.patch(`/tournaments/${tournamentId}/courts`, selectedCourts);
api.patch(`/tournaments/${tournamentId}/courts`, selectedCourts)
]);
} else { } else {
const fullPayload = { const fullPayload = {
name, code, type: formattedType, timestamp, duration: parsedDuration, name, code, type: formattedType, timestamp, duration: parsedDuration,
teams, courts: selectedCourts teams: finalTeams, courts: selectedCourts
}; };
await api.post('/tournaments', fullPayload); await api.post('/tournaments', fullPayload);
} }
onSuccess(); onSuccess();
} catch (err: unknown) { } catch (err: any) {
console.error(err); console.error("Save error:", err);
setError("Error saving tournament"); const msg = err?.response?.data?.detail || err?.detail || err?.message || "An error occurred while saving.";
setError(msg);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -153,10 +193,12 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
defaultTime = t ? t.substring(0, 5) : "09:00"; defaultTime = t ? t.substring(0, 5) : "09:00";
} }
const hasStarted = initialData?.has_started;
return ( return (
<form key={initialData?.id || 'new'} onSubmit={handleSubmit} className="space-y-4"> <form key={initialData?.id || 'new'} onSubmit={handleSubmit} className="space-y-4">
{error && ( {error && (
<div className="text-xs text-red-500 dark:text-red-400 text-center mb-4 bg-red-50 dark:bg-red-900/10 p-2 rounded border border-red-200 dark:border-red-900/30"> <div className="text-xs text-red-500 dark:text-red-400 text-center mb-4 bg-red-50 dark:bg-red-900/10 p-3 rounded-lg border border-red-200 dark:border-red-900/30 font-bold leading-relaxed">
{error} {error}
</div> </div>
)} )}
@@ -196,7 +238,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
</div> </div>
</div> </div>
{/* --- NEW: GLOBAL COURT SELECTOR --- */}
<div className="p-3 bg-zinc-50 dark:bg-zinc-900/50 rounded-lg border border-zinc-200 dark:border-zinc-800"> <div className="p-3 bg-zinc-50 dark:bg-zinc-900/50 rounded-lg border border-zinc-200 dark:border-zinc-800">
<label className="text-xs font-bold text-zinc-500 uppercase mb-2 block">Venue Courts</label> <label className="text-xs font-bold text-zinc-500 uppercase mb-2 block">Venue Courts</label>
<div className="flex flex-wrap gap-2 mb-3"> <div className="flex flex-wrap gap-2 mb-3">
@@ -231,27 +272,92 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
</div> </div>
<div> <div>
<label className="text-xs font-bold text-zinc-500 uppercase">Teams</label> <div className="flex justify-between items-end mb-1">
<textarea <label className="text-xs font-bold text-zinc-500 uppercase">Teams ({teams.length})</label>
name="teams" {tournamentId && (
placeholder="One team per line..." <span className="text-[9px] font-bold text-orange-500 uppercase tracking-widest bg-orange-100 dark:bg-orange-900/30 px-2 py-0.5 rounded border border-orange-200 dark:border-orange-800">Inline Editable</span>
defaultValue={initialData?.teams?.map(t => t.name || t).join('\n')} )}
rows={5} </div>
required
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 font-mono text-sm focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white resize-none" <div className="bg-zinc-50 dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-800 rounded-lg p-1.5 space-y-1.5">
/> <div className="space-y-1 max-h-56 overflow-y-auto pr-1">
{teams.map((t, i) => (
<div key={t.id} className="flex items-center gap-1 bg-white dark:bg-zinc-950 p-0.5 border border-zinc-200 dark:border-zinc-800 rounded shadow-sm group transition-colors focus-within:border-orange-500 focus-within:ring-1 focus-within:ring-orange-500/20">
<div className="px-1 text-[9px] font-black text-zinc-400 w-6 text-right select-none">{i + 1}.</div>
<input
value={t.name}
onChange={(e) => updateTeamName(t.id, e.target.value)}
disabled={t.dropped_out}
className={`flex-1 bg-transparent border-none outline-none text-xs font-bold px-1 py-0.5 focus:text-orange-600 transition-all ${t.dropped_out ? 'text-red-500 line-through opacity-60' : 'text-zinc-700 dark:text-zinc-200'
}`}
/>
{hasStarted ? (
<button
type="button"
onClick={() => toggleDropOut(t.id)}
className={`p-1 rounded transition opacity-0 group-hover:opacity-100 focus:opacity-100 ${t.dropped_out ? 'text-red-500 bg-red-50 dark:bg-red-900/30 opacity-100' : 'text-zinc-300 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30'
}`}
title={t.dropped_out ? "Restore Team" : "Mark as Dropped Out"}
>
<UserMinus size={14} />
</button>
) : (
<button
type="button"
onClick={() => removeTeam(t.id)}
className="p-1 text-zinc-300 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Remove Team"
>
<Trash2 size={14} />
</button>
)}
</div>
))}
{teams.length === 0 && (
<div className="text-center p-4 text-[9px] font-black text-zinc-400 uppercase tracking-widest border-2 border-dashed border-zinc-200 dark:border-zinc-800 rounded">
No teams added
</div>
)}
</div>
{!hasStarted && (
<div className="pt-1.5 border-t border-zinc-200 dark:border-zinc-800">
<div className="flex gap-1.5 items-start">
<textarea
value={newTeamInput}
onChange={(e) => setNewTeamInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAddTeams();
}
}}
placeholder="Type name (Enter to add) or paste list..."
rows={newTeamInput.includes('\n') ? 3 : 1}
className="flex-1 bg-white dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded px-2 py-1.5 text-xs focus:border-orange-500 outline-none text-zinc-900 dark:text-white resize-none transition-all shadow-sm"
/>
{/* FIX 5: Replaced min-h-[32px] with the canonical min-h-8 */}
<button type="button" onClick={handleAddTeams} className="bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 px-3 py-1.5 rounded font-bold transition flex items-center justify-center shrink-0 shadow-sm self-stretch min-h-8">
<Plus size={16} />
</button>
</div>
</div>
)}
</div>
</div> </div>
</div> </div>
<div className="flex justify-between mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4"> <div className="flex justify-between mt-6 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4 items-center">
{tournamentId && onDelete && ( {tournamentId && onDelete && (
<button type="button" onClick={() => onDelete(tournamentId)} className="text-red-500 text-sm hover:underline h-5 self-end"> <button type="button" onClick={() => onDelete(tournamentId)} className="text-red-500 text-sm hover:underline font-bold">
Delete Tournament Delete Tournament
</button> </button>
)} )}
{!tournamentId && <div className="hidden"></div>} {!tournamentId && <div className="hidden"></div>}
<button disabled={isSubmitting} type="submit" className="bg-orange-600 hover:bg-orange-500 text-white px-6 py-2 rounded font-bold shadow-lg shadow-orange-900/20 ml-auto transition active:scale-95 disabled:opacity-50"> <button disabled={isSubmitting} type="submit" className="bg-orange-600 hover:bg-orange-500 text-white px-8 py-2.5 rounded-lg font-bold shadow-lg shadow-orange-900/20 transition active:scale-95 disabled:opacity-50 flex items-center gap-2 ml-auto">
{isSubmitting && <Loader2 size={16} className="animate-spin" />}
{isSubmitting ? 'Saving...' : (tournamentId ? 'Save Changes' : 'Create')} {isSubmitting ? 'Saving...' : (tournamentId ? 'Save Changes' : 'Create')}
</button> </button>
</div> </div>
@@ -16,6 +16,8 @@ interface MatchData {
p1_label?: string; p1_label?: string;
p2?: string; p2?: string;
p2_label?: string; p2_label?: string;
p1_is_real?: boolean; // <-- NEW
p2_is_real?: boolean; // <-- NEW
ref_label?: string; ref_label?: string;
ref_team?: { name: string }; ref_team?: { name: string };
isFinished: boolean; isFinished: boolean;
@@ -33,35 +35,53 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
const [sets, setSets] = useState<SetData[]>(match.sets && match.sets.length ? match.sets : [{ p1: '', p2: '' }]); const [sets, setSets] = useState<SetData[]>(match.sets && match.sets.length ? match.sets : [{ p1: '', p2: '' }]);
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); // <-- NEW: Lock state const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const refName = match.ref_team?.name || match.ref_label; const refName = match.ref_team?.name || match.ref_label;
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting) return; // Prevent double-execution if (isSubmitting) return;
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
try { try {
await onSubmit(match.id, sets, code); await onSubmit(match.id, sets, code);
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { detail?: string }; const error = err as { detail?: string };
setError(typeof error?.detail === 'string' ? error.detail : "Check code or scores"); setError(typeof error?.detail === 'string' ? error.detail : "Check code or scores");
setIsSubmitting(false); // Only re-enable the button if it failed (if it succeeds, modal closes) setIsSubmitting(false);
} }
}; };
const handleClear = async () => { const handleClear = async () => {
if (isSubmitting) return; // Prevent double-execution if (isSubmitting) return;
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
try { try {
await onClear(match.id, code); await onClear(match.id, code);
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { detail?: string }; const error = err as { detail?: string };
setError(typeof error?.detail === 'string' ? error.detail : "Failed to clear score"); setError(typeof error?.detail === 'string' ? error.detail : "Failed to clear score");
setIsSubmitting(false); // Re-enable on error setIsSubmitting(false);
}
};
// --- NEW: Walkover Function ---
const handleWalkover = async (winnerIndex: 1 | 2) => {
if (isSubmitting) return;
const winnerName = winnerIndex === 1 ? match.p1 : match.p2;
if (!window.confirm(`Award Walkover to ${winnerName}?`)) return;
setIsSubmitting(true);
setError(null);
const woSets = winnerIndex === 1 ? [{ p1: 21, p2: 0 }, { p1: 21, p2: 0 }] : [{ p1: 0, p2: 21 }, { p1: 0, p2: 21 }];
try {
await onSubmit(match.id, woSets, code);
} catch (err: unknown) {
const error = err as { detail?: string };
setError(typeof error?.detail === 'string' ? error.detail : "Failed to record walkover");
setIsSubmitting(false);
} }
}; };
@@ -78,7 +98,6 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{/* Time & Court Row */}
<div className="flex justify-center items-center gap-4 bg-zinc-50 dark:bg-zinc-950 p-3 rounded-lg border border-gray-200 dark:border-zinc-800 shadow-sm transition-colors"> <div className="flex justify-center items-center gap-4 bg-zinc-50 dark:bg-zinc-950 p-3 rounded-lg border border-gray-200 dark:border-zinc-800 shadow-sm transition-colors">
<div className="flex items-center gap-2 text-sm font-mono text-zinc-600 dark:text-zinc-300"> <div className="flex items-center gap-2 text-sm font-mono text-zinc-600 dark:text-zinc-300">
<Clock className="text-orange-500" size={16} /> <Clock className="text-orange-500" size={16} />
@@ -91,7 +110,6 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
</div> </div>
</div> </div>
{/* Dedicated Ref Row (Always visible) */}
{refName && ( {refName && (
<div className={`flex justify-center items-center gap-2 p-2.5 rounded-lg border transition-colors ${match.ref_team <div className={`flex justify-center items-center gap-2 p-2.5 rounded-lg border transition-colors ${match.ref_team
? 'bg-orange-50 dark:bg-orange-900/10 border-orange-100 dark:border-orange-900/30' ? 'bg-orange-50 dark:bg-orange-900/10 border-orange-100 dark:border-orange-900/30'
@@ -128,10 +146,23 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
</div> </div>
)} )}
<div className="grid grid-cols-3 gap-2 text-center font-bold text-zinc-700 dark:text-zinc-200 items-center px-2"> {/* --- NEW: Team Names with Walkover Buttons --- */}
<div className="wrap-break-word text-sm leading-tight uppercase">{match.p1 || match.p1_label}</div> <div className="grid grid-cols-3 gap-2 text-center font-bold text-zinc-700 dark:text-zinc-200 items-start px-2">
<div className="text-zinc-400 dark:text-zinc-600 text-[10px] font-bold uppercase bg-zinc-100 dark:bg-zinc-950 px-3 py-1 rounded-full w-fit mx-auto shadow-sm">VS</div> <div className="flex flex-col items-center gap-2">
<div className="wrap-break-word text-sm leading-tight uppercase">{match.p2 || match.p2_label}</div> <div className="wrap-break-word text-sm leading-tight uppercase">{match.p1 || match.p1_label}</div>
{isAuthenticated && !match.isFinished && match.p1_is_real && (
<button onClick={() => handleWalkover(1)} type="button" className="text-[9px] bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 px-2 py-1 rounded uppercase font-black hover:bg-orange-500 hover:text-white transition">Win by WO</button>
)}
</div>
<div className="text-zinc-400 dark:text-zinc-600 text-[10px] font-bold uppercase bg-zinc-100 dark:bg-zinc-950 px-3 py-1 rounded-full w-fit mx-auto shadow-sm mt-1">VS</div>
<div className="flex flex-col items-center gap-2">
<div className="wrap-break-word text-sm leading-tight uppercase">{match.p2 || match.p2_label}</div>
{isAuthenticated && !match.isFinished && match.p2_is_real && (
<button onClick={() => handleWalkover(2)} type="button" className="text-[9px] bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 px-2 py-1 rounded uppercase font-black hover:bg-orange-500 hover:text-white transition">Win by WO</button>
)}
</div>
</div> </div>
<div className="space-y-2 max-h-48 overflow-y-auto pr-1"> <div className="space-y-2 max-h-48 overflow-y-auto pr-1">
@@ -154,15 +185,10 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
title="Remove Set" title="Remove Set"
className="mx-auto p-2 text-zinc-300 hover:text-red-500 transition shrink-0 group" className="mx-auto p-2 text-zinc-300 hover:text-red-500 transition shrink-0 group"
> >
<Eraser <Eraser size={18} className="group-hover:scale-110 transition-transform" />
size={18}
className="group-hover:scale-110 transition-transform"
/>
</button> </button>
) : ( ) : (
<span className="text-zinc-400 dark:text-zinc-600 text-center font-bold"> <span className="text-zinc-400 dark:text-zinc-600 text-center font-bold">-</span>
-
</span>
)} )}
<input <input