Untitled

 avatar
4ae4d
plain_text
3 months ago
89 kB
6
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
f=src/backend/app/filters/filters/organizer.py
f=src/backend/app/repositories/event.py
f=src/backend/app/schemas/event.py
f=src/bot/core/clients/event.py
f=src/bot/core/services/event.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,
    requester_messenger_id: Annotated[int, Query()],
    session: SessionDep,
):
    admin = Administrator(session)
    service = EventService(admin)
    return await service.update_event(
        event_id=event_id,
        data=body,
        requester_messenger_id=requester_messenger_id,
    )


@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()],
    requester_messenger_id: Annotated[int, Query()],
    session: SessionDep,
):
    """удалить мероприятие по id"""
    admin = Administrator(session)
    service = EventService(admin)

    deleted = await service.delete_event(
        event_id=event_id,
        requester_messenger_id=requester_messenger_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_delete_event, can_update_event, can_view_event_participants
from ..schemas import (
    EventCreateSchema,
    EventSearchQuerySchema,
    EventSignOutSchema,
    EventSignUpSchema,
    EventUpdateSchema,
    ResponseEventParticipantSchema,
)
from ..utils.errors import AuthError, 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, requester_messenger_id: int) -> bool:
        """
        удалить мероприятие по id.
        true - получилось удалить
        false - не получилось удалить
        """
        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:
                return False

            if not can_delete_event(requester, event):
                raise AuthError("Access denied")

            await admin.event.delete(event_id)
            return True

    async def update_event(
        self,
        event_id: UUID,
        data: EventUpdateSchema,
        requester_messenger_id: int,
    ):
        """
        обновляем мероприятие,
        проверяем что количество поинтов адекватное
        """
        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_update_event(requester, event):
                raise AuthError("Access denied")

            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_delete_event,
    can_update_event,
    can_view_event_participants,
    can_view_event_report,
    is_admin,
    is_event_organizer,
    is_moderator,
)

__all__ = [
    "can_create_event_report",
    "can_delete_event",
    "can_update_event",
    "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


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)


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


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

--- src/backend/app/filters/filters/organizer.py ---
from __future__ import annotations

from dataclasses import dataclass

from sqlalchemy.sql import Select

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


@dataclass(slots=True)
class OrganizerEventsFilter(QueryFilter):
    """only_organizer: события, где requester является creator"""

    enabled: bool = False

    def is_enabled(self) -> bool:
        return bool(self.enabled)

    def apply(self, query: Select, ctx: FilterContext) -> Select:
        requester_user_id = ctx.get("requester_user_id")
        if requester_user_id is None:
            return query

        return query.where(Event.creator_id == requester_user_id)

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

from sqlalchemy import and_, delete, func, 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.event_organizers))
            .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.event_organizers))
            .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()

    async def get_cities_events_with_total(
        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,
    ) -> tuple[list[Event], int]:
        """
        как get_cities_events, но дополнительно возвращает total count
        для текущих фильтров (и cursor, если задан).
        total считается на стороне бд (без limit/offset).
        """

        ids_base = select(Event.id, Event.date)
        if city_id is not None:
            ids_base = ids_base.where(Event.city_id == city_id)

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

        ids_base = ids_base.group_by(Event.id, Event.date)
        ids_base = self._apply_keyset(ids_base, cursor_date=cursor_date, cursor_id=cursor_id)

        total_q = select(func.count()).select_from(ids_base.subquery())
        self._log_sql(total_q)
        total_res = await self.session.execute(total_q)
        total = int(total_res.scalar() or 0)

        ids_q = ids_base.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 ([], total)

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

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

--- src/bot/core/clients/event.py ---
from core.clients.base import BaseApiClient


