diff --git a/backend/alembics/versions/c08e04993dd7_tournaments_dashboard_endpoint_unique.py b/backend/alembics/versions/c08e04993dd7_tournaments_dashboard_endpoint_unique.py new file mode 100644 index 00000000..e46551ae --- /dev/null +++ b/backend/alembics/versions/c08e04993dd7_tournaments_dashboard_endpoint_unique.py @@ -0,0 +1,29 @@ +"""tournaments.dashboard_endpoint unique + +Revision ID: c08e04993dd7 +Revises: 39ec08a054af +Create Date: 2024-02-09 18:44:32.138133 + +""" + + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str | None = "c08e04993dd7" +down_revision: str | None = "39ec08a054af" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.create_index( + op.f("ix_tournaments_dashboard_endpoint"), + "tournaments", + ["dashboard_endpoint"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_tournaments_dashboard_endpoint"), table_name="tournaments") diff --git a/backend/bracket/routes/tournaments.py b/backend/bracket/routes/tournaments.py index a564d165..c8b57b6e 100644 --- a/backend/bracket/routes/tournaments.py +++ b/backend/bracket/routes/tournaments.py @@ -1,3 +1,4 @@ +import asyncpg # type: ignore[import-untyped] from fastapi import APIRouter, Depends, HTTPException, UploadFile from heliclockter import datetime_utc from starlette import status @@ -27,6 +28,7 @@ from bracket.sql.tournaments import ( ) from bracket.sql.users import get_user_access_to_club, get_which_clubs_has_user_access_to from bracket.utils.db import fetch_one_parsed_certain +from bracket.utils.errors import check_constraint_and_raise_http_exception from bracket.utils.types import assert_some router = APIRouter() @@ -79,10 +81,14 @@ async def update_tournament_by_id( tournament_body: TournamentUpdateBody, _: UserPublic = Depends(user_authenticated_for_tournament), ) -> SuccessResponse: - await database.execute( - query=tournaments.update().where(tournaments.c.id == tournament_id), - values=tournament_body.model_dump(), - ) + try: + await database.execute( + query=tournaments.update().where(tournaments.c.id == tournament_id), + values=tournament_body.model_dump(), + ) + except asyncpg.exceptions.UniqueViolationError as exc: + check_constraint_and_raise_http_exception(exc) + await update_start_times_of_matches(tournament_id) return SuccessResponse() diff --git a/backend/bracket/schema.py b/backend/bracket/schema.py index 44420a13..5be71043 100644 --- a/backend/bracket/schema.py +++ b/backend/bracket/schema.py @@ -24,7 +24,7 @@ tournaments = Table( Column("club_id", BigInteger, ForeignKey("clubs.id"), index=True, nullable=False), Column("dashboard_public", Boolean, nullable=False), Column("logo_path", String, nullable=True), - Column("dashboard_endpoint", String, nullable=True), + Column("dashboard_endpoint", String, nullable=True, index=True, unique=True), Column("players_can_be_in_multiple_teams", Boolean, nullable=False, server_default="f"), Column("auto_assign_courts", Boolean, nullable=False, server_default="f"), Column("duration_minutes", Integer, nullable=False, server_default="15"), diff --git a/backend/bracket/utils/errors.py b/backend/bracket/utils/errors.py new file mode 100644 index 00000000..49ec5294 --- /dev/null +++ b/backend/bracket/utils/errors.py @@ -0,0 +1,34 @@ +from enum import auto +from typing import NoReturn + +import asyncpg # type: ignore[import-untyped] +from fastapi import HTTPException +from starlette import status + +from bracket.utils.types import EnumAutoStr + + +class UniqueIndex(EnumAutoStr): + ix_tournaments_dashboard_endpoint = auto() + ix_users_email = auto() + + +unique_index_violation_error_lookup = { + UniqueIndex.ix_tournaments_dashboard_endpoint: "This dashboard link is already taken", + UniqueIndex.ix_users_email: "This email is already taken", +} + + +def check_constraint_and_raise_http_exception( + exc: asyncpg.exceptions.UniqueViolationError, +) -> NoReturn: + constraint_name = exc.as_dict()["constraint_name"] + assert constraint_name, "UniqueViolationError occurred but no constraint_name defined" + + if constraint_name in unique_index_violation_error_lookup: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=unique_index_violation_error_lookup[constraint_name], + ) + + raise exc diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 54702f14..57943f8c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -68,6 +68,7 @@ disable = [ 'wrong-import-position', 'fixme', 'broad-except', + 'consider-iterating-dictionary', ] [tool.bandit] diff --git a/backend/tests/integration_tests/api/auth_test.py b/backend/tests/integration_tests/api/auth_test.py index b23ddf4d..92da87e2 100644 --- a/backend/tests/integration_tests/api/auth_test.py +++ b/backend/tests/integration_tests/api/auth_test.py @@ -83,7 +83,9 @@ async def test_not_authenticated_for_tournament( ) -> None: async with inserted_club(DUMMY_CLUB) as club_inserted: async with inserted_tournament( - DUMMY_TOURNAMENT.model_copy(update={"club_id": club_inserted.id}) + DUMMY_TOURNAMENT.model_copy( + update={"club_id": club_inserted.id, "dashboard_endpoint": "some-slug"} + ) ) as tournament_inserted: response = JsonDict( await send_auth_request( diff --git a/backend/tests/integration_tests/index_lookup_test.py b/backend/tests/integration_tests/index_lookup_test.py new file mode 100644 index 00000000..55d0f990 --- /dev/null +++ b/backend/tests/integration_tests/index_lookup_test.py @@ -0,0 +1,25 @@ +from bracket.database import database +from bracket.utils.errors import ( + unique_index_violation_error_lookup, +) + + +async def test_all_unique_indices_in_lookup() -> None: + query = """ + SELECT + idx.relname AS index_name + FROM pg_index pgi + JOIN pg_class idx ON idx.oid = pgi.indexrelid + JOIN pg_namespace insp ON insp.oid = idx.relnamespace + JOIN pg_class tbl ON tbl.oid = pgi.indrelid + JOIN pg_namespace tnsp ON tnsp.oid = tbl.relnamespace + WHERE pgi.indisunique + AND tnsp.nspname = 'public' + AND idx.relname NOT LIKE '%_pkey' + AND idx.relname !='alembic_version_pkc' + """ + result = await database.fetch_all(query) + indices = {ix.index_name for ix in result} # type: ignore[attr-defined] + + expected_indices = {ix.name for ix in unique_index_violation_error_lookup.keys()} + assert indices == expected_indices diff --git a/frontend/src/components/navbar/_main_links.tsx b/frontend/src/components/navbar/_main_links.tsx index 5692a754..e3ea1cef 100644 --- a/frontend/src/components/navbar/_main_links.tsx +++ b/frontend/src/components/navbar/_main_links.tsx @@ -173,7 +173,7 @@ export function TournamentLinks({ tournament_id }: any) { return ( <>
-

{t('tournament_title')}

+

{capitalize(t('tournament_title'))}

{links}