Untitled

4ae4d avatar
4ae4d
plain_text
02/04/2026 11:17 AM
70.3 KB
10
Indexable
f=./src/bot/core/clients/event.py
f=./src/bot/core/services/event.py
f=./src/bot/core/handlers/points.py
f=./src/bot/core/handlers/events_pages.py
f=./src/bot/core/markups/points.py
f=./src/bot/core/markups/pagination.py
f=./src/bot/core/schemas/event.py
f=./src/bot/core/utils/messages.py
f=./src/bot/core/utils/logger.py
f=.gitignore
f=src/backend/app/api/v1/event.py
f=src/backend/app/filters/filters/report.py
f=src/backend/app/repositories/event.py
f=src/backend/app/schemas/event_search.py
--- ./src/bot/core/clients/event.py ---
from core.clients.base import BaseApiClient


class EventClient(BaseApiClient):
    def get_city_events(self, city_id: str, offset: int = 0, limit: int = 50):
        """GET /events/id/{city_id}"""
        return self.get(f"/events/id/{city_id}", params={"offset": offset, "limit": limit})

    def get_event_info(self, event_id: str):
        """GET /events/info/{event_id}"""
        return self.get(f"/events/info/{event_id}")

    def get_event_participants(
        self,
        event_id: str,
        requester_messenger_id: int,
        offset: int = 0,
        limit: int = 50,
    ):
        """GET /events/{event_id}/participants?requester_messenger_id=..."""
        return self.get(
            f"/events/{event_id}/participants",
            params={
                "requester_messenger_id": requester_messenger_id,
                "offset": offset,
                "limit": limit,
            },
        )

    def get_my_events(self, messenger_id: int):
        """GET /events/my?user_id={messenger_id}"""
        return self.get("/events/my", params={"user_id": messenger_id})

    def create_event(
        self,
        *,
        name: str,
        description: str,
        bonus_code: str,
        points: int,
        date: str,
        city_id: str,
        location: str | None = None,
        app_tag_ids: list[str] | None = None,
        creator_id: str | None = None,
    ):
        """POST /events"""
        payload = {
            "name": name,
            "description": description,
            "bonus_code": bonus_code,
            "points": points,
            "date": date,
            "city_id": city_id,
            "location": location,
            "app_tag_ids": app_tag_ids or [],
        }
        if creator_id is not None:
            payload["creator_id"] = creator_id
        return self.post("/events", json=payload)

    def delete_event(self, event_id: str):
        """DELETE /events/{event_id}"""
        return self.delete(f"/events/{event_id}")

    def sign_out(self, participation_id: str):
        """DELETE /events/sign-out"""
        return self.delete("/events/sign-out", json={"participation_id": participation_id})

    def sign_up(self, event_id: str, messenger_id: int):
        """POST /events/sign-up"""
        return self.post(
            "/events/sign-up", json={"event_id": event_id, "messenger_id": messenger_id}
        )

    def enter_code(self, participation_id: str, bonus_code: str):
        """POST /events/code"""
        return self.post(
            "/events/code", json={"participation_id": participation_id, "bonus_code": bonus_code}
        )

    def update_event(self, event_id: str, payload: dict):
        """PATCH /events/{event_id}"""
        return self.patch(f"/events/{event_id}", json=payload)

--- ./src/bot/core/services/event.py ---
from datetime import datetime, timezone
from typing import Any
from uuid import UUID

from core.clients.event import EventClient
from core.schemas.event import EventSchema
from core.schemas.user import UserSchema
from core.services.base import BaseService
from core.services.registry import BaseServiceRegistry
from core.utils.logger import logger

DEFAULT_CITY_UUID = "be9da3ba-a0e4-42f9-9ae2-9875c8625083"  # SPB
# TODO убрать хардкод, брать city_id из профиля пользователя


