Untitled

4ae4d avatar
4ae4d
plain_text
02/03/2026 6:57 AM
28.7 KB
11
Indexable
f=src/backend/main.py
f=src/backend/app/application.py
f=src/backend/app/api/v1/__init__.py
f=src/backend/app/api/v1/event.py
f=src/backend/app/services/event.py
f=src/backend/app/schemas/event_search.py
f=src/backend/app/schemas/event.py
--- src/backend/main.py ---
import logging
import subprocess
import sys

import pytest
from app.application import app
from app.config.settings import settings
from server.gunicorn import GunicornServer, create_options

logger = logging.getLogger(__name__)


def main():
    if len(sys.argv) != 2 or sys.argv[1] not in ["test", "production"]:
        print("Usage: python main.py [test|production]")
        sys.exit(1)
    option = sys.argv[1]

    output = subprocess.check_output(["alembic", "upgrade", "head"], text=True)
    logger.critical(output)

    if option == "test":
        pytest.main(["-s", "-v"])
    elif option == "production":
        server = GunicornServer(
            app=app,
            options=create_options(
                host=settings.HOST,
                port=settings.PORT,
                workers=settings.WORKERS,
                forwarded_allow_ips=settings.FORWARDED_ALLOW_IPS,
                keepalive=settings.KEEPALIVE,
            ),
        )
        server.run()


if __name__ == "__main__":
    main()

--- src/backend/app/application.py ---
import traceback
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import PlainTextResponse

from app.api.v1 import (
    city_router,
    event_router,
    health_router,
    report_router,
    tag_router,
    user_router,
)
from app.cache import redis_client
from app.config.settings import settings
from app.middlewares import LoggerMiddleware

# from app.admin.setup import setup_admin
from app.utils.logger import logger


@asynccontextmanager
async def lifespan(app: FastAPI):
    logger.info("The API service started")
    app.state.redis = await redis_client.get_client(settings.REDIS_DSN)

    yield

    logger.info("The API service has shut down")
    await app.state.redis.close()


app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# setup_admin(app)

api = FastAPI(root_path="/api/v1")


@api.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
    tb = traceback.format_exc()
    logger.error("Unhandled exception: %s\n%s", exc, tb)

    print("UNHANDLED EXCEPTION in /api/v1:", repr(exc))
    print(f"tb: {tb}")

    return PlainTextResponse(f"Internal Server Error. tb: {tb}", status_code=500)


api.add_middleware(LoggerMiddleware)

api.include_router(city_router)
api.include_router(event_router)
api.include_router(report_router)
api.include_router(tag_router)
api.include_router(user_router)
api.include_router(health_router)

app.mount("/api/v1", api)

--- src/backend/app/api/v1/__init__.py ---
from .city import api_router as city_router
from .event import api_router as event_router
from .health import api_router as health_router
from .report import api_router as report_router
from .tag import api_router as tag_router
from .user import api_router as user_router

__all__ = [
    "user_router",
    "event_router",
    "city_router",
    "tag_router",
    "report_router",
    "health_router",
]

--- src/backend/app/api/v1/event.py ---
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, status
from fastapi.responses import ORJSONResponse
from sqlalchemy.ext.asyncio import AsyncSession

from ...db.connection import get_db
from ...dependencies.administrator import Administrator
from ...schemas import (
    DefaultResponseSchema,
    EventCodeSchema,
    EventCreateSchema,
    EventSearchQuerySchema,
    EventSignOutSchema,
    EventSignUpSchema,
    EventUpdateSchema,
    ResponseEventParticipantSchema,
    ResponseEventSchema,
    ResponseUserSchema,
)
from ...services import EventService

api_router = APIRouter(prefix="/events", tags=["Events"])


SessionDep = Annotated[AsyncSession, Depends(get_db)]


@api_router.get(
    "/search",
    status_code=status.HTTP_200_OK,
    response_model=list[ResponseEventSchema],
)
async def search_events(
    session: SessionDep,
    city_id: UUID | None = None,
    offset: int = Query(default=0, ge=0),
    limit: int = Query(default=50, ge=1),
    requester_messenger_id: int | None = None,
    only_claimed: bool = False,
    only_organizer: bool = False,
    tag_ids: list[UUID] | None = None,
):
    tag_ids = tag_ids or []
    admin = Administrator(session)
    service = EventService(admin)
    query = EventSearchQuerySchema(
        city_id=city_id,
        offset=offset,
        limit=limit,
        requester_messenger_id=requester_messenger_id,
        only_claimed=only_claimed,
        only_organizer=only_organizer,
        tag_ids=tag_ids or [],
    )
    return await service.search_events(query)


