Untitled

4ae4d avatar
4ae4d
plain_text
02/10/2026 6:12 AM
125.6 KB
8
Indexable
f=src/bot/core/handlers/__init__.py
f=src/bot/core/handlers/event.py
f=src/bot/core/handlers/events_filters.py
f=src/bot/core/handlers/events_ui.py
f=src/bot/core/handlers/moderation.py
f=src/bot/core/handlers/points.py
f=src/bot/core/handlers/volunteer_home.py
f=src/bot/core/markups/events_filters.py
f=src/bot/core/utils/events_filter.py
--- src/bot/core/handlers/__init__.py ---
from .admin import admin_menu_handler
from .ai import ai_assistant_handler
from .enter_event_code import rt as enter_code_rt
from .event import (
    event_participants_open_handler,
    event_user_enter_code_start_handler,
    event_user_sign_out_handler,
    event_user_sign_up_handler,
    events_menu_handler,
    user_events_open_handler,
    user_events_page_handler,
    user_events_rt,
)
from .events_filters import (
    events_filters_apply_handler,
    events_filters_back_handler,
    events_filters_not_implemented_handler,
    events_filters_open_handler,
    events_filters_reset_handler,
)
from .main import main_menu_handler, start_handler
from .moderation import (
    all_events_handler,
    all_events_open_handler,
    all_events_page_handler,
    create_event_start_handler,
    event_menu_delete_handler,
    event_menu_edit_field_handler,
    event_menu_edit_handler,
    event_menu_enter_code_start_handler,
    event_menu_sign_out_handler,
    event_menu_sign_up_handler,
    event_tags_toggle_handler,
    event_view_by_id_start,
    events_rt,
    moderation_menu_handler,
    my_events_handler,
    my_events_open_handler,
    my_events_page_handler,
)
from .noop import noop_handler
from .points import points_event_open_handler, points_menu_handler, points_page_handler, points_rt
from .profile import (
    my_profile_handler,
    profile_tags_toggle_handler,
    update_user_interests_start,
    update_user_menu_handler,
    update_user_name_start,
    update_user_region_start,
)
from .profile import rt as update_user_rt
from .report import (
    report_create_start_handler,
    report_rt,
    report_view_handler,
    reports_export_handler,
)
from .view_by_uuid import rt as view_by_uuid_rt
from .view_by_uuid import view_by_uuid_start_handler
from .volunteer_home import points_leaderboard_handler, volunteer_home_handler
from .events_filters import (
    events_filters_tags_open_handler,
    events_filters_tags_toggle_handler,
)

__all__ = [
    "start_handler",
    "main_menu_handler",
    "volunteer_home_handler",
    "my_profile_handler",
    "ai_assistant_handler",
    "update_user_menu_handler",
    "update_user_name_start",
    "update_user_region_start",
    "update_user_interests_start",
    "profile_tags_toggle_handler",
    "points_menu_handler",
    "points_leaderboard_handler",
    "events_menu_handler",
    "points_page_handler",
    "points_event_open_handler",
    "points_rt",
    "view_by_uuid_start_handler",
    "moderation_menu_handler",
    "all_events_handler",
    "all_events_page_handler",
    "all_events_open_handler",
    "create_event_start_handler",
    "event_tags_toggle_handler",
    "event_view_by_id_start",
    "admin_menu_handler",
    "event_menu_sign_up_handler",
    "event_menu_sign_out_handler",
    "event_menu_enter_code_start_handler",
    "event_menu_delete_handler",
    "event_menu_edit_handler",
    "event_menu_edit_field_handler",
    "event_participants_open_handler",
    "user_events_page_handler",
    "user_events_open_handler",
    "event_user_sign_up_handler",
    "event_user_sign_out_handler",
    "event_user_enter_code_start_handler",
    "noop_handler",
    "reports_export_handler",
    "report_create_start_handler",
    "report_view_handler",
    "my_events_handler",
    "my_events_page_handler",
    "my_events_open_handler",
    "events_filters_apply_handler",
    "events_filters_back_handler",
    "events_filters_not_implemented_handler",
    "events_filters_open_handler",
    "events_filters_reset_handler",
    "events_filters_tags_open_handler",
    "events_filters_tags_toggle_handler",
    # Routers
    "user_events_rt",
    "enter_code_rt",
    "update_user_rt",
    "events_rt",
    "view_by_uuid_rt",
    "report_rt",
]

--- src/bot/core/handlers/event.py ---
from dialog_bot_sdk.entities.messaging import UpdateInteractiveMediaEvent, UpdateMessage

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,
    split_event_value,
)
from core.schemas.user import UserSchema
from core.services.event import EventService
from core.services.report import ReportService
from core.utils import delete_prev_message, delete_prev_message_by_peer
from core.utils.events_filter import clear_context_keep_events_filters

DEFAULT_CITY_UUID = "be9da3ba-a0e4-42f9-9ae2-9875c8625083"  # SPB

user_events_rt = Router()


class UserEventEnterCodeState(StatesGroup):
    wait_code = State()


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


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

    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="events",
        context=context,
    )


@bot.di
def user_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 "events"

    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 event_user_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 "events"

    ok = event_service.sign_up(event_id, event.peer.id)
    note = (
        "✅ Запись на мероприятие прошла успешно!"
        if ok
        else "⚠️ Не удалось записаться на мероприятие."
    )

    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_user_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 "events"

    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)
        note = "✅ Ты успешно отписался от мероприятия." if ok else "⚠️ Не удалось отписаться."

    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_user_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 "events"
    context.update_data({"current_event_id": event_id, "current_event_ctx": ctx_name})
    context.set_state(UserEventEnterCodeState.wait_code)

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


