Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow changing inputs after creating stage item #962

Merged
merged 15 commits into from
Oct 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""add unique constraints to stage_item_inputs

Revision ID: c97976608633
Revises: 8d7ab856d95f
Create Date: 2024-10-26 15:29:26.585658

"""

from alembic import op

# revision identifiers, used by Alembic.
revision: str | None = "c97976608633"
down_revision: str | None = "8d7ab856d95f"
branch_labels: str | None = None
depends_on: str | None = None


def upgrade() -> None:
op.create_unique_constraint(
"stage_item_inputs_stage_item_id_team_id_key",
"stage_item_inputs",
["stage_item_id", "team_id"],
)
op.create_unique_constraint(
"stage_item_inputs_stage_item_id_winner_from_stage_item_id_w_key",
"stage_item_inputs",
["stage_item_id", "winner_from_stage_item_id", "winner_position"],
)


def downgrade() -> None:
op.drop_constraint(
"stage_item_inputs_stage_item_id_winner_from_stage_item_id_w_key",
"stage_item_inputs",
type_="unique",
)
op.drop_constraint(
"stage_item_inputs_stage_item_id_team_id_key",
"stage_item_inputs",
type_="unique",
)
2 changes: 2 additions & 0 deletions backend/bracket/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
players,
rankings,
rounds,
stage_item_inputs,
stage_items,
stages,
teams,
Expand Down Expand Up @@ -68,6 +69,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
"Rankings": rankings.router,
"Rounds": rounds.router,
"Stage Items": stage_items.router,
"Stage Item Inputs": stage_item_inputs.router,
"Stages": stages.router,
"Teams": teams.router,
"Tournaments": tournaments.router,
Expand Down
23 changes: 16 additions & 7 deletions backend/bracket/logic/scheduling/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,27 +75,36 @@
teams: list[FullTeamWithPlayers],
stages: list[StageWithStageItems],
) -> list[StageItemInputOptionTentative | StageItemInputOptionFinal]:
results_team_ids = [team.id for team in teams]
results_team_ids = {team.id: False for team in teams}
results_tentative = []

for stage in stages:
if stage_id == stage.id:
break

for stage_item in stage.stage_items:
item_team_id_inputs = [
input.team_id for input in stage_item.inputs if input.team_id is not None
]
for input_ in item_team_id_inputs:
if input_ in results_team_ids:
results_team_ids.remove(input_)
if stage_id != stage.id:
results_team_ids.pop(input_)

Check warning on line 89 in backend/bracket/logic/scheduling/builder.py

View check run for this annotation

Codecov / codecov/patch

backend/bracket/logic/scheduling/builder.py#L88-L89

Added lines #L88 - L89 were not covered by tests
else:
results_team_ids[input_] = True

Check warning on line 91 in backend/bracket/logic/scheduling/builder.py

View check run for this annotation

Codecov / codecov/patch

backend/bracket/logic/scheduling/builder.py#L91

Added line #L91 was not covered by tests

if stage_id == stage.id:
break

for stage_item in stage.stage_items:

Check warning on line 96 in backend/bracket/logic/scheduling/builder.py

View check run for this annotation

Codecov / codecov/patch

backend/bracket/logic/scheduling/builder.py#L96

Added line #L96 was not covered by tests
for winner_position in range(1, 5):
results_tentative.append(
StageItemInputOptionTentative(
winner_from_stage_item_id=stage_item.id, winner_position=winner_position
winner_from_stage_item_id=stage_item.id,
winner_position=winner_position,
already_taken=False,
)
)

results_final = [StageItemInputOptionFinal(team_id=team_id) for team_id in results_team_ids]
results_final = [
StageItemInputOptionFinal(team_id=team_id, already_taken=taken)
for team_id, taken in results_team_ids.items()
]
return results_final + results_tentative
4 changes: 2 additions & 2 deletions backend/bracket/logic/scheduling/round_robin.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ def get_round_robin_combinations(team_count: int) -> list[list[tuple[int, int]]]
async def build_round_robin_stage_item(
tournament_id: TournamentId, stage_item: StageItemWithRounds
) -> None:
matches = get_round_robin_combinations(len(stage_item.inputs))
matches = get_round_robin_combinations(stage_item.team_count)
tournament = await sql_get_tournament(tournament_id)

for i, round_ in enumerate(stage_item.rounds):
for team_1_id, team_2_id in matches[i]:
if team_1_id < len(stage_item.inputs) and team_2_id < len(stage_item.inputs):
if team_1_id < stage_item.team_count and team_2_id < stage_item.team_count:
stage_item_1, stage_item_2 = (
stage_item.inputs[team_1_id],
stage_item.inputs[team_2_id],
Expand Down
8 changes: 7 additions & 1 deletion backend/bracket/models/db/stage_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,18 @@
name: str | None = None
type: StageType
team_count: int = Field(ge=2, le=64)
inputs: list[StageItemInputCreateBody]
ranking_id: RankingId | None = None

def get_name_or_default_name(self) -> str:
return self.name if self.name is not None else self.type.value.replace("_", " ").title()


class StageItemWithInputsCreate(StageItemCreateBody):
inputs: list[StageItemInputCreateBody]

def get_name_or_default_name(self) -> str:
return self.name if self.name is not None else self.type.value.replace("_", " ").title()

Check warning on line 60 in backend/bracket/models/db/stage_item.py

View check run for this annotation

Codecov / codecov/patch

backend/bracket/models/db/stage_item.py#L60

Added line #L60 was not covered by tests

@model_validator(mode="before")
def handle_inputs_length(cls, values: Any) -> Any:
if ("inputs" in values and "team_count" in values) and (
Expand Down
44 changes: 41 additions & 3 deletions backend/bracket/models/db/stage_item_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ class StageItemInputFinal(StageItemInputBase, StageItemInputGeneric):
team: Team


StageItemInput = StageItemInputTentative | StageItemInputFinal
class StageItemInputEmpty(StageItemInputBase, StageItemInputGeneric):
team_id: None = None
winner_from_stage_item_id: None = None
winner_position: None = None


StageItemInput = StageItemInputTentative | StageItemInputFinal | StageItemInputEmpty


class StageItemInputCreateBodyTentative(BaseModel):
Expand All @@ -63,20 +69,52 @@ class StageItemInputCreateBodyFinal(BaseModel):
team_id: TeamId


StageItemInputCreateBody = StageItemInputCreateBodyTentative | StageItemInputCreateBodyFinal
class StageItemInputCreateBodyEmpty(BaseModel):
slot: int


StageItemInputCreateBody = (
StageItemInputCreateBodyTentative
| StageItemInputCreateBodyFinal
| StageItemInputCreateBodyEmpty
)


class StageItemInputUpdateBodyTentative(BaseModelORM):
winner_from_stage_item_id: StageItemId
winner_position: int = Field(ge=1)


class StageItemInputUpdateBodyFinal(BaseModelORM):
team_id: TeamId


class StageItemInputUpdateBodyEmpty(BaseModelORM):
team_id: None = None
winner_from_stage_item_id: None = None
winner_position: None = None


StageItemInputUpdateBody = (
StageItemInputUpdateBodyTentative
| StageItemInputUpdateBodyFinal
| StageItemInputUpdateBodyEmpty
)


class StageItemInputInsertable(BaseModel):
slot: int
team_id: TeamId
team_id: TeamId | None = None
tournament_id: TournamentId
stage_item_id: StageItemId


class StageItemInputOptionFinal(BaseModel):
team_id: TeamId
already_taken: bool


class StageItemInputOptionTentative(BaseModel):
winner_from_stage_item_id: StageItemId
winner_position: int
already_taken: bool
4 changes: 2 additions & 2 deletions backend/bracket/routes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from bracket.models.db.user import UserPublic
from bracket.models.db.util import StageWithStageItems
from bracket.routes.auth import Token
from bracket.utils.id_types import StageItemId, StageItemInputId
from bracket.utils.id_types import StageId, StageItemId, StageItemInputId

DataT = TypeVar("DataT")

Expand Down Expand Up @@ -105,7 +105,7 @@ class RankingsResponse(DataResponse[list[Ranking]]):


class StageItemInputOptionsResponse(
DataResponse[list[StageItemInputOptionTentative | StageItemInputOptionFinal]]
DataResponse[dict[StageId, list[StageItemInputOptionTentative | StageItemInputOptionFinal]]]
):
pass

Expand Down
112 changes: 112 additions & 0 deletions backend/bracket/routes/stage_item_inputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from fastapi import APIRouter, Depends, HTTPException
from starlette import status

from bracket.database import database
from bracket.models.db.stage_item_inputs import (
StageItemInput,
StageItemInputUpdateBody,
StageItemInputUpdateBodyFinal,
StageItemInputUpdateBodyTentative,
)
from bracket.models.db.user import UserPublic
from bracket.models.db.util import StageItemWithRounds
from bracket.routes.auth import (
user_authenticated_for_tournament,
)
from bracket.routes.models import SuccessResponse
from bracket.routes.util import stage_item_dependency
from bracket.sql.stage_item_inputs import get_stage_item_input_by_id
from bracket.sql.stages import get_full_tournament_details
from bracket.sql.teams import get_team_by_id
from bracket.utils.errors import (
ForeignKey,
UniqueIndex,
check_foreign_key_violation,
check_unique_constraint_violation,
)
from bracket.utils.id_types import StageItemId, StageItemInputId, TournamentId

router = APIRouter()


async def validate_stage_item_update(
stage_item_input_db: StageItemInput | None,
stage_item_input_body: StageItemInputUpdateBody,
tournament_id: TournamentId,
) -> None:
if stage_item_input_db is None:
raise HTTPException(

Check warning on line 38 in backend/bracket/routes/stage_item_inputs.py

View check run for this annotation

Codecov / codecov/patch

backend/bracket/routes/stage_item_inputs.py#L38

Added line #L38 was not covered by tests
status_code=status.HTTP_404_NOT_FOUND,
detail="Could not find the stage item input",
)

if isinstance(stage_item_input_body, StageItemInputUpdateBodyTentative):
input_id = stage_item_input_body.winner_from_stage_item_id
winner_from_stage = await get_full_tournament_details(

Check warning on line 45 in backend/bracket/routes/stage_item_inputs.py

View check run for this annotation

Codecov / codecov/patch

backend/bracket/routes/stage_item_inputs.py#L44-L45

Added lines #L44 - L45 were not covered by tests
tournament_id, stage_item_ids={input_id}
)
if winner_from_stage is None:
raise HTTPException(

Check warning on line 49 in backend/bracket/routes/stage_item_inputs.py

View check run for this annotation

Codecov / codecov/patch

backend/bracket/routes/stage_item_inputs.py#L48-L49

Added lines #L48 - L49 were not covered by tests
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Could not find stage item with id {input_id}",
)

if (
isinstance(stage_item_input_body, StageItemInputUpdateBodyFinal)
and await get_team_by_id(stage_item_input_body.team_id, tournament_id) is None
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Could not find team with id {stage_item_input_body.team_id}",
)


@router.put(
"/tournaments/{tournament_id}/stage_items/{stage_item_id}/inputs/{stage_item_input_id}",
response_model=SuccessResponse,
)
async def update_stage_item_input(
tournament_id: TournamentId,
stage_item_id: StageItemId,
stage_item_input_id: StageItemInputId,
stage_item_body: StageItemInputUpdateBody,
_: UserPublic = Depends(user_authenticated_for_tournament),
__: StageItemWithRounds = Depends(stage_item_dependency),
) -> SuccessResponse:
stage_item_input = await get_stage_item_input_by_id(tournament_id, stage_item_input_id)
await validate_stage_item_update(stage_item_input, stage_item_body, tournament_id)

query = """
UPDATE stage_item_inputs
SET team_id = :team_id,
winner_position = :winner_position,
winner_from_stage_item_id = :winner_from_stage_item_id
WHERE stage_item_inputs.id = :stage_item_input_id
AND stage_item_inputs.tournament_id = :tournament_id
"""
with (
check_unique_constraint_violation(
{
UniqueIndex.stage_item_inputs_stage_item_id_team_id_key,
UniqueIndex.stage_item_inputs_stage_item_id_winner_from_stage_item_id_w_key,
},
),
check_foreign_key_violation({ForeignKey.stage_item_inputs_team_id_fkey}),
):
await database.execute(
query=query,
values={
"tournament_id": tournament_id,
"stage_item_input_id": stage_item_input_id,
"team_id": stage_item_body.team_id
if isinstance(stage_item_body, StageItemInputUpdateBodyFinal)
else None,
"winner_position": stage_item_body.winner_position
if isinstance(stage_item_body, StageItemInputUpdateBodyTentative)
else None,
"winner_from_stage_item_id": stage_item_body.winner_from_stage_item_id
if isinstance(stage_item_body, StageItemInputUpdateBodyTentative)
else None,
},
)
return SuccessResponse()
22 changes: 11 additions & 11 deletions backend/bracket/routes/stage_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@
from bracket.sql.rounds import set_round_active_or_draft
from bracket.sql.shared import sql_delete_stage_item_with_foreign_keys
from bracket.sql.stage_items import (
get_stage_item,
sql_create_stage_item,
sql_create_stage_item_with_empty_inputs,
)
from bracket.sql.stages import get_full_tournament_details
from bracket.sql.validation import check_foreign_keys_belong_to_tournament
from bracket.utils.errors import (
ForeignKey,
check_foreign_key_violation,
)
from bracket.utils.id_types import StageItemId, TournamentId

router = APIRouter()
Expand All @@ -45,7 +48,10 @@ async def delete_stage_item(
_: UserPublic = Depends(user_authenticated_for_tournament),
__: StageItemWithRounds = Depends(stage_item_dependency),
) -> SuccessResponse:
await sql_delete_stage_item_with_foreign_keys(stage_item_id)
with check_foreign_key_violation(
{ForeignKey.matches_stage_item_input1_id_fkey, ForeignKey.matches_stage_item_input2_id_fkey}
):
await sql_delete_stage_item_with_foreign_keys(stage_item_id)
return SuccessResponse()


Expand All @@ -55,19 +61,13 @@ async def create_stage_item(
stage_body: StageItemCreateBody,
user: UserPublic = Depends(user_authenticated_for_tournament),
) -> SuccessResponse:
if stage_body.team_count != len(stage_body.inputs):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Team count doesn't match number of inputs",
)

await check_foreign_keys_belong_to_tournament(stage_body, tournament_id)

stages = await get_full_tournament_details(tournament_id)
existing_stage_items = [stage_item for stage in stages for stage_item in stage.stage_items]
check_requirement(existing_stage_items, user, "max_stage_items")

stage_item = await sql_create_stage_item(tournament_id, stage_body)
stage_item = await sql_create_stage_item_with_empty_inputs(tournament_id, stage_body)
await build_matches_for_stage_item(stage_item, tournament_id)
return SuccessResponse()

Expand All @@ -82,7 +82,7 @@ async def update_stage_item(
_: UserPublic = Depends(user_authenticated_for_tournament),
stage_item: StageItemWithRounds = Depends(stage_item_dependency),
) -> SuccessResponse:
if await get_stage_item(tournament_id, stage_item_id) is None:
if stage_item is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Could not find all stages",
Expand Down
Loading