Untitled

4ae4d avatar
4ae4d
plain_text
03/30/2026 8:44 AM
28.7 KB
32
Indexable
f=src/backend/app/api/v1/event.py
f=src/backend/app/services/event.py
f=src/backend/app/policy/__init__.py
f=src/backend/app/policy/event_access.py
--- src/backend/app/api/v1/event.py ---
from datetime import datetime
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
from ...utils.logger import logger

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,
    response: Response,
    city_id: Annotated[UUID | None, Query()] = None,
    offset: Annotated[int, Query(ge=0)] = 0,
    limit: Annotated[int, Query(ge=1)] = 50,
    cursor_date: Annotated[datetime | None, Query()] = None,
    cursor_id: Annotated[UUID | None, Query()] = None,
    requester_messenger_id: Annotated[int | None, Query()] = None,
    only_claimed: Annotated[bool, Query()] = False,
    only_organizer: Annotated[bool, Query()] = False,
    only_participant: Annotated[bool, Query()] = False,
    has_report: Annotated[bool | None, Query()] = None,
    tag_ids: Annotated[list[UUID] | None, Query()] = None,
):
    logger.info(
        "HTTP /events/search: city_id=%s offset=%s limit=%s cursor_date=%s cursor_id=%s requester_messenger_id=%s only_claimed=%s only_organizer=%s only_participant=%s has_report=%s tag_ids=%s",
        city_id,
        offset,
        limit,
        cursor_date,
        cursor_id,
        requester_messenger_id,
        only_claimed,
        only_organizer,
        only_participant,
        has_report,
        tag_ids,
    )

    admin = Administrator(session)
    service = EventService(admin)
    query = EventSearchQuerySchema(
        city_id=city_id,
        offset=offset,
        limit=limit,
        cursor_date=cursor_date,
        cursor_id=cursor_id,
        requester_messenger_id=requester_messenger_id,
        only_claimed=only_claimed,
        only_organizer=only_organizer,
        only_participant=only_participant,
        has_report=has_report,
        tag_ids=tag_ids or [],
    )

    items, total = await service.search_events(query)
    response.headers["X-Total-Count"] = str(total)
    return items


@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 await service.get_cities_events(city_id, offset, limit)


@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 await service.get_by_id(event_id)


@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 await service.get_users_events(user_id)


@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 await service.enter_code(body.participation_id, body.bonus_code)


@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)

    return await service.create_event(body)


@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 typing import Any
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.participant import ParticipantEventsFilter
from ..filters.filters.report import ReportEventsFilter
from ..filters.filters.tags import TagsEventsFilter
from ..policy import can_view_event_participants
from ..schemas import (
    EventCreateSchema,
    EventSearchQuerySchema,
    EventSignOutSchema,
    EventSignUpSchema,
    EventUpdateSchema,
    ResponseEventParticipantSchema,
)
from ..utils.errors import BadRequestError, ConflictError, NotFoundError
from ..utils.logger import logger


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:
            return await admin.event.get_cities_events(city_id=city_id, offset=offset, limit=limit)

    async def search_events(self, query: EventSearchQuerySchema):
        logger.info(
            "search_events: city_id=%s offset=%s limit=%s cursor_date=%s cursor_id=%s requester_messenger_id=%s only_claimed=%s only_organizer=%s only_participant=%s has_report=%s tag_ids=%s",
            query.city_id,
            query.offset,
            query.limit,
            query.cursor_date,
            query.cursor_id,
            query.requester_messenger_id,
            query.only_claimed,
            query.only_organizer,
            query.only_participant,
            query.has_report,
            query.tag_ids,
        )

        # keyset cursor must be comlete
        if (query.cursor_date is None) ^ (query.cursor_id is None):
            raise BadRequestError("cursor_date and cursor_id must be provided together")

        needs_requester = bool(query.only_claimed or query.only_organizer or query.only_participant)
        if needs_requester and query.requester_messenger_id is None:
            raise BadRequestError(
                "requester_messenger_id is required for claimed/organizer/participant 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_data: dict[str, Any] = {"tag_ids": query.tag_ids}
            if requester is not None:
                ctx_data["requester_user_id"] = getattr(requester, "id", None)
                ctx_data["requester_messenger_id"] = query.requester_messenger_id
            ctx = FilterContext(ctx_data)

            spec = FilterSpecification()
            spec.add(ClaimedEventsFilter(enabled=bool(query.only_claimed)))
            spec.add(OrganizerEventsFilter(enabled=bool(query.only_organizer)))
            spec.add(ParticipantEventsFilter(enabled=bool(query.only_participant)))
            spec.add(ReportEventsFilter(has_report=query.has_report))
            spec.add(TagsEventsFilter(tag_ids=list(query.tag_ids or [])))

            return await admin.event.get_cities_events_with_total(
                city_id=query.city_id,
                offset=query.offset,
                limit=query.limit,
                cursor_date=query.cursor_date,
                cursor_id=query.cursor_id,
                spec=spec,
                ctx=ctx,
            )

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

    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})
            return await admin.user.add_points(participation.user_id, event.points)

    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}
            return await admin.participation.insert(participation_data)

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

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

    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)
            return await admin.participation.get_user_participations(user.id)

    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
                and (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")

            if not can_view_event_participants(requester, event):
                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/policy/__init__.py ---
from .event_access import (
    can_create_event_report,
    can_view_event_participants,
    can_view_event_report,
    is_admin,
    is_event_organizer,
    is_moderator,
)

__all__ = [
    "can_create_event_report",
    "can_view_event_participants",
    "can_view_event_report",
    "is_admin",
    "is_event_organizer",
    "is_moderator",
]

--- src/backend/app/policy/event_access.py ---
from __future__ import annotations

from ..db.models import Event, User
from ..utils.logger import logger


def _role_value(role: object) -> str | None:
    if role is None:
        return None
    return str(getattr(role, "value", role))


def is_admin(user: User | None) -> bool:
    if user is None:
        return False
    return _role_value(getattr(user, "role", None)) == "admin"


def is_moderator(user: User | None) -> bool:
    if user is None:
        return False
    return _role_value(getattr(user, "role", None)) == "moderator"


def is_event_organizer(user: User | None, event: Event | None) -> bool:
    if user is None or event is None:
        return False

    user_id = str(getattr(user, "id", ""))
    if not user_id:
        return False

    # legacy fallback
    creator_id = getattr(event, "creator_id", None)
    if creator_id is not None and str(creator_id) == user_id:
        return True

    # target model
    for link in getattr(event, "event_organizers", []) or []:
        if str(getattr(link, "user_id", "")) == user_id:
            return True

    return False


def can_view_event_participants(user: User | None, event: Event | None) -> bool:
    return is_admin(user) or is_event_organizer(user, event)


def can_create_event_report(user: User | None, event: Event | None) -> bool:
    return is_admin(user) or is_event_organizer(user, event)


def can_view_event_report(user: User | None, event: Event | None) -> bool:
    # NOTE:
    # moderator scope by TB is not enforced yet
    # we only centralise the current behavior and add event_organizer support
    return is_admin(user) or is_moderator(user) or is_event_organizer(user, event)

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

from pydantic import BaseModel, ConfigDict, Field

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: str | None = Field(default=None, max_length=128)
    description: str | None = None
    location: str | None = Field(default=None, max_length=255)
    app_tag_ids: list[UUID] = Field(default_factory=list)
    bonus_code: str | None = Field(default=None, 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: str = Field(..., max_length=128)
    description: str | None = None
    location: str | None = None
    app_tag_ids: list[UUID] | None = None
    bonus_code: str | None = Field(default=None, max_length=16)
    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