@user_events_rt.message(state=UserEventEnterCodeState.wait_code)
@bot.di
def event_user_enter_code_step(
    message: UpdateMessage,
    context: FSMContext,
    event_service: EventService,
    user: UserSchema | None,
):
    delete_prev_message(bot, message)
    data = context.get_data()

    event_id = data.get("current_event_id")
    ctx_name = data.get("current_event_ctx") or "events"
    code = message.message.text_message.text.strip()

    note: str
    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', '-')}"

    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_participants_open_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)
    ctx_name = ctx or "events"

    items = event_service.get_event_participants(event_id, event.peer.id, offset=0, limit=200)

    if not items:
        text = "👥 *Участники*\n\nНе удалось найти волонтёров, записанных на это мероприятие."
    else:
        lines = ["👥 *Участники*", ""]
        for i, it in enumerate(items, start=1):
            name = (it.get("firstname") or "—").strip()
            claimed = bool(it.get("is_claimed"))
            status = "☑️ Получил баллы" if claimed else "📝 Записан"
            points = it.get("points", "—")
            lines.append(f"{i}. {name} — {status} — баллы: {points}")
        text = "\n".join(lines)

    bot.messaging.send_message(
        peer=event.peer,
        text=text,
        interactive_media_groups=build_back_to_event_keyboard(event_id, ctx_name),
    )

--- src/bot/core/handlers/events_filters.py ---
from dialog_bot_sdk.entities.messaging import UpdateInteractiveMediaEvent

from core.bot_kit.fsm import FSMContext, State, StatesGroup
from core.config import bot
from core.handlers.events_ui import send_city_events_page, send_my_events_page, send_points_page
from core.markups.events_filters import events_filters_menu_keyboard
from core.markups.tags import TagChoice
from core.services.event import EventService
from core.services.tag import TagService
from core.utils import delete_prev_message_by_peer
from core.utils.events_filter import (
    format_events_filter_summary,
    get_events_filter,
    reset_events_filter,
    set_events_filter,
)
from core.utils.tags_toggle_ui import TagsToggleCfg, is_done, parse_toggle_key, send_tags_toggle_ui


class EventsFiltersTagsState(StatesGroup):
    tags = State()


FILTERS_TAGS_CFG = TagsToggleCfg(
    media_id="events_filters_tags_toggle",
    toggle_prefix="toggle:",
    done_value="done",
)

_CTX_KEY = "events_filters_edit_ctx"
_OFFSET_KEY = "events_filters_edit_offset"
_TAG_IDS_KEY = "events_filters_edit_tags_selected"


def _split_ctx_offset(raw: object) -> tuple[str, int]:
    s = str(raw or "").strip()
    if "|" not in s:
        return (s or "events", 0)
    a, b = s.split("|", 1)
    ctx = (a or "events").strip()
    try:
        off = int(str(b).strip())
        if off < 0:
            off = 0
    except Exception:
        off = 0
    return ctx, off


def _send_list(
    *, peer, ctx_name: str, offset: int, context: FSMContext, event_service: EventService
):
    # apply/back используют эту маршрутизацию
    if ctx_name == "points":
        return send_points_page(peer, offset=offset, event_service=event_service, context=context)
    if ctx_name == "my_events":
        return send_my_events_page(
            peer,
            offset=offset,
            event_service=event_service,
            requester_messenger_id=peer.id,
            context=context,
        )
    # events / moderation
    return send_city_events_page(
        peer,
        offset=offset,
        event_service=event_service,
        ctx_name=ctx_name,
        context=context,
    )


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

    ctx_name, offset = _split_ctx_offset(event.data.value)
    flt = get_events_filter(context, ctx_name=ctx_name)
    summary = format_events_filter_summary(flt)

    bot.messaging.send_message(
        peer=event.peer,
        text=f"🧰 *Фильтры*\n\n{summary}\n\nВыбери, что изменить:",
        interactive_media_groups=events_filters_menu_keyboard(ctx_name=ctx_name, offset=offset),
    )


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

    ctx_name, offset = _split_ctx_offset(event.data.value)
    reset_events_filter(context, ctx_name=ctx_name)

    # остаёмся в меню, чтобы пользователь видел что “сбросилось”
    flt = get_events_filter(context, ctx_name=ctx_name)
    summary = format_events_filter_summary(flt)
    bot.messaging.send_message(
        peer=event.peer,
        text=f"🧰 *Фильтры*\n\n{summary}\n\nВыбери, что изменить:",
        interactive_media_groups=events_filters_menu_keyboard(ctx_name=ctx_name, offset=offset),
    )


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

    ctx_name, _offset = _split_ctx_offset(event.data.value)
    # безопаснее возвращать на первую страницу (фильтр мог сузить выдачу)
    _send_list(
        peer=event.peer, ctx_name=ctx_name, offset=0, context=context, event_service=event_service
    )


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

    ctx_name, offset = _split_ctx_offset(event.data.value)
    _send_list(
        peer=event.peer,
        ctx_name=ctx_name,
        offset=offset,
        context=context,
        event_service=event_service,
    )


@bot.di
def events_filters_not_implemented_handler(event: UpdateInteractiveMediaEvent, context: FSMContext):
    # handlers для tags/city/date/mode до следующих коммитов
    delete_prev_message_by_peer(bot, event.peer)
    context.set_state(None)

    ctx_name, offset = _split_ctx_offset(event.data.value)
    flt = get_events_filter(context, ctx_name=ctx_name)
    summary = format_events_filter_summary(flt)
    bot.messaging.send_message(
        peer=event.peer,
        text=f"⚠️ Пока не реализовано в этом коммите.\n\n{summary}\n\nВыбери, что изменить:",
        interactive_media_groups=events_filters_menu_keyboard(ctx_name=ctx_name, offset=offset),
    )