@api_router.get(
    "/id/{city_id}", status_code=status.HTTP_200_OK, response_model=list[ResponseEventSchema]
)
async def get_cities_events(
    city_id: Annotated[UUID, Path()],
    offset: Annotated[int, Query()],
    limit: Annotated[int, Query()],
    session: SessionDep,
):
    admin = Administrator(session)
    service = EventService(admin)
    return_data = await service.get_cities_events(city_id, offset, limit)
    return return_data


@api_router.get(
    "/info/{event_id}", status_code=status.HTTP_200_OK, response_model=ResponseEventSchema
)
async def get_event_info(event_id: Annotated[UUID, Path()], session: SessionDep):
    admin = Administrator(session)
    service = EventService(admin)
    return_data = await service.get_by_id(event_id)
    return return_data


@api_router.get(
    "/{event_id}/participants",
    status_code=status.HTTP_200_OK,
    response_model=list[ResponseEventParticipantSchema],
)
async def get_event_participants(
    event_id: Annotated[UUID, Path()],
    requester_messenger_id: Annotated[int, Query()],
    session: SessionDep,
    offset: Annotated[int, Query()] = 0,
    limit: Annotated[int, Query()] = 50,
):
    admin = Administrator(session)
    service = EventService(admin)
    return await service.get_event_participants(
        event_id=event_id,
        requester_messenger_id=requester_messenger_id,
        offset=offset,
        limit=limit,
    )


@api_router.get("/my", status_code=status.HTTP_200_OK)
async def get_users_events(user_id: Annotated[int, Query()], session: SessionDep):
    admin = Administrator(session)
    service = EventService(admin)
    return_data = await service.get_users_events(user_id)
    return return_data


@api_router.post("/code", status_code=status.HTTP_200_OK, response_model=ResponseUserSchema)
async def event_enter_code(body: EventCodeSchema, session: SessionDep):
    admin = Administrator(session)
    service = EventService(admin)
    return_data = await service.enter_code(body.participation_id, body.bonus_code)
    return return_data


@api_router.post("/sign-up", status_code=status.HTTP_200_OK, response_model=DefaultResponseSchema)
async def event_sign_up(body: EventSignUpSchema, session: SessionDep):
    admin = Administrator(session)
    service = EventService(admin)
    await service.sign_up(body)
    return ORJSONResponse(
        {
            "status": "OK",
            "description": f"User {body.messenger_id} successfully signed up for event {body.event_id}",
        }
    )


@api_router.patch("/{event_id}", status_code=status.HTTP_200_OK, response_model=ResponseEventSchema)
async def update_event(
    event_id: Annotated[UUID, Path()],
    body: EventUpdateSchema,
    session: SessionDep,
):
    admin = Administrator(session)
    service = EventService(admin)
    return await service.update_event(event_id, body)


@api_router.delete(
    "/sign-out", status_code=status.HTTP_200_OK, response_model=DefaultResponseSchema
)
async def event_sign_out(body: EventSignOutSchema, session: SessionDep):
    admin = Administrator(session)
    service = EventService(admin)
    await service.sign_out(body)
    return ORJSONResponse(
        {
            "status": "OK",
            "description": f"User successfully signed out for participation {body.participation_id}",
        }
    )


@api_router.post("", status_code=status.HTTP_201_CREATED, response_model=ResponseEventSchema)
async def create_event(body: EventCreateSchema, session: SessionDep):
    """
    создать мероприятие.

    пока без привязки к конкретному модератору:
    creator_id передаём как None (в модели поле nullable)
    позже можно будет подставлять id текущего пользователя
    """  # TODO
    admin = Administrator(session)
    service = EventService(admin)

    event = await service.create_event(body)
    return event