class EventClient(BaseApiClient):
    def search_events(
        self,
        *,
        city_id: str | None = None,
        offset: int = 0,
        limit: int = 50,
        requester_messenger_id: int | None = None,
        only_claimed: bool = False,
        only_organizer: bool = False,
        only_participant: bool = False,
        has_report: bool | None = None,
        tag_ids: list[str] | None = None,
        cursor_date: str | None = None,
        cursor_id: str | None = None,
    ):
        """GET /events/search"""
        params: dict = {
            "offset": offset,
            "limit": limit,
            "only_claimed": bool(only_claimed),
            "only_organizer": bool(only_organizer),
            "only_participant": bool(only_participant),
        }
        if city_id is not None:
            params["city_id"] = city_id
        if requester_messenger_id is not None:
            params["requester_messenger_id"] = int(requester_messenger_id)
        if has_report is not None:
            params["has_report"] = bool(has_report)
        if tag_ids:
            params["tag_ids"] = list(tag_ids)
        if cursor_date is not None:
            params["cursor_date"] = cursor_date
        if cursor_id is not None:
            params["cursor_id"] = cursor_id
        return self.get("/events/search", params=params)

    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 _parse_total_count(self, resp) -> int | None:
        try:
            raw = None
            headers = getattr(resp, "headers", None)
            if headers is not None:
                raw = headers.get("X-Total-Count")
            if raw is None:
                return None
            return int(raw)
        except Exception:
            logger.warning("EventService failed to get total events count from header.")
            return None

    def search_events(
        self,
        *,
        city_id: str | None = None,
        offset: int = 0,
        limit: int = 50,
        requester_messenger_id: int | None = None,
        only_claimed: bool = False,
        only_organizer: bool = False,
        only_participant: bool = False,
        has_report: bool | None = None,
        tag_ids: list[str] | None = None,
        cursor_date: str | None = None,
        cursor_id: str | None = None,
    ) -> list[EventSchema]:
        resp = self.client.search_events(
            city_id=city_id,
            offset=offset,
            limit=limit,
            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,
            cursor_date=cursor_date,
            cursor_id=cursor_id,
        )
        if resp.status_code != 200:
            raise Exception(f"Error while searching events: {resp.status_code}\n{resp.text}")
        body = resp.json()
        return [self._map_event(item) for item in (body or [])]

    def search_events_with_total(
        self,
        *,
        city_id: str | None = None,
        offset: int = 0,
        limit: int = 0,
        requester_messenger_id: int | None = None,
        only_claimed: bool = False,
        only_organizer: bool = False,
        only_participant: bool = False,
        has_report: bool | None = None,
        tag_ids: list[str] | None = None,
        cursor_date: str | None = None,
        cursor_id: str | None = None,
    ) -> tuple[list[EventSchema], int | None]:
        resp = self.client.search_events(
            city_id=city_id,
            offset=offset,
            limit=limit,
            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,
            cursor_date=cursor_date,
            cursor_id=cursor_id,
        )
        if resp.status_code != 200:
            raise Exception(f"Error while searching events: {resp.status_code}\n{resp.text}")
        body = resp.json()
        items = [self._map_event(item) for item in (body or [])]
        total = self._parse_total_count(resp)
        return items, total

    def get_claimed_events_page(
        self,
        *,
        messenger_id: int,
        city_id: str | None,
        offset: int,
        limit: int,
    ) -> tuple[list[EventSchema], int | None]:
        # requester_messenger_id обязателен для only_[claimed|organizer|participant] на backend
        return self.search_events_with_total(
            city_id=city_id,
            offset=offset,
            limit=limit,
            requester_messenger_id=messenger_id,
            only_claimed=True,
        )

    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:
                raise Exception("Cannot use '-' for organizer, current user is None")
            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(timezone.utc)

        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(timezone.utc)

--- src/bot/core/handlers/moderation.py ---
import re
from datetime import datetime
from uuid import UUID

from dialog_bot_sdk.entities.messaging import UpdateInteractiveMediaEvent, UpdateMessage
from dialog_bot_sdk.interactive_media import Button

from core.bot_kit.fsm import FSMContext, State, StatesGroup
from core.bot_kit.router import Router
from core.config import bot
from core.handlers.events_ui import (
    build_back_to_event_keyboard,
    send_city_events_page,
    send_event_card,
    send_my_events_page,
    split_event_value,
)
from core.markups.event import event_actions_keyboard, format_event_details
from core.markups.tags import choices_from_backend_tags, tags_toggle_keyboard
from core.schemas.event import EventSchema
from core.schemas.user import UserSchema
from core.services.event import EventService
from core.services.report import ReportService
from core.services.tag import TagService
from core.utils import delete_prev_message, delete_prev_message_by_peer
from core.utils.events_filter import clear_context_keep_events_filters

from ..markups import (
    back_to_moderation_keyboard,
    event_edit_fields_keyboard,
    moderation_menu_keyboard,
)

TAGS_MEDIA_ID = "event_tags_toggle"
TAGS_TOGGLE_PREFIX = "toggle:"
TAGS_DONE_VALUE = "done"
DEFAULT_CITY_UUID = "be9da3ba-a0e4-42f9-9ae2-9875c8625083"  # SPB
# TODO убрать хардкод


events_rt = Router()


class EventEditTagsState(StatesGroup):
    tags = State()


class EventEditState(StatesGroup):
    wait_value = State()


class EventEnterCodeState(StatesGroup):
    wait_code = State()


class EventViewState(StatesGroup):
    wait_event_id = State()


class EventCreateState(StatesGroup):
    name = State()
    date = State()
    time = State()
    location = State()
    organizer = State()
    description = State()
    points = State()
    tags = State()
    code = State()


@bot.di
def moderation_menu_handler(event: UpdateInteractiveMediaEvent, context: FSMContext):
    clear_context_keep_events_filters(context)
    delete_prev_message_by_peer(bot, event.peer)

    bot.messaging.send_message(
        peer=event.peer,
        text=("Ты в доме модерации!\n\nПожалуйста, выбери, что хочешь сделать 👇"),
        interactive_media_groups=moderation_menu_keyboard(),
    )