def _tag_choices_from_backend(all_tags) -> list[TagChoice]:
    out: list[TagChoice] = []
    for t in all_tags or []:
        tid = getattr(t, "id", None)
        name = getattr(t, "name", None)
        if tid and name:
            out.append(TagChoice(key=str(tid), label=str(name)))
    return out


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

    ctx_name, offset = _split_ctx_offset(event.data.value)
    flt = get_events_filter(context, ctx_name=ctx_name)
    selected_ids = set(flt.tag_ids or [])

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

    # сохраняем "куда вернуться"
    context.update_data(
        {
            _CTX_KEY: ctx_name,
            _OFFSET_KEY: int(offset),
            _TAG_IDS_KEY: list(selected_ids),
        }
    )
    context.set_state(EventsFiltersTagsState.tags)

    send_tags_toggle_ui(
        peer=event.peer,
        title="🏷️ *Фильтры: теги*\n\nВыбери теги, затем нажми «Готово»:",
        choices=choices,
        selected_keys=set(selected_ids),
        cfg=FILTERS_TAGS_CFG,
        done_label="✅ Готово",
    )


@bot.di
def events_filters_tags_toggle_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    event_service: EventService,
    tag_service: TagService,
):
    state = context.get_state()
    if not (state == EventsFiltersTagsState.tags or str(state) == str(EventsFiltersTagsState.tags)):
        return

    delete_prev_message_by_peer(bot, event.peer)

    raw = str(event.data.value or "")
    data = context.get_data() or {}

    ctx_name = str(data.get(_CTX_KEY) or "events")
    offset = int(data.get(_OFFSET_KEY) or 0)
    selected = set(data.get(_TAG_IDS_KEY) or [])

    # DONE -> сохранить filter.tag_ids и вернуться в меню фильтров
    if is_done(raw, FILTERS_TAGS_CFG):
        flt = get_events_filter(context, ctx_name=ctx_name)
        flt.tag_ids = sorted([str(x) for x in selected if str(x).strip()])

        all_tags = tag_service.get_all_tags()
        id_to_name = {
            str(getattr(t, "id", "")).strip(): str(getattr(t, "name", "")).strip()
            for t in (all_tags or [])
            if str(getattr(t, "id", "")).strip() and str(getattr(t, "name", "")).strip()
        }
        flt.tag_names = [id_to_name[i] for i in flt.tag_ids if i in id_to_name]
        set_events_filter(context, ctx_name=ctx_name, flt=flt)

        context.set_state(None)
        # чистим только "служебные" ключи экрана выбора тегов
        context.update_data({_CTX_KEY: None, _OFFSET_KEY: None, _TAG_IDS_KEY: None})

        summary = format_events_filter_summary(get_events_filter(context, ctx_name=ctx_name))
        bot.messaging.send_message(
            peer=event.peer,
            text=f"🧰 *Фильтры*\n\n{summary}\n\nВыбери, что изменить:",
            interactive_media_groups=events_filters_menu_keyboard(ctx_name=ctx_name, offset=offset),
        )
        return

    # TOGGLE
    key = parse_toggle_key(raw, FILTERS_TAGS_CFG)
    if not key:
        return

    if key in selected:
        selected.remove(key)
    else:
        selected.add(key)

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

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

    send_tags_toggle_ui(
        peer=event.peer,
        title="🏷️ *Фильтры: теги*\n\nВыбери теги, затем нажми «Готово»:",
        choices=choices,
        selected_keys=set(selected),
        cfg=FILTERS_TAGS_CFG,
        done_label="✅ Готово",
    )

--- src/bot/core/handlers/events_ui.py ---
from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

from dialog_bot_sdk.interactive_media import Button, InteractiveMediaGroup, MediaGroupBuilder

from core.bot_kit.fsm import FSMContext
from core.config import bot
from core.handlers.events_pages import EVENTS_PAGE_SIZE, fetch_events_page
from core.markups.event import (
    event_actions_keyboard,
    events_select_keyboard,
    format_event_details,
    my_events_pagination_keyboard,
    user_events_pagination_keyboard,
)
from core.markups.events_filters import events_filters_button_keyboard
from core.markups.inline import all_events_pagination_keyboard
from core.markups.points import points_events_pagination_keyboard, points_menu_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.utils.events_filter import format_events_filter_summary, get_events_filter

DEFAULT_CITY_UUID = "be9da3ba-a0e4-42f9-9ae2-9875c8625083"  # SPB
ADMIN_CAN_MANAGE_OTHERS = True  # имеет ли администратор доступ ко всем мероприятиям?


@dataclass(frozen=True)
class EventUIContext:
    name: str
    # list
    list_open_media_id: str
    pagination_builder: Callable[..., list[InteractiveMediaGroup]]
    extra_groups_builder: Callable[[], list[InteractiveMediaGroup]] | None
    # card
    back_value: str
    back_label: str
    enter_code_media_id: str
    sign_up_media_id: str
    sign_out_media_id: str


