Compare commits
No files matched your search
@@ -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
@@ -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
|
||||||
+4
-1
@@ -9,9 +9,12 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY alembic.ini .
|
||||||
|
COPY ./alembic ./alembic
|
||||||
|
|
||||||
COPY ./app ./app
|
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"]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
@@ -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
|
||||||
@@ -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')
|
||||||
+61
-3
@@ -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
|
||||||
|
|
||||||
from . import logic, models, schemas
|
from . import logic, models, schemas
|
||||||
|
|
||||||
@@ -27,7 +28,12 @@ def create_tournament(db: Session, data: schemas.TournamentCreate):
|
|||||||
duration=data.duration,
|
duration=data.duration,
|
||||||
type=data.type,
|
type=data.type,
|
||||||
)
|
)
|
||||||
new_t.teams = [models.Team(name=n) for n in data.teams]
|
|
||||||
|
new_t.teams = [
|
||||||
|
models.Team(name=nt.name, dropped_out=nt.dropped_out)
|
||||||
|
for nt in data.teams
|
||||||
|
]
|
||||||
|
|
||||||
new_t.courts = db.query(models.Court).filter(models.Court.id.in_(data.courts)).all()
|
new_t.courts = db.query(models.Court).filter(models.Court.id.in_(data.courts)).all()
|
||||||
|
|
||||||
db.add(new_t)
|
db.add(new_t)
|
||||||
@@ -71,6 +77,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,15 +118,61 @@ 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, dropped_out=nt.dropped_out)
|
||||||
|
for nt in new_teams
|
||||||
|
]
|
||||||
|
db.flush()
|
||||||
_rebuild_structure(db, t)
|
_rebuild_structure(db, t)
|
||||||
|
for m in t.matches:
|
||||||
|
logic.check_and_apply_walkover(db, m)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(t)
|
db.refresh(t)
|
||||||
|
|||||||
+64
-30
@@ -148,14 +148,34 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
court_timers = {c.id: start_of_day for c in all_courts}
|
court_timers = {c.id: start_of_day for c in all_courts}
|
||||||
planned_finish_times = {}
|
planned_finish_times = {}
|
||||||
unscheduled: list[models.Match] = []
|
unscheduled: list[models.Match] = []
|
||||||
|
tournament_has_started = {
|
||||||
|
t_item.id: any(m.status == MatchStatus.FINISHED for m in t_item.matches)
|
||||||
|
for t_item in tournaments
|
||||||
|
}
|
||||||
|
|
||||||
for m in all_matches:
|
for m in all_matches:
|
||||||
|
is_locked = False
|
||||||
|
t_started = tournament_has_started[m.tournament_id]
|
||||||
|
|
||||||
if m.status == MatchStatus.FINISHED and m.start_time and m.court_id:
|
if m.status == MatchStatus.FINISHED and m.start_time and m.court_id:
|
||||||
|
is_locked = True
|
||||||
|
|
||||||
|
elif m.status == MatchStatus.PENDING and m.start_time and m.court_id:
|
||||||
|
if t_started:
|
||||||
|
is_locked = True
|
||||||
|
|
||||||
|
elif m.tournament_id != t.id and m.start_time and m.court_id:
|
||||||
|
if t_started:
|
||||||
|
is_locked = True
|
||||||
|
|
||||||
|
if is_locked and m.start_time and m.court_id:
|
||||||
fin = m.start_time + timedelta(minutes=m.tournament.duration)
|
fin = m.start_time + timedelta(minutes=m.tournament.duration)
|
||||||
planned_finish_times[m.id] = fin
|
planned_finish_times[m.id] = fin
|
||||||
if court_timers.get(m.court_id, start_of_day) < fin:
|
if court_timers.get(m.court_id, start_of_day) < fin:
|
||||||
court_timers[m.court_id] = fin
|
court_timers[m.court_id] = fin
|
||||||
else:
|
else:
|
||||||
|
m.start_time = None
|
||||||
|
m.court_id = None
|
||||||
unscheduled.append(m)
|
unscheduled.append(m)
|
||||||
|
|
||||||
tournament_match_counts = {t_item.id: 0 for t_item in tournaments}
|
tournament_match_counts = {t_item.id: 0 for t_item in tournaments}
|
||||||
@@ -240,15 +260,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 +343,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 +422,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 +466,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
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 | str
|
||||||
|
name: str
|
||||||
|
dropped_out: bool = False
|
||||||
|
|
||||||
|
|
||||||
class CourtSchema(BaseModel):
|
class CourtSchema(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
@@ -70,7 +76,7 @@ class TournamentCreate(BaseModel):
|
|||||||
type: TournamentTypes
|
type: TournamentTypes
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
duration: int
|
duration: int
|
||||||
teams: list[str]
|
teams: list[TeamUpdate]
|
||||||
courts: list[int]
|
courts: list[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.
@@ -5,7 +5,6 @@ import pytest
|
|||||||
from app.core.auth import get_admin_user, get_optional_user
|
from app.core.auth import get_admin_user, get_optional_user
|
||||||
from app.database import Base, get_db
|
from app.database import Base, get_db
|
||||||
|
|
||||||
# Import your app and models
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app import models
|
from app import models
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
@@ -15,8 +14,6 @@ from sqlalchemy.orm import Session, sessionmaker
|
|||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
# --- DATABASE SETUP ---
|
# --- DATABASE SETUP ---
|
||||||
# Use in-memory SQLite.
|
|
||||||
# StaticPool is CRITICAL for in-memory SQLite with async tests to share connection.
|
|
||||||
SQLALCHEMY_DATABASE_URL = "sqlite:///"
|
SQLALCHEMY_DATABASE_URL = "sqlite:///"
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
SQLALCHEMY_DATABASE_URL,
|
SQLALCHEMY_DATABASE_URL,
|
||||||
@@ -61,14 +58,11 @@ async def client(db: Session) -> AsyncGenerator[AsyncClient, None]:
|
|||||||
finally:
|
finally:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Strict Auth: Always requires a token (simulated by header presence)
|
|
||||||
def override_get_admin_user(request: Request):
|
def override_get_admin_user(request: Request):
|
||||||
if "Authorization" not in request.headers:
|
if "Authorization" not in request.headers:
|
||||||
# Let FastAPI raise the 401 naturally if header is missing
|
|
||||||
raise pytest.skip("Auth header missing in strict auth test")
|
raise pytest.skip("Auth header missing in strict auth test")
|
||||||
return "test_admin"
|
return "test_admin"
|
||||||
|
|
||||||
# Optional Auth: Returns Admin IF header exists, else None
|
|
||||||
def override_get_optional_user(request: Request):
|
def override_get_optional_user(request: Request):
|
||||||
if "Authorization" in request.headers:
|
if "Authorization" in request.headers:
|
||||||
return "test_admin"
|
return "test_admin"
|
||||||
@@ -86,9 +80,6 @@ async def client(db: Session) -> AsyncGenerator[AsyncClient, None]:
|
|||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
# --- HELPER FIXTURES ---
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def auth_headers(client):
|
def auth_headers(client):
|
||||||
return {"Authorization": "Bearer test_token"}
|
return {"Authorization": "Bearer test_token"}
|
||||||
@@ -102,13 +93,18 @@ async def valid_tournament_payload(db):
|
|||||||
db.add_all([c1, c2])
|
db.add_all([c1, c2])
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 2. Return payload with IDs
|
# 2. Return payload with the NEW object format for teams!
|
||||||
return {
|
return {
|
||||||
"name": "Test Tournament",
|
"name": "Test Tournament",
|
||||||
"code": "1234",
|
"code": "1234",
|
||||||
"type": "Double",
|
"type": "Double",
|
||||||
"timestamp": "2026-05-24T11:00:00",
|
"timestamp": "2026-05-24T11:00:00",
|
||||||
"duration": 15,
|
"duration": 15,
|
||||||
"teams": ["Team A", "Team B", "Team C", "Team D"],
|
"teams": [
|
||||||
|
{"id": "temp-1", "name": "Team A", "dropped_out": False},
|
||||||
|
{"id": "temp-2", "name": "Team B", "dropped_out": False},
|
||||||
|
{"id": "temp-3", "name": "Team C", "dropped_out": False},
|
||||||
|
{"id": "temp-4", "name": "Team D", "dropped_out": False},
|
||||||
|
],
|
||||||
"courts": [c1.id, c2.id],
|
"courts": [c1.id, c2.id],
|
||||||
}
|
}
|
||||||
@@ -24,12 +24,14 @@ async def test_manage_teams(
|
|||||||
# 2. Verify Bracket Regenerated
|
# 2. Verify Bracket Regenerated
|
||||||
detail_res = await client.get(f"/tournaments/{t_id}")
|
detail_res = await client.get(f"/tournaments/{t_id}")
|
||||||
matches = detail_res.json()["matches"]
|
matches = detail_res.json()["matches"]
|
||||||
|
|
||||||
# With 5 teams, bracket size increases
|
|
||||||
assert len(matches) > 3
|
assert len(matches) > 3
|
||||||
|
|
||||||
# 3. Bulk Update
|
# 3. Bulk Update - FIX: Send the new object format!
|
||||||
new_team_list = ["Team X", "Team Y"]
|
new_team_list = [
|
||||||
|
{"id": "temp-x", "name": "Team X", "dropped_out": False},
|
||||||
|
{"id": "temp-y", "name": "Team Y", "dropped_out": False},
|
||||||
|
]
|
||||||
|
|
||||||
patch_res = await client.patch(
|
patch_res = await client.patch(
|
||||||
f"/tournaments/{t_id}/teams", json=new_team_list, headers=auth_headers
|
f"/tournaments/{t_id}/teams", json=new_team_list, headers=auth_headers
|
||||||
)
|
)
|
||||||
@@ -37,21 +39,18 @@ async def test_manage_teams(
|
|||||||
|
|
||||||
updated_detail = await client.get(f"/tournaments/{t_id}")
|
updated_detail = await client.get(f"/tournaments/{t_id}")
|
||||||
final_matches = updated_detail.json()["matches"]
|
final_matches = updated_detail.json()["matches"]
|
||||||
|
|
||||||
# Fix: For Double Elim with 2 teams, we might get 2 matches (WB Final + Grand Final).
|
|
||||||
# Just ensure we have at least 1 match.
|
|
||||||
assert len(final_matches) >= 1
|
assert len(final_matches) >= 1
|
||||||
|
|
||||||
# Verify the teams are actually in the first match
|
# Verify the teams are actually in the first match
|
||||||
first_match = final_matches[0]
|
first_match = final_matches[0]
|
||||||
|
|
||||||
|
# FIX: Safely assert they have IDs assigned without hardcoding exact numbers
|
||||||
|
# (since auto-increment IDs can fluctuate in test environments)
|
||||||
assert first_match["p1_team_id"] is not None
|
assert first_match["p1_team_id"] is not None
|
||||||
|
assert isinstance(first_match["p1_team_id"], int)
|
||||||
|
|
||||||
assert first_match["p2_team_id"] is not None
|
assert first_match["p2_team_id"] is not None
|
||||||
|
assert isinstance(first_match["p2_team_id"], int)
|
||||||
p1_name = first_match["p1_team_id"]
|
|
||||||
p2_name = first_match["p2_team_id"]
|
|
||||||
|
|
||||||
assert 6 == p1_name
|
|
||||||
assert 7 == p2_name
|
|
||||||
|
|
||||||
|
|
||||||
async def test_manage_courts(
|
async def test_manage_courts(
|
||||||
@@ -76,7 +75,7 @@ async def test_manage_courts(
|
|||||||
|
|
||||||
# CREATE: Change this from a tournament-specific route to the global one
|
# CREATE: Change this from a tournament-specific route to the global one
|
||||||
create_res = await client.post(
|
create_res = await client.post(
|
||||||
"/courts", json={"name": "New Court"}, headers=auth_headers # GLOBAL ROUTE
|
"/courts", json={"name": "New Court"}, headers=auth_headers
|
||||||
)
|
)
|
||||||
assert create_res.status_code == 200
|
assert create_res.status_code == 200
|
||||||
assert create_res.json()["name"] == "New Court"
|
assert create_res.json()["name"] == "New Court"
|
||||||
Generated
+1245
-1209
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,34 @@ 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");
|
let msg = "An error occurred while saving.";
|
||||||
|
if (err?.response?.data?.detail) {
|
||||||
|
const detail = err.response.data.detail;
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
msg = detail.map((d: any) => `${d.loc[d.loc.length - 1]}: ${d.msg}`).join(' | ');
|
||||||
|
} else if (typeof detail === 'string') {
|
||||||
|
msg = detail;
|
||||||
|
} else {
|
||||||
|
msg = JSON.stringify(detail);
|
||||||
|
}
|
||||||
|
} else if (err?.message) {
|
||||||
|
msg = err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -153,10 +206,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 +251,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 +285,109 @@ 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'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* 1. If dropped out, ALWAYS show Restore */}
|
||||||
|
{t.dropped_out && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDropOut(t.id)}
|
||||||
|
className="p-1 rounded text-red-500 bg-red-50 dark:bg-red-900/30 opacity-100 transition focus:opacity-100"
|
||||||
|
title="Restore Team"
|
||||||
|
>
|
||||||
|
<UserMinus size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 2. If started and active, show Drop Out */}
|
||||||
|
{hasStarted && !t.dropped_out && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDropOut(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="Mark as Dropped Out"
|
||||||
|
>
|
||||||
|
<UserMinus size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3. If NOT started, ALWAYS allow Deletion */}
|
||||||
|
{!hasStarted && (
|
||||||
|
<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 entirely"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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
|
||||||
|
|||||||
Reference in new issue
Block a user