@bot.di
def all_events_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    event_service: EventService,
    user: UserSchema | None,
):
    """
    показывает модератору список всех мероприятий в городе (пока один город)
    привязан к кнопке value="all_events" в moderation_menu_keyboard()
    реализована пагинация, в планах фильтры и интерактивная кнопка на каждое мероприятие
    дефолтный фильтр: ваш город, планируется возможность изменения дефолтного фильтра
    """
    delete_prev_message_by_peer(bot, event.peer)
    clear_context_keep_events_filters(context)
    send_city_events_page(
        event.peer,
        offset=0,
        event_service=event_service,
        ctx_name="moderation",
        context=context,
    )


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

    send_my_events_page(
        event.peer,
        offset=0,
        event_service=event_service,
        requester_messenger_id=event.peer.id,
        context=context,
    )


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

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

    send_my_events_page(
        event.peer,
        offset=offset,
        event_service=event_service,
        requester_messenger_id=event.peer.id,
        context=context,
    )


@bot.di
def my_events_open_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    event_service: EventService,
    report_service: ReportService,
    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="my_events",
        report_service=report_service,
    )


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

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

    send_city_events_page(
        event.peer,
        offset=offset,
        event_service=event_service,
        ctx_name="moderation",
        context=context,
    )


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

    event_id, ctx = split_event_value(event.data.value)
    ctx_name = ctx or "moderation"
    send_event_card(
        event.peer,
        event_id=event_id,
        event_service=event_service,
        user=user,
        ctx_name=ctx_name,
        report_service=report_service,
    )


@bot.di
def create_event_start_handler(
    event: UpdateInteractiveMediaEvent,
    user: UserSchema | None,
    context: FSMContext,
):
    """старт мастера создания мероприятия"""
    delete_prev_message_by_peer(bot, event.peer)

    """ DEBUG PART """
    bot.messaging.send_message(
        peer=event.peer,
        text="ПЕЧАТАЮ USER В ЛОГИ...",
    )
    print(user)

    bot.messaging.send_message(
        peer=event.peer,
        text="ПРОДОЛЖАЮ РАБОТУ ПО СЦЕНАРИЮ...",
    )
    """ DEBUG PART """

    context.clear()
    bot.messaging.send_message(
        peer=event.peer,
        text=("Создание мероприятия.\n\nШаг 1/9: введи, пожалуйста, название мероприятия:"),
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventCreateState.name)


@events_rt.message(state=EventCreateState.name)
@bot.di
def event_create_name_step(message: UpdateMessage, context: FSMContext):
    delete_prev_message(bot, message)

    name = message.message.text_message.text.strip()
    context.update_data({"event_name": name})

    bot.messaging.send_message(
        peer=message.peer,
        text=("Шаг 2/9: введи, пожалуйста, дату мероприятия (dd.mm.yyyy)\nНапример: `20.02.2002`:"),
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventCreateState.date)


@events_rt.message(state=EventCreateState.date)
@bot.di
def event_create_date_step(message: UpdateMessage, context: FSMContext):
    delete_prev_message(bot, message)

    date = message.message.text_message.text.strip()
    try:
        date_obj = datetime.strptime(date, "%d.%m.%Y").date()
    except ValueError:
        bot.messaging.send_message(
            peer=message.peer,
            text=(
                "⚠️ Я не смог распознать дату.\n"
                "Пожалуйста, введи в формате `dd.mm.yyyy`, например: `20.02.2002`:"
            ),
            interactive_media_groups=back_to_moderation_keyboard(),
        )
        return

    context.update_data({"event_date": date_obj})

    bot.messaging.send_message(
        peer=message.peer,
        text=("Шаг 3/9: введи время мероприятия в формате `HH:MM`\nНапример: `10:30`:"),
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventCreateState.time)


@events_rt.message(state=EventCreateState.time)
@bot.di
def event_create_time_step(message: UpdateMessage, context: FSMContext):
    delete_prev_message(bot, message)

    time = message.message.text_message.text.strip()
    try:
        time_obj = datetime.strptime(time, "%H:%M").time()
    except ValueError:
        bot.messaging.send_message(
            peer=message.peer,
            text=(
                "⚠️ Я не смог распознать время.\n"
                "Пожалуйста, введи в формате `HH:MM`, например: `10:30`:"
            ),
            interactive_media_groups=back_to_moderation_keyboard(),
        )
        return

    context.update_data({"event_time": time_obj})

    bot.messaging.send_message(
        peer=message.peer,
        text="Шаг 4/9: Введи локацию:",
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventCreateState.location)