_UI: dict[str, EventUIContext] = {
    # дом волонтёра -> мероприятия
    "events": EventUIContext(
        name="events",
        list_open_media_id="user_events_open",
        pagination_builder=user_events_pagination_keyboard,
        extra_groups_builder=None,
        back_value="events",
        back_label="⬅️ К списку мероприятий",
        enter_code_media_id="event_user_enter_code",
        sign_up_media_id="event_user_sign_up",
        sign_out_media_id="event_user_sign_out",
    ),
    # дом волонтёра -> посмотреть по uuid
    "volunteer_home": EventUIContext(
        name="volunteer_home",
        list_open_media_id="user_events_open",  # не используется в этом контексте
        pagination_builder=user_events_pagination_keyboard,
        extra_groups_builder=None,
        back_value="volunteer_home",
        back_label="⬅️ В дом волонтёра",
        enter_code_media_id="event_user_enter_code",
        sign_up_media_id="event_user_sign_up",
        sign_out_media_id="event_user_sign_out",
    ),
    # дом модерации -> все мероприятия
    "moderation": EventUIContext(
        name="moderation",
        list_open_media_id="all_events_open",
        pagination_builder=all_events_pagination_keyboard,
        extra_groups_builder=None,
        back_value="all_events",
        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",
    ),
    # меню баллов -> мероприятия с начисленными баллами
    "points": EventUIContext(
        name="points",
        list_open_media_id="points_event_open",
        pagination_builder=points_events_pagination_keyboard,
        extra_groups_builder=points_menu_keyboard,  # лидерборд + назад
        back_value="points",
        back_label="⬅️ К баллам",
        enter_code_media_id="event_user_enter_code",
        sign_up_media_id="event_user_sign_up",
        sign_out_media_id="event_user_sign_out",
    ),
    # дом модерации -> посмотреть по uuid
    "moderation_menu": EventUIContext(
        name="moderation_menu",
        list_open_media_id="all_events_open",  # не используется в этом контексте
        pagination_builder=all_events_pagination_keyboard,
        extra_groups_builder=None,
        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",
    ),
    # дом модерации -> мои мероприятия
    "my_events": EventUIContext(
        name="my_events",
        list_open_media_id="my_events_open",
        pagination_builder=my_events_pagination_keyboard,
        extra_groups_builder=None,
        back_value="my_events",
        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",
    ),
}


def get_ui(ctx_name: str) -> EventUIContext:
    return _UI.get(ctx_name, _UI["events"])


def build_back_keyboard(ctx_name: str) -> list[InteractiveMediaGroup]:
    ui = get_ui(ctx_name)
    return MediaGroupBuilder(
        [Button(media_id="leave", value=ui.back_value, label=ui.back_label)]
    ).build()


def split_event_value(raw: Any) -> tuple[str, str | None]:
    """
    ожидаем:
      - "<event_id>"
      - "<event_id>|<ctx>"
    """
    s = str(raw or "").strip()
    if "|" in s:
        ev_id, ctx = s.split("|", 1)
        return ev_id.strip(), (ctx.strip() or None)
    return s, None


def can_manage_event(ev: EventSchema, user: UserSchema | None) -> bool:
    if getattr(user, "role", None) == "admin" and ADMIN_CAN_MANAGE_OTHERS:
        return True
    uid = getattr(user, "id", None)
    if not uid:
        return False
    return bool(getattr(ev, "creator_id", None)) and (str(ev.creator_id) == str(uid))


def can_view_code(ev: EventSchema, user: UserSchema | None) -> bool:
    # кто может управлять - тот видит код
    return can_manage_event(ev, user)


def _send_events_list_screen(
    peer,
    *,
    ui: EventUIContext,
    ctx_name: str,
    offset: int,
    limit: int,
    page_events: list[EventSchema],
    total: int | None,
    has_prev: bool,
    has_next: bool,
    note: str | None = None,
    header: str | None = None,
    empty_text: str | None = None,
    empty_groups: list[InteractiveMediaGroup] | None = None,
    filter_summary: str | None = None,
):
    prefix = f"{note}\n\n" if note else ""
    header_part = header if header is not None else "📅 Мероприятия\n\n"
    filter_part = f"\n\n{filter_summary}" if filter_summary else ""

    if not page_events:
        text = (
            prefix
            + header_part
            + (
                empty_text
                or "⚠️ Мероприятий не найдено.\n\nПопробуй изменить критерии или вернись назад."
            )
            + filter_part
        )
        im: list[InteractiveMediaGroup] = []
        footer: list[InteractiveMediaGroup] = []
        if empty_groups is not None:
            footer = empty_groups
        elif ui.extra_groups_builder:
            footer = ui.extra_groups_builder()
        im += events_filters_button_keyboard(ctx_name=ctx_name, offset=offset)
        im += ui.pagination_builder(
            offset=offset,
            limit=limit,
            has_prev=has_prev,
            has_next=has_next,
        )
        im += footer
        bot.messaging.send_message(peer=peer, text=text, interactive_media_groups=im)
        return

    total_str = str(total) if total is not None else "?"
    text = (
        prefix
        + header_part
        + f"Показаны: {offset + 1} — {offset + len(page_events)} из {total_str}\n\n"
        + "Выбери мероприятие:"
        + filter_part
    )

    im: list[InteractiveMediaGroup] = []
    im += events_select_keyboard(
        page_events, open_media_id=ui.list_open_media_id, start_index=offset + 1, per_row=1
    )
    im += events_filters_button_keyboard(ctx_name=ctx_name, offset=offset)
    im += ui.pagination_builder(offset=offset, limit=limit, has_prev=has_prev, has_next=has_next)

    if ui.extra_groups_builder:
        im += ui.extra_groups_builder()

    bot.messaging.send_message(peer=peer, text=text, interactive_media_groups=im)


def send_city_events_page(
    peer,
    *,
    offset: int,
    event_service: EventService,
    ctx_name: str,
    city_id: str = DEFAULT_CITY_UUID,
    note: str | None = None,
    context: FSMContext | None = None,
):
    ui = get_ui(ctx_name)
    limit = EVENTS_PAGE_SIZE

    flt = get_events_filter(context, ctx_name=ctx_name) if context is not None else None
    summary = format_events_filter_summary(flt) if context is not None else None

    page_events, has_prev, has_next, total = fetch_events_page(
        event_service=event_service,
        city_id=city_id,
        offset=offset,
        limit=limit,
        requester_messenger_id=peer.id,
        filters=flt,
    )

    _send_events_list_screen(
        peer,
        ui=ui,
        ctx_name=ctx_name,
        offset=offset,
        limit=limit,
        page_events=page_events,
        total=total,
        has_prev=has_prev,
        has_next=has_next,
        note=note,
        filter_summary=summary,
    )