@api_router.delete("/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_event(event_id: Annotated[UUID, Path()], session: SessionDep):
    """удалить мероприятие по id"""
    admin = Administrator(session)
    service = EventService(admin)

    deleted = await service.delete_event(event_id)

    if not deleted:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found")

    return Response(status_code=status.HTTP_204_NO_CONTENT)

--- src/backend/app/services/event.py ---
from uuid import UUID

from ..dependencies.administrator import Administrator
from ..filters import FilterContext, FilterSpecification
from ..filters.filters.claimed import ClaimedEventsFilter
from ..filters.filters.organizer import OrganizerEventsFilter
from ..filters.filters.tags import TagsEventsFilter
from ..schemas import (
    EventCreateSchema,
    EventSearchQuerySchema,
    EventSignOutSchema,
    EventSignUpSchema,
    EventUpdateSchema,
    ResponseEventParticipantSchema,
)
from ..utils.errors import BadRequestError, ConflictError, NotFoundError


class EventService:
    def __init__(self, administrator: Administrator):
        self.administrator = administrator

    async def get_cities_events(self, city_id: UUID, offset: int, limit: int):
        async with self.administrator.start() as admin:
            events = await admin.event.get_cities_events(
                city_id=city_id, offset=offset, limit=limit
            )
            return events

    async def search_events(self, query: EventSearchQuerySchema):
        if query.city_id is None:
            raise BadRequestError("city_id is required")

        needs_requester = bool(query.only_claimed or query.only_organizer)
        if needs_requester and query.requester_messenger_id is None:
            raise BadRequestError(
                "requester_messenger_id is required for claimed/organizer filters"
            )

        async with self.administrator.start() as admin:
            requester = None
            if query.requester_messenger_id is not None:
                requester = await admin.user.get_by_messenger_id(query.requester_messenger_id)
            if not requester:
                raise NotFoundError("User not found")

            ctx = FilterContext(
                {
                    "requester_user_id": getattr(requester, "id", None),
                    "requester_messenger_id": query.requester_messenger_id,
                }
            )

            spec = FilterSpecification()
            spec.add(ClaimedEventsFilter(enabled=bool(query.only_claimed)))
            spec.add(OrganizerEventsFilter(enabled=bool(query.only_organizer)))
            spec.add(TagsEventsFilter(enabled=bool(query.tag_ids or [])))

            return await admin.event.get_cities_events(
                city_id=query.city_id,
                offset=query.offset,
                limit=query.limit,
                spec=spec,
                ctx=ctx,
            )

    async def get_by_id(self, event_id: UUID):
        async with self.administrator.start() as admin:
            event = await admin.event.get_by_id(event_id)
            return event

    async def enter_code(self, participation_id: UUID, bonus_code: str):
        async with self.administrator.start() as admin:
            participation = await admin.participation.get_by_id(participation_id)
            if not participation:
                raise NotFoundError()
            if participation.is_claimed:
                raise BadRequestError("User already claimed points")

            event = await admin.event.get_by_id(participation.event_id)
            if event.bonus_code != bonus_code:
                raise BadRequestError("Codes do not match")

            await admin.participation.update(participation_id, {"is_claimed": True})
            updated_user = await admin.user.add_points(participation.user_id, event.points)

            return updated_user

    async def sign_up(self, data: EventSignUpSchema):
        async with self.administrator.start() as admin:
            user = await admin.user.get_by_messenger_id(data.messenger_id)
            if not user:
                raise NotFoundError("User not found")

            participation = await admin.participation.get_participation(user.id, data.event_id)
            if participation:
                raise ConflictError()

            participation_data = {"user_id": user.id, "event_id": data.event_id}

            participation = await admin.participation.insert(participation_data)
            return participation

    async def sign_out(self, data: EventSignOutSchema):
        async with self.administrator.start() as admin:
            participation = await admin.participation.get_by_id(data.participation_id)
            if not participation:
                raise NotFoundError()
            if participation.is_claimed:
                raise BadRequestError()

            deleted_participation = await admin.participation.delete(participation.id)
            return deleted_participation

    async def get_users_events(self, user_id: int):
        async with self.administrator.start() as admin:
            user = await admin.user.get_by_messenger_id(user_id)
            user_events = await admin.participation.get_user_participations(user.id)

            return user_events

    async def create_event(self, data: EventCreateSchema):
        """
        создать мероприятие в БД.
        creator_id можно пробросить из текущего пользователя (модератора),
        пока можно передавать None - поле в модели nullable.
        """
        async with self.administrator.start() as admin:
            event_data = {
                "creator_id": data.creator_id,
                "city_id": data.city_id,
                "name": data.name,
                "description": data.description,
                "location": data.location,
                "bonus_code": data.bonus_code,
                "points": data.points,
                "date": data.date,
            }
            event = await admin.event.insert(event_data)

            if data.app_tag_ids:
                await admin.event.set_app_tags(event.id, data.app_tag_ids)

            return await admin.event.get_by_id(event.id)

    async def delete_event(self, event_id: UUID) -> bool:
        """
        удалить мероприятие по id.
        true - получилось удалить
        false - не получилось удалить
        """
        async with self.administrator.start() as admin:
            event = await admin.event.get_by_id(event_id)
            if not event:
                return False

            await admin.event.delete(event_id)
            return True

    async def update_event(self, event_id: UUID, data: EventUpdateSchema):
        """
        обновляем мероприятие,
        проверяем что количество поинтов адекватное
        """
        async with self.administrator.start() as admin:
            event = await admin.event.get_by_id(event_id)
            if not event:
                raise NotFoundError("Event not found")

            patch = data.model_dump(exclude_unset=True)
            tag_ids = patch.pop("app_tag_ids", None)

            if "points" in patch and patch["points"] is not None:
                if patch["points"] <= 0 or patch["points"] >= 2**31:
                    raise BadRequestError("Invalid points value")

            if patch:
                updated = await admin.event.update_fields(event_id, patch)
                if not updated:
                    raise BadRequestError("Nothing to update")

            if tag_ids is not None:
                await admin.event.set_app_tags(event_id, tag_ids)

            refreshed = await admin.event.get_by_id(event_id)
            if not refreshed:
                raise BadRequestError("Event not found")

            return refreshed

    async def get_event_participants(
        self,
        event_id: UUID,
        requester_messenger_id: int,
        offset: int = 0,
        limit: int = 50,
    ) -> list[ResponseEventParticipantSchema]:
        async with self.administrator.start() as admin:
            requester = await admin.user.get_by_messenger_id(requester_messenger_id)
            if not requester:
                raise NotFoundError("User not found")

            event = await admin.event.get_by_id(event_id)
            if not event:
                raise NotFoundError("Event not found")

            role = getattr(requester, "role", None)
            role_val = getattr(role, "value", role)
            is_admin = str(role_val) == "admin"

            if not is_admin:
                if not event.creator_id or str(event.creator_id) != str(requester.id):
                    raise BadRequestError("Access denied: only organizer can view participants")
                    # TODO replace with AuthError

            parts = await admin.participation.get_event_participations(
                event_id=event_id,
                offset=offset,
                limit=limit,
            )

            out: list[ResponseEventParticipantSchema] = []
            for p in parts:
                u = getattr(p, "user", None)
                if not u:
                    continue

                out.append(
                    ResponseEventParticipantSchema(
                        participation_id=p.id,
                        is_claimed=bool(getattr(p, "is_claimed", False)),
                        user_id=u.id,
                        firstname=getattr(u, "firstname", None),
                        messenger_id=int(getattr(u, "messenger_id", 0)),
                        employee_number=int(getattr(u, "employee_number", 0)),
                        points=int(getattr(u, "points", 0)),
                    )
                )

            return out

--- src/backend/app/schemas/event_search.py ---
from __future__ import annotations

from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field


class EventSearchQuerySchema(BaseModel):
    """
    схема будущего /events/search
    сейчас нигде не используется
    """

    model_config = ConfigDict(extra="forbid")

    city_id: UUID | None = None
    offset: int = 0
    limit: int = 50

    requester_messenger_id: int | None = None

    only_claimed: bool = False
    only_organizer: bool = False

    tag_ids: list[UUID] = Field(default_factory=list)

--- src/backend/app/schemas/event.py ---
from datetime import datetime
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field, constr

from .app_tag import ResponseAppTagSchema
from .base import TimestampSchema


class BaseEvent(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    name: str
    description: str | None = None
    location: str | None = None
    tags: list[ResponseAppTagSchema] = Field(default_factory=list)
    bonus_code: str
    points: int
    date: datetime


class EventCreateSchema(BaseModel):
    """то, что приходит от бота при создании мероприятия"""

    model_config = ConfigDict(extra="forbid")

    name: constr(max_length=128)
    description: str | None = None
    location: constr(max_length=255) | None = None
    app_tag_ids: list[UUID] = Field(default_factory=list)
    bonus_code: constr(max_length=16)
    points: int = 0
    date: datetime
    city_id: UUID
    creator_id: UUID | None = None


class RequestEventSchema(BaseEvent):
    pass


class ResponseEventSchema(BaseEvent, TimestampSchema):
    id: UUID
    creator_id: UUID | None = (
        None  # TODO решить, оставляем ли или всегда приписываем UUID создателя
    )
    creator_name: str | None = None
    city_id: UUID


class EventSignUpSchema(BaseModel):
    event_id: UUID
    messenger_id: int


class EventSignOutSchema(BaseModel):
    participation_id: UUID


class EventCodeSchema(EventSignOutSchema):
    bonus_code: str


class EventUpdateSchema(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: constr(max_length=128) | None = None
    description: str | None = None
    location: str | None = None
    app_tag_ids: list[UUID] | None = None
    bonus_code: constr(max_length=16) | None = None
    points: int | None = None
    date: datetime | None = None
    city_id: UUID | None = None
    creator_id: UUID | None = None


class ResponseEventParticipantSchema(BaseModel):
    """один участник мероприятия"""

    model_config = ConfigDict(extra="forbid")

    participation_id: UUID
    is_claimed: bool

    user_id: UUID
    firstname: str | None = None
    messenger_id: int
    employee_number: int
    points: int

Editor is loading...
Leave a Comment