@events_rt.message(state=EventCreateState.location)
@bot.di
def event_create_location_step(message: UpdateMessage, context: FSMContext):
    delete_prev_message(bot, message)

    location = message.message.text_message.text.strip()
    context.update_data({"event_location": location})
    bot.messaging.send_message(
        peer=message.peer,
        text='Шаг 5/9: Введи UUID организатора (введи минус "-", чтобы поставить организатором себя):',
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventCreateState.organizer)


@events_rt.message(state=EventCreateState.organizer)
@bot.di
def event_create_organizer_step(message: UpdateMessage, context: FSMContext):
    delete_prev_message(bot, message)

    raw = message.message.text_message.text.strip()
    if raw != "-":
        try:
            UUID(raw)
        except Exception:
            bot.messaging.send_message(
                peer=message.peer,
                text=(
                    "Организатор должен быть UUID пользователя или `-`.\n"
                    "Отправь `-`, чтобы поставить себя организатором, или напиши UUID.\n"
                    "пример UUID:  `d3ac9bc9-19ae-4ded-9687-cbde5ea14eb0`"
                ),
                interactive_media_groups=back_to_moderation_keyboard(),
            )
            return

    context.update_data({"event_organizer": raw})
    bot.messaging.send_message(
        peer=message.peer,
        text="Шаг 6/9: Введи описание:",
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventCreateState.description)


@events_rt.message(state=EventCreateState.description)
@bot.di
def event_create_description_step(message: UpdateMessage, context: FSMContext):
    delete_prev_message(bot, message)

    description = message.message.text_message.text.strip()
    context.update_data({"event_description": description})
    bot.messaging.send_message(
        peer=message.peer,
        text="Шаг 7/9: Введи количество очков начисляемых за мероприятие:",
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventCreateState.points)


@events_rt.message(state=EventCreateState.points)
@bot.di
def event_create_points_step(message: UpdateMessage, context: FSMContext, tag_service: TagService):
    delete_prev_message(bot, message)

    text = message.message.text_message.text.strip()
    try:
        points = int(text)
        if points <= 0 or points >= 2**31:
            raise ValueError
    except ValueError:
        bot.messaging.send_message(
            peer=message.peer,
            text=f"Количество баллов должно быть натуральным числом, меньшим {2**31}. Попробуй ещё раз.",
            interactive_media_groups=back_to_moderation_keyboard(),
        )
        return

    all_tags = tag_service.get_all_tags()
    choices = choices_from_backend_tags(all_tags)

    context.update_data(
        {
            "event_points": points,
            "event_tag_keys": [],
            "event_all_tags": [{"id": t.id, "name": t.name} for t in all_tags],
        }
    )

    _send_tags_toggle_ui(
        peer=message.peer,
        title="Шаг 8/9: Выбери теги мероприятий, затем нажми «Готово»:",
        choices=choices,
        selected_keys=set(),
        footer_buttons=[Button(media_id="leave", value="moderation", label="⬅️ В меню модерации")],
    )
    context.set_state(EventCreateState.tags)