def send_points_page(
    peer,
    *,
    offset: int,
    event_service: EventService,
    context: FSMContext | None = None,
):
    limit = EVENTS_PAGE_SIZE

    points_total = "?"
    flt = get_events_filter(context, ctx_name="points") if context is not None else None
    summary = format_events_filter_summary(flt) if context is not None else None

    page_events, has_prev, has_next, total = fetch_events_page(
        event_service=event_service,
        city_id=DEFAULT_CITY_UUID,
        offset=offset,
        limit=limit,
        requester_messenger_id=peer.id,
        only_claimed=True,
        filters=flt,
    )

    _send_events_list_screen(
        peer,
        ui=get_ui("points"),
        ctx_name="points",
        offset=offset,
        limit=limit,
        page_events=page_events,
        total=total,
        has_prev=has_prev,
        has_next=has_next,
        header=f"🏆 Твои баллы: {points_total}\n\n",
        empty_text=(
            "Пока нет мероприятий, за которые уже начислены баллы.\n\n"
            "Как получить баллы:\n"
            "1) Запишитесь на мероприятие\n"
            "2) После участия введи код\n"
        ),
        empty_groups=points_menu_keyboard(),
        filter_summary=summary,
    )


def send_my_events_page(
    peer,
    *,
    offset: int,
    event_service: EventService,
    requester_messenger_id: int,
    city_id: str = DEFAULT_CITY_UUID,
    note: str | None = None,
    context: FSMContext | None = None,
):
    ui = get_ui("my_events")
    limit = EVENTS_PAGE_SIZE

    flt = get_events_filter(context, ctx_name="my_events") if context is not None else None
    summary = format_events_filter_summary(flt) if context is not None else None

    page_events, has_prev, has_next, total = fetch_events_page(
        event_service=event_service,
        city_id=city_id,
        offset=offset,
        limit=limit,
        requester_messenger_id=requester_messenger_id,
        only_organizer=True,
        filters=flt,
        lock_mode=True,
    )

    _send_events_list_screen(
        peer,
        ui=ui,
        ctx_name="my_events",
        offset=offset,
        limit=limit,
        page_events=page_events,
        total=total,
        has_prev=has_prev,
        has_next=has_next,
        note=note,
        header="📋 Мои мероприятия\n\n",
        empty_text="Пока у тебя нет созданных мероприятий.",
        filter_summary=summary,
    )


def send_event_card(
    peer,
    *,
    event_id: str,
    event_service: EventService,
    report_service: ReportService | None = None,
    user: UserSchema | None,
    ctx_name: str,
    note: str | None = None,
    show_organizer: bool = True,
):
    ui = get_ui(ctx_name)

    ev = event_service.get_event_by_id(event_id)
    if ev is None:
        text = f"{note}\n\n⚠️ Мероприятие не найдено." if note else "⚠️ Мероприятие не найдено."
        bot.messaging.send_message(
            peer=peer,
            text=text,
            interactive_media_groups=build_back_keyboard(ctx_name),
        )
        return

    part = event_service.get_participation_status(ev.id, peer.id)
    is_participant = part is not None

    can_manage = can_manage_event(ev, user)
    show_code = can_view_code(ev, user)

    role = getattr(user, "role", None) if user else None
    can_access_reports = (role in ("admin", "moderator")) or can_manage

    report_exists: bool | None = None
    if can_access_reports and report_service is not None:
        report_exists = report_service.report_exists(
            event_id=str(ev.id),
            requester_messenger_id=peer.id,
        )
    else:
        report_exists = None

    can_create_report = bool(can_manage and (report_exists is False))
    can_view_report = bool(can_access_reports and (report_exists is True))

    base = format_event_details(
        ev,
        show_code=show_code,
        show_organizer=show_organizer,
        show_report_status=bool(can_access_reports and report_exists is not None),
        report_exists=bool(report_exists) if report_exists is not None else None,
    )
    text = f"{note}\n\n{base}" if note else base

    bot.messaging.send_message(
        peer=peer,
        text=text,
        interactive_media_groups=event_actions_keyboard(
            ev.id,
            is_participant=is_participant,
            can_manage=can_manage,
            back_value=ui.back_value,
            back_label=ui.back_label,
            enter_code_media_id=ui.enter_code_media_id,
            sign_up_media_id=ui.sign_up_media_id,
            sign_out_media_id=ui.sign_out_media_id,
            ctx=ui.name,  # важно: чтобы value был "<event_id>|<ctx>"
            can_create_report=can_create_report,
            can_view_report=can_view_report,
        ),
    )


def build_back_to_event_keyboard(event_id: str, ctx_name: str) -> list[InteractiveMediaGroup]:
    ui = get_ui(ctx_name)
    return MediaGroupBuilder(
        [
            Button(
                media_id=ui.list_open_media_id,
                value=f"{event_id}|{ui.name}",
                label="⬅️ К мероприятию",
            )
        ]
    ).build()

--- 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, context: FSMContext):
    """старт мастера создания мероприятия"""
    delete_prev_message_by_peer(bot, event.peer)
    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 0 >= points 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,
):
    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,
        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.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:
        if 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,
    )

--- 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
from core.utils.events_filter import clear_context_keep_events_filters

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)
    clear_context_keep_events_filters(context)
    send_points_page(
        event.peer,
        offset=0,
        event_service=event_service,
        context=context,
    )


@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,
        context=context,
    )


@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/volunteer_home.py ---
from dialog_bot_sdk.entities.messaging import UpdateInteractiveMediaEvent

from core.utils.events_filter import clear_context_keep_events_filters