class EventService(BaseService):
    """
    сервис мероприятий на стороне бота.

    на данный момент:
    - ходит в backend через EventClient
    - умеет получать список мероприятий по городу
    - умеет получать одно мероприятие по id
    - создает мероприятие через клиент используя мастера создания
    """

    def __init__(self, registry: BaseServiceRegistry, client: EventClient):
        # DI может вызвать клиент без аргументов.
        super().__init__(registry)
        self.client = client

    def get_event_participants(
        self,
        event_id: str,
        requester_messenger_id: int,
        offset: int = 0,
        limit: int = 50,
    ) -> list[dict[str, Any]]:
        """
        возвращает список EventSchema для города
        пока что используем дефолтный город - спб
        """
        resp = self.client.get_event_participants(
            event_id=event_id,
            requester_messenger_id=requester_messenger_id,
            offset=offset,
            limit=limit,
        )

        if resp.status_code != 200:
            raise Exception(
                f"Error while getting event participants: {resp.status_code}\n{resp.text}"
            )

        body = resp.json()
        return body if isinstance(body, list) else []

    def get_events(self, city_id: str, offset: int = 0, limit: int = 50) -> list[EventSchema]:
        """
        возвращает список EventSchema для города
        пока что используем дефолтный город - спб
        """
        resp = self.client.get_city_events(city_id=city_id, offset=offset, limit=limit)

        if resp.status_code != 200:
            raise Exception(f"Error while getting events: {resp.status_code}\n{resp.text}")

        body = resp.json()
        return [self._map_event(item) for item in body]

    def get_event_by_id(self, id: str) -> EventSchema | None:
        resp = self.client.get_event_info(id)

        logger.info(
            "[EventService::get_event_by_id] event_id=%r status=%s url=%r method=%r body=%r",
            id,
            resp.status_code,
            getattr(getattr(resp, "request", None), "url", None),
            getattr(getattr(resp, "request", None), "method", None),
            resp.text,
        )

        if resp.status_code == 404:
            return None

        if resp.status_code != 200:
            # TODO сделать нормальную обработку
            logger.error(f"[EventService] get_events failed: {resp.status_code} {resp.text}")
            return None

        return self._map_event(resp.json())

    def sign_up(self, event_id: str, messenger_id: int) -> bool:
        """messenger_id= = peer.id (как на бэкенде: user.get_by_messenger_id)"""
        resp = self.client.sign_up(event_id=event_id, messenger_id=messenger_id)
        return resp.status_code == 200

    def delete_event(self, event_id: str) -> bool:
        resp = self.client.delete_event(event_id)
        return resp.status_code == 204  # in [200, 204]

    def update_event(self, event_id: str, payload: dict) -> EventSchema | None:
        resp = self.client.update_event(event_id, payload)
        if resp.status_code != 200:
            logger.error(f"[EventService] update_event failed: {resp.status_code}\n{resp.text}")
            return None
        return self._map_event(resp.json())

    def _get_user_participations_raw(self, messenger_id: int) -> list[dict]:
        """
        GET /events/my?user_id=<messenger_id>
        на бекэнд возвращается список participation (без response_model),
        поэтому на стороне бота парсим максимально терпимо
        """
        resp = self.client.get_my_events(messenger_id=messenger_id)
        if resp.status_code != 200:
            raise Exception(f"Error while getting user events: {resp.status_code}\n{resp.text}")

        body = resp.json()
        # ожидаем list[dict]
        if isinstance(body, list):
            return body
        return []

    def _find_participation_for_event(
        self, messenger_id: int, event_id: str
    ) -> tuple[str | None, bool | None]:
        """
        ищем participation_id для конкретного event_id
        возвращаем (participation_id, is claimed)
        """
        items = self._get_user_participations_raw(messenger_id)
        target_event_id = str(event_id)

        for item in items:
            if not isinstance(item, dict):
                continue

            pid = item.get("id")
            ev_id = item.get("event_id")
            is_claimed = item.get("is_claimed")

            # если event вложен
            if ev_id is None and isinstance(item.get("event"), dict):
                ev_id = item["event"].get("id")

            if ev_id is None:
                continue

            if str(ev_id) == target_event_id:
                return (
                    str(pid) if pid else None,
                    bool(is_claimed) if is_claimed is not None else None,
                )

        return (None, None)

    def sign_out_by_event(self, event_id: str, messenger_id: int) -> bool:
        """
        backend sign-out requires participation_id, so:
        1. GET /event/my
        2. находим participation_id для event_id
        3. DELETE /events/sign-out
        """
        participation_id, _ = self._find_participation_for_event(messenger_id, event_id)
        if not participation_id:
            return False
        resp = self.client.sign_out(participation_id=participation_id)
        return resp.status_code == 200

    def enter_code_by_event(
        self, event_id: str, messenger_id: int, bonus_code: str
    ) -> dict[str, Any] | None:
        """
        аналогично sign-out: нужен participation_id
        возвращает обновленного пользователя (ResponseUserSchema) на успехе
        """
        participation_id, _ = self._find_participation_for_event(messenger_id, event_id)
        if not participation_id:
            return None
        resp = self.client.enter_code(participation_id=participation_id, bonus_code=bonus_code)
        if resp.status_code != 200:
            return None
        return resp.json()

    def get_participation_status(self, event_id: str, messenger_id: int) -> dict[str, Any] | None:
        participation_id, is_claimed = self._find_participation_for_event(messenger_id, event_id)
        if not participation_id:
            return None
        return {
            "participation_id": participation_id,
            "is_claimed": bool(is_claimed) if is_claimed is not None else False,
        }

    def enter_code(self, user_id: int, code: str):
        # TODO: replace with client usage
        return code in ["code", "код"]

    def create_event(
        self,
        *,
        name: str,
        description: str,
        bonus_code: str,
        points: int,
        date_obj: datetime,
        city_id: str,
        creator_id: str | None = None,
    ):
        """
        создает мероприятие через backend и возвращает dict из backend'a
        - ожидает, что все данные уже провалидированы и разобраны
        - метод не мапит ответ backend'a в EventSchema и не делает никакой обработки, а возвращает ровно тот json, который вернул backend (dect)
        - может бросить Exception, если status_code не 201/200
        """
        date_iso = date_obj.isoformat()

        resp = self.client.create_event(
            name=name,
            description=description,
            bonus_code=bonus_code,
            points=points,
            date=date_iso,
            city_id=city_id,
            creator_id=creator_id,
        )

        if resp.status_code not in [200, 201]:
            raise Exception(f"Error while creating event: {resp.status_code}\n{resp.text}")

        return resp.json()

    def create_from_wizard(self, user: UserSchema | None, data: dict[str, Any]) -> dict[str, Any]:
        """
        обрабатываем данные полученный из мастера создания,
        посылаем их в бекенд.
        """

        name = data.get("event_name")
        date_obj = data.get("event_date")
        time_obj = data.get("event_time")
        location = data.get("event_location")
        organizer_raw = data.get("event_organizer") or ""
        description = data.get("event_description") or ""
        points = data.get("event_points")
        app_tag_ids = data.get("event_app_tag_ids") or []
        bonus_code = data.get("event_bonus_code")

        event_dt = datetime.combine(date_obj, time_obj).replace(tzinfo=timezone.utc)
        date = event_dt.isoformat().replace("+00:00", "Z")

        creator_id: str | None = None
        if organizer_raw == "-":
            if not user.id:
                raise Exception("Cannot use '-' for organizer, current user has no id")
            creator_id = user.id
        else:
            try:
                creator_id = str(UUID(organizer_raw))
            except Exception as e:
                raise ValueError(f"Invalid organizer UUID: {organizer_raw!r}") from e

        resp = self.client.create_event(
            name=name,
            description=description,
            bonus_code=bonus_code,
            points=points,
            date=date,
            city_id=DEFAULT_CITY_UUID,
            location=location,
            app_tag_ids=app_tag_ids,
            creator_id=creator_id,
        )

        if resp.status_code not in [200, 201]:
            raise Exception(f"Error while creating event: {resp.status_code}\n{resp.text}")

        return self._map_event(resp.json())

    def _map_event(self, body: dict) -> EventSchema:
        """преобразует json из backend'a в EventSchema бота"""
        date_raw = body.get("date")
        date = self._parse_iso_datetime(date_raw)

        return EventSchema(
            id=body.get("id"),
            name=body.get("name", ""),
            date=date,
            description=body.get("description", "") or "",
            location=body.get("location"),
            tags=body.get("tags"),
            bonus_code=body.get("bonus_code"),
            points=body.get("points"),
            city_id=body.get("city_id"),
            creator_id=body.get("creator_id"),
            creator_name=body.get("creator_name"),
        )

    def _parse_iso_datetime(self, value: str | None) -> datetime:
        if not value:
            return datetime.now()

        if value.endswith("Z"):
            value = value[:-1] + "+00:00"

        try:
            return datetime.fromisoformat(value)
        except Exception as e:
            logger.error(f"[EventService] failed to parse date {value!r}, returing now(): {e}")
            return datetime.now()

