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 ( +