from ..bot_kit.fsm import FSMContext
from ..config import bot
from ..markups import volunteer_home_keyboard
from ..markups.points import leaderboard_keyboard
from ..schemas.user import UserSchema
from ..services import UserService
from ..utils import delete_prev_message_by_peer


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

    bot.messaging.send_message(
        peer=event.peer,
        text=("🏡 Дом волонтёра\n\nВыбери действие:"),
        interactive_media_groups=volunteer_home_keyboard(),
    )


@bot.di
def points_leaderboard_handler(
    event: UpdateInteractiveMediaEvent, user_service: UserService, user: UserSchema | None
):
    delete_prev_message_by_peer(bot, event.peer)

    LIMIT, OFFSET = 5, 0
    my_rank: int | None = None
    try:
        resp = user_service.client.get_global_leaderboard(limit=LIMIT, offset=OFFSET)
        if resp.status_code != 200:
            raise Exception(f"bad status {resp.status_code}: {resp.text}")
        users = resp.json() or []

        rank_mid = user.messenger_id if user is not None else user_service.messenger_id
        rank_resp = user_service.client.get_rank(rank_mid)
        if rank_resp.status_code == 200:
            my_rank = (rank_resp.json() or {}).get("rank")
    except Exception:
        bot.messaging.send_message(
            peer=event.peer,
            text="⚠️ Не удалось загрузить лидерборд. Попробуй позже.",
            interactive_media_groups=leaderboard_keyboard(),
        )
        return

    if not users:
        bot.messaging.send_message(
            peer=event.peer,
            text="🏆 Лидерборд\n\nПока пусто — ещё никто не набрал баллы.",
            interactive_media_groups=leaderboard_keyboard(),
        )
        return

    lines = ["🏆 *Лидерборд волонтёров* 🏆\n"]
    medals = ["🥇", "🥈", "🥉"]

    for idx, u in enumerate(users[:5], start=1):
        name = (u.get("firstname") or "—").strip()
        pts = u.get("points")
        pts = 0 if pts is None else pts

        prefix = medals[idx - 1] if idx <= len(medals) else f"{idx}."
        lines.append(f"{prefix} *{name}* — {pts} баллов")

    if my_rank is not None:
        lines.append(f"\nВаше место в рейтинге: *{my_rank}*")

    bot.messaging.send_message(
        peer=event.peer, text="\n".join(lines), interactive_media_groups=leaderboard_keyboard()
    )

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


def events_filters_button_keyboard(*, ctx_name: str, offset: int) -> list[InteractiveMediaGroup]:
    return MediaGroupBuilder(
        [Button(media_id="events_filters_open", value=f"{ctx_name}|{offset}", label="🧰 Фильтры")]
    ).build()


def events_filters_menu_keyboard(*, ctx_name: str, offset: int) -> list[InteractiveMediaGroup]:
    # value всегда "ctx|offset" чтобы назад возвращаться туда же
    v = f"{ctx_name}|{offset}"
    top = MediaGroupBuilder(
        [
            Button(media_id="events_filters_tags", value=v, label="🏷️ Теги"),
            Button(media_id="events_filters_city", value=v, label="🏙️ Город"),
        ]
    ).build()
    mid = MediaGroupBuilder(
        [
            Button(media_id="events_filters_date", value=v, label="🗓️ Дата/время"),
            Button(media_id="events_filters_mode", value=v, label="🎛️ Режим"),
        ]
    ).build()
    bottom = MediaGroupBuilder(
        [
            Button(media_id="events_filters_apply", value=v, label="✅ Применить"),
            Button(media_id="events_filters_reset", value=v, label="♻️ Сбросить"),
            Button(media_id="events_filters_back", value=v, label="⬅️ Назад"),
        ]
    ).build()
    return top + mid + bottom

--- src/bot/core/utils/events_filter.py ---
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Literal

EventsMode = Literal["all", "participant", "organizer"]


FILTERS_CTX_KEY = "events_filters_v1"


@dataclass(slots=True)
class EventsFilter:
    """
    фильтры на стороне бота (храним в fsmcontext.data).

    - date_from/date_to сейчас не поддержаны backend (/events/search), поэтому
      они участвуют только в UI/summary, но не уходят в query params.
    """

    city_id: str | None = None
    tag_ids: list[str] = field(default_factory=list)
    tag_names: list[str] = field(default_factory=list)
    mode: EventsMode = "all"

    # UI-only (пока не поддержано backend)
    date_from: str | None = None
    date_to: str | None = None

    @classmethod
    def from_dict(cls, raw: Any) -> EventsFilter:
        if not isinstance(raw, dict):
            return cls()

        mode = raw.get("mode") or "all"
        if mode not in ("all", "participant", "organizer"):
            mode = "all"

        city_id = raw.get("city_id")
        city_id = str(city_id) if city_id else None

        tag_ids_raw = raw.get("tag_ids") or []
        if not isinstance(tag_ids_raw, list):
            tag_ids_raw = []
        tag_ids = [str(x) for x in tag_ids_raw if str(x).strip()]

        tag_names_raw = raw.get("tag_names") or []
        if not isinstance(tag_names_raw, list):
            tag_names_raw = []
        tag_names = [str(x) for x in tag_names_raw if str(x).strip()]

        date_from = raw.get("date_from")
        date_from = str(date_from) if date_from else None
        date_to = raw.get("date_to")
        date_to = str(date_to) if date_to else None

        return cls(
            city_id=city_id,
            tag_ids=tag_ids,
            tag_names=tag_names,
            mode=mode,  # type: ignore[assignment]
            date_from=date_from,
            date_to=date_to,
        )

    def to_dict(self) -> dict[str, Any]:
        return {
            "city_id": self.city_id,
            "tag_ids": list(self.tag_ids or []),
            "tag_names": list(self.tag_names or []),
            "mode": self.mode,
            "date_from": self.date_from,
            "date_to": self.date_to,
        }