--- ./src/bot/core/handlers/points.py ---
from datetime import datetime

from dialog_bot_sdk.entities.messaging import UpdateInteractiveMediaEvent

from core.bot_kit.fsm import FSMContext
from core.bot_kit.router import Router
from core.config import bot
from core.handlers.events_ui import send_event_card, send_points_page
from core.schemas.event import EventSchema
from core.schemas.user import UserSchema
from core.services.event import EventService
from core.utils import delete_prev_message_by_peer

DEFAULT_CITY_UUID = "be9da3ba-a0e4-42f9-9ae2-9875c8625083"  # SPB
# TODO уйти от хардкода, брать город пользователя


points_rt = Router()


def _format_event_line(event: EventSchema) -> str:
    date_val = getattr(event, "date", None)
    if isinstance(date_val, datetime):
        date_str = date_val.strftime("%d.%m.%Y %H:%M")
    else:
        # на случай, если backend отдаст строку/иное
        date_str = str(date_val)

    name = getattr(event, "name", "-")
    location = getattr(event, "location", "-")
    event_id = getattr(event, "id", "-")

    return f" - {name} --- {date_str} --- ({location}) --- `{event_id}`"


def _safe_int(value, default: int = 0) -> int:
    try:
        if value is None:
            return default
        return int(value)
    except Exception:
        return default