@bot.di
def event_tags_toggle_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    tag_service: TagService,
    event_service: EventService,
):
    state = context.get_state()
    is_create = state == EventCreateState.tags or str(state) == str(EventCreateState.tags)
    is_edit = state == EventEditTagsState.tags or str(state) == str(EventEditTagsState.tags)
    if not (is_create or is_edit):
        return

    raw = str(event.data.value or "")

    data = context.get_data()

    # выбранные имена (keys)
    key_field = "event_tag_keys" if is_create else "edit_tag_keys"
    selected = set(data.get(key_field) or [])

    # теги backend (id+name)
    all_tags = data.get("event_all_tags") or data.get("edit_all_tags")
    if not all_tags:
        # если по какой-то причине нет в контексте, догрузим
        tags = tag_service.get_all_tags()
        all_tags = [{"id": t.id, "name": t.name} for t in tags]
    else:
        norm = []
        for t in all_tags:
            if isinstance(t, dict):
                norm.append(t)
            else:
                norm.append({"id": getattr(t, "id", None), "name": getattr(t, "name", None)})
        all_tags = norm

    # общий список choices
    choices = choices_from_backend_tags(all_tags)

    # DONE
    if raw == TAGS_DONE_VALUE:
        name_to_id = {t["name"]: t["id"] for t in all_tags if t.get("name") and t.get("id")}
        app_tag_ids = [name_to_id[n] for n in selected if n in name_to_id]

        delete_prev_message_by_peer(bot, event.peer)

        if is_create:
            context.update_data({"event_app_tag_ids": app_tag_ids})

            bot.messaging.send_message(
                peer=event.peer,
                text=(
                    "Шаг 9/9: введи код мероприятия:\n"
                    "Без пробелов, не больше 16 символов. Например: WELCOME2025"
                ),
                interactive_media_groups=back_to_moderation_keyboard(),
            )
            context.set_state(EventCreateState.code)
            return

        # EDIT TAGS
        event_id = data.get("edit_event_id")
        if not event_id:
            context.set_state(None)
            bot.messaging.send_message(
                peer=event.peer, text="Сессия редактирования сброшена. Открой мероприятие заново."
            )
            return

        updated = event_service.update_event(event_id, {"app_tag_ids": app_tag_ids})
        context.set_state(None)

        if not updated:
            _send_event_edit_menu(
                event.peer,
                event_id=event_id,
                event_service=event_service,
                note="⚠️ Не удалось обновить теги.",
                event_obj=event_service.get_event_by_id(event_id),
                show_organizer=True,
            )
            return

        _send_event_edit_menu(
            event.peer,
            event_id=event_id,
            event_service=event_service,
            note="✅ Теги обновлены.",
            event_obj=event_service.get_event_by_id(event_id),
            show_organizer=True,
        )
        return

    # TOGGLE
    if raw.startswith(TAGS_TOGGLE_PREFIX):
        key = raw[len(TAGS_TOGGLE_PREFIX) :].strip()
        if key in selected:
            selected.remove(key)
        else:
            selected.add(key)

        context.update_data({key_field: list(selected)})

        delete_prev_message_by_peer(bot, event.peer)

        if is_create:
            footer = [Button(media_id="leave", value="moderation", label="⬅️ В меню модерации")]
            title = "Шаг 8/9: Выбери теги мероприятия, затем нажми «Готово»:"
        else:
            # для редактирования возвращаемся в edit menu мероприятия
            event_id = data.get("edit_event_id") or ""
            footer = [
                Button(
                    media_id="event_menu_edit",
                    value=event_id,
                    label="⬅️ К редактированию мероприятия",
                )
            ]
            title = "Редактирование тегов: выбери теги, затем нажми «Готово»:"

        _send_tags_toggle_ui(
            peer=event.peer,
            title=title,
            choices=choices,
            selected_keys=set(selected),
            footer_buttons=footer,
        )
        return


@events_rt.message(state=EventCreateState.code)
@bot.di
def event_create_code_step(
    message: UpdateMessage,
    context: FSMContext,
    user: UserSchema | None,
    event_service: EventService,
    report_service: ReportService,
):
    delete_prev_message(bot, message)

    code = message.message.text_message.text.strip()
    if not re.fullmatch(r"^[A-Za-zА-Яа-я0-9]{1,16}$", code):
        bot.messaging.send_message(
            peer=message.peer,
            text="⚠️ Код должен быть комбинацией русских и латинских букв и цифр, длиной не больше 16 символов. Попробуй еще раз:",
            interactive_media_groups=back_to_moderation_keyboard(),
        )
        return
    context.update_data({"event_bonus_code": code})
    data = context.get_data()
    try:
        created = event_service.create_from_wizard(user, data)
    except Exception as e:
        bot.messaging.send_message(
            peer=message.peer,
            text="⚠️ Не удалось создать мероприятие.",
            interactive_media_groups=back_to_moderation_keyboard(),
        )
        context.clear()
        raise e
        return

    context.clear()

    send_event_card(
        message.peer,
        event_id=created.id,
        event_service=event_service,
        report_service=report_service,
        user=user,
        ctx_name="moderation_menu",
        note="Мероприятие создано успешно",
    )


@bot.di
def event_view_by_id_start(event: UpdateInteractiveMediaEvent, context: FSMContext):
    """старт диалога - просим у модератора UUID мероприятия"""
    delete_prev_message_by_peer(bot, event.peer)

    bot.messaging.send_message(
        peer=event.peer,
        text="Введи UUID мероприятия, которое хочешь посмотреть.\n\n",
        interactive_media_groups=back_to_moderation_keyboard(),
    )
    context.set_state(EventViewState.wait_event_id)


@events_rt.message(state=EventViewState.wait_event_id)
@bot.di
def event_view_by_id_step(
    message: UpdateMessage, context: FSMContext, event_service: EventService, user: UserSchema
):
    """читаем UUID, тянем мероприятие из бэкенда и показываем информацию"""
    delete_prev_message(bot, message)
    raw_id = message.message.text_message.text.strip()
    event = event_service.get_event_by_id(raw_id)

    if event is None:
        bot.messaging.send_message(
            peer=message.peer,
            text="⚠️ Мероприятие с таким UUID не найдено.\nПопробуй ещё раз. Пример UUID: `932cf1cb-1ee7-4a45-bc96-41a232f3f538`",
            interactive_media_groups=back_to_moderation_keyboard(),
        )
        return

    context.update_data({"current_event_id": event.id})
    context.set_state(None)

    send_event_card(
        message.peer,
        event_id=event.id,
        event_service=event_service,
        user=user,
        ctx_name="moderation_menu",
    )