@dataclass(frozen=True, slots=True)
class EventsSearchBase:
    """
    базовые параметры конкретного списка (контекст):
    - например points всегда only_claimed=True
    - my_events (организатор) может фиксировать режим
    """

    city_id: str | None
    requester_messenger_id: int | None

    only_claimed: bool = False
    only_organizer: bool = False
    only_participant: bool = False
    has_report: bool | None = None

    tag_ids: list[str] = field(default_factory=list)

    # если True — mode из EventsFilter игнорируется
    lock_mode: bool = False


def build_events_search_params(
    *,
    base: EventsSearchBase,
    flt: EventsFilter | None,
    offset: int,
    limit: int,
) -> dict[str, Any]:
    """собираем kwargs для EventService.search_events_with_total(...)"""

    f = flt or EventsFilter()

    # city/tags: если в фильтре задано — переопределяет базу
    city_id = f.city_id if f.city_id else base.city_id
    tag_ids = f.tag_ids if f.tag_ids else (base.tag_ids or [])

    only_claimed = bool(base.only_claimed)
    only_organizer = bool(base.only_organizer)
    only_participant = bool(base.only_participant)

    # mode: работаем только с organizer/participant
    if not base.lock_mode:
        if f.mode == "organizer":
            only_organizer = True
            only_participant = False
        elif f.mode == "participant":
            only_participant = True
            only_organizer = False
        else:
            # all
            # оставляем только то, что задано базой
            pass

    params: dict[str, Any] = {
        "offset": int(offset),
        "limit": int(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"] = str(city_id)
    if base.requester_messenger_id is not None:
        params["requester_messenger_id"] = int(base.requester_messenger_id)
    if base.has_report is not None:
        params["has_report"] = bool(base.has_report)
    if tag_ids:
        params["tag_ids"] = list(tag_ids)

    return params


def format_events_filter_summary(flt: EventsFilter | None) -> str:
    f = flt or EventsFilter()
    parts: list[str] = []

    if f.tag_names:
        names = [str(x).strip() for x in f.tag_names if str(x).strip()]
        parts.append("теги: " + ", ".join(names))
    # elif f.tag_ids:
    #     parts.append(f"теги: {len(f.tag_ids)}")

    if f.mode == "participant":
        parts.append("режим: участвую")
    elif f.mode == "organizer":
        parts.append("режим: организую")

    if f.city_id:
        parts.append(f"город: {str(f.city_id)[:8]}…")

    if f.tag_ids:
        parts.append(f"теги: {len(f.tag_ids)}")

    if f.date_from or f.date_to:
        a = f.date_from or "…"
        b = f.date_to or "…"
        parts.append(f"даты: {a} — {b}")

    if not parts:
        return "Фильтр\nбез доп. условий"
    return "Фильтр\n" + "\n".join(parts)


def get_events_filter(context, *, ctx_name: str) -> EventsFilter:
    data = (context.get_data() or {}).get(FILTERS_CTX_KEY) or {}
    if not isinstance(data, dict):
        return EventsFilter()
    return EventsFilter.from_dict(data.get(str(ctx_name)))


def set_events_filter(context, *, ctx_name: str, flt: EventsFilter) -> None:
    raw = (context.get_data() or {}).get(FILTERS_CTX_KEY) or {}
    data = dict(raw) if isinstance(raw, dict) else {}
    data[str(ctx_name)] = flt.to_dict()
    context.update_data({FILTERS_CTX_KEY: data})


def reset_events_filter(context, *, ctx_name: str) -> None:
    raw = (context.get_data() or {}).get(FILTERS_CTX_KEY) or {}
    data = dict(raw) if isinstance(raw, dict) else {}
    data.pop(str(ctx_name), None)
    context.update_data({FILTERS_CTX_KEY: data})


def clear_context_keep_events_filters(context) -> None:
    """
    нужен для мест, где раньше делали context.clear(),
    но теперь фильтры должны переживать пагинацию
    """
    data = context.get_data() or {}
    keep = data.get(FILTERS_CTX_KEY)
    context.clear()
    if keep is not None:
        context.update_data({FILTERS_CTX_KEY: keep})

--- src/bot/main.py ---
from core.bot_kit.router import Router
from core.config import bot
from core.handlers import (
    admin_menu_handler,
    ai_assistant_handler,
    all_events_handler,
    all_events_open_handler,
    all_events_page_handler,
    create_event_start_handler,
    event_menu_delete_handler,
    event_menu_edit_field_handler,
    event_menu_edit_handler,
    event_menu_enter_code_start_handler,
    event_menu_sign_out_handler,
    event_menu_sign_up_handler,
    event_participants_open_handler,
    event_tags_toggle_handler,
    event_user_enter_code_start_handler,
    event_user_sign_out_handler,
    event_user_sign_up_handler,
    event_view_by_id_start,
    events_filters_apply_handler,
    events_filters_back_handler,
    events_filters_not_implemented_handler,
    events_filters_open_handler,
    events_filters_reset_handler,
    events_filters_tags_open_handler,
    events_filters_tags_toggle_handler,
    events_menu_handler,
    events_rt,
    main_menu_handler,
    moderation_menu_handler,
    my_events_handler,
    my_events_open_handler,
    my_events_page_handler,
    my_profile_handler,
    noop_handler,
    points_event_open_handler,
    points_leaderboard_handler,
    points_menu_handler,
    points_page_handler,
    points_rt,
    profile_tags_toggle_handler,
    report_create_start_handler,
    report_rt,
    report_view_handler,
    reports_export_handler,
    start_handler,
    update_user_interests_start,
    update_user_menu_handler,
    update_user_name_start,
    update_user_region_start,
    update_user_rt,
    user_events_open_handler,
    user_events_page_handler,
    user_events_rt,
    view_by_uuid_rt,
    view_by_uuid_start_handler,
    volunteer_home_handler,
)
from dialog_bot_sdk.entities.messaging import CommandHandler, EventHandler


def handlers_setting() -> None:
    router = Router()
    router.register(update_user_rt)
    router.register(events_rt)
    router.register(user_events_rt)
    router.register(points_rt)
    router.register(view_by_uuid_rt)
    router.register(report_rt)

    router.subscribe(bot)
    bot.messaging.command_handler(
        [
            CommandHandler(
                function=start_handler,
                command="start",
                description="Расскажу о себе",
            ),
            CommandHandler(
                function=reports_export_handler,
                command="reports_export",
                description="Выгрузка отчётов",
            ),
        ]
    )

    bot.messaging.event_handler(
        [
            EventHandler(function=volunteer_home_handler, value="volunteer_home"),
            EventHandler(function=main_menu_handler, value="main_menu"),
            EventHandler(function=ai_assistant_handler, value="ai_assistant"),
            EventHandler(function=my_profile_handler, value="my_profile"),
            EventHandler(function=update_user_menu_handler, value="update_profile_menu"),
            EventHandler(function=update_user_name_start, _id="update_user_name"),
            EventHandler(function=update_user_region_start, _id="update_user_region"),
            EventHandler(function=update_user_interests_start, _id="update_user_interests"),
            EventHandler(function=profile_tags_toggle_handler, _id="profile_tags_toggle"),
            EventHandler(function=points_menu_handler, value="points"),
            EventHandler(function=points_leaderboard_handler, value="points_leaderboard"),
            EventHandler(function=moderation_menu_handler, value="moderation"),
            EventHandler(function=reports_export_handler, value="download_reports_list"),
            EventHandler(function=all_events_handler, value="all_events"),
            EventHandler(function=events_menu_handler, value="events"),
            EventHandler(function=user_events_page_handler, _id="user_events_prev"),
            EventHandler(function=user_events_page_handler, _id="user_events_next"),
            EventHandler(function=event_user_sign_up_handler, _id="event_user_sign_up"),
            EventHandler(function=event_user_sign_out_handler, _id="event_user_sign_out"),
            EventHandler(function=event_user_enter_code_start_handler, _id="event_user_enter_code"),
            EventHandler(function=event_tags_toggle_handler, _id="event_tags_toggle"),
            EventHandler(function=event_participants_open_handler, _id="event_participants_open"),
            EventHandler(function=report_create_start_handler, _id="event_report_create"),
            EventHandler(function=report_view_handler, _id="event_report_view"),
            EventHandler(function=all_events_page_handler, _id="all_events_prev"),
            EventHandler(function=all_events_page_handler, _id="all_events_next"),
            EventHandler(function=my_events_handler, value="my_events"),
            EventHandler(function=my_events_page_handler, _id="my_events_prev"),
            EventHandler(function=my_events_page_handler, _id="my_events_next"),
            EventHandler(function=my_events_open_handler, _id="my_events_open"),
            EventHandler(function=create_event_start_handler, value="create_event"),
            EventHandler(function=event_view_by_id_start, value="update_event"),
            EventHandler(function=admin_menu_handler, value="administration"),
            EventHandler(function=event_menu_sign_up_handler, _id="event_menu_sign_up"),
            EventHandler(function=event_menu_sign_out_handler, _id="event_menu_sign_out"),
            EventHandler(function=event_menu_enter_code_start_handler, _id="event_menu_enter_code"),
            EventHandler(function=event_menu_delete_handler, _id="event_menu_delete"),
            EventHandler(function=event_menu_edit_handler, _id="event_menu_edit"),
            EventHandler(function=view_by_uuid_start_handler, _id="view_by_uuid"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_name"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_date"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_time"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_location"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_tags"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_creator_id"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_points"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_description"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_bonus_code"),
            EventHandler(function=user_events_open_handler, _id="user_events_open"),
            EventHandler(function=all_events_open_handler, _id="all_events_open"),
            EventHandler(function=points_page_handler, _id="points_prev"),
            EventHandler(function=points_page_handler, _id="points_next"),
            EventHandler(function=points_event_open_handler, _id="points_event_open"),
            EventHandler(function=points_event_open_handler, _id="points_events_open"),
            EventHandler(function=events_filters_open_handler, _id="events_filters_open"),
            EventHandler(function=events_filters_apply_handler, _id="events_filters_apply"),
            EventHandler(function=events_filters_reset_handler, _id="events_filters_reset"),
            EventHandler(function=events_filters_back_handler, _id="events_filters_back"),
            EventHandler(function=events_filters_tags_open_handler, _id="events_filters_tags"),
            EventHandler(function=events_filters_tags_toggle_handler, _id="events_filters_tags_toggle"),
            EventHandler(
                function=events_filters_not_implemented_handler, _id="events_filters_date"
            ),
            EventHandler(
                function=events_filters_not_implemented_handler, _id="events_filters_mode"
            ),
            # TODO почистить
            EventHandler(function=noop_handler, _id="noop_prev"),
            EventHandler(function=noop_handler, _id="noop_mid"),
            EventHandler(function=noop_handler, _id="noop_next"),
            EventHandler(function=noop_handler, _id="noop_uuid"),
        ]
    )


def main() -> None:
    handlers_setting()
    bot.profile.edit_about_sync("Волонтёрский бот, который помогает сотрудникам Сбера нести добро!")
    bot.updates.on_updates(do_read_message=True, do_register_commands=True)


if __name__ == "__main__":
    main()

Editor is loading...
Leave a Comment