@bot.di
def points_menu_handler(
    event: UpdateInteractiveMediaEvent, context: FSMContext, event_service: EventService
):
    delete_prev_message_by_peer(bot, event.peer)
    context.clear()
    send_points_page(event.peer, offset=0, event_service=event_service)


@bot.di
def points_page_handler(
    event: UpdateInteractiveMediaEvent, context: FSMContext, event_service: EventService
):
    delete_prev_message_by_peer(bot, event.peer)
    context.set_state(None)

    try:
        offset = int(event.data.value)
        if offset < 0:
            offset = 0
    except Exception:
        offset = 0

    send_points_page(event.peer, offset=offset, event_service=event_service)


@bot.di
def points_event_open_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    event_service: EventService,
    user: UserSchema | None,
):
    delete_prev_message_by_peer(bot, event.peer)
    context.set_state(None)

    event_id = str(event.data.value or "").strip()
    send_event_card(
        event.peer, event_id=event_id, event_service=event_service, user=user, ctx_name="points"
    )

--- ./src/bot/core/handlers/events_pages.py ---
from core.schemas.event import EventSchema
from core.services.event import EventService

EVENTS_PAGE_SIZE = 10


def fetch_events_page(
    *, event_service: EventService, city_id: str, offset: int, limit: int
) -> tuple[list[EventSchema], bool, bool, int | None]:
    events_raw = event_service.get_events(city_id=city_id, offset=offset, limit=limit + 1)

    has_next = len(events_raw) > limit
    has_prev = offset > 0
    page_events = events_raw[:limit]

    total: int | None = None
    if not has_next:
        total = offset + len(page_events)
    else:  # TODO оптимизировать. на каждом листании тянем список всех мероприятий.
        all_events = event_service.get_events(city_id=city_id, offset=0, limit=2**31)
        total = len(all_events)

    return page_events, has_prev, has_next, total