def _update_event_card(
    peer,
    event_service: EventService,
    event_id: str,
    can_manage: bool,
    show_organizer: bool,
):  # deprecated?
    refreshed = event_service.get_event_by_id(event_id)
    part = event_service.get_participation_status(event_id, peer.id)
    is_participant = part is not None

    prev = bot.messaging.load_message_history_sync(peer=peer, limit=1)[0]
    bot.messaging.update_message_sync(
        prev,
        text=format_event_details(refreshed, show_code=True, show_organizer=show_organizer)
        if refreshed
        else "⚠️ Мероприятие не найдено",
        interactive_media_groups=event_actions_keyboard(
            event_id,
            is_participant=is_participant,
            can_manage=can_manage,
            back_value="moderation",
            back_label="⬅️ В меню модерации",
            enter_code_media_id="event_menu_enter_code",
            sign_up_media_id="event_menu_sign_up",
            sign_out_media_id="event_menu_sign_out",
            ctx="moderation_menu",
        ),
    )


@bot.di
def event_menu_sign_up_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, ctx = split_event_value(event.data.value)
    ctx_name = ctx or "moderation"
    ok = event_service.sign_up(event_id, event.peer.id)
    if not ok:
        note = "⚠️ Не удалось записаться на мероприятие."
    else:
        note = "✅ Запись на мероприятие прошла успешно!"

    send_event_card(
        event.peer,
        event_id=event_id,
        event_service=event_service,
        user=user,
        ctx_name=ctx_name,
        note=note,
    )


@bot.di
def event_menu_sign_out_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, ctx = split_event_value(event.data.value)
    ctx_name = ctx or "moderation"
    status = event_service.get_participation_status(event_id, event.peer.id)

    if not status:
        note = "⚠️ Не удалось отписаться: ты не записан на это мероприятие."
    elif status.get("is_claimed"):
        note = "⚠️ Нельзя отписаться: баллы уже получен за это участие."
    else:
        ok = event_service.sign_out_by_event(event_id, event.peer.id)
        if ok:
            note = "✅ Ты успешно отписался от мероприятия."
        else:
            note = "⚠️ Не удалось отписаться. Попробуй ещё раз."

    send_event_card(
        event.peer,
        event_id=event_id,
        event_service=event_service,
        user=user,
        ctx_name=ctx_name,
        note=note,
    )


@bot.di
def event_menu_enter_code_start_handler(event: UpdateInteractiveMediaEvent, context: FSMContext):
    delete_prev_message_by_peer(bot, event.peer)
    event_id, ctx = split_event_value(event.data.value)
    ctx_name = ctx or "moderation"
    context.update_data({"current_event_id": event_id, "current_event_ctx": ctx_name})
    context.set_state(EventEnterCodeState.wait_code)

    bot.messaging.send_message(
        peer=event.peer,
        text="🎟️ Введи бонус-код мероприятия (без пробелов):",
        interactive_media_groups=build_back_to_event_keyboard(event_id, ctx_name),
    )


@events_rt.message(state=EventEnterCodeState.wait_code)
@bot.di
def event_menu_enter_code_step(
    message: UpdateMessage, context: FSMContext, event_service: EventService, user: UserSchema
):
    delete_prev_message(bot, message)

    event_id = context.get_data().get("current_event_id")
    code = message.message.text_message.text.strip()
    note = None
    status = event_service.get_participation_status(event_id, message.peer.id)
    if not status:
        note = "⚠️ Код не принят, сначала нужно записаться на мероприятие"
    elif status.get("is_claimed"):
        note = "⚠️ Код не принят, баллы уже получены"
    else:
        result = event_service.enter_code_by_event(event_id, message.peer.id, code)
        if not result:
            note = "⚠️ Код не принят, проверь правильность кода"
        else:
            note = f"✅ Код принят. Текущие баллы: {result.get('points', '-')}"

    data = context.get_data()
    ctx_name = data.get("current_event_ctx") or "moderation"
    context.set_state(None)

    send_event_card(
        message.peer,
        event_id=event_id,
        event_service=event_service,
        user=user,
        ctx_name=ctx_name,
        note=note,
    )


@bot.di
def event_menu_delete_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, ctx = split_event_value(event.data.value)
    ctx_name = ctx or "moderation"
    if ctx_name not in ("moderation", "moderation_menu"):
        ctx_name = "moderation"

    try:
        ok = event_service.delete_event(event_id)
    except Exception:
        ok = False

    if ok:
        context.clear()
        if ctx_name == "moderation":
            send_city_events_page(
                event.peer,
                offset=0,
                event_service=event_service,
                ctx_name="moderation",
                note="Мероприятие удалено.",
            )
        else:
            bot.messaging.send_message(
                peer=event.peer,
                text="Мероприятие удалено.\n\nТы в доме модерации",
                interactive_media_groups=moderation_menu_keyboard(),
            )
        return

    send_event_card(
        event.peer,
        event_id=event_id,
        event_service=event_service,
        user=user,
        ctx_name=ctx_name,
        note="⚠️ Не удалось удалить мероприятие",
    )


