diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 55643a9f..a5462e6f 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -70,79 +70,49 @@ jobs:
path: www
key: ${{ inputs.runs-on }}-www
- setup_backend:
- name: Setup Backend
- runs-on: ${{ inputs.runs-on }}
- steps:
- - uses: actions/checkout@v6
- - uses: actions/setup-python@v6
- with:
- python-version-file: pyproject.toml
- - uses: astral-sh/setup-uv@v7
- with:
- activate-environment: true
- enable-cache: true
- - run: uv venv
- - run: uv sync
- - uses: actions/cache@v5
- with:
- path: .venv
- key: ${{ inputs.runs-on }}-.venv
lint_backend:
name: Lint Backend
- needs: setup_backend
runs-on: ${{ inputs.runs-on }}
steps:
- uses: actions/checkout@v6
- - uses: actions/cache@v5
- with:
- path: .venv
- key: ${{ inputs.runs-on }}-.venv
- - uses: actions/setup-python@v6
- with:
- python-version-file: pyproject.toml
-
- uses: astral-sh/setup-uv@v7
with:
activate-environment: true
enable-cache: true
+ - run: uv sync
+ - run: ruff check
+ - run: ty check
test_backend:
name: Test Backend
- needs: [setup_backend, build_frontend]
+ needs: build_frontend
runs-on: ${{ inputs.runs-on }}
steps:
- uses: actions/checkout@v6
- - uses: actions/cache@v5
+ - uses: astral-sh/setup-uv@v7
with:
- path: .venv
- key: ${{ inputs.runs-on }}-.venv
+ activate-environment: true
+ enable-cache: true
+ - run: uv sync
- uses: actions/cache@v5
with:
path: www
key: ${{ inputs.runs-on }}-www
fail-on-cache-miss: true
- - uses: actions/setup-python@v6
- with:
- python-version-file: pyproject.toml
- - uses: astral-sh/setup-uv@v7
- with:
- activate-environment: true
- enable-cache: true
- run: pytest backend/tests --tag-name ${{ github.ref_name }}
if: ${{ inputs.test-release }}
- run: pytest
if: ${{ !inputs.test-release }}
build_backend:
name: Build backend
- needs: [setup_backend, build_frontend]
+ needs: build_frontend
runs-on: ${{ inputs.runs-on }}
steps:
- uses: actions/checkout@v6
- - uses: actions/cache@v5
+ - uses: astral-sh/setup-uv@v7
with:
- path: .venv
- key: ${{ inputs.runs-on }}-.venv
- fail-on-cache-miss: true
+ activate-environment: true
+ enable-cache: true
+ - run: uv sync
- uses: actions/cache@v5
with:
path: www
@@ -151,10 +121,6 @@ jobs:
- uses: actions/setup-python@v6
with:
python-version-file: pyproject.toml
- - uses: astral-sh/setup-uv@v7
- with:
- activate-environment: true
- enable-cache: true
- run: pyinstaller onedir.spec
- run: rm -rf "dist/NSO Bridge"
if: ${{ inputs.runs-on == 'macos-latest' }}
diff --git a/backend/src/core/__init__.py b/backend/src/core/__init__.py
index a2a2896b..83aab73e 100644
--- a/backend/src/core/__init__.py
+++ b/backend/src/core/__init__.py
@@ -4,7 +4,7 @@
"""
from .database import CASCADE_CHILD, CASCADE_OTHER, BaseSQLModel, DatabaseEngine
-from .dependencies import AsyncSessionDepends, EngineFactory
+from .dependencies import EngineFactory, GetAsyncSession
from .protocols import Memento
from .router import api_router, assets, pages_router
from .schemas import APIResponseClass, ClientSchema, ServerSchema
@@ -21,7 +21,6 @@
'api_router',
'APIResponseClass',
'assets',
- 'AsyncSessionDepends',
'BaseSQLModel',
'CASCADE_CHILD',
'CASCADE_OTHER',
@@ -33,6 +32,7 @@
'get_default_route',
'get_resource_path',
'get_server',
+ 'GetAsyncSession',
'Memento',
'pages_router',
'ServerSchema',
diff --git a/backend/src/core/database.py b/backend/src/core/database.py
index d9ae24e3..4b3592ad 100644
--- a/backend/src/core/database.py
+++ b/backend/src/core/database.py
@@ -10,6 +10,7 @@
ClassVar,
Final,
)
+from uuid import UUID, uuid4
from sqlalchemy.engine import URL
from sqlalchemy.ext.asyncio import (
@@ -45,7 +46,7 @@ class BaseSQLModel(AsyncAttrs, DeclarativeBase):
"""
- id: Mapped[int | None] = mapped_column(nullable=False, primary_key=True)
+ uuid: Mapped[UUID] = mapped_column(default=uuid4, primary_key=True)
__abstract__: bool = True
__type_annotation_map__: dict = {timedelta: _TimedeltaAsMilliseconds}
diff --git a/backend/src/core/dependencies.py b/backend/src/core/dependencies.py
index 1f309c49..08c4b1fc 100644
--- a/backend/src/core/dependencies.py
+++ b/backend/src/core/dependencies.py
@@ -78,7 +78,7 @@ async def yield_async_session(cls) -> AsyncGenerator[AsyncSession, None]:
await session.commit() # Automatically commit after each session
-AsyncSessionDepends: TypeAlias = Annotated[
+GetAsyncSession: TypeAlias = Annotated[
AsyncSession,
Depends(EngineFactory.yield_async_session),
]
diff --git a/backend/src/core/schemas.py b/backend/src/core/schemas.py
index b4151519..cd7bc1ef 100644
--- a/backend/src/core/schemas.py
+++ b/backend/src/core/schemas.py
@@ -116,5 +116,5 @@ def render(self, content: Any) -> bytes:
allow_nan=False,
indent=None,
separators=(',', ':'),
- default=(lambda dt: str(dt)), # Serialize datetime objects
+ default=(str), # Serialize datetime objects
).encode('utf-8')
diff --git a/backend/src/game/__init__.py b/backend/src/game/__init__.py
index b96523d2..9284a7c3 100644
--- a/backend/src/game/__init__.py
+++ b/backend/src/game/__init__.py
@@ -26,17 +26,16 @@
from .bouts.router import router as bout_router
from .jams.router import router as jam_router
from .models import CacheableSQLModel, CacheKey
-from .rosters.models import Roster
-from .rosters.router import router as roster_router
from .rulesets import wftda_2025
from .series.models import Series
from .series.router import router as series_router
+from .skaters.router import router as skater_router
from .timeouts.router import router as timeout_router
routers: Final[tuple[APIRouter, ...]] = (
bout_router,
jam_router,
- roster_router,
+ skater_router,
series_router,
timeout_router,
)
@@ -45,7 +44,6 @@
__all__ = (
'CacheKey',
'CacheableSQLModel',
- 'Roster', # Non-rule-bound objects can be exported
'routers',
'Series', # Non-rule-bound objects can be exported
'wftda_2025',
diff --git a/backend/src/game/bouts/dependencies.py b/backend/src/game/bouts/dependencies.py
index 432a3471..aedebb48 100644
--- a/backend/src/game/bouts/dependencies.py
+++ b/backend/src/game/bouts/dependencies.py
@@ -1,8 +1,9 @@
"""The FastAPI dependencies methods for Bouts."""
from typing import TYPE_CHECKING, Annotated, TypeAlias
+from uuid import UUID
-from core import AsyncSessionDepends
+from core import GetAsyncSession
from core.exceptions import ModelLookupError
from fastapi import Depends, Query, Request
from sqlalchemy import select
@@ -18,17 +19,18 @@
async def _get_bout(
request: Request,
user: GetUser,
- session: AsyncSessionDepends,
- bout_id: Annotated[int, Query(alias='boutId')],
+ session: GetAsyncSession,
+ bout_uuid: Annotated[UUID, Query(alias='boutUuid')],
) -> BaseBout:
- # Query the database for the desired Bout
- statement: Select[tuple[BaseBout]] = select(BaseBout).where(BaseBout.id == bout_id)
+ statement: Select[tuple[BaseBout]] = select(BaseBout).where(
+ BaseBout.uuid == bout_uuid
+ )
results: Result[tuple[BaseBout]] = await session.execute(statement)
try:
bout: BaseBout = results.scalar_one()
except NoResultFound as e:
- raise ModelLookupError(f'Could not find Bout with ID {bout_id}') from e
+ raise ModelLookupError(f'Could not find Bout ({bout_uuid=})') from e
# Optionally take a snapshot of the Bout state and return the Bout
if request.method != 'GET':
@@ -36,4 +38,4 @@ async def _get_bout(
return bout
-BoutDepends: TypeAlias = Annotated[BaseBout, Depends(_get_bout)]
+GetBout: TypeAlias = Annotated[BaseBout, Depends(_get_bout)]
diff --git a/backend/src/game/bouts/models.py b/backend/src/game/bouts/models.py
index 5f37bf41..79947655 100644
--- a/backend/src/game/bouts/models.py
+++ b/backend/src/game/bouts/models.py
@@ -4,6 +4,7 @@
from datetime import datetime # noqa: TC003
from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, final, override
+from uuid import UUID # noqa: TC003
from core import CASCADE_CHILD, CASCADE_OTHER
from game.clocks.models import Clock
@@ -32,8 +33,10 @@ class BaseBout(CacheableSQLModel):
ruleset: ClassVar[Ruleset]
- series_id: Mapped[int] = mapped_column(ForeignKey('series.id'))
- clock_id: Mapped[int] = mapped_column(ForeignKey('clocks.id', ondelete='RESTRICT'))
+ _clock_uuid: Mapped[UUID] = mapped_column(
+ ForeignKey('clocks.uuid', ondelete='RESTRICT')
+ )
+ series_uuid: Mapped[UUID] = mapped_column(ForeignKey('series.uuid'))
start_countdown: Mapped[datetime | None] = mapped_column(default=None)
is_final: Mapped[bool] = mapped_column(default=False)
@@ -43,11 +46,11 @@ class BaseBout(CacheableSQLModel):
_series: Mapped[Series] = relationship(
back_populates='bouts',
cascade=CASCADE_OTHER,
- foreign_keys=[series_id],
+ foreign_keys=[series_uuid],
)
clock: Mapped[Clock] = relationship(
cascade=CASCADE_CHILD,
- foreign_keys=[clock_id],
+ foreign_keys=[_clock_uuid],
lazy='joined',
single_parent=True,
)
@@ -55,6 +58,7 @@ class BaseBout(CacheableSQLModel):
back_populates='_bout',
cascade=CASCADE_CHILD,
lazy='selectin',
+ order_by=[column('num')],
)
jams: Mapped[list[BaseJam]] = relationship(
back_populates='_bout',
@@ -66,7 +70,7 @@ class BaseBout(CacheableSQLModel):
back_populates='_bout',
cascade=CASCADE_CHILD,
lazy='selectin',
- order_by=[column('id')],
+ order_by=[column('num')],
)
__tablename__: str = 'bouts'
@@ -82,7 +86,7 @@ def __str__(self) -> str:
str: a str representation of this Bout.
"""
- return f'[Bout ID: {self.id}]'
+ return f'[Bout UUID: {self.uuid}]'
def __init__(self, ruleset_name: str, *teams: BaseTeam) -> None:
"""Instantiate a Bout.
@@ -96,8 +100,8 @@ def __init__(self, ruleset_name: str, *teams: BaseTeam) -> None:
super().__init__(clock=Clock(), ruleset_name=ruleset_name, teams=list(teams))
@override
- def cache_key(self) -> CacheKey:
- return (self.__tablename__, self.id)
+ async def cache_key(self) -> CacheKey:
+ return (self.__tablename__, self.uuid)
@override
async def get_parents(self) -> tuple[BaseSQLModel, ...]:
@@ -136,6 +140,15 @@ def state(self) -> Literal['final', 'jam', 'lineup', 'stopped', 'timeout']:
else:
return 'stopped'
+ def get_active_jam(self) -> BaseJam:
+ """Get the most recently started Jam or upcoming Jam.
+
+ Returns:
+ BaseJam: the active Jam.
+
+ """
+ return next((j for j in self.jams if j.is_started()), self.jams[-1])
+
def get_running_jam(self) -> BaseJam | None:
"""Get the running Jam if there is one.
@@ -165,16 +178,14 @@ def get_running_timeout(self) -> BaseTimeout | None:
"""
return next((t for t in self.timeouts if t.is_running()), None)
- def get_upcoming_timeout(self) -> BaseTimeout | None:
- """Get the upcoming Timeout if there is one.
-
- The upcoming Timeout is the first Timeout that is not started.
+ def get_last_timeout(self) -> BaseTimeout | None:
+ """Get most recently complete Timeout if there is one.
Returns:
- BaseTimeout | None: the upcoming Timeout or None.
+ BaseTimeout | None: the most recently complete Timeout or None.
"""
- return next((t for t in self.timeouts if not t.is_started()), None)
+ return next((t for t in reversed(self.timeouts) if not t.is_running()), None)
async def begin_period(self, timestamp: datetime) -> None:
"""Begin the next Period.
diff --git a/backend/src/game/bouts/router.py b/backend/src/game/bouts/router.py
index aeb013df..c0e07ba1 100644
--- a/backend/src/game/bouts/router.py
+++ b/backend/src/game/bouts/router.py
@@ -1,13 +1,15 @@
"""FastAPI routes associated with Bouts."""
from datetime import datetime
-from typing import TYPE_CHECKING, Annotated, Final
+from typing import TYPE_CHECKING, Annotated, Final, Sequence
+from core import GetAsyncSession
from fastapi import APIRouter, Body
from game.rulesets.schemas import Ruleset
-from game.teams.dependencies import OptionalTeamDepends
+from sqlalchemy import Result, Select, select
-from .dependencies import BoutDepends, _get_bout
+from .dependencies import GetBout, _get_bout
+from .models import BaseBout
from .schemas import BoutSchema
if TYPE_CHECKING:
@@ -15,54 +17,63 @@
BOUTS_TAG = 'Bouts'
-router: Final[APIRouter] = APIRouter(prefix='/bout')
-router.add_api_route('', _get_bout, response_model=BoutSchema, tags=[BOUTS_TAG])
+router: Final[APIRouter] = APIRouter(prefix='/bout', tags=[BOUTS_TAG])
+router.add_api_route('', _get_bout, response_model=BoutSchema)
-@router.get('/ruleset', response_model=Ruleset, tags=[BOUTS_TAG])
-async def _get_ruleset(bout: BoutDepends) -> Ruleset:
+@router.get('/allBouts', response_model=list[BoutSchema])
+async def get_all_bouts(session: GetAsyncSession) -> Sequence[BaseBout]:
+ """Get all the Bouts in the database."""
+ statement: Select[tuple[BaseBout]] = select(BaseBout)
+ results: Result[tuple[BaseBout]] = await session.execute(statement)
+
+ return results.scalars().all()
+
+
+@router.get('/ruleset', response_model=Ruleset)
+async def _get_ruleset(bout: GetBout) -> Ruleset:
return bout.ruleset
-@router.post('/beginPeriod', tags=[BOUTS_TAG])
-async def begin_period(bout: BoutDepends) -> None:
+@router.post('/beginPeriod')
+async def begin_period(bout: GetBout) -> None:
"""Begin the period of the specified Bout."""
await bout.begin_period(datetime.now())
-@router.post('/endPeriod', tags=[BOUTS_TAG])
-async def end_period(bout: BoutDepends) -> None:
+@router.post('/endPeriod')
+async def end_period(bout: GetBout) -> None:
"""End the period of the specified Bout."""
await bout.end_period(datetime.now())
-@router.post('/startJam', tags=[BOUTS_TAG])
-async def start_jam(bout: BoutDepends) -> None:
+@router.post('/startJam')
+async def start_jam(bout: GetBout) -> None:
"""Start the next Jam of the specified Bout."""
await bout.start_jam(datetime.now())
-@router.post('/stopJam', tags=[BOUTS_TAG])
-async def stop_jam(bout: BoutDepends) -> None:
+@router.post('/stopJam')
+async def stop_jam(bout: GetBout) -> None:
"""Stop the active Jam of the specified Bout."""
await bout.stop_jam(datetime.now())
-@router.post('/startTimeout', tags=[BOUTS_TAG])
+@router.post('/startTimeout')
async def start_timeout(
- bout: BoutDepends,
- team: OptionalTeamDepends = None, # TODO: dependency should be in Body
+ bout: GetBout,
+ team_num: Annotated[int | None, Body(alias='teamNum')] = None,
is_review: Annotated[bool, Body(alias='isReview')] = False,
) -> None:
"""Start a new Timeout in the specified Bout."""
timeout: BaseTimeout = await bout.start_timeout(datetime.now())
timeout.is_review = is_review
- if team is not None:
- timeout.team = team
+ if team_num is not None:
+ timeout.team = bout.teams[team_num]
-@router.post(path='/stopTimeout', tags=[BOUTS_TAG])
-async def stop_timeout(bout: BoutDepends) -> None:
+@router.post(path='/stopTimeout')
+async def stop_timeout(bout: GetBout) -> None:
"""Stop the active Timeout in the specified Bout."""
await bout.stop_timeout(datetime.now())
diff --git a/backend/src/game/bouts/schemas.py b/backend/src/game/bouts/schemas.py
index 703c4144..dc6a9a63 100644
--- a/backend/src/game/bouts/schemas.py
+++ b/backend/src/game/bouts/schemas.py
@@ -4,6 +4,7 @@
from datetime import datetime # noqa: TC003
from typing import Literal # noqa: TC003
+from uuid import UUID # noqa: TC003
from core import ServerSchema
from game.clocks.schemas import ClockSchema # noqa: TC002
@@ -16,8 +17,7 @@
class BoutSchema(ServerSchema):
"""Represent a Bout as a JSON schema."""
- id: int
- series_id: int
+ uuid: UUID
ruleset_name: str
clock: ClockSchema
is_running: bool
@@ -30,27 +30,25 @@ class BoutSchema(ServerSchema):
@computed_field
@property
- def jam_ids(self) -> list[list[int]]:
- """Get a list of lists representing the IDs of this Bout's Jams.
+ def jam_counts(self) -> tuple[int, int, int]:
+ """A tuple representing the number of jams in this Bout per Period.
Returns:
- list[list[int]]: the Jam IDs of this Bout's Jams. Each list represents a
- Period such that a specific Jam may be queried using
- `bout.jam_ids[period_num][jam_num]`.
+ tuple[int, int, int]: the number of Jams in each Period of this Bout.
"""
- jam_ids: list[list[int]] = [[], [], []]
+ counts: dict[int, int] = {}
for jam in self.jams:
- jam_ids[jam.period].append(jam.id)
- return jam_ids
+ counts[jam.period] = counts.get(jam.period, 0) + 1
+ return counts.get(0, 0), counts.get(1, 0), counts.get(2, 0)
@computed_field
@property
- def timeout_ids(self) -> list[int]:
- """Get a list representing the IDs of this Bout's Timeouts.
+ def timeout_count(self) -> int:
+ """Get the number of Timeouts in this Bout.
Returns:
- list[int]: the Timeout IDs of this Bout's Timeouts.
+ list[int]: the number of timeouts in this Bout.
"""
- return [timeout.id for timeout in self.timeouts]
+ return len(self.timeouts)
diff --git a/backend/src/game/clocks/schemas.py b/backend/src/game/clocks/schemas.py
index ffb16a4c..3d458297 100644
--- a/backend/src/game/clocks/schemas.py
+++ b/backend/src/game/clocks/schemas.py
@@ -11,7 +11,6 @@
class ClockSchema(ServerSchema):
"""Represent a Clock as a JSON schema."""
- id: int
start_timestamp: datetime | None
elapsed: Annotated[timedelta, timedelta_serializer]
alarm: Annotated[timedelta, timedelta_serializer]
diff --git a/backend/src/game/jams/dependencies.py b/backend/src/game/jams/dependencies.py
index 35c22e62..b606024f 100644
--- a/backend/src/game/jams/dependencies.py
+++ b/backend/src/game/jams/dependencies.py
@@ -1,34 +1,28 @@
"""The FastAPI dependencies methods for Jams."""
-from typing import TYPE_CHECKING, Annotated, TypeAlias
+from typing import Annotated, TypeAlias
-from core import AsyncSessionDepends
from core.exceptions import ModelLookupError
from fastapi import Depends, Query, Request
-from sqlalchemy import select
-from sqlalchemy.exc import NoResultFound
+from game.bouts.dependencies import GetBout
from user import GetUser
from .models import BaseJam
-if TYPE_CHECKING:
- from sqlalchemy.engine.result import Result
- from sqlalchemy.sql.selectable import Select
-
async def _get_jam(
request: Request,
user: GetUser,
- session: AsyncSessionDepends,
- jam_id: Annotated[int, Query(alias='jamId')],
+ bout: GetBout,
+ period: Annotated[int, Query()],
+ num: Annotated[int, Query()],
) -> BaseJam:
- statement: Select[tuple[BaseJam]] = select(BaseJam).where(BaseJam.id == jam_id)
- results: Result[tuple[BaseJam]] = await session.execute(statement)
-
try:
- jam: BaseJam = results.scalar_one()
- except NoResultFound as e:
- raise ModelLookupError(f'Could not find Jam with ID {jam_id}') from e
+ jam: BaseJam = next(
+ jam for jam in bout.jams if jam.period == period and jam.num == num
+ )
+ except StopIteration as e:
+ raise ModelLookupError(f'Could not find Jam ({bout=} {period=} {num=})') from e
if request.method != 'GET':
user.stage(jam.get_memento())
@@ -36,4 +30,4 @@ async def _get_jam(
return jam
-GetJamByID: TypeAlias = Annotated[BaseJam, Depends(_get_jam)]
+GetJam: TypeAlias = Annotated[BaseJam, Depends(_get_jam)]
diff --git a/backend/src/game/jams/models.py b/backend/src/game/jams/models.py
index 6dddffed..3df73b08 100644
--- a/backend/src/game/jams/models.py
+++ b/backend/src/game/jams/models.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, override
+from uuid import UUID
from core import CASCADE_CHILD, CASCADE_OTHER, BaseSQLModel
from game.bouts.models import BaseBout
@@ -29,25 +30,30 @@
class BaseJam(AbstractOneShotModel, CacheableSQLModel):
"""An abstract Jam without any associated ruleset."""
- bout_id: Mapped[int | None] = mapped_column(ForeignKey('bouts.id'), nullable=False)
+ bout_uuid: Mapped[UUID | None] = mapped_column(
+ ForeignKey('bouts.uuid'), nullable=False
+ )
num: Mapped[int] = mapped_column(index=True)
period: Mapped[int] = mapped_column(index=True)
+
stop_reason: Mapped[StopReasonStr | None] = mapped_column(default=None)
_bout: Mapped[BaseBout | None] = relationship(
back_populates='jams',
cascade=CASCADE_OTHER,
- foreign_keys=[bout_id],
+ foreign_keys=[bout_uuid],
)
team_jams: Mapped[list[TeamJam]] = relationship(
- back_populates='_jam',
+ back_populates='jam',
cascade=CASCADE_CHILD,
lazy='selectin',
)
_ruleset: MappedSQLExpression[str] = column_property(
- select(BaseBout.ruleset_name).where(BaseBout.id == bout_id).scalar_subquery()
+ select(BaseBout.ruleset_name)
+ .where(BaseBout.uuid == bout_uuid)
+ .scalar_subquery()
)
__tablename__: str = 'jams'
@@ -57,7 +63,7 @@ class BaseJam(AbstractOneShotModel, CacheableSQLModel):
'confirm_deleted_rows': False,
}
__table_args__: tuple[Constraint, ...] = AbstractOneShotModel.__table_args__ + (
- UniqueConstraint('bout_id', 'num', 'period'),
+ UniqueConstraint('bout_uuid', 'num', 'period'),
)
def __str__(self) -> str:
@@ -67,7 +73,7 @@ def __str__(self) -> str:
str: a str representation of this Jam.
"""
- return f'[Bout ID: {self.bout_id}, P{self.period} J{self.num}]'
+ return f'[Bout ID: {self.bout_uuid}, P{self.period} J{self.num}]'
def __init__(self, period_num: int, jam_num: int, *team_jams: TeamJam) -> None:
"""Initialize a Jam.
@@ -81,8 +87,8 @@ def __init__(self, period_num: int, jam_num: int, *team_jams: TeamJam) -> None:
super().__init__(period=period_num, num=jam_num, team_jams=list(team_jams))
@override
- def cache_key(self) -> CacheKey:
- return (self.__tablename__, self.bout_id, self.period, self.num)
+ async def cache_key(self) -> CacheKey:
+ return (self.__tablename__, self.bout_uuid, self.period, self.num)
@override
async def get_parents(self) -> tuple[BaseSQLModel, ...]:
@@ -97,11 +103,11 @@ async def get_bout(self) -> BaseBout:
"""
return await self.awaitable_attrs._bout
- def get_team_jam(self, team: BaseTeam | int) -> TeamJam:
+ def get_team_jam(self, team: BaseTeam | UUID) -> TeamJam:
"""Get the TeamJam associated with the desired Team.
Args:
- team (BaseTeam | int): the Team or Team ID of the desired TeamJam.
+ team (BaseTeam | UUID): the Team or Team UUID of the desired TeamJam.
Raises:
KeyError: the specified Team is not persistent in the database.
@@ -111,14 +117,14 @@ def get_team_jam(self, team: BaseTeam | int) -> TeamJam:
TeamJam: the TeamJam associated with the desired Team.
"""
- if not isinstance(team, int):
- if team.id is None:
+ if not isinstance(team, UUID):
+ if team.uuid is None:
raise KeyError('this team does not exist')
- team = team.id
+ team = team.uuid
# Get the first TeamJam that has the specified Team ID
team_jam: TeamJam | None = next(
- (tj for tj in self.team_jams if tj.team_id == team), None
+ (tj for tj in self.team_jams if tj.team_uuid == team), None
)
if team_jam is None:
@@ -138,33 +144,33 @@ def lead_is_declared(self) -> bool:
return True
return False
- async def add_trip(self, team_id: int, timestamp: datetime, passes: int) -> None:
+ async def add_trip(self, team: BaseTeam, timestamp: datetime, passes: int) -> None:
"""Add a Jammer trip to the desired Team's TeamJam.
Args:
- team_id (int): the ID of the desired Team.
+ team (BaseTeam): the desired Team.
timestamp (datetime): the timestamp at which to add the trip.
passes (int): the number of passes the Jammer earned.
"""
...
- async def set_lead(self, team_id: int, timestamp: datetime, lead: bool) -> None:
+ async def set_lead(self, team: BaseTeam, timestamp: datetime, lead: bool) -> None:
"""Set the lead Jammer status for the desired Team.
Args:
- team_id (int): the ID of the desired Team.
+ team (BaseTeam): the desired Team.
timestamp (datetime): the timestamp at which to set lead.
lead (bool): True if the Jammer has been declared lead.
"""
...
- async def set_lost(self, team_id: int, timestamp: datetime, lost: bool) -> None:
+ async def set_lost(self, team: BaseTeam, timestamp: datetime, lost: bool) -> None:
"""Set the lead eligibility for the desired Team.
Args:
- team_id (int): the ID of the desired Team.
+ team (BaseTeam): the desired Team.
timestamp (datetime): the timestamp at which to set lead eligibility.
lost (bool): True if the Jammer has lost lead eligibility.
@@ -172,12 +178,12 @@ async def set_lost(self, team_id: int, timestamp: datetime, lost: bool) -> None:
...
async def set_star_pass(
- self, team_id: int, timestamp: datetime, star_pass: bool
+ self, team: BaseTeam, timestamp: datetime, star_pass: bool
) -> None:
"""Add a star pass to the desired Team.
Args:
- team_id (int): the ID of the desired Team.
+ team (BaseTeam): the desired Team.
timestamp (datetime): the timestamp at which to add the star pass.
star_pass (bool): True if the star has been successfully passed.
diff --git a/backend/src/game/jams/router.py b/backend/src/game/jams/router.py
index 8f27b82d..d04a3b56 100644
--- a/backend/src/game/jams/router.py
+++ b/backend/src/game/jams/router.py
@@ -3,9 +3,10 @@
from datetime import datetime
from typing import Annotated, Final
-from fastapi import APIRouter, Body, Query
+from fastapi import APIRouter, Body
+from game.teams.dependencies import GetTeam
-from .dependencies import GetJamByID, _get_jam
+from .dependencies import GetJam, _get_jam
from .schemas import JamSchema
JAMS_TAG = 'Jams'
@@ -16,39 +17,39 @@
@router.post('/addTrip', tags=[JAMS_TAG])
async def add_trip(
- jam: GetJamByID,
- team_id: Annotated[int, Query(alias='teamId')],
+ jam: GetJam,
+ team: GetTeam,
passes: Annotated[int, Body()],
) -> None:
"""Add a Trip for the specified Team of the specified Jam."""
- await jam.add_trip(team_id, datetime.now(), passes)
+ await jam.add_trip(team, datetime.now(), passes)
@router.post('/setLead', tags=[JAMS_TAG])
async def set_lead(
- jam: GetJamByID,
- team_id: Annotated[int, Query(alias='teamId')],
+ jam: GetJam,
+ team: GetTeam,
lead: Annotated[bool, Body()],
) -> None:
"""Set Lead for the specified Team of the specified Jam."""
- await jam.set_lead(team_id, datetime.now(), lead)
+ await jam.set_lead(team, datetime.now(), lead)
@router.post('/setLost', tags=[JAMS_TAG])
async def set_lost(
- jam: GetJamByID,
- team_id: Annotated[int, Query(alias='teamId')],
+ jam: GetJam,
+ team: GetTeam,
lost: Annotated[bool, Body()],
) -> None:
"""Set Lost for the specified Team of the specified Jam."""
- await jam.set_lost(team_id, datetime.now(), lost)
+ await jam.set_lost(team, datetime.now(), lost)
@router.post('/setStarPass', tags=[JAMS_TAG])
async def set_star_pass(
- jam: GetJamByID,
- team_id: Annotated[int, Query(alias='teamId')],
- star_pass: Annotated[bool, Body()],
+ jam: GetJam,
+ team: GetTeam,
+ star_pass: Annotated[bool, Body(alias='starPass')],
) -> None:
"""Set a Star Pass for the specified Team of the specified Jam."""
- await jam.set_star_pass(team_id, datetime.now(), star_pass)
+ await jam.set_star_pass(team, datetime.now(), star_pass)
diff --git a/backend/src/game/jams/schemas.py b/backend/src/game/jams/schemas.py
index 020104b3..99bbcf34 100644
--- a/backend/src/game/jams/schemas.py
+++ b/backend/src/game/jams/schemas.py
@@ -1,6 +1,7 @@
"""Pydantic Jam schemas."""
from datetime import datetime
+from uuid import UUID
from core import ServerSchema
from game.jams.models import StopReasonStr
@@ -10,8 +11,7 @@
class JamSchema(ServerSchema):
"""Represent a Jam as a JSON schema."""
- id: int
- bout_id: int
+ bout_uuid: UUID
period: int
num: int
diff --git a/backend/src/game/models.py b/backend/src/game/models.py
index 5a42d50a..dc80255e 100644
--- a/backend/src/game/models.py
+++ b/backend/src/game/models.py
@@ -27,7 +27,7 @@ class CacheableSQLModel(BaseSQLModel):
__abstract__: bool = True
- def cache_key(self) -> CacheKey:
+ async def cache_key(self) -> CacheKey:
"""Get the cache key of this model.
Return a unique cache key for this model which can be used by clients to cache
diff --git a/backend/src/game/rosters/dependencies.py b/backend/src/game/rosters/dependencies.py
deleted file mode 100644
index a59f0d92..00000000
--- a/backend/src/game/rosters/dependencies.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""The FastAPI dependencies methods for Rosters."""
-
-from typing import Annotated, TypeAlias
-
-from core import AsyncSessionDepends
-from core.exceptions import ModelLookupError
-from fastapi import Depends, Query
-from sqlalchemy import Result, select
-from sqlalchemy.exc import NoResultFound
-
-from .models import Roster
-
-
-async def _get_roster(
- session: AsyncSessionDepends,
- roster_id: Annotated[int, Query(alias='rosterId')],
-) -> Roster:
- results: Result[tuple[Roster]] = await session.execute(
- select(Roster).where(Roster.id == roster_id)
- )
-
- try:
- roster: Roster = results.scalar_one()
- except NoResultFound as e:
- raise ModelLookupError(f'Could not find Roster with ID {roster_id}') from e
-
- # TODO: handle mementos
-
- return roster
-
-
-GetRoster: TypeAlias = Annotated[Roster, Depends(_get_roster)]
diff --git a/backend/src/game/rosters/models.py b/backend/src/game/rosters/models.py
deleted file mode 100644
index c32ec8c7..00000000
--- a/backend/src/game/rosters/models.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""The Roster and Skater model and associated business logic."""
-
-from __future__ import annotations
-
-from typing import override
-
-from core import CASCADE_CHILD, CASCADE_OTHER, BaseSQLModel
-from game.models import CacheableSQLModel, CacheKey
-from sqlalchemy import ForeignKey, column
-from sqlalchemy.orm import Mapped, mapped_column, relationship
-
-
-class Skater(BaseSQLModel):
- """Represent a singular Skater in roller derby."""
-
- roster_id: Mapped[int] = mapped_column(ForeignKey('rosters.id'))
- name: Mapped[str] = mapped_column()
- pronouns: Mapped[str] = mapped_column() # TODO: Implement pronouns
- number: Mapped[str] = mapped_column()
-
- _roster: Mapped[Roster] = relationship(
- back_populates='skaters',
- cascade=CASCADE_OTHER,
- foreign_keys=[roster_id],
- lazy='selectin',
- )
-
- __tablename__: str = 'skaters'
-
- def __init__(self, name: str, number: str) -> None:
- """Initialize a Skater.
-
- Args:
- name (str): The roller derby name of this Skater. This should not be the
- legal name of this Skater.
- number (str): The number of this skater. This value is represented as a
- `str` because Skater number are allowed to include characters and because
- numbers with leading zeroes should be considered distinct from numbers
- without leading zeroes, e.g. `007 != 7`.
-
- """
- super().__init__(name=name, number=number)
-
- @override
- async def get_parents(self) -> tuple[BaseSQLModel, ...]:
- return (await self.get_roster(),)
-
- async def get_roster(self) -> Roster:
- """Get the Roster to which this Skater belongs.
-
- Returns:
- Roster: the Roster to which this Skater belongs.
-
- """
- return await self.awaitable_attrs._roster
-
-
-class Roster(CacheableSQLModel):
- """Represent a Roster of skaters."""
-
- name: Mapped[str] = mapped_column()
- league: Mapped[str] = mapped_column()
- mnemonic: Mapped[str] = mapped_column()
-
- skaters: Mapped[list[Skater]] = relationship(
- back_populates='_roster',
- cascade=CASCADE_CHILD,
- lazy='selectin',
- order_by=[column('number')],
- )
-
- __tablename__: str = 'rosters'
-
- def __init__(self, name: str, league: str, mnemonic: str = '') -> None:
- """Initialize a Roster.
-
- Args:
- name (str): the name of this Roster.
- league (str): the league to which this Roster belongs.
- mnemonic (str, optional): a three to four letter mnemonic of this Roster's
- name. When no mnemonic is provided, one will be automatically generated.
- Defaults to ''.
-
- Raises:
- ValueError: if a blank Team name is provided.
-
- """
- name = name.strip()
- if name == '':
- raise ValueError('Team name cannot be blank')
- mnemonic = mnemonic.strip()
- if mnemonic == '':
- pass # TODO: Implement team name mnemonic algorithm
- super().__init__(name=name, league=league, mnemonic=mnemonic)
-
- @override
- def cache_key(self) -> CacheKey:
- return (self.__tablename__, self.id)
-
- @override
- async def get_parents(self) -> tuple[BaseSQLModel, ...]:
- return ()
diff --git a/backend/src/game/rosters/router.py b/backend/src/game/rosters/router.py
deleted file mode 100644
index 823c4b4c..00000000
--- a/backend/src/game/rosters/router.py
+++ /dev/null
@@ -1,13 +0,0 @@
-"""FastAPI routes associated with Rosters."""
-
-from typing import Final
-
-from fastapi import APIRouter
-
-from .dependencies import _get_roster
-from .schemas import RosterSchema
-
-ROSTERS_TAG = 'Rosters'
-
-router: Final[APIRouter] = APIRouter(prefix='/roster')
-router.add_api_route('', _get_roster, response_model=RosterSchema, tags=[ROSTERS_TAG])
diff --git a/backend/src/game/rosters/schemas.py b/backend/src/game/rosters/schemas.py
deleted file mode 100644
index 73e7ab90..00000000
--- a/backend/src/game/rosters/schemas.py
+++ /dev/null
@@ -1,10 +0,0 @@
-"""Pydantic Roster schemas."""
-
-from core import ServerSchema
-
-
-class RosterSchema(ServerSchema):
- """Represent a Roster as a JSON schema."""
-
- id: int
- name: str
diff --git a/backend/src/game/rulesets/wftda_2025.py b/backend/src/game/rulesets/wftda_2025.py
index 79a36aa3..b2f4266b 100644
--- a/backend/src/game/rulesets/wftda_2025.py
+++ b/backend/src/game/rulesets/wftda_2025.py
@@ -1,5 +1,7 @@
"""Models and Business logic pertaining to the WFTDA 2025 ruleset."""
+from __future__ import annotations
+
import logging
from datetime import datetime, timedelta
from typing import ClassVar, override
@@ -7,7 +9,6 @@
from core.exceptions import GameRulesError, GameStateError
from game.bouts.models import REQUIRED_NUM_TEAMS, BaseBout
from game.jams.models import BaseJam
-from game.rosters.models import Roster
from game.team_jams.models import TeamJam
from game.teams.models import BaseTeam
from game.timeouts.models import BaseTimeout
@@ -41,11 +42,14 @@ class Bout(_WFTDAModel, BaseBout):
)
@override
- def __init__(self, home: Roster, away: Roster) -> None:
- super().__init__(RULESET_NAME, Team(home), Team(away))
+ def __init__(self, home_team_name: str, away_team_name: str) -> None:
+ super().__init__(
+ RULESET_NAME,
+ Team(home_team_name, 0),
+ Team(away_team_name, 1),
+ )
self.clock.alarm = timedelta(minutes=30)
self.jams.append(Jam(0, 0, *[TeamJam(team) for team in self.teams]))
- self.timeouts.append(Timeout(self, 0))
@override
async def begin_period(self, timestamp: datetime) -> None:
@@ -164,19 +168,14 @@ async def start_timeout(self, timestamp: datetime) -> BaseTimeout:
logging.info(f'Calling Timeout {self}')
# Instantiate and start the Timeout
- timeout: BaseTimeout | None = self.get_upcoming_timeout()
- if timeout is None:
- raise NotImplementedError() # FIXME push a new Timeout
-
+ timeout: Timeout = Timeout(self.get_active_jam(), len(self.timeouts))
timeout.clock_elapsed = self.clock.get_duration(timestamp)
timeout.start(timestamp)
+ self.timeouts.append(timeout)
if self.clock.is_running():
self.clock.stop(timestamp)
- # Push a new Timeout to allow users to prefetch it
- self.timeouts.append(Timeout(self, timeout.num + 1))
-
return timeout
@override
@@ -208,8 +207,8 @@ class Team(_WFTDAModel, BaseTeam):
"""A Team model using the WFTDA 2025 ruleset."""
@override
- def __init__(self, roster: Roster) -> None:
- super().__init__(roster)
+ def __init__(self, name: str, team_num: int) -> None:
+ super().__init__(name, team_num)
self.timeouts_remaining = Bout.ruleset.num_timeouts
self.reviews_remaining = Bout.ruleset.num_reviews
@@ -227,25 +226,25 @@ class Jam(_WFTDAModel, BaseJam):
"""A Jam model using the WFTDA 2025 ruleset."""
@override
- async def add_trip(self, team_id: int, timestamp: datetime, passes: int) -> None:
- team_jam: TeamJam = self.get_team_jam(team_id)
+ async def add_trip(self, team: BaseTeam, timestamp: datetime, passes: int) -> None:
+ team_jam: TeamJam = self.get_team_jam(team)
- logging.info(f'Adding {passes} passes to Team ID {team_id} in {self}')
+ logging.info(f'Adding {passes} passes to {team} in {self}')
is_initial: bool = len(team_jam.events) == 0
is_overtime: bool = self.period >= Bout.ruleset.num_periods
if is_initial:
- logging.info(f'This is the initial pass for Team ID {team_id} in {self}')
+ logging.info(f'This is the initial pass for {team} in {self}')
event: TripEvent = TripEvent(timestamp, passes=passes)
# Automatically set lead on the first 4-point trip
if not self.lead_is_declared() and passes == Bout.ruleset.points_per_trip:
- await self.set_lead(team_id, timestamp, True)
+ await self.set_lead(team, timestamp, True)
# Lose eligibility on initial no-pass/no-penalty
if len(team_jam.events) == 0 and passes < Bout.ruleset.points_per_trip:
- await self.set_lost(team_id, timestamp, True)
+ await self.set_lost(team, timestamp, True)
# Jammer cannot earn points on the initial pass
if is_initial and not is_overtime:
@@ -254,12 +253,10 @@ async def add_trip(self, team_id: int, timestamp: datetime, passes: int) -> None
team_jam.events.append(event)
@override
- async def set_lead(self, team_id: int, timestamp: datetime, lead: bool) -> None:
- team_jam: TeamJam = self.get_team_jam(team_id)
+ async def set_lead(self, team: BaseTeam, timestamp: datetime, lead: bool) -> None:
+ team_jam: TeamJam = self.get_team_jam(team)
- logging.info(
- f'{"Setting" if lead else "Unsetting"} lead for Team ID {team_id} in {self}'
- )
+ logging.info(f'{"Setting" if lead else "Unsetting"} lead for {team} in {self}')
if lead:
# Add a new Trip Event in which lead is declared
@@ -276,12 +273,10 @@ async def set_lead(self, team_id: int, timestamp: datetime, lead: bool) -> None:
team_jam.events.remove(event)
@override
- async def set_lost(self, team_id: int, timestamp: datetime, lost: bool) -> None:
- team_jam: TeamJam = self.get_team_jam(team_id)
+ async def set_lost(self, team: BaseTeam, timestamp: datetime, lost: bool) -> None:
+ team_jam: TeamJam = self.get_team_jam(team)
- logging.info(
- f'{"Setting" if lost else "Unsetting"} lost for Team ID {team_id} in {self}'
- )
+ logging.info(f'{"Setting" if lost else "Unsetting"} lost for {team} in {self}')
if lost:
# Add a new Trip Event in which the Jammer has lost eligibility for lead
@@ -299,13 +294,12 @@ async def set_lost(self, team_id: int, timestamp: datetime, lost: bool) -> None:
@override
async def set_star_pass(
- self, team_id: int, timestamp: datetime, star_pass: bool
+ self, team: BaseTeam, timestamp: datetime, star_pass: bool
) -> None:
- team_jam: TeamJam = self.get_team_jam(team_id)
+ team_jam: TeamJam = self.get_team_jam(team)
logging.info(
- f'{"Setting" if star_pass else "Unsetting"} star pass for Team ID '
- f'{team_id} in {self}'
+ f'{"Setting" if star_pass else "Unsetting"} star pass {team} in {self}'
)
if star_pass:
@@ -335,22 +329,21 @@ class Timeout(_WFTDAModel, BaseTimeout):
def set_type(self, is_review: bool) -> None:
logging.info(
f'Setting {self} to {"official review" if is_review else "timeout"} type '
- f'in Bout ID {self.bout_id}'
+ f'in Bout ID {self.bout_uuid}'
)
self.is_review = is_review
@override
def set_team(self, team: BaseTeam | None) -> None:
- if team is not None and team.bout_id != self.bout_id:
+ if team is not None and team.bout_uuid != self.bout_uuid:
raise GameStateError('Team and timeout are not part of the same Bout')
if team is None and self.is_review:
raise GameRulesError('Official reviews can only be called by teams')
logging.info(
- f'Setting Timeout ID {self.id} calling team to '
- f'{f"Team ID {team.id}" if team is not None else "officials"} in Bout ID '
- f'{self.bout_id}'
+ f'Setting {self} calling team to {team or "officials"} in Bout ID '
+ f'{self.bout_uuid}'
)
self.team = team
@@ -360,7 +353,7 @@ def set_team(self, team: BaseTeam | None) -> None:
def set_retained(self, retained: bool) -> None:
logging.info(
f'Setting {self} to {"" if retained else "un"}retained in Bout ID '
- f'{self.bout_id}'
+ f'{self.bout_uuid}'
)
self.retained = retained
diff --git a/backend/src/game/series/dependencies.py b/backend/src/game/series/dependencies.py
index 40f036c9..0dcccb88 100644
--- a/backend/src/game/series/dependencies.py
+++ b/backend/src/game/series/dependencies.py
@@ -1,9 +1,14 @@
"""The FastAPI dependencies methods for Series."""
-from typing import TYPE_CHECKING, Sequence
+from typing import TYPE_CHECKING, Annotated, TypeAlias
+from uuid import UUID
-from core import AsyncSessionDepends
+from core import GetAsyncSession
+from core.exceptions import ModelLookupError
+from fastapi import Depends, Query, Request
from sqlalchemy import select
+from sqlalchemy.exc import NoResultFound
+from user import GetUser
from .models import Series
@@ -12,10 +17,24 @@
from sqlalchemy.sql.selectable import Select
-async def _get_all_series(session: AsyncSessionDepends) -> Sequence[Series]:
- statement: Select[tuple[Series]] = select(Series)
+async def _get_series(
+ request: Request,
+ user: GetUser,
+ session: GetAsyncSession,
+ series_uuid: Annotated[UUID, Query(alias='seriesUuid')],
+) -> Series:
+ statement: Select[tuple[Series]] = select(Series).where(Series.uuid == series_uuid)
results: Result[tuple[Series]] = await session.execute(statement)
- # TODO: figure out how to handle series dependencies
+ try:
+ series: Series = results.scalar_one()
+ except NoResultFound as e:
+ raise ModelLookupError(f'Could not find Series ({series_uuid=})') from e
- return results.scalars().all()
+ # Optionally take a snapshot of the Bout state and return the Bout
+ if request.method != 'GET':
+ user.stage(series.get_memento())
+ return series
+
+
+GetSeries: TypeAlias = Annotated[Series, Depends(_get_series)]
diff --git a/backend/src/game/series/models.py b/backend/src/game/series/models.py
index e8dc8b18..97ed776a 100644
--- a/backend/src/game/series/models.py
+++ b/backend/src/game/series/models.py
@@ -21,7 +21,6 @@ class Series(CacheableSQLModel):
"""
- rowid: Mapped[int] = mapped_column(system=True)
name: Mapped[str] = mapped_column(default='')
bouts: Mapped[list[BaseBout]] = relationship(
@@ -32,10 +31,19 @@ class Series(CacheableSQLModel):
__tablename__: str = 'series'
+ def __init__(self, name: str = '') -> None:
+ """Initialize a Series.
+
+ Args:
+ name (str, optional): the name of the Series. Defaults to ''.
+
+ """
+ super().__init__(name=name)
+
@override
- def cache_key(self) -> CacheKey:
+ async def cache_key(self) -> CacheKey:
# Special case where updating one Series invalidates the cache for all Series
- return (self.__tablename__, self.id)
+ return (self.__tablename__, self.uuid)
@override
async def get_parents(self) -> tuple[BaseSQLModel, ...]:
diff --git a/backend/src/game/series/router.py b/backend/src/game/series/router.py
index cc0a2148..f9b62d2d 100644
--- a/backend/src/game/series/router.py
+++ b/backend/src/game/series/router.py
@@ -1,15 +1,25 @@
"""FastAPI routes associated with Series."""
-from typing import Final
+from typing import Final, Sequence
+from core import GetAsyncSession
from fastapi import APIRouter
+from sqlalchemy import Result, Select, select
-from .dependencies import _get_all_series
+from .dependencies import _get_series
+from .models import Series
from .schemas import SeriesSchema
SERIES_TAG = 'Series'
-router: Final[APIRouter] = APIRouter(prefix='/series')
-router.add_api_route(
- '', _get_all_series, response_model=list[SeriesSchema], tags=[SERIES_TAG]
-)
+router: Final[APIRouter] = APIRouter(prefix='/series', tags=[SERIES_TAG])
+router.add_api_route('', _get_series, response_model=SeriesSchema)
+
+
+@router.get('/allSeries', response_model=list[SeriesSchema])
+async def get_all_series(session: GetAsyncSession) -> Sequence[Series]:
+ """Get all the Series in the database."""
+ statement: Select[tuple[Series]] = select(Series)
+ results: Result[tuple[Series]] = await session.execute(statement)
+
+ return results.scalars().all()
diff --git a/backend/src/game/series/schemas.py b/backend/src/game/series/schemas.py
index b2371242..071cfd27 100644
--- a/backend/src/game/series/schemas.py
+++ b/backend/src/game/series/schemas.py
@@ -1,5 +1,7 @@
"""Pydantic Series schemas."""
+from uuid import UUID
+
from core import ServerSchema
from game.bouts.schemas import BoutSchema
from pydantic import Field, computed_field
@@ -8,22 +10,22 @@
class SeriesSchema(ServerSchema):
"""Represent a Series as a JSON schema."""
- id: int
+ uuid: UUID
name: str
bouts: list[BoutSchema] = Field(exclude=True)
@computed_field
@property
- def bout_ids(self) -> list[int]:
+ def bout_uuids(self) -> list[UUID]:
"""Get a list representing the IDs of this Series' Bouts."""
- return [bout.id for bout in self.bouts]
+ return [bout.uuid for bout in self.bouts]
@computed_field
@property
- def active_bout_id(self) -> int | None:
+ def active_bout_index(self) -> int | None:
"""The active Bout ID of this Series or None if there is no active Bout.
The active Bout is the first Bout in the Series which is not final.
"""
- return next((bout.id for bout in self.bouts if not bout.is_final), None)
+ return next((i for i, bout in enumerate(self.bouts) if not bout.is_final), None)
diff --git a/backend/src/game/skaters/models.py b/backend/src/game/skaters/models.py
new file mode 100644
index 00000000..014aef70
--- /dev/null
+++ b/backend/src/game/skaters/models.py
@@ -0,0 +1,64 @@
+"""The Roster and Skater model and associated business logic."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, override
+from uuid import UUID # noqa: TC003
+
+from core import CASCADE_OTHER, BaseSQLModel
+from game.models import CacheableSQLModel, CacheKey
+from sqlalchemy import Constraint, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+if TYPE_CHECKING:
+ from game.teams.models import BaseTeam
+
+
+class Skater(CacheableSQLModel):
+ """Represent a singular Skater in roller derby."""
+
+ team_uuid: Mapped[UUID] = mapped_column(ForeignKey('teams.uuid'))
+
+ num: Mapped[str] = mapped_column()
+ name: Mapped[str] = mapped_column()
+ pronouns: Mapped[str] = mapped_column() # TODO: Implement pronouns
+
+ _team: Mapped[BaseTeam] = relationship(
+ cascade=CASCADE_OTHER,
+ foreign_keys=[team_uuid],
+ )
+
+ __tablename__: str = 'skaters'
+ __table_args__: tuple[Constraint, ...] = (UniqueConstraint('team_uuid', 'num'),)
+
+ def __init__(self, name: str, number: str) -> None:
+ """Initialize a Skater.
+
+ Args:
+ name (str): The roller derby name of this Skater. This should not be the
+ legal name of this Skater.
+ number (str): The number of this skater. This value is represented as a
+ `str` because Skater number are allowed to include characters and because
+ numbers with leading zeroes should be considered distinct from numbers
+ without leading zeroes, e.g. `007 != 7`.
+
+ """
+ super().__init__(name=name, number=number)
+
+ @override
+ async def cache_key(self) -> CacheKey:
+ team: BaseTeam = await self.get_team()
+ return (self.__tablename__, team.bout_uuid, team.num, self.num)
+
+ @override
+ async def get_parents(self) -> tuple[BaseSQLModel, ...]:
+ return (await self.get_team(),)
+
+ async def get_team(self) -> BaseTeam:
+ """Get the Team to which this Skater belongs.
+
+ Returns:
+ BaseTeam: the Team to which this Skater belongs.
+
+ """
+ return await self.awaitable_attrs._team
diff --git a/backend/src/game/skaters/router.py b/backend/src/game/skaters/router.py
new file mode 100644
index 00000000..6634f21a
--- /dev/null
+++ b/backend/src/game/skaters/router.py
@@ -0,0 +1,9 @@
+"""FastAPI routes associated with Rosters."""
+
+from typing import Final
+
+from fastapi import APIRouter
+
+SKATERS_TAG = 'Skater'
+
+router: Final[APIRouter] = APIRouter(prefix='/skater', tags=[SKATERS_TAG])
diff --git a/backend/src/game/skaters/schemas.py b/backend/src/game/skaters/schemas.py
new file mode 100644
index 00000000..102ed4a1
--- /dev/null
+++ b/backend/src/game/skaters/schemas.py
@@ -0,0 +1,12 @@
+"""Pydantic Skater schemas."""
+
+from __future__ import annotations
+
+from core import ServerSchema
+
+
+class SkaterSchema(ServerSchema):
+ """Represent a Skater as a JSON schema."""
+
+ name: str
+ num: str
diff --git a/backend/src/game/team_jams/models.py b/backend/src/game/team_jams/models.py
index fa7fc618..2447d8c5 100644
--- a/backend/src/game/team_jams/models.py
+++ b/backend/src/game/team_jams/models.py
@@ -3,11 +3,12 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, override
+from uuid import UUID # noqa: TC003
from core import CASCADE_CHILD, CASCADE_OTHER, BaseSQLModel
from game.jams.models import BaseJam
from game.trip_events.models import TripEvent
-from sqlalchemy import ForeignKey, select
+from sqlalchemy import ForeignKey, column, select, table
from sqlalchemy.orm import (
Mapped,
MappedSQLExpression,
@@ -32,18 +33,21 @@ class TeamJam(BaseSQLModel):
"""
- team_id: Mapped[int | None] = mapped_column(ForeignKey('teams.id'), nullable=False)
- jam_id: Mapped[int | None] = mapped_column(ForeignKey('jams.id'), nullable=False)
+ _jam_uuid: Mapped[UUID | None] = mapped_column(
+ ForeignKey('jams.uuid'), nullable=False
+ )
+ team_uuid: Mapped[UUID] = mapped_column(ForeignKey('teams.uuid'))
_team: Mapped[BaseTeam | None] = relationship(
back_populates='team_jams',
cascade=CASCADE_OTHER,
- foreign_keys=[team_id],
+ foreign_keys=[team_uuid],
)
- _jam: Mapped[BaseJam] = relationship(
+ jam: Mapped[BaseJam] = relationship(
back_populates='team_jams',
cascade=CASCADE_OTHER,
- foreign_keys=[jam_id],
+ foreign_keys=[_jam_uuid],
+ lazy='selectin',
)
events: Mapped[list[TripEvent]] = relationship(
back_populates='_team_jam',
@@ -53,10 +57,15 @@ class TeamJam(BaseSQLModel):
)
jam_num: MappedSQLExpression[int] = column_property(
- select(BaseJam.num).where(BaseJam.id == jam_id).scalar_subquery()
+ select(BaseJam.num).where(BaseJam.uuid == _jam_uuid).scalar_subquery()
)
period_num: MappedSQLExpression[int] = column_property(
- select(BaseJam.period).where(BaseJam.id == jam_id).scalar_subquery()
+ select(BaseJam.period).where(BaseJam.uuid == _jam_uuid).scalar_subquery()
+ )
+ team_num: MappedSQLExpression[int] = column_property(
+ select(table('teams', column('num')))
+ .where(column('uuid') == team_uuid)
+ .scalar_subquery()
)
__tablename__: str = 'team_jams'
@@ -79,7 +88,7 @@ def __init__(self, team: BaseTeam) -> None:
@override
async def get_parents(self) -> tuple[BaseSQLModel, ...]:
- return (await self.get_team(), await self.get_jam())
+ return (await self.get_team(), self.jam)
async def get_team(self) -> BaseTeam:
"""Get the Team that owns this TeamJam.
@@ -89,12 +98,3 @@ async def get_team(self) -> BaseTeam:
"""
return await self.awaitable_attrs._team
-
- async def get_jam(self) -> BaseJam:
- """Get the Jam that owns this TeamJam.
-
- Returns:
- BaseJam: the Jam that owns this TeamJam.
-
- """
- return await self.awaitable_attrs._jam
diff --git a/backend/src/game/team_jams/schemas.py b/backend/src/game/team_jams/schemas.py
index 81a6d209..947a63e0 100644
--- a/backend/src/game/team_jams/schemas.py
+++ b/backend/src/game/team_jams/schemas.py
@@ -7,6 +7,5 @@
class TeamJamSchema(ServerSchema):
"""Represent a TeamJam as a JSON schema."""
- jam_id: int
- team_id: int
+ team_num: int
events: list[TripEventSchema]
diff --git a/backend/src/game/teams/dependencies.py b/backend/src/game/teams/dependencies.py
index 3c49563d..731b5284 100644
--- a/backend/src/game/teams/dependencies.py
+++ b/backend/src/game/teams/dependencies.py
@@ -1,51 +1,21 @@
"""The FastAPI dependencies methods for Teams."""
-from typing import TYPE_CHECKING, Annotated, TypeAlias
+from typing import Annotated, TypeAlias
-from core import AsyncSessionDepends
-from fastapi import Body, Depends, Query, Request
-from sqlalchemy import select
-from user import GetUser
+from core.exceptions import ModelLookupError
+from fastapi import Depends, Query
+from game.bouts.dependencies import GetBout
from .models import BaseTeam
-if TYPE_CHECKING:
- from sqlalchemy import Result, Select
+async def _get_team(
+ bout: GetBout, team_num: Annotated[int, Query(alias='teamNum')]
+) -> BaseTeam:
+ try:
+ return bout.teams[team_num]
+ except KeyError as e:
+ raise ModelLookupError(f'Could not find Team ({bout=} {team_num=})') from e
-async def _query_team_or_none(
- request: Request,
- user: GetUser,
- session: AsyncSessionDepends,
- team_id: Annotated[int | None, Query(alias='teamId')] = None,
-) -> BaseTeam | None:
- if team_id is None:
- return None
- # Query the database for the desired Bout
- statement: Select[tuple[BaseTeam]] = select(BaseTeam).where(BaseTeam.id == team_id)
- results: Result[tuple[BaseTeam]] = await session.execute(statement)
- team: BaseTeam = results.scalar_one()
-
- return team
-
-
-async def _get_team_or_none(
- session: AsyncSessionDepends,
- team_id: Annotated[int | None, Body(alias='teamId')] = None,
-) -> BaseTeam | None:
- if team_id is None:
- return None
-
- # Query the database for the desired Bout
- statement: Select[tuple[BaseTeam]] = select(BaseTeam).where(BaseTeam.id == team_id)
- results: Result[tuple[BaseTeam]] = await session.execute(statement)
- team: BaseTeam = results.scalar_one()
-
- return team
-
-
-OptionalTeamDepends: TypeAlias = Annotated[
- BaseTeam | None, Depends(_query_team_or_none)
-]
-GetTeamOrNoneByID: TypeAlias = Annotated[BaseTeam | None, Depends(_get_team_or_none)]
+GetTeam: TypeAlias = Annotated[BaseTeam, Depends(_get_team)]
diff --git a/backend/src/game/teams/models.py b/backend/src/game/teams/models.py
index 63c5edda..69899de9 100644
--- a/backend/src/game/teams/models.py
+++ b/backend/src/game/teams/models.py
@@ -2,14 +2,16 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Any, Final, override
+from typing import Any, Final, override
+from uuid import UUID # noqa: TC003
from core import CASCADE_CHILD, CASCADE_OTHER, BaseSQLModel
from game.bouts.models import BaseBout
from game.jams.models import BaseJam
+from game.skaters.models import Skater
from game.team_jams.models import TeamJam
from game.timeouts.models import BaseTimeout
-from sqlalchemy import ForeignKey, select
+from sqlalchemy import Constraint, ForeignKey, UniqueConstraint, select
from sqlalchemy.orm import (
Mapped,
MappedSQLExpression,
@@ -19,10 +21,6 @@
)
from sqlalchemy.sql import desc
-if TYPE_CHECKING:
- from game.rosters.models import Roster
-
-
REQUIRED_NUM_TEAMS: Final[int] = 2
@@ -34,22 +32,30 @@ class BaseTeam(BaseSQLModel):
data like a team's score offset.
"""
- roster_id: Mapped[int] = mapped_column(ForeignKey('rosters.id'))
- bout_id: Mapped[int | None] = mapped_column(ForeignKey('bouts.id'), nullable=False)
+ bout_uuid: Mapped[UUID | None] = mapped_column(
+ ForeignKey('bouts.uuid'), nullable=False
+ )
+ num: Mapped[int] = mapped_column()
+
+ name: Mapped[str] = mapped_column()
+ league: Mapped[str] = mapped_column(default='')
+ mnemonic: Mapped[str] = mapped_column(default='')
# TODO: Implement Team colors
score_offset: Mapped[int] = mapped_column(default=0)
timeouts_remaining: Mapped[int] = mapped_column()
reviews_remaining: Mapped[int] = mapped_column()
- _roster: Mapped[Roster] = relationship(
- cascade=CASCADE_OTHER,
- foreign_keys=[roster_id],
- )
_bout: Mapped[BaseBout | None] = relationship(
back_populates='teams',
cascade=CASCADE_OTHER,
- foreign_keys=[bout_id],
+ foreign_keys=[bout_uuid],
+ )
+ skaters: Mapped[list[Skater]] = relationship(
+ back_populates='_team',
+ cascade=CASCADE_CHILD,
+ lazy='selectin',
+ order_by=[Skater.num],
)
team_jams: Mapped[list[TeamJam]] = relationship(
back_populates='_team',
@@ -59,24 +65,27 @@ class BaseTeam(BaseSQLModel):
)
timeouts: Mapped[list[BaseTimeout]] = relationship(
back_populates='team',
- cascade=CASCADE_CHILD,
+ cascade='all', # Exclude `delete-orphan` as Timeouts can be called by officials
lazy='selectin',
order_by=[BaseTimeout.num],
)
# Used to calculate the current Jam score
- _active_jam_id: MappedSQLExpression[int | None] = column_property(
- select(BaseJam.id)
+ # SQLAlchemy does not understand `is` keyword in WHERE clauses, thus ignore E711.
+ _active_jam_uuid: MappedSQLExpression[UUID] = column_property(
+ select(BaseJam.uuid)
.where(BaseJam.start_timestamp != None) # noqa: E711
.order_by(desc(BaseJam.period), desc(BaseJam.num))
.scalar_subquery()
)
-
_ruleset: MappedSQLExpression[str] = column_property(
- select(BaseBout.ruleset_name).where(BaseBout.id == bout_id).scalar_subquery()
+ select(BaseBout.ruleset_name)
+ .where(BaseBout.uuid == bout_uuid)
+ .scalar_subquery()
)
__tablename__: str = 'teams'
+ __table_args__: tuple[Constraint, ...] = (UniqueConstraint('bout_uuid', 'num'),)
__mapper_args__: dict[str, Any] = {
'polymorphic_abstract': True,
'polymorphic_on': _ruleset,
@@ -97,29 +106,21 @@ def get_team_jam_score(cls, team_jam: TeamJam) -> int:
"""
raise NotImplementedError('BaseTeam.get_team_jam_score() must be overridden')
- def __init__(self, roster: Roster) -> None:
+ def __init__(self, name: str, team_num: int) -> None:
"""Initialize a Team.
Args:
- bout (BaseBout): the owning Bout of the Team.
- roster (Roster): the Roster that this Team will use.
+ name (str): the name of this Team.
+ team_num (int): the Team number in the Bout. Each Team in a Bout must have
+ a unique team number. A 0 represents the home Team of a Bout.
"""
- super().__init__(_roster=roster)
+ super().__init__(name=name, num=team_num)
@override
async def get_parents(self) -> tuple[BaseSQLModel, ...]:
return (await self.get_bout(),)
- async def get_roster(self) -> Roster:
- """Get the Roster to which this Team belongs.
-
- Returns:
- Roster: the Roster to which this Team belongs.
-
- """
- return await self.awaitable_attrs._roster
-
async def get_bout(self) -> BaseBout:
"""Get the Bout to which this Team belongs.
@@ -155,7 +156,7 @@ def jam_score(self) -> int:
"""
active_team_jam: TeamJam | None = next(
- (tj for tj in self.team_jams if tj.jam_id == self._active_jam_id), None
+ (tj for tj in self.team_jams if tj._jam_uuid == self._active_jam_uuid), None
)
if active_team_jam is None:
return 0
diff --git a/backend/src/game/teams/schemas.py b/backend/src/game/teams/schemas.py
index 5a08258e..f6e7c386 100644
--- a/backend/src/game/teams/schemas.py
+++ b/backend/src/game/teams/schemas.py
@@ -3,16 +3,19 @@
from __future__ import annotations
from core import ServerSchema
+from game.skaters.schemas import SkaterSchema # noqa: TC002
class TeamSchema(ServerSchema):
"""Represent a Team as a JSON schema."""
- id: int
- roster_id: int
- bout_id: int
+ name: str
+ league: str
+ mnemonic: str
+ num: int
bout_score: int
jam_score: int
timeouts_remaining: int
reviews_remaining: int
score_offset: int
+ skaters: list[SkaterSchema]
diff --git a/backend/src/game/timeouts/dependencies.py b/backend/src/game/timeouts/dependencies.py
index c3cd80d4..03113b83 100644
--- a/backend/src/game/timeouts/dependencies.py
+++ b/backend/src/game/timeouts/dependencies.py
@@ -1,35 +1,25 @@
"""The FastAPI dependencies methods for Timeouts."""
-from typing import TYPE_CHECKING, Annotated, TypeAlias
+from typing import Annotated, TypeAlias
-from core import AsyncSessionDepends
from core.exceptions import ModelLookupError
from fastapi import Depends, Query, Request
-from sqlalchemy import select
-from sqlalchemy.exc import NoResultFound
+from game.bouts.dependencies import GetBout
from user import GetUser
from .models import BaseTimeout
-if TYPE_CHECKING:
- from sqlalchemy import Result, Select
-
async def _get_timeout(
request: Request,
user: GetUser,
- session: AsyncSessionDepends,
- timeout_id: Annotated[int, Query(alias='timeoutId')],
+ bout: GetBout,
+ num: Annotated[int, Query()],
) -> BaseTimeout:
- statement: Select[tuple[BaseTimeout]] = select(BaseTimeout).where(
- BaseTimeout.id == timeout_id
- )
- results: Result[tuple[BaseTimeout]] = await session.execute(statement)
-
try:
- timeout: BaseTimeout = results.scalar_one()
- except NoResultFound as e:
- raise ModelLookupError(f'Could not find Timeout with ID {timeout_id}') from e
+ timeout: BaseTimeout = bout.timeouts[num]
+ except IndexError as e:
+ raise ModelLookupError(f'Could not find Timeout ({bout=} {num=})') from e
# Optionally take a snapshot of the Timeout state
if request.method != 'GET':
@@ -37,4 +27,4 @@ async def _get_timeout(
return timeout
-GetTimeoutByID: TypeAlias = Annotated[BaseTimeout, Depends(_get_timeout)]
+GetTimeout: TypeAlias = Annotated[BaseTimeout, Depends(_get_timeout)]
diff --git a/backend/src/game/timeouts/models.py b/backend/src/game/timeouts/models.py
index e61be35c..66593a14 100644
--- a/backend/src/game/timeouts/models.py
+++ b/backend/src/game/timeouts/models.py
@@ -4,6 +4,7 @@
from datetime import timedelta # noqa: TC003
from typing import TYPE_CHECKING, Any, override
+from uuid import UUID # noqa: TC003
from core import CASCADE_OTHER, BaseSQLModel
from game.bouts.models import BaseBout
@@ -29,11 +30,14 @@ class BaseTimeout(AbstractOneShotModel, CacheableSQLModel):
Timeouts models can represent either a timeout or an official review.
"""
- bout_id: Mapped[int] = mapped_column(ForeignKey('bouts.id'))
- team_id: Mapped[int | None] = mapped_column(ForeignKey('teams.id'))
- jam_id: Mapped[int | None] = mapped_column(ForeignKey('jams.id'))
+ _jam_uuid: Mapped[UUID] = mapped_column(ForeignKey('jams.uuid'))
+ _team_uuid: Mapped[UUID | None] = mapped_column(ForeignKey('teams.uuid'))
+ bout_uuid: Mapped[UUID | None] = mapped_column(
+ ForeignKey('bouts.uuid'), nullable=False
+ )
num: Mapped[int] = mapped_column()
+
clock_elapsed: Mapped[timedelta | None] = mapped_column(default=None)
team_is_officials: Mapped[bool] = mapped_column(default=False)
is_review: Mapped[bool] = mapped_column(default=False)
@@ -41,33 +45,35 @@ class BaseTimeout(AbstractOneShotModel, CacheableSQLModel):
result: Mapped[str] = mapped_column(default='')
retained: Mapped[bool] = mapped_column(default=False)
- _bout: Mapped[BaseBout] = relationship(
+ _bout: Mapped[BaseBout | None] = relationship(
back_populates='timeouts',
cascade=CASCADE_OTHER,
- foreign_keys=[bout_id],
+ foreign_keys=[bout_uuid],
+ )
+ jam: Mapped[BaseJam] = relationship(
+ cascade=CASCADE_OTHER,
+ foreign_keys=[_jam_uuid],
+ lazy='selectin', # Eagerly fetch despite being a parent relationship
)
-
- # The next two relationships are special cases - they can be eagerly loaded
team: Mapped[BaseTeam | None] = relationship(
back_populates='timeouts',
cascade=CASCADE_OTHER,
- foreign_keys=[team_id],
- lazy='selectin',
- )
- jam: Mapped[BaseJam | None] = relationship(
- cascade=CASCADE_OTHER, foreign_keys=[jam_id], lazy='selectin'
+ foreign_keys=[_team_uuid],
+ lazy='selectin', # Eagerly fetch despite being a parent relationship
)
- ruleset: MappedSQLExpression[str] = column_property(
- select(BaseBout.ruleset_name).where(BaseBout.id == bout_id).scalar_subquery()
+ _ruleset: MappedSQLExpression[str] = column_property(
+ select(BaseBout.ruleset_name)
+ .where(BaseBout.uuid == bout_uuid)
+ .scalar_subquery()
)
__tablename__: str = 'timeouts'
__mapper_args__: dict[str, Any] = {
'polymorphic_abstract': True,
- 'polymorphic_on': ruleset,
+ 'polymorphic_on': _ruleset,
}
- __table_args__: tuple[Constraint, ...] = (UniqueConstraint('bout_id', 'num'),)
+ __table_args__: tuple[Constraint, ...] = (UniqueConstraint('bout_uuid', 'num'),)
def __str__(self) -> str:
"""Return a str representation of this Timeout.
@@ -76,24 +82,24 @@ def __str__(self) -> str:
str: a str representation of this Timeout.
"""
- return f'[Bout ID: {self.bout_id}, T{self.num}]'
+ return f'[Bout ID: {self.bout_uuid}, T{self.num}]'
- def __init__(self, bout: BaseBout, num: int) -> None:
+ def __init__(self, jam: BaseJam, num: int) -> None:
"""Initialize a Timeout.
The default state for a Timeout is a regular timeout (not an official review)
with an unknown caller (called by neither a Team nor by the officials).
Args:
- bout (BaseBout): the Bout that owns this Timeout.
+ jam (BaseJam): the Jam preceding this Timeout
num (int): the unique Timeout number associated with this Bout.
"""
- super().__init__(_bout=bout, bout_id=bout.id, num=num)
+ super().__init__(jam=jam, num=num)
@override
- def cache_key(self) -> CacheKey:
- return (self.__tablename__, self.bout_id, self.id)
+ async def cache_key(self) -> CacheKey:
+ return (self.__tablename__, self.bout_uuid, self.num)
@override
async def get_parents(self) -> tuple[BaseSQLModel, ...]:
diff --git a/backend/src/game/timeouts/router.py b/backend/src/game/timeouts/router.py
index 71404d9b..aa7f7831 100644
--- a/backend/src/game/timeouts/router.py
+++ b/backend/src/game/timeouts/router.py
@@ -1,13 +1,15 @@
"""FastAPI routes associated with Timeouts."""
-from typing import Annotated, Final, Literal
+from typing import TYPE_CHECKING, Annotated, Final, Literal
from fastapi import APIRouter, Body
-from game.teams.dependencies import GetTeamOrNoneByID
-from .dependencies import GetTimeoutByID, _get_timeout
+from .dependencies import GetTimeout, _get_timeout
from .schemas import TimeoutSchema
+if TYPE_CHECKING:
+ from game.bouts.models import BaseBout
+
TIMEOUTS_TAG = 'Timeouts'
router: Final[APIRouter] = APIRouter(prefix='/timeout')
@@ -18,7 +20,7 @@
@router.post('/type', tags=[TIMEOUTS_TAG])
async def set_type(
- timeout: GetTimeoutByID,
+ timeout: GetTimeout,
is_review: Annotated[Literal['timeout', 'review'], Body()],
) -> None:
"""Set the type of the specified Timeout."""
@@ -26,27 +28,28 @@ async def set_type(
@router.post('/team', tags=[TIMEOUTS_TAG])
-async def set_team(timeout: GetTimeoutByID, team: GetTeamOrNoneByID) -> None:
+async def set_team(
+ timeout: GetTimeout, team_num: Annotated[int | None, Body()] = None
+) -> None:
"""Set the calling Team of the specified Timeout."""
- timeout.set_team(team)
+ bout: BaseBout = await timeout.get_bout()
+ timeout.set_team(bout.teams[team_num] if team_num is not None else None)
pass
@router.post('/retained', tags=[TIMEOUTS_TAG])
-async def set_retained(
- timeout: GetTimeoutByID, retained: Annotated[bool, Body()]
-) -> None:
+async def set_retained(timeout: GetTimeout, retained: Annotated[bool, Body()]) -> None:
"""Set whether or not the Timeout is retained."""
timeout.set_retained(retained)
@router.put('/details', tags=[TIMEOUTS_TAG])
-async def set_details(timeout: GetTimeoutByID, details: Annotated[str, Body()]) -> None:
+async def set_details(timeout: GetTimeout, details: Annotated[str, Body()]) -> None:
"""Add details about the specified Timeout."""
timeout.details = details
@router.put('/result', tags=[TIMEOUTS_TAG])
-async def set_result(timeout: GetTimeoutByID, result: Annotated[str, Body()]) -> None:
+async def set_result(timeout: GetTimeout, result: Annotated[str, Body()]) -> None:
"""Add results about the specified Timeout."""
timeout.result = result
diff --git a/backend/src/game/timeouts/schemas.py b/backend/src/game/timeouts/schemas.py
index 490464a6..f59807bf 100644
--- a/backend/src/game/timeouts/schemas.py
+++ b/backend/src/game/timeouts/schemas.py
@@ -2,19 +2,22 @@
from datetime import datetime, timedelta # noqa: TC003
from typing import Annotated
+from uuid import UUID
from core import ServerSchema, timedelta_serializer
+from game.jams.schemas import JamSchema
+from game.teams.schemas import TeamSchema
+from pydantic import Field, computed_field
class TimeoutSchema(ServerSchema):
"""Represent a Timeout as a JSON schema."""
- id: int
- bout_id: int
+ bout_uuid: UUID
num: int
- team_id: int | None
- jam_id: int | None
+ jam: JamSchema = Field(exclude=True)
+ team: TeamSchema | None = Field(exclude=True)
start_timestamp: datetime | None
stop_timestamp: datetime | None
@@ -25,3 +28,45 @@ class TimeoutSchema(ServerSchema):
details: str
result: str
retained: bool
+
+ @computed_field
+ @property
+ def period_num(self) -> int:
+ """Get the Period number of the Jam preceding this Timeout.
+
+ Timeouts cannot be uniquely identified by their Period and Jam number because
+ multiple Timeouts may be called after a single Jam.
+
+ Returns:
+ int: the Period number of the Jam preceding this Timeout.
+
+ """
+ return self.jam.period
+
+ @computed_field
+ @property
+ def jam_num(self) -> int | None:
+ """Get the Jam number of the Jam preceding this Timeout.
+
+ Timeouts cannot be uniquely identified by their Period and Jam number because
+ multiple Timeouts may be called after a single Jam.
+
+ Returns:
+ int: the Jam number of the Jam preceding this Timeout.
+
+ """
+ return self.jam.num
+
+ @computed_field
+ @property
+ def team_num(self) -> int | None:
+ """Get the Team number of the Team that called this Timeout, if any.
+
+ Returns:
+ int | None: the Team number of the calling Team or None if not yet
+ determined.
+
+ """
+ if self.team is None:
+ return None
+ return self.team.num
diff --git a/backend/src/game/trip_events/models.py b/backend/src/game/trip_events/models.py
index 4353d014..c15f3d1d 100644
--- a/backend/src/game/trip_events/models.py
+++ b/backend/src/game/trip_events/models.py
@@ -4,6 +4,7 @@
from datetime import datetime # noqa: TC003
from typing import TYPE_CHECKING, override
+from uuid import UUID # noqa: TC003
from core import CASCADE_OTHER, BaseSQLModel
from sqlalchemy import CheckConstraint, Constraint, ForeignKey
@@ -26,8 +27,8 @@ class TripEvent(BaseSQLModel):
eligibility.
"""
- team_jam_id: Mapped[int | None] = mapped_column(
- ForeignKey('team_jams.id'), nullable=False
+ team_jam_uuid: Mapped[UUID | None] = mapped_column(
+ ForeignKey('team_jams.uuid'), nullable=False
)
timestamp: Mapped[datetime] = mapped_column()
@@ -37,7 +38,9 @@ class TripEvent(BaseSQLModel):
star_pass: Mapped[bool] = mapped_column(default=False)
_team_jam: Mapped[TeamJam | None] = relationship(
- back_populates='events', cascade=CASCADE_OTHER, foreign_keys=[team_jam_id]
+ back_populates='events',
+ cascade=CASCADE_OTHER,
+ foreign_keys=[team_jam_uuid],
)
__tablename__: str = 'trip_events'
diff --git a/backend/src/game/trip_events/schemas.py b/backend/src/game/trip_events/schemas.py
index fbeb5be4..ebc3f8c3 100644
--- a/backend/src/game/trip_events/schemas.py
+++ b/backend/src/game/trip_events/schemas.py
@@ -8,7 +8,6 @@
class TripEventSchema(ServerSchema):
"""Represent a TripEvent as a JSON schema."""
- id: int
timestamp: datetime
lead: bool
lost: bool
diff --git a/backend/src/game/utils.py b/backend/src/game/utils.py
index f6dcd7a7..aeff3207 100644
--- a/backend/src/game/utils.py
+++ b/backend/src/game/utils.py
@@ -33,7 +33,7 @@ async def restore(self) -> Memento:
# Query and detach the current state of the database object
table: type[CacheableSQLModel] = self._detached_state_to_restore.__class__
statement: Select[tuple[CacheableSQLModel]] = select(table).where(
- table.id == self._detached_state_to_restore.id
+ table.uuid == self._detached_state_to_restore.uuid
)
results: Result[tuple[CacheableSQLModel]] = await session.execute(statement)
current_state: CacheableSQLModel = results.scalar_one()
diff --git a/backend/src/main.py b/backend/src/main.py
index de3afd2e..e988e6c5 100644
--- a/backend/src/main.py
+++ b/backend/src/main.py
@@ -14,7 +14,7 @@
from core import APIResponseClass, DatabaseEngine, EngineFactory
from fastapi import FastAPI
from fastapi.concurrency import asynccontextmanager
-from game import Roster, Series, wftda_2025
+from game import Series, wftda_2025
from semver import VersionInfo
from sqlalchemy import Result, Select, select
from update import GithubReleaseSchema
@@ -80,16 +80,12 @@ async def lifespan(app: FastAPI):
results: Result[tuple[wftda_2025.Bout]] = await session.execute(statement)
if results.scalar_one_or_none() is None:
logging.info('Instantiating the initial Bout model')
+ bout: wftda_2025.Bout = wftda_2025.Bout('Home', 'Away')
series: Series = Series()
- series.bouts.append(
- wftda_2025.Bout(
- Roster('Home', 'Default League'),
- Roster('Away', 'Default League'),
- )
- )
+ series.bouts.append(bout)
session.add(series)
await session.commit()
- logging.debug('The Bout model was inserted into the database')
+ logging.debug(f'{bout} was inserted into the database')
else:
logging.debug('Model data was found in the database')
diff --git a/backend/src/with_gui.py b/backend/src/with_gui.py
index 74d0b2c5..0f430d94 100644
--- a/backend/src/with_gui.py
+++ b/backend/src/with_gui.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python3
"""The injection point of the program."""
from __future__ import annotations
diff --git a/backend/src/ws/service.py b/backend/src/ws/service.py
index be63abab..4c70d990 100644
--- a/backend/src/ws/service.py
+++ b/backend/src/ws/service.py
@@ -112,9 +112,9 @@ async def invalidate_queries(models: Iterable[BaseSQLModel]) -> None:
if isinstance(parent, CacheableSQLModel)
}
cache_keys: list[CacheKey] = [
- cacheable.cache_key()
+ await cacheable.cache_key()
for cacheable in cacheables
- if cacheable.id is not None
+ if cacheable.uuid is not None
]
if len(cache_keys) == 0:
return
diff --git a/frontend/app/main.tsx b/frontend/app/main.tsx
index fe61196a..f6826c65 100644
--- a/frontend/app/main.tsx
+++ b/frontend/app/main.tsx
@@ -1,29 +1,26 @@
-import BoutControlButtons from "@/components/bout-control-buttons";
-import BoutStateView from "@/components/bout-state-view";
-import TeamJamView from "@/components/team-jam-view";
+import BoutPicker from "@/components/bout-picker";
+import BoutControlButtons from "@/features/bout-control/bout-control-buttons";
+import BoutStateView from "@/features/bout-state-view/bout-state-view";
+import TeamJamView from "@/features/team-jam-vew/team-jam-view";
import TeamView from "@/features/team-view/team-view";
-import { useSuspenseBout } from "@/hooks/use-bout";
+import { useSuspenseAllBouts, useSuspenseBout } from "@/hooks/use-bout";
import { useJam, useSuspenseJam } from "@/hooks/use-jam";
-import { useSuspenseRuleset } from "@/hooks/use-ruleset";
-import { useSuspenseSeries } from "@/hooks/use-series";
import { usePrefetchServerTime } from "@/hooks/use-server-time";
-import { useSuspenseTimeout, useTimeout } from "@/hooks/use-timeout";
import queryClient from "@/lib/cache";
import { Team } from "@/lib/game/bouts";
import { redo, undo } from "@/lib/history";
-import { BoutContext, JamContext, RulesetContext } from "@/utils/contexts";
+import { BoutContext } from "@/utils/contexts";
import {
AppShell,
Burger,
- Container,
- Grid,
MantineProvider,
+ SimpleGrid,
Stack,
} from "@mantine/core";
import "@mantine/core/styles.css";
import { useDisclosure } from "@mantine/hooks";
import { QueryClientProvider } from "@tanstack/react-query";
-import { StrictMode, Suspense } from "react";
+import { StrictMode, Suspense, useState } from "react";
import { createRoot } from "react-dom/client";
import "./global.css";
@@ -43,6 +40,10 @@ createRoot(root).render();
export default function App() {
const [opened, { toggle }] = useDisclosure();
+
+ const { data: bouts } = useSuspenseAllBouts();
+ const [boutUuid, setBoutUuid] = useState(bouts[bouts.length - 1].uuid);
+
return (
@@ -65,11 +66,14 @@ export default function App() {
/>
- {/* TODO: Navbar */}
+
+ setBoutUuid(uuid)} />
+ {/* TODO: Navbar */}
+
-
+
@@ -79,59 +83,34 @@ export default function App() {
);
}
-function Main() {
+function Main({ boutUuid }: { boutUuid: string }) {
usePrefetchServerTime();
- const { data: series } = useSuspenseSeries(0);
- const { data: bout } = useSuspenseBout(series);
+ const { data: bout } = useSuspenseBout(boutUuid);
// Eagerly query the latest Jam and Timeout to avoid suspending
- void useJam(bout, ...bout.getLatestJamIndex());
- void useTimeout(bout, bout.getLatestTimeoutIndex());
-
- const { data: ruleset } = useSuspenseRuleset(bout);
+ void useJam(bout, ...bout.getLatestJamNum());
// Fetch Jam data
- const jamIndex = bout.getActiveOrLatestJamIndex();
- const [periodNum, jamNum] = jamIndex;
+ const [periodNum, jamNum] = bout.getActiveOrLatestJamNum();
const { data: jam } = useSuspenseJam(bout, periodNum, jamNum);
- // Fetch Timeout data
- const timeoutIndex = bout.getActiveOrLatestTimeoutIndex();
- const { data: timeout } = useSuspenseTimeout(bout, timeoutIndex);
-
return (
-
-
-
-
-
-
- {bout.teams.map((team: Team, i: number) => (
-
-
-
-
-
-
- ))}
-
-
-
-
-
-
+
+
+
+ {bout.teams.map((team: Team, i: number) => (
+
+ ))}
+
+
+
+ {bout.teams.map((team: Team, i: number) => (
+
+ ))}
+
+
);
}
diff --git a/frontend/app/scoreboard.tsx b/frontend/app/scoreboard.tsx
index c8a679e5..acfe0e83 100644
--- a/frontend/app/scoreboard.tsx
+++ b/frontend/app/scoreboard.tsx
@@ -1,13 +1,12 @@
-import BoutStateView from "@/components/bout-state-view";
+import BoutStateView from "@/features/bout-state-view/bout-state-view";
import TeamView from "@/features/team-view/team-view";
import { useSuspenseBout } from "@/hooks/use-bout";
-import { useJam, useSuspenseJam } from "@/hooks/use-jam";
-import { useSuspenseRuleset } from "@/hooks/use-ruleset";
-import { useSuspenseSeries } from "@/hooks/use-series";
+import { useJam } from "@/hooks/use-jam";
+import { useSuspenseAllSeries } from "@/hooks/use-series";
import { usePrefetchServerTime } from "@/hooks/use-server-time";
-import { useSuspenseTimeout, useTimeout } from "@/hooks/use-timeout";
import queryClient from "@/lib/cache";
import { Team } from "@/lib/game/bouts";
+import { Series } from "@/lib/game/series";
import FitScreen from "@fit-screen/react";
import { Center, Grid, MantineProvider, Stack } from "@mantine/core";
import "@mantine/core/styles.css";
@@ -38,46 +37,28 @@ export default function App() {
function Scoreboard() {
usePrefetchServerTime();
- const { data: series } = useSuspenseSeries(0);
- const { data: bout } = useSuspenseBout(series);
+ const { data: allSeries } = useSuspenseAllSeries();
- // Eagerly query the latest Jam and Timeout to avoid suspending
- void useJam(bout, ...bout.getLatestJamIndex());
- void useTimeout(bout, bout.getLatestTimeoutIndex());
-
- const { data: ruleset } = useSuspenseRuleset(bout);
-
- // Fetch Jam data
- const jamIndex = bout.getActiveOrLatestJamIndex();
- const [periodNum, jamNum] = jamIndex;
- const { data: jam } = useSuspenseJam(bout, periodNum, jamNum);
+ const series: Series = allSeries[0];
+ const { data: bout } = useSuspenseBout(
+ series.boutUuids[series.activeBoutIndex ?? series.boutUuids.length - 1],
+ );
- // Fetch Timeout data
- const timeoutIndex = bout.getActiveOrLatestTimeoutIndex();
- const { data: timeout } = useSuspenseTimeout(bout, timeoutIndex);
+ // Eagerly query the latest Jam and Timeout to avoid suspending
+ void useJam(bout, ...bout.getLatestJamNum());
return (
{bout.teams.map((team: Team, i: number) => (
-
+
))}
{/* TODO: Lead Jam Status */}
-
+
);
diff --git a/frontend/components/bout-picker.tsx b/frontend/components/bout-picker.tsx
new file mode 100644
index 00000000..562ab5b3
--- /dev/null
+++ b/frontend/components/bout-picker.tsx
@@ -0,0 +1,34 @@
+import { useSuspenseAllBouts } from "@/hooks/use-bout";
+import { Bout } from "@/lib/game/bouts";
+import { Select } from "@mantine/core";
+import { useEffect, useState } from "react";
+
+interface BoutPickerProps {
+ onChange: (boutUUid: string) => void;
+}
+
+export default function BoutPicker({ onChange }: BoutPickerProps) {
+ const { data: bouts } = useSuspenseAllBouts();
+
+ const selectableData = bouts.map((bout: Bout) => ({
+ value: bout.uuid,
+ label: `${bout.teams[0].name} vs. ${bout.teams[1].name}`,
+ }));
+
+ const [selectedBoutUuid, setSelectedBoutUuid] = useState(
+ selectableData[selectableData.length - 1].value,
+ );
+
+ useEffect(() => {
+ onChange(selectedBoutUuid);
+ }, [selectedBoutUuid, onChange]);
+
+ return (
+