def render_events_list_text(
    *, events: list[EventSchema], offset: int, total: int | None, footer: str | None = None
) -> str:
    if not events:
        return "⚠️ Мероприятий не найдено."

    header_total = str(total) if total is not None else "?"
    lines = [f"Мероприятия (показаны {offset + 1} - {offset + len(events)} из {header_total}):\n"]

    for event in events:
        date_str = event.date.strftime("%d.%m.%Y %H:%M")
        lines.append(f" - {event.name} --- {date_str} --- ({event.location}) --- `{event.id}`")

    if footer:
        lines.append("")
        lines.append(footer)

    return "\n".join(lines)

--- ./src/bot/core/markups/points.py ---
from dialog_bot_sdk.interactive_media import Button, MediaGroupBuilder

from core.markups.pagination import pagination_keyboard


def points_menu_keyboard():
    return MediaGroupBuilder(
        [
            Button("1", "points_leaderboard", "🏆 Лидерборд"),
            Button("2", "volunteer_home", "⬅️ В дом волонтёра"),
        ]
    ).build()


def back_to_points_menu():
    return MediaGroupBuilder([Button("1", "points", "⬅️ К баллам")]).build()


def leaderboard_keyboard():
    return MediaGroupBuilder(
        [Button("1", "points_leaderboard", "🔂 Обновить"), Button("2", "points", "⬅️ К баллам")]
    ).build()


def points_events_pagination_keyboard(*, offset: int, limit: int, has_prev: bool, has_next: bool):
    return pagination_keyboard(
        offset=offset,
        limit=limit,
        has_prev=has_prev,
        has_next=has_next,
        prev_media_id="points_prev",
        next_media_id="points_next",
        include_leave=False,
        keep_layout=True,
        hide_when_all_placeholders=True,
    )

--- ./src/bot/core/markups/pagination.py ---
from __future__ import annotations

from dialog_bot_sdk.entities.messaging import InteractiveMediaStyle
from dialog_bot_sdk.interactive_media import Button, InteractiveMediaGroup, MediaGroupBuilder


