From 8fb576afc71dbdeb5300a27a4776be6d5bf18612 Mon Sep 17 00:00:00 2001 From: openit Date: Fri, 6 Mar 2026 17:31:13 +0000 Subject: [PATCH] 15 phase 3 tournament add --- backend/api/deps.py | 11 +++ backend/api/routers/tournaments.py | 76 +++++++++++++++++++ backend/database/models.py | 2 + backend/main.py | 2 + .../c82cc216a199_add_is_superuser_to_users.py | 32 ++++++++ ...d096f9d0b612_add_tournament_description.py | 32 ++++++++ backend/schemas/tournament.py | 36 +++++++++ 7 files changed, 191 insertions(+) create mode 100644 backend/api/routers/tournaments.py create mode 100644 backend/migrations/versions/c82cc216a199_add_is_superuser_to_users.py create mode 100644 backend/migrations/versions/d096f9d0b612_add_tournament_description.py create mode 100644 backend/schemas/tournament.py diff --git a/backend/api/deps.py b/backend/api/deps.py index 9dd2912..656dc13 100644 --- a/backend/api/deps.py +++ b/backend/api/deps.py @@ -34,3 +34,14 @@ async def get_current_user( raise credentials_exception return user + + +async def get_current_superuser( + current_user: User = Depends(get_current_user), +) -> User: + if not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough privileges", + ) + return current_user diff --git a/backend/api/routers/tournaments.py b/backend/api/routers/tournaments.py new file mode 100644 index 0000000..5b8467d --- /dev/null +++ b/backend/api/routers/tournaments.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.deps import get_current_superuser +from database.models import Seat, Tournament, User +from database.session import get_db +from schemas.tournament import SeatGenerateRequest, TournamentCreate, TournamentResponse + +router = APIRouter(prefix="/api/tournaments", tags=["tournaments"]) + + +@router.post("", response_model=TournamentResponse, status_code=status.HTTP_201_CREATED) +async def create_tournament( + body: TournamentCreate, + current_user: User = Depends(get_current_superuser), + db: AsyncSession = Depends(get_db), +) -> Tournament: + tournament = Tournament( + title=body.title, + description=body.description, + event_date=body.event_date, + ) + db.add(tournament) + await db.commit() + await db.refresh(tournament) + return tournament + + +@router.post( + "/{tournament_id}/generate-seats", + status_code=status.HTTP_200_OK, +) +async def generate_seats( + tournament_id: int, + request: SeatGenerateRequest, + current_user: User = Depends(get_current_superuser), + db: AsyncSession = Depends(get_db), +) -> dict[str, str]: + # 1. Проверяем существование турнира + result = await db.execute(select(Tournament).where(Tournament.id == tournament_id)) + tournament: Tournament | None = result.scalar_one_or_none() + if tournament is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tournament not found") + + # 2. Проверяем, что места ещё не генерировались (защита от дублей) + count_result = await db.execute( + select(func.count()).where(Seat.tournament_id == tournament_id) + ) + existing_count: int = count_result.scalar_one() + if existing_count > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Места уже сгенерированы", + ) + + # 3. Генерируем места для каждого сектора + seats: list[Seat] = [] + for sector_cfg in request.sectors: + for row in range(1, sector_cfg.rows + 1): + for number in range(1, sector_cfg.seats_per_row + 1): + seats.append( + Seat( + tournament_id=tournament_id, + sector=sector_cfg.sector_name, + row=row, + number=number, + price=sector_cfg.price, + ) + ) + + # 4. Массовая вставка + db.add_all(seats) + await db.commit() + + return {"message": f"Generated {len(seats)} seats successfully"} diff --git a/backend/database/models.py b/backend/database/models.py index 312bbda..297623c 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -18,12 +18,14 @@ class User(Base): id: Mapped[int] = mapped_column(primary_key=True) email: Mapped[str] = mapped_column(String, unique=True, index=True) hashed_password: Mapped[str] = mapped_column(String) + is_superuser: Mapped[bool] = mapped_column(Boolean, default=False) tickets: Mapped[list["Ticket"]] = relationship(back_populates="user") class Tournament(Base): __tablename__ = "tournaments" id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(String) + description: Mapped[str | None] = mapped_column(String, nullable=True) event_date: Mapped[datetime] = mapped_column(DateTime(timezone=True)) is_active: Mapped[bool] = mapped_column(Boolean, default=True) seats: Mapped[list["Seat"]] = relationship(back_populates="tournament") diff --git a/backend/main.py b/backend/main.py index ea7def1..05b7c4b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,6 +8,7 @@ from core.redis import acquire_seat_lock, release_seat_lock from api.routers.auth import router as auth_router from api.routers.webhooks import router as webhooks_router from api.routers.tickets import router as tickets_router +from api.routers.tournaments import router as tournaments_router app = FastAPI(title="Ticketing System API") @@ -24,6 +25,7 @@ app.add_middleware( app.include_router(auth_router) app.include_router(webhooks_router) app.include_router(tickets_router) +app.include_router(tournaments_router) @app.post("/api/seats/{seat_id}/lock", status_code=status.HTTP_200_OK) async def lock_seat(seat_id: int, user_id: int, db: AsyncSession = Depends(get_db)): diff --git a/backend/migrations/versions/c82cc216a199_add_is_superuser_to_users.py b/backend/migrations/versions/c82cc216a199_add_is_superuser_to_users.py new file mode 100644 index 0000000..7c7f9dd --- /dev/null +++ b/backend/migrations/versions/c82cc216a199_add_is_superuser_to_users.py @@ -0,0 +1,32 @@ +"""add is_superuser to users + +Revision ID: c82cc216a199 +Revises: a55d80c4b300 +Create Date: 2026-03-06 16:26:44.600332 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c82cc216a199' +down_revision: Union[str, Sequence[str], None] = 'a55d80c4b300' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('is_superuser', sa.Boolean(), server_default='false', nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'is_superuser') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/d096f9d0b612_add_tournament_description.py b/backend/migrations/versions/d096f9d0b612_add_tournament_description.py new file mode 100644 index 0000000..548a530 --- /dev/null +++ b/backend/migrations/versions/d096f9d0b612_add_tournament_description.py @@ -0,0 +1,32 @@ +"""add_tournament_description + +Revision ID: d096f9d0b612 +Revises: c82cc216a199 +Create Date: 2026-03-06 17:18:22.377857 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd096f9d0b612' +down_revision: Union[str, Sequence[str], None] = 'c82cc216a199' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tournaments', sa.Column('description', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tournaments', 'description') + # ### end Alembic commands ### diff --git a/backend/schemas/tournament.py b/backend/schemas/tournament.py new file mode 100644 index 0000000..317aab5 --- /dev/null +++ b/backend/schemas/tournament.py @@ -0,0 +1,36 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, field_validator + + +class SectorConfig(BaseModel): + sector_name: str + rows: int + seats_per_row: int + price: int + + @field_validator("rows", "seats_per_row", "price") + @classmethod + def must_be_positive(cls, v: int) -> int: + if v <= 0: + raise ValueError("Must be a positive integer") + return v + + +class SeatGenerateRequest(BaseModel): + sectors: list[SectorConfig] + + +class TournamentCreate(BaseModel): + title: str + description: str | None = None + event_date: datetime + + +class TournamentResponse(BaseModel): + id: int + title: str + description: str | None + event_date: datetime + + model_config = ConfigDict(from_attributes=True)