def _send_event_edit_menu(
    peer,
    *,
    event_id: str,
    event_service: EventService,
    note: str | None = None,
    event_obj: EventSchema | None = None,
    show_organizer: bool,
):
    refreshed = event_obj or event_service.get_event_by_id(event_id)
    base = (
        format_event_details(refreshed, show_code=True, show_organizer=show_organizer)
        if refreshed
        else "⚠️ Мероприятие не найдено"
    )

    prefix = f"{note}\n\n" if note else ""
    bot.messaging.send_message(
        peer=peer,
        text=(f"{prefix}Редактирование мероприятия\n\n{base}\n\nВыбери поле для Редактирования:"),
        interactive_media_groups=event_edit_fields_keyboard(event_id),
    )


@bot.di
def event_menu_edit_handler(
    event: UpdateInteractiveMediaEvent, context: FSMContext, event_service: EventService
):
    delete_prev_message_by_peer(bot, event.peer)
    context.set_state(None)
    event_id, _ctx = split_event_value(event.data.value)

    _send_event_edit_menu(
        event.peer,
        event_id=event_id,
        event_service=event_service,
        show_organizer=True,
    )


def _get_field_label(field: str) -> str:
    return {
        "name": "Название",
        "date": "Дата",
        "time": "Время",
        "location": "Локация",
        "tags": "Теги",
        "creator_id": "Организатор",
        "points": "Баллы",
        "description": "Описание",
        "bonus_code": "Код",
    }.get(field, field)


def _get_current_value(event: EventSchema, field: str) -> str:
    val = getattr(event, field, None)
    if val is None or val == "":
        return "-"
    if field == "date":
        try:
            return event.date.strftime("%d.%m.%Y")
        except Exception:
            return str(val)
    if field == "time":
        try:
            return event.date.strftime("%H:%M")
        except Exception:
            return str(val)

    return str(val)


def _send_tags_toggle_ui(
    *, peer, title: str, choices, selected_keys: set[str], footer_buttons: list[Button]
):
    bot.messaging.send_message(
        peer=peer,
        text=title,
        interactive_media_groups=tags_toggle_keyboard(
            choices=choices,
            selected_keys=selected_keys,
            media_id=TAGS_MEDIA_ID,
            toggle_value_prefix=TAGS_TOGGLE_PREFIX,
            done_value=TAGS_DONE_VALUE,
            done_label="✅ Готово",
            extra_footer=footer_buttons,
        ),
    )


@bot.di
def event_menu_edit_field_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    event_service: EventService,
    tag_service: TagService,
):
    delete_prev_message_by_peer(bot, event.peer)
    raw = str(event.data.value or "")
    if "|" not in raw:
        bot.messaging.send_message(peer=event.peer, text="⚠️ Некорректная команда редактирования.")
        return

    event_id, field = raw.split("|", 1)
    field = field.strip()

    refreshed = event_service.get_event_by_id(event_id)
    if not refreshed:
        bot.messaging.send_message(peer=event.peer, text="⚠️ Мероприятие не найдено.")
        return

    if field == "tags":
        # редактируем теги через toggle UI
        all_tags = tag_service.get_all_tags()
        all_tags_dump = [{"id": t.id, "name": t.name} for t in all_tags]
        choices = choices_from_backend_tags(all_tags)

        selected_names: set[str] = set()
        for t in refreshed.tags or []:
            if isinstance(t, dict) and t.get("name"):
                selected_names.add(str(t["name"]))
            else:
                name = getattr(t, "name", None)
                if name:
                    selected_names.add(str(name))

        context.update_data(
            {
                "edit_event_id": event_id,
                "edit_all_tags": all_tags_dump,
                "edit_tag_keys": list(selected_names),
            }
        )
        context.set_state(EventEditTagsState.tags)

        _send_tags_toggle_ui(
            peer=event.peer,
            title="Редактирование тегов: выбери теги, затем нажми «Готово»:",
            choices=choices,
            selected_keys=set(selected_names),
            footer_buttons=[
                Button(
                    media_id="event_menu_edit",
                    value=event_id,
                    label="⬅️ К редактированию мероприятия",
                )
            ],
        )
        return

    context.update_data({"edit_event_id": event_id, "edit_field": field})
    context.set_state(EventEditState.wait_value)

    label = _get_field_label(field)
    cur = _get_current_value(refreshed, field)

    if field == "creator_id":
        hint = 'Введи UUID организатора или отправь минус "-", чтобы поставить организатором себя.'
    elif field == "date":
        hint = "Введи дату в формате `dd.mm.yyyy`, например: `20.02.2002`"
    elif field == "time":
        hint = "Введи время в формате `HH:MM`, например: `10:30`"
    else:
        hint = 'Введи новое значение. Чтобы очистить поле - отправь: "-"'

    bot.messaging.send_message(
        peer=event.peer, text=(f"Редактирование: {label}\nТекущее значение: {cur}\n{hint}")
    )