def pagination_keyboard(
    *,
    offset: int,
    limit: int,
    has_prev: bool,
    has_next: bool,
    prev_media_id: str,
    next_media_id: str,
    prev_label: str = "⬅️ Назад",
    next_label: str = "➡️ Вперёд",
    include_leave: bool = True,
    leave_media_id: str = "leave",
    leave_value: str | None = None,
    leave_label: str | None = None,
    style: InteractiveMediaStyle | None = None,
    keep_layout: bool = False,
    placeholder_media_id: str = "noop",
    placeholder_value: str = "noop",
    placeholder_label: str = " ",
    hide_when_all_placeholders: bool = False,
    add_view_by_uuid_button: bool = False,
    view_by_uuid_media_id: str = "view_by_uuid",
    view_by_uuid_value: str = "events",
    view_by_uuid_label: str = "🔎 По UUID",
) -> list[InteractiveMediaGroup]:
    """
    унифицированная пагинация.

    keep_layout=True:
      если add_view_by_uuid_button=True:
        - если include_leave=True: 4 слота [prev|ph] [leave|ph] [uuid] [next|ph]
        - если include_leave=False: 3 слота [prev|ph] [uuid] [next|ph]
      если add_view_by_uuid_button=False:
        - если include_leave=True: 3 слота [prev|ph] [leave|ph] [next|ph]
        - если include_leave=False: 2 слота [prev|ph] [next|ph]

    hide_when_all_placeholders=True: если из кнопок только ph - то возвращаем пустой лист.
    """

    def _btn(media_id: str, value: str, label: str) -> Button:
        if style is None:
            return Button(media_id=media_id, value=value, label=label)
        return Button(media_id, value, label, style)

    def _ph(slot: str) -> Button:
        return _btn(
            placeholder_media_id + "_" + slot,
            placeholder_value,
            placeholder_label,
        )

    def _prev() -> Button:
        return _btn(prev_media_id, str(max(offset - limit, 0)), prev_label)

    def _next() -> Button:
        return _btn(next_media_id, str(offset + limit), next_label)

    def _view_by_uuid() -> Button:
        return _btn(
            view_by_uuid_media_id,
            str(view_by_uuid_value),
            str(view_by_uuid_label),
        )

    def _leave() -> Button | None:
        if not (include_leave and leave_value and leave_label):
            return None
        return _btn(leave_media_id, str(leave_value), str(leave_label))

    # --- 3-slot layout for include_leave
    if include_leave and keep_layout:
        buttons: list[Button] = []
        leave_btn = _leave()

        buttons.append(_prev() if has_prev else _ph("prev"))
        buttons.append(leave_btn if leave_btn is not None else _ph("mid"))

        # --- 4-slot: [prev] [leave] [uuid] [next]
        if add_view_by_uuid_button:
            buttons.append(_view_by_uuid())

        buttons.append(_next() if has_next else _ph("next"))

        if hide_when_all_placeholders and all(
            str(getattr(b, "media_id", "")).startswith(placeholder_media_id + "_") for b in buttons
        ):
            return []
        return MediaGroupBuilder(buttons).build()

    # --- default linear layout (как было)
    buttons: list[Button] = []
    if has_prev:
        buttons.append(_prev())
    elif keep_layout:
        buttons.append(_ph("prev"))

    leave_btn = _leave()
    if leave_btn is not None:
        buttons.append(leave_btn)

    if add_view_by_uuid_button:
        buttons.append(_view_by_uuid())

    if has_next:
        buttons.append(_next())
    elif keep_layout:
        buttons.append(_ph("next"))

    if hide_when_all_placeholders and all(
        str(getattr(b, "media_id", "")).startswith(placeholder_media_id + "_") for b in buttons
    ):
        return []

    return MediaGroupBuilder(buttons).build() if buttons else []

--- ./src/bot/core/schemas/event.py ---
from datetime import datetime


class EventSchema:
    def __init__(
        self,
        id: str,
        name: str,
        date: datetime,
        description: str,
        *,
        location: str | None = None,
        tags: list | None = None,
        bonus_code: str | None = None,
        points: int | None = None,
        city_id: str | None = None,
        creator_id: str | None = None,
        creator_name: str | None = None,
    ):
        self.id = id
        self.name = name
        self.date = date
        self.description = description
        self.location = location
        self.tags = tags
        self.bonus_code = bonus_code
        self.points = points
        self.city_id = city_id
        self.creator_id = creator_id
        self.creator_name = creator_name

--- ./src/bot/core/utils/messages.py ---
from dialog_bot_sdk.bot import DialogBot
from dialog_bot_sdk.entities.messaging import Message, UpdateMessage


def find_prev_bot_message(bot: DialogBot, history: list[Message]) -> Message | None:
    for msg in history:
        if msg.sender_peer.id == bot.user_info.user.peer.id:
            return msg
    return None


def delete_prev_message(bot: DialogBot, message: UpdateMessage) -> bool:
    # TODO: Ability to optimize - save last bot message in bot object
    factories = [
        lambda: bot.messaging.load_message_history_sync(message.peer),
        lambda: bot.messaging.load_message_history_sync(message.peer, limit=8),
        lambda: bot.messaging.load_message_history_sync(message.peer, limit=16),
        lambda: bot.messaging.load_message_history_sync(message.peer, limit=32),
    ]

    for hist_factory in factories:
        msg = find_prev_bot_message(bot, hist_factory())
        if msg:
            bot.messaging.delete_sync(msg)
            return True

        try:
            history = hist_factory()
        except Exception:
            # не валим обработчик если не смогли получить историю
            continue

        msg = find_prev_bot_message(bot, history)
        if msg is not None:
            try:
                bot.messaging.delete_sync(msg)
                return True
            except Exception:
                return False

    return False


def delete_prev_message_by_peer(bot: DialogBot, peer) -> bool:
    factories = [
        lambda: bot.messaging.load_message_history_sync(peer),
        lambda: bot.messaging.load_message_history_sync(peer, limit=8),
        lambda: bot.messaging.load_message_history_sync(peer, limit=16),
        lambda: bot.messaging.load_message_history_sync(peer, limit=32),
    ]

    bot_id = bot.user_info.user.peer.id
    for hist_factory in factories:
        try:
            history = hist_factory()
        except Exception:
            # не валим обработчик если не смогли получить историю
            continue

        for msg in history:
            if msg.sender_peer.id == bot_id:
                try:
                    bot.messaging.delete_sync(msg)
                    return True
                except Exception:
                    return False

    return False

--- ./src/bot/core/utils/logger.py ---
import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path

LOG_FILE = Path(os.getenv("BOT_LOG_FILE", "bot.log"))

LOG_FORMAT = "[%(asctime)s] [%(levelname)s] %(message)s"
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"

logger = logging.getLogger("pomogai_bot")

level_name = os.getenv("LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
logger.setLevel(level)

formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT)

if not logger.hasHandlers():
    console_handler = logging.StreamHandler()
    console_handler.setFormatter(formatter)
    logger.addHandler(console_handler)

    if os.getenv("BOT_LOG_TO_FILE", "1") == "1":
        file_handler = RotatingFileHandler(
            LOG_FILE,
            maxBytes=5_000_000,  # 5 MB
            backupCount=3,
            encoding="utf-8",
        )
        file_handler.setFormatter(formatter)
        logger.addHandler(file_handler)

logger.propagate = False

--- .gitignore ---
# Python
__pycache__/
*.py[cod]
*.so
src/settings.py
test.py

# Virtual environments
.venv
venv
.venv/
node_modules

# IDEs and editors
.vscode/
.idea/
*.swp
.gigaide