@events_rt.message(state=EventEditState.wait_value)
@bot.di
def event_edit_value_step(
    message: UpdateMessage,
    context: FSMContext,
    event_service: EventService,
    user: UserSchema | None,
):
    delete_prev_message(bot, message)

    data = context.get_data()
    event_id = data.get("edit_event_id")
    field = data.get("edit_field")

    if not event_id or not field:
        context.set_state(None)
        bot.messaging.send_message(
            peer=message.peer, text="Сессия редактирования сброшена. Открой мероприятие заново."
        )
        return

    raw = message.message.text_message.text.strip()
    value: object = None if raw == "-" else raw

    if field == "creator_id":
        if raw == "-":
            if not user:
                bot.messaging.send_message(
                    peer=message.peer, text="⚠️ Не удалось определить ваш user."
                )
                return
            if not user.id:
                bot.messaging.send_message(
                    peer=message.peer, text="⚠️ Не удалось определить ваш user.id."
                )
                return
            value = user.id
        else:
            try:
                value = str(UUID(raw))
            except Exception:
                bot.messaging.send_message(
                    peer=message.peer,
                    text=(
                        'Организатор должен быть UUID пользователя или "-". Попробуй ещё раз.\n'
                        "Пример UUID: `d0a794d0-a138-47b7-b5f3-da53d19bfb07`"
                    ),
                )
                return

    if field == "points":
        if raw == "-":
            value = None
        else:
            try:
                value = int(raw)
                if value <= 0 or value >= 2**31:
                    raise ValueError
            except ValueError:
                bot.messaging.send_message(
                    peer=message.peer,
                    text=(
                        "⚠️ Баллы должны быть натуральным числом,"
                        f" не большим {2**31}, попробуй еще раз."
                    ),
                )
                return

    if (
        field == "bonus_code"
        and value is not None
        and not re.fullmatch(r"^[A-Za-zА-Яа-я0-9]{1,16}$", str(value))
    ):
        bot.messaging.send_message(
            peer=message.peer,
            text=(
                "Код должен быть до 16 символов: латинские и кириллические"
                " буквы и цифры без пробелов до 16 символов."
            ),
        )
        return

    if field == "date" and value is not None:
        try:
            value = datetime.strptime(value, "%d.%m.%Y").date()
        except ValueError:
            bot.messaging.send_message(
                peer=message.peer,
                text=(
                    "⚠️ Я не смог распознать дату.\n"
                    "Пожалуйста, введи в формате `dd.mm.yyyy`, например: `20.02.2002`:"
                ),
                interactive_media_groups=back_to_moderation_keyboard(),
            )
            return

    if field == "time" and value is not None:
        try:
            value = datetime.strptime(value, "%H:%M").time()
        except ValueError:
            bot.messaging.send_message(
                peer=message.peer,
                text=(
                    "⚠️ Я не смог распознать время.\n"
                    "Пожалуйста, введи в формате `HH:MM`, например: `10:30`:"
                ),
                interactive_media_groups=back_to_moderation_keyboard(),
            )
            return

    if field in ("date", "time"):
        current = event_service.get_event_by_id(event_id)
        if not current:
            context.set_state(None)
            bot.messaging.send_message(peer=message.peer, text="⚠️ Мероприятие не найдено.")
            return

        current_dt = current.date
        tz = getattr(current_dt, "tzinfo", None)

        if value is None:
            bot.messaging.send_message(
                peer=message.peer, text="⚠️ Дату/время нельзя очистить. Выбери другое значение."
            )
            return

        if field == "date":
            new_dt = datetime.combine(value, current_dt.time())
        else:
            new_dt = datetime.combine(current_dt.date(), value)

        if tz is not None:
            new_dt = new_dt.replace(tzinfo=tz)

        iso = new_dt.isoformat()
        if iso.endswith("+00:00"):
            iso = iso.replace("+00:00", "Z")

        payload = {"date": iso}
    else:
        payload = {field: value}

    updated = event_service.update_event(event_id, payload)
    context.set_state(None)

    if not updated:
        _send_event_edit_menu(
            message.peer,
            event_id=event_id,
            event_service=event_service,
            note="⚠️ Не удалось обновить поле.",
            event_obj=event_service.get_event_by_id(event_id),
            show_organizer=True,
        )
        return

    _send_event_edit_menu(
        message.peer,
        event_id=event_id,
        event_service=event_service,
        note="✅ Поле обновлено.",
        event_obj=event_service.get_event_by_id(event_id),
        show_organizer=True,
    )

Editor is loading...
Leave a Comment