# Alembic
alembic/versions/*.pyc

# Logs
*.log

# Database files
*.sqlite3

# Byte-compiled / optimized / DLL files
*$py.class


# Distribution / packaging
.Python
build/
develop-eggs/

downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
pip-wheel-metadata/
*.egg-info/
.installed.cfg
*.egg

# Testing
.tox/
.nox/
.coverage
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
.pyre/
.pytype/

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
.env

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

prettify_logs.py
format
check_events_keyset.zsh

--- 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,
    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 [],
    )
    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/filters/filters/report.py ---
from __future__ import annotations

from dataclasses import dataclass

from sqlalchemy import exists, select
from sqlalchemy.sql import Select

from ...db.models import Event, EventReport
from ..base import FilterContext, QueryFilter


@dataclass(slots=True)
class ReportEventsFilter(QueryFilter):
    """
    has_report:
      None  -> не фильтруем
      True  -> только архив (есть EventReport)
      False -> только активные (нет EventReport)
    """

    has_report: bool | None = None

    def is_enabled(self) -> bool:
        return self.has_report is not None

    def apply(self, query: Select, ctx: FilterContext) -> Select:
        subq = select(1).select_from(EventReport).where(EventReport.event_id == Event.id)
        cond = exists(subq)
        if self.has_report is True:
            return query.where(cond)
        return query.where(~cond)

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

from sqlalchemy import and_, delete, or_, select, update
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import selectinload

from ..db.models import AppTagEvent, Event
from ..filters import FilterContext, FilterSpecification
from ..utils.logger import logger
from .base import SQLRepository


class EventRepository(SQLRepository):
    model = Event

    def _base_events_query(self):
        return (
            select(self.model)
            .options(selectinload(self.model.app_tag_events).selectinload(AppTagEvent.app_tag))
            .options(selectinload(self.model.creator))
        )

    def _base_city_events_query(self, city_id: UUID):
        query = self._base_events_query()
        if city_id is not None:
            query = query.where(self.model.city_id == city_id)
        return query

    def _log_sql(self, query) -> None:
        try:
            compiled = query.compile(
                dialect=postgresql.dialect(),
                compile_kwargs={"literal_binds": True},
            )
            logger.debug(f"SQL:\n{compiled}")
        except Exception as e:
            logger.debug(f"SQL (repr):\n{query!r}")
            logger.debug(f"error: {e}")

    def _apply_keyset(
        self,
        query,
        *,
        cursor_date: datetime | None,
        cursor_id: UUID | None,
    ):
        if cursor_date is None or cursor_id is None:
            return query

        # order is (date desc, id desc) => next page: strictly smaller
        return query.where(
            or_(Event.date < cursor_date, and_(Event.date == cursor_date, Event.id < cursor_id))
        )

    async def get_cities_events(
        self,
        city_id: UUID | None,
        offset: int,
        limit: int,
        *,
        cursor_date: datetime | None = None,
        cursor_id: UUID | None = None,
        spec: FilterSpecification | None = None,
        ctx: FilterContext | None = None,
    ):
        # Phase 1: select ids with filters/joins, apply keyset and ordering, limit
        ids_q = select(Event.id, Event.date)
        if city_id is not None:
            ids_q = ids_q.where(Event.city_id == city_id)

        if spec is not None:
            ids_q = spec.apply(ids_q, ctx or FilterContext())

        ids_q = ids_q.group_by(Event.id, Event.date)
        ids_q = self._apply_keyset(ids_q, cursor_date=cursor_date, cursor_id=cursor_id)
        ids_q = ids_q.order_by(Event.date.desc(), Event.id.desc()).limit(limit)

        # NOTE: if cursor is used, offset is intentionally ignored
        if cursor_date is None and cursor_id is None:
            ids_q = ids_q.offset(offset)

        self._log_sql(ids_q)
        ids_res = await self.session.execute(ids_q)
        ids = [row[0] for row in ids_res.all()]
        if not ids:
            return []

        # Phase 2: load events with relationships
        query = (
            self._base_events_query()
            .where(Event.id.in_(ids))
            .order_by(Event.date.desc(), Event.id.desc())
        )
        self._log_sql(query)
        result = await self.session.execute(query)
        return result.scalars().all()

    async def get_by_id(self, event_id: UUID) -> Event | None:
        query = (
            select(self.model)
            .where(self.model.id == event_id)
            .options(selectinload(self.model.app_tag_events).selectinload(AppTagEvent.app_tag))
            .options(selectinload(self.model.creator))
        )
        result = await self.session.execute(query)
        return result.scalar_one_or_none()

    async def update_fields(self, event_id: UUID, data: dict) -> Event | None:
        if not data:
            return None

        query = (
            update(self.model).where(self.model.id == event_id).values(**data).returning(self.model)
        )
        result = await self.session.execute(query)
        await self.session.commit()
        return result.scalar_one_or_none()

    async def set_app_tags(self, event_id: UUID, app_tag_ids: list[UUID]) -> None:
        uniq = list(dict.fromkeys(app_tag_ids or []))

        await self.session.execute(delete(AppTagEvent).where(AppTagEvent.event_id == event_id))

        self.session.add_all([AppTagEvent(event_id=event_id, app_tag_id=tid) for tid in uniq])

        await self.session.commit()

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

from datetime import datetime
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

    # keyset pagination cursor (date desc, id desc)
    cursor_date: datetime | None = None
    cursor_id: UUID | None = None

    requester_messenger_id: int | None = None

    only_claimed: bool = False
    only_organizer: bool = False
    only_participant: bool = False
    # None - не фильтруем; True - архивные; False - не архивные
    has_report: bool | None = None

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

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

    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 provieded 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 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(
                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:
            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

Editor is loading...
Leave a Comment