Untitled

 avatar
4ae4d
plain_text
24 days ago
52 kB
3
Indexable
f=services/bot/core/clients/request.py
f=services/bot/core/services/requests.py
f=services/bot/core/handlers/request.py
f=services/bot/core/handlers/__init__.py
f=services/bot/main.py
f=services/bot/core/markups/inline.py
f=services/bot/core/markups/request.py
--- services/bot/core/clients/request.py ---
from requests import Response

from core.clients.base import BaseApiClient


class RequestClient(BaseApiClient):
    """Клиент для работы с API заявок"""

    def get_request_cards(
        self,
        requester_messenger_id: int,
        page: int = 1,
        limit: int = 10,
        status: str | None = None,
    ) -> Response:
        """Получает список заявок"""
        url = "/requests/card/list"
        params = {
            "page": page,
            "limit": limit,
        }
        if status:
            params["status"] = status

        return self.get(
            url,
            headers={"Authorization": str(requester_messenger_id)},
            params=params,
        )

    def get_request(self, request_id: str, requester_messenger_id: int) -> Response:
        """Получает заявку по ID"""
        url = f"/requests/{request_id}/card"
        params = {"messenger_id": requester_messenger_id}

        return self.get(
            url,
            headers={"Authorization": str(requester_messenger_id)},
            params=params,
        )

    def approve_request(self, request_id: str, requester_messenger_id: int) -> Response:
        """Одобряет заявку"""
        url = f"/requests/{request_id}/approve"

        return self.post(url, headers={"Authorization": str(requester_messenger_id)})

    def reject_request(self, request_id: str, requester_messenger_id: int, reason: str) -> Response:
        """Отклоняет заявку с указанием причины"""
        url = f"/requests/{request_id}/reject"
        json_data = {
            "reject_reason": reason,
        }

        return self.post(
            url, headers={"Authorization": str(requester_messenger_id)}, json=json_data
        )

--- services/bot/core/services/requests.py ---
from core.clients import RequestClient
from core.schemas import RequestCardSchema, RequestCardsPageSchema
from core.services.base import BaseService
from core.services.registry import BaseServiceRegistry
from core.utils import logger


class RequestService(BaseService):
    """Сервис для работы с заявками"""

    def __init__(self, registry: BaseServiceRegistry, client: RequestClient):
        super().__init__(registry)
        self.client = client

    def get_request_cards_page(
        self,
        *,
        requester_messenger_id: int,
        page: int = 1,
        limit: int = 10,
        status: str | None = None,
    ) -> RequestCardsPageSchema:
        """Получает страницу с заявками"""
        resp = self.client.get_request_cards(
            requester_messenger_id=requester_messenger_id,
            page=page,
            limit=limit,
            status=status,
        )

        if resp.status_code != 200:
            logger.error(f"[RequestService] get_request_cards_page failed: {resp.status_code}")
            return RequestCardsPageSchema(request_cards=[], current_page=1, total_pages=1)

        try:
            return RequestCardsPageSchema.from_response(resp.json())
        except Exception as e:
            logger.error(f"[RequestService] Failed to parse response: {e}")
            return RequestCardsPageSchema(request_cards=[], current_page=1, total_pages=1)

    def get_request_by_id(
        self, request_id: str, requester_messenger_id: int
    ) -> RequestCardSchema | None:
        """Получает заявку по ID"""
        resp = self.client.get_request(request_id, requester_messenger_id)

        if resp.status_code == 404:
            return None
        if resp.status_code != 200:
            logger.error(f"[RequestService] get_request_by_id failed: {resp.status_code}")
            return None

        try:
            return RequestCardSchema.from_dict(resp.json())
        except Exception as e:
            logger.error(f"[RequestService] Failed to parse request: {e}")
            return None

    def approve_request(self, request_id: str, requester_messenger_id: int) -> bool:
        """Одобряет заявку"""
        resp = self.client.approve_request(request_id, requester_messenger_id)
        return resp.status_code == 200

    def reject_request(self, request_id: str, requester_messenger_id: int, reason: str) -> bool:
        """Отклоняет заявку с причиной"""
        resp = self.client.reject_request(request_id, requester_messenger_id, reason)
        return resp.status_code == 200

--- services/bot/core/handlers/request.py ---
from __future__ import annotations

from dataclasses import dataclass

from dialog_bot_sdk.entities.messaging import (
    InteractiveMediaGroup,
    UpdateInteractiveMediaEvent,
    UpdateMessage,
)
from dialog_bot_sdk.interactive_media import Button, MediaGroupBuilder

from core.bot_kit.fsm import FSMContext, State, StatesGroup
from core.bot_kit.router import Router
from core.config import bot
from core.markups import (
    format_request_details,
    requests_pagination_keyboard,
    requests_select_keyboard,
)
from core.schemas import RequestCardsPageSchema
from core.services import RequestService
from core.utils import delete_prev_message, delete_prev_message_by_peer

requests_rt = Router()


class RequestRejectState(StatesGroup):
    wait_reason = State()


@dataclass(frozen=True)
class RequestUIContext:
    name: str
    back_value: str
    back_label: str


_UI: dict[str, RequestUIContext] = {
    # модерация -> заявки
    "requests": RequestUIContext(
        name="requests",
        back_value="requests",
        back_label="⬅️ Назад",
    ),
    "my_requests": RequestUIContext(
        name="my_requests",
        back_value="my_requests",
        back_label="⬅️ Назад",
    ),
}


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


def build_context_back(*, label: str = "❌ Отмена", value: str = "", ctx: str = ""):
    ui = get_ui(ctx)  # noqa: F841


def build_back_keyboard(
    *, value: str = "", label: str = "❌ Отмена", media_id: str = "request_open"
) -> list[InteractiveMediaGroup]:
    return MediaGroupBuilder(
        [Button(media_id=media_id, value=value, label=label)],
    ).build()  # type: ignore


def _send_requests_list_screen(
    peer,
    *,
    ctx_name: str,
    offset: int,
    limit: int,
    page_data: RequestCardsPageSchema,
    header: str | None = None,
    empty_text: str | None = None,
    filter_summary: str | None = None,
    note: str | None = None,
):
    """Отправляет список заявок"""

    request_cards = page_data.request_cards
    has_prev = page_data.has_prev
    has_next = page_data.has_next

    header_part = header if header is not None else "📋 Заявки\n\n"
    header_part = f"{note}\n\n{header_part}" if note else header_part
    filter_part = f"\n\n{filter_summary}" if filter_summary else ""

    if not request_cards:
        text = (
            header_part
            + (
                empty_text
                or "⚠️ Заявок не найдено.\n\nПопробуй изменить критерии или вернись назад."
            )
            + filter_part
        )
        im: list[InteractiveMediaGroup] = []
        im += requests_pagination_keyboard(
            offset=offset,
            limit=limit,
            has_prev=has_prev,
            has_next=has_next,
            leave_value=ctx_name,
            leave_label="⬅️ Назад",
        )
        bot.messaging.send_message(peer=peer, text=text, interactive_media_groups=im)
        return

    text = (
        header_part
        + f"Страница: {page_data.current_page} / {page_data.total_pages}\n\n"
        + "Выбери заявку для просмотра:"
        + filter_part
    )

    im: list[InteractiveMediaGroup] = []
    im += requests_select_keyboard(
        request_cards,
        open_media_id="request_open",
        start_index=offset + 1,
        per_row=1,
    )

    im += requests_pagination_keyboard(
        offset=offset,
        limit=limit,
        has_prev=has_prev,
        has_next=has_next,
        leave_value=ctx_name,
        leave_label="🛡️ В меню модерации",
    )

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


def send_request_cards_page(
    peer,
    *,
    offset: int,
    request_service: RequestService,
    ctx_name: str,
    note: str | None = None,
    context: FSMContext | None = None,
):
    """Отправляет страницу с заявками"""
    limit = 10
    page = offset // limit + 1

    page_data = request_service.get_request_cards_page(
        requester_messenger_id=peer.id,
        page=page,
        limit=limit,
    )

    _send_requests_list_screen(
        peer,
        ctx_name=ctx_name,
        offset=offset,
        limit=limit,
        page_data=page_data,
        note=note,
    )


@bot.di
def all_requests_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик для просмотра всех заявок"""
    delete_prev_message_by_peer(bot, event.peer)

    send_request_cards_page(
        event.peer,
        offset=0,
        request_service=request_service,
        ctx_name="requests",
        context=context,
    )


@bot.di
def requests_page_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик пагинации заявок"""
    delete_prev_message_by_peer(bot, event.peer)

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

    send_request_cards_page(
        event.peer,
        offset=offset,
        request_service=request_service,
        ctx_name="requests",
        context=context,
    )


@bot.di
def request_open_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик открытия конкретной заявки"""
    delete_prev_message_by_peer(bot, event.peer)
    context.set_state(None)

    request_id = str(event.data.value or "").strip()

    # Получаем детали заявки
    request_card = request_service.get_request_by_id(request_id, event.peer.id)

    if request_card is None:
        bot.messaging.send_message(
            peer=event.peer,
            text="⚠️ Заявка не найдена.",
            interactive_media_groups=build_back_keyboard(media_id="requests"),
        )
        return

    # Формируем детальное сообщение
    text = format_request_details(request_card)

    # Клавиатура для действий с заявкой
    keyboard = _build_request_actions_keyboard(request_card, ctx_name="requests")

    bot.messaging.send_message(
        peer=event.peer,
        text=text,
        interactive_media_groups=keyboard,
    )


def _build_request_actions_keyboard(request_card, ctx_name: str):
    """Строит клавиатуру для действий с заявкой"""

    actions = []
    req = request_card.request

    actions.append(
        Button(
            media_id="leave",
            value=ctx_name,
            label="⬅️ Назад",
        )
    )

    if req.status.lower() == "pending":
        actions.append(
            Button(
                media_id="request_approve",
                value=req.id,
                label="✅ Одобрить",
            )
        )
        actions.append(
            Button(
                media_id="request_reject",
                value=req.id,
                label="❌ Отклонить",
            )
        )

    return MediaGroupBuilder(actions).build()


@bot.di
def requests_prev_page_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик предыдущей страницы заявок"""
    delete_prev_message_by_peer(bot, event.peer)

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

    send_request_cards_page(
        event.peer,
        offset=offset,
        request_service=request_service,
        ctx_name="moderation",
        context=context,
    )


@bot.di
def requests_next_page_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик следующей страницы заявок"""
    delete_prev_message_by_peer(bot, event.peer)

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

    send_request_cards_page(
        event.peer,
        offset=offset,
        request_service=request_service,
        ctx_name="moderation",
        context=context,
    )


@bot.di
def request_approve_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик одобрения заявки"""
    delete_prev_message_by_peer(bot, event.peer)

    request_id = str(event.data.value or "").strip()

    success = request_service.approve_request(request_id, event.peer.id)

    if success:
        note = "✅ Заявка успешно одобрена!"
    else:
        note = "⚠️ Не удалось одобрить заявку. Попробуй позже."

    send_request_cards_page(
        event.peer,
        offset=0,
        request_service=request_service,
        ctx_name="moderation",
        context=context,
        note=note,
    )


@bot.di
def request_reject_handler(
    event: UpdateInteractiveMediaEvent,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик отклонения заявки - запрашивает причину"""
    delete_prev_message_by_peer(bot, event.peer)

    request_id = str(event.data.value or "").strip()

    context.update_data({"reject_request_id": request_id})
    context.set_state(RequestRejectState.wait_reason)

    bot.messaging.send_message(
        peer=event.peer,
        text="💬 Укажите причину отклонения заявки:",
        interactive_media_groups=build_back_keyboard(value=request_id, media_id="request_open"),
    )


@requests_rt.message(state=RequestRejectState.wait_reason)
@bot.di
def request_reject_reason_handler(
    message: UpdateMessage,
    context: FSMContext,
    request_service: RequestService,
):
    """Обработчик получения причины отклонения заявки"""
    delete_prev_message(bot, message)

    data = context.get_data()
    request_id = data.get("reject_request_id")
    reason = message.message.text_message.text.strip()

    note = ""
    if not reason:
        note = "⚠️ Причина не может быть пустой."
    else:
        success = request_service.reject_request(request_id, message.peer.id, reason)
        note = "ℹ️ Заявка успешно отклонена." if success else "⚠️ Не удалось отклонить заявку."

    context.set_state(None)
    context.update_data({"reject_request_id": None})

    send_request_cards_page(
        message.peer,
        offset=0,
        request_service=request_service,
        ctx_name="moderation",
        note=note,
    )

--- services/bot/core/handlers/__init__.py ---
from .admin import admin_menu_handler
from .ai import ai_assistant_handler, ai_rt, event_source_callback_handler
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,
    part_events_menu_handler,
    part_events_page_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_mode_open_handler,
    events_filters_mode_select_handler,
    events_filters_not_implemented_handler,
    events_filters_open_handler,
    events_filters_reset_handler,
    events_filters_tags_open_handler,
    events_filters_tags_toggle_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_create_back_to_time_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,
    gosb_approved,
    gosb_update_selected,
    moderation_menu_handler,
    my_events_handler,
    my_events_open_handler,
    my_events_page_handler,
    project_input_dash_handler,
    tb_approved,
    tb_reselect_handler,
    tb_update_selected,
)
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,
)
from .profile import rt as update_user_rt
from .report import (
    report_create_start_handler,
    report_rt,
    report_view_handler,
    reports_export_handler,
)
from .request import (
    all_requests_handler,
    request_approve_handler,
    request_open_handler,
    request_reject_handler,
    requests_next_page_handler,
    requests_page_handler,
    requests_prev_page_handler,
    requests_rt,
)
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

__all__ = [
    "admin_menu_handler",
    "ai_assistant_handler",
    "ai_rt",
    "all_events_handler",
    "all_events_open_handler",
    "all_events_page_handler",
    "all_requests_handler",
    "create_event_start_handler",
    "event_create_back_to_time_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_source_callback_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_mode_open_handler",
    "events_filters_mode_select_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",
    "gosb_approved",
    "gosb_update_selected",
    "main_menu_handler",
    "moderation_menu_handler",
    "my_events_handler",
    "my_events_open_handler",
    "my_events_page_handler",
    "my_profile_handler",
    "noop_handler",
    "part_events_menu_handler",
    "part_events_page_handler",
    "points_event_open_handler",
    "points_leaderboard_handler",
    "points_menu_handler",
    "points_page_handler",
    "points_rt",
    "profile_tags_toggle_handler",
    "project_input_dash_handler",
    "report_create_start_handler",
    "report_rt",
    "report_view_handler",
    "reports_export_handler",
    "request_approve_handler",
    "request_open_handler",
    "request_reject_handler",
    "requests_next_page_handler",
    "requests_page_handler",
    "requests_prev_page_handler",
    "requests_rt",
    "start_handler",
    "tb_approved",
    "tb_reselect_handler",
    "tb_update_selected",
    "update_user_interests_start",
    "update_user_menu_handler",
    "update_user_name_start",
    "update_user_rt",
    "user_events_open_handler",
    "user_events_page_handler",
    # Routers
    "user_events_rt",
    "view_by_uuid_rt",
    "view_by_uuid_start_handler",
    "volunteer_home_handler",
]

--- services/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,
    ai_rt,
    all_events_handler,
    all_events_open_handler,
    all_events_page_handler,
    all_requests_handler,
    create_event_start_handler,
    event_create_back_to_time_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_source_callback_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_mode_open_handler,
    events_filters_mode_select_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,
    gosb_approved,
    gosb_update_selected,
    main_menu_handler,
    moderation_menu_handler,
    my_events_handler,
    my_events_open_handler,
    my_events_page_handler,
    my_profile_handler,
    noop_handler,
    part_events_menu_handler,
    part_events_page_handler,
    points_event_open_handler,
    points_leaderboard_handler,
    points_menu_handler,
    points_page_handler,
    points_rt,
    profile_tags_toggle_handler,
    project_input_dash_handler,
    report_create_start_handler,
    report_rt,
    report_view_handler,
    reports_export_handler,
    request_approve_handler,
    request_open_handler,
    request_reject_handler,
    requests_next_page_handler,
    requests_page_handler,
    requests_prev_page_handler,
    requests_rt,
    start_handler,
    tb_approved,
    tb_reselect_handler,
    tb_update_selected,
    update_user_interests_start,
    update_user_menu_handler,
    update_user_name_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:  # legacy
    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.register(ai_rt)
    router.register(requests_rt)

    router.subscribe(bot)
    bot.messaging.command_handler(
        [
            CommandHandler(
                function=start_handler,
                command="start",
                description="Расскажу о себе",
            ),
            CommandHandler(
                function=create_event_start_handler,
                command="create_event",
                description="Создать мероприятие",
            ),
            CommandHandler(
                function=all_events_handler,
                command="all_events",
                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_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=part_events_menu_handler, value="part_events"),
            # TODO почистить
            EventHandler(function=project_input_dash_handler, value="project_input_dash"),
            EventHandler(function=user_events_page_handler, _id="user_events_prev"),
            EventHandler(function=user_events_page_handler, _id="user_events_next"),
            EventHandler(function=part_events_page_handler, _id="part_events_prev"),
            EventHandler(function=part_events_page_handler, _id="part_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_description"),
            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_tags"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_organizers"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_hours"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_code"),
            EventHandler(function=event_menu_edit_field_handler, _id="event_menu_edit_gosb"),
            EventHandler(
                function=event_menu_edit_field_handler, _id="event_menu_edit_participation_limit"
            ),
            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_mode_open_handler, _id="events_filters_mode"),
            EventHandler(
                function=events_filters_mode_select_handler, _id="events_filters_mode_select"
            ),
            EventHandler(function=all_requests_handler, value="requests"),
            EventHandler(function=requests_page_handler, _id="requests_prev"),
            EventHandler(function=requests_page_handler, _id="requests_next"),
            EventHandler(function=request_open_handler, _id="request_open"),
            EventHandler(function=request_approve_handler, _id="request_approve"),
            EventHandler(function=request_reject_handler, _id="request_reject"),
            # TODO почистить
            EventHandler(function=project_input_dash_handler, value="project_input_dash"),
            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"),
            EventHandler(function=requests_prev_page_handler, _id="requests_prev"),
            EventHandler(function=requests_next_page_handler, _id="requests_next"),
            EventHandler(function=tb_approved, _id="tb_approved"),
            EventHandler(function=gosb_approved, _id="gosb_approved"),
            EventHandler(function=tb_update_selected, _id="tb_select"),
            EventHandler(
                function=event_create_back_to_time_handler, _id="event_create_back_to_time"
            ),
            EventHandler(function=tb_reselect_handler, _id="tb_reselect"),
            EventHandler(function=gosb_update_selected, _id="gosb_select"),
            EventHandler(function=event_source_callback_handler, _id="ai_event_card"),
        ]
    )


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


if __name__ == "__main__":
    main()

--- services/bot/core/markups/inline.py ---
from dialog_bot_sdk.entities.messaging import InteractiveMediaStyle
from dialog_bot_sdk.interactive_media import Button, MediaGroupBuilder

from core.markups.pagination import pagination_keyboard


# Основные клавиатуры
def main_keyboard_admin():  # legacy
    group_builder_1 = MediaGroupBuilder(
        [
            Button(
                "1",
                "volunteer_home",
                "🏡 Дом волонтёра",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
            Button(
                "2",
                "ai_assistant",
                "✨ ИИ Волонтёр",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
        ]
    )
    group_builder_2 = MediaGroupBuilder(
        [
            Button(
                "3",
                "moderation",
                "🛡️ Модерация",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
            Button(
                "4",
                "administration",
                "⚙️ Администрация",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
        ]
    )

    return group_builder_1.merge([group_builder_2])


def main_keyboard_moder():  # legacy
    group_builder_1 = MediaGroupBuilder(
        [
            Button(
                "1",
                "volunteer_home",
                "🏡 Дом волонтёра",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
            Button(
                "2",
                "ai_assistant",
                "✨ ИИ Волонтёр",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
        ]
    )
    group_builder_2 = MediaGroupBuilder(
        [
            Button(
                "3", "moderation", "Модерация", InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT
            )
        ]
    )

    return group_builder_1.merge([group_builder_2])


def main_keyboard_user():  # legacy
    builder = MediaGroupBuilder(
        [
            Button(
                "1",
                "volunteer_home",
                "🏡 Дом волонтёра",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
            Button(
                "2",
                "ai_assistant",
                "✨ ИИ Волонтёр",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
        ]
    )
    return builder.build()


def back_to_main_menu_keyboard():  # legacy
    builder = MediaGroupBuilder(
        [Button("20", "main_menu", "⬅️ В меню", InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT)]
    )
    return builder.build()


# Дом волонтёра
def volunteer_home_keyboard():  # legacy
    group_builder_1 = MediaGroupBuilder(
        [
            Button(
                "1",
                "my_profile",
                "👤 Мой профиль",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
            Button(
                "2", "events", "🌐 Мероприятия", InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT
            ),
        ]
    )
    group_builder_2 = MediaGroupBuilder(
        [
            Button("3", "points", "🏆 Баллы", InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT),
            Button(
                "4",
                "part_events",
                "🎉 Мои мероприятия",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
        ]
    )
    group_builder_3 = MediaGroupBuilder(
        [
            Button(
                "5", "main_menu", "⬅️ В меню", InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT
            ),
        ]
    )

    return group_builder_1.merge([group_builder_2, group_builder_3])


def back_to_moderation_and_skip_keyboard():
    builder = MediaGroupBuilder(
        [
            Button(
                "2",
                "project_input_dash",
                "➡️ Пропустить",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
            Button(
                "1",
                "moderation",
                "⬅️ В меню модерации",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
        ]
    )
    return builder.build()


def back_to_moderation_keyboard():  # legacy
    builder = MediaGroupBuilder(
        [
            Button(
                "1",
                "moderation",
                "⬅️ В меню модерации",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
        ]
    )
    return builder.build()


def back_to_volunteer_home_keyboard():  # legacy
    builder = MediaGroupBuilder(
        [
            Button(
                "1",
                "volunteer_home",
                "⬅️ В дом волонтёра",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            )
        ]
    )
    return builder.build()


# Модерация
def moderation_menu_keyboard():  # legacy
    group_builder_1 = MediaGroupBuilder(
        [
            Button("1", "create_event", "🗓️ Создать мероприятие"),
            Button("4", "my_events", "📋 Мои мероприятия"),
        ]
    )

    group_builder_2 = MediaGroupBuilder(
        [
            Button(
                "5",
                "all_events",
                "🌐 Все мероприятия",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
            Button("7", "download_reports_list", "📥 Выгрузить отчёты"),
        ]
    )

    group_builder_3 = MediaGroupBuilder(
        [
            Button("2", "requests", "📩 Заявки"),
            Button("3", "my_requests", "✉️ Мои заявки"),
        ]
    )

    group_builder_4 = MediaGroupBuilder(
        [
            Button("10", "main_menu", "⬅️ В меню"),
        ]
    )

    return group_builder_1.merge([group_builder_2, group_builder_3, group_builder_4])


# Администрация
def admin_menu_keyboard():  # legacy
    group_builder_1 = MediaGroupBuilder(
        [
            Button("1", "add_admin", "Добавить администратора"),
            Button("2", "delete_admin", "Убрать администратора"),
        ]
    )

    group_builder_2 = MediaGroupBuilder(
        [
            Button("3", "add_moderator", "Добавить модератора"),
            Button("4", "delete_moderator", "Убрать модератора"),
        ]
    )

    group_builder_3 = MediaGroupBuilder(
        [
            Button("5", "ban_user", "Забанить пользователя"),
            Button("6", "unban_user", "Разбанить пользователя"),
        ]
    )

    group_builder_4 = MediaGroupBuilder([Button("7", "main_menu", "⬅️ В меню")])

    return group_builder_1.merge([group_builder_2, group_builder_3, group_builder_4])


def reports_export_keyboard():  # legacy
    """
    value везде = 'download_reports_list', обработчик один
    выбор различаем по media_id
    """

    group_1 = MediaGroupBuilder(
        [
            Button("rep_day", "download_reports_list", "📅 Последний день"),
            Button("rep_week", "download_reports_list", "📆 Неделя"),
        ]
    )
    group_2 = MediaGroupBuilder(
        [
            Button("rep_month", "download_reports_list", "🗓️ Месяц"),
            Button("rep_all", "download_reports_list", "🌐 Всё время"),
        ]
    )
    group_3 = MediaGroupBuilder(
        [
            Button("rep_from", "download_reports_list", "📌 От даты до сегодня"),
            Button("rep_range", "download_reports_list", "🗓️ От даты до даты"),
        ]
    )
    group_4 = MediaGroupBuilder(
        [
            Button("rep_back", "moderation", "⬅️ В меню модерации"),
        ]
    )
    return group_1.merge([group_2, group_3, group_4])


def back_to_reports_export_keyboard():  # legacy
    return MediaGroupBuilder(
        [
            Button(
                "9",
                "download_reports_list",
                "⬅️ В меню выгрузки отчётов",
                InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
            ),
        ]
    ).build()


def all_events_pagination_keyboard(
    *, offset: int, limit: int, has_prev: bool, has_next: bool
):  # legacy
    return pagination_keyboard(
        offset=offset,
        limit=limit,
        has_prev=has_prev,
        has_next=has_next,
        prev_media_id="all_events_prev",
        next_media_id="all_events_next",
        leave_value="moderation",
        leave_label="🛡️ В меню модерации",
        style=InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
        keep_layout=True,
        add_view_by_uuid_button=True,
        view_by_uuid_value="moderation",
    )


def all_requests_pagination_keyboard(
    *, offset: int, limit: int, has_prev: bool, has_next: bool
):  # untested
    return pagination_keyboard(
        offset=offset,
        limit=limit,
        has_prev=has_prev,
        has_next=has_next,
        prev_media_id="all_requests_prev",
        next_media_id="all_requests_next",
        leave_value="moderation",
        leave_label="🛡️ В меню модерации",
        style=InteractiveMediaStyle.INTERACTIVEMEDIASTYLE_DEFAULT,
        keep_layout=True,
        add_view_by_uuid_button=True,
        view_by_uuid_value="moderation",
    )

--- services/bot/core/markups/request.py ---
from dialog_bot_sdk.interactive_media import Button, InteractiveMediaGroup, MediaGroupBuilder

from core.markups.event import format_event_details
from core.markups.pagination import pagination_keyboard
from core.schemas import RequestCardSchema
from core.utils import format_datetime, wrap_text


def _get_status_emoji(status: str) -> str:

    status_emojis = {
        "pending": "⏳",  # на рассмотрении
        "approved": "✅",  # одобрено
        "rejected": "❌",  # отклонено
    }
    return status_emojis.get(status.lower(), "⚪")


def _get_status_label(status: str) -> str:
    status_label = {
        "pending": "на рассмотрении",
        "approved": "одобрено",
        "rejected": "отлконено",
    }
    return status_label.get(status.lower(), "-")


def _get_request_type_label(request_type: str) -> str:
    types = {
        "event": "Мероприятие вне проекта",
        "project_event": "Мероприятие в рамках",
        "project": "Проект",
    }
    return types.get(request_type.lower(), request_type.capitalize())


def _request_button_label(request_card: RequestCardSchema) -> str:
    """Формирует подпись для кнопки заявки (короткий вариант)"""
    req = request_card.request
    resource = request_card.resource

    request_type = _get_request_type_label(req.request_type)

    resource_name = "—"
    if resource.event:
        resource_name = resource.event.title or "—"

    status_emoji = _get_status_emoji(req.status)
    status_label = _get_status_label(req.status)

    return f"{request_type}\nНазвание: {resource_name}\nСтатус: {status_label} {status_emoji}"


def requests_select_keyboard(
    requests_cards: list[RequestCardSchema],
    *,
    open_media_id: str,
    start_index: int = 1,
    per_row: int = 1,
) -> list[InteractiveMediaGroup]:
    """
    Клавиатура для выбора заявки из списка (аналог events_select_keyboard)
    """
    groups: list[InteractiveMediaGroup] = []
    row: list[Button] = []

    idx = start_index
    for request_card in requests_cards:
        label = _request_button_label(request_card)
        row.append(Button(media_id=open_media_id, value=str(request_card.request.id), label=label))
        idx += 1

        if len(row) >= per_row:
            groups.extend(MediaGroupBuilder(row).build())
            row = []

    if row:
        groups.extend(MediaGroupBuilder(row).build())

    return groups


def requests_pagination_keyboard(
    *,
    offset: int,
    limit: int,
    has_prev: bool,
    has_next: bool,
    prev_media_id: str = "requests_prev",
    next_media_id: str = "requests_next",
    leave_value: str = "moderation",
    leave_label: str = "🛡️ В меню модерации",
    keep_layout: bool = True,
) -> list[InteractiveMediaGroup]:
    """
    Клавиатура пагинации для списка заявок
    """
    return pagination_keyboard(
        offset=offset,
        limit=limit,
        has_prev=has_prev,
        has_next=has_next,
        prev_media_id=prev_media_id,
        next_media_id=next_media_id,
        leave_value=leave_value,
        leave_label=leave_label,
        keep_layout=keep_layout,
    )


def requests_filters_keyboard(
    ctx_name: str = "moderation",
    offset: int = 0,
) -> list[InteractiveMediaGroup]:
    """
    Клавиатура фильтров для заявок
    """
    return MediaGroupBuilder(
        [Button(media_id="requests_filters_open", value=f"{ctx_name}|{offset}", label="🔍 Фильтры")]
    ).build()


def format_request_details(request_card: RequestCardSchema) -> str:
    """Форматирует детальную информацию о заявке"""

    def _format_reject_reason(reject_reason: str) -> str:
        reject_reason = wrap_text(reject_reason, 40, "")
        return "❌ Причина отказа:\n```plain\n" + reject_reason + "\n```"

    req = request_card.request
    resource = request_card.resource

    status_emoji = _get_status_emoji(req.status)
    status_label = _get_status_label(req.status)

    lines = [
        f"📋 *Заявка* {status_emoji}",
        "",
        f"🆔 ID: `{req.id}`",
        f"📝 Тип: {_get_request_type_label(req.request_type)}",
        f"👤 Отправитель: `{req.messenger_id}`",
        f"📅 Создана: {format_datetime(req.created_at)}",
        f"🔄 Обновлена: {format_datetime(req.updated_at)}",
        f"📊 Статус: {status_label} {status_emoji}",
        "",
    ]

    if req.status.lower() == "rejected" and req.reject_reason:
        lines.append(_format_reject_reason(req.reject_reason))

    if req.request_type == "event" or req.request_type == "project_event":
        event_formated = format_event_details(
            resource,
            show_code=True,
            show_organizer=True,
            make_frame=True,
            wrap=True,
        )
        lines.append(event_formated)

    return "\n".join(lines)


def request_actions_keyboard(
    request_id: str,
    *,
    status: str,
    back_value: str,
    back_label: str,
    approve_media_id: str = "request_approve",
    reject_media_id: str = "request_reject",
) -> list[InteractiveMediaGroup]:
    """
    Клавиатура для действий с заявкой (аналог event_actions_keyboard)
    """
    actions = []

    if status.lower() == "pending":
        actions.append(Button(media_id=approve_media_id, value=request_id, label="✅ Одобрить"))
        actions.append(Button(media_id=reject_media_id, value=request_id, label="❌ Отклонить"))

    actions.append(Button(media_id="leave", value=back_value, label=back_label))

    return MediaGroupBuilder(actions).build()

--- services/bot/core/schemas/request.py ---
from datetime import datetime
from typing import Any

from pydantic import BaseModel

from core.schemas.event import EventCardSchema
from core.utils import parse_datetime_value, require_datetime


class RequestSchema(BaseModel):
    """Схема заявки"""

    id: str
    messenger_id: str
    request_type: str
    reason: str
    source_id: str
    status: str
    reject_reason: str
    reviewed_by: str
    reviewed_at: datetime | None
    created_at: datetime
    updated_at: datetime

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "RequestSchema":
        reviewed_at = data.get("reviewed_at")
        if reviewed_at and reviewed_at.startswith("0001-01-01"):
            reviewed_at = None

        return cls(
            id=str(data.get("id", "")),
            messenger_id=str(data.get("messenger_id", "")),
            request_type=str(data.get("request_type", "")),
            reason=str(data.get("reason", "")),
            source_id=str(data.get("source_id", "")),
            status=str(data.get("status", "")),
            reject_reason=str(data.get("reject_reason", "")),
            reviewed_by=str(data.get("reviewed_by", "")),
            reviewed_at=parse_datetime_value(reviewed_at) if reviewed_at else None,
            created_at=require_datetime(data.get("created_at"), "participation.created_at"),
            updated_at=require_datetime(data.get("updated_at"), "participation.updated_at"),
        )


class RequestCardSchema(BaseModel):
    """Схема карточки заявки"""

    request: RequestSchema
    resource: EventCardSchema  # | ProjectCardSchema

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "RequestCardSchema":
        request_data = data.get("request")
        if not isinstance(request_data, dict):
            raise ValueError("Invalid request contract: request_data is required")
        request = RequestSchema.from_dict(request_data)

        resource_data = data.get("resource")
        if not isinstance(resource_data, dict):
            raise ValueError("Invalid request contract: resource_data is required")
        resource = EventCardSchema.from_dict(resource_data)

        # TODO: Сделать обработку карточки проекта здесь

        return cls(
            request=request,
            resource=resource,
        )


class RequestCardsPageSchema(BaseModel):
    """Схема страницы с карточками заявок"""

    request_cards: list[RequestCardSchema]
    current_page: int
    total_pages: int

    @classmethod
    def from_response(cls, response_data: dict[str, Any]) -> "RequestCardsPageSchema":
        """Создаёт схему из ответа бэкенда"""
        cards_data = response_data.get("request_cards") or []
        if not isinstance(cards_data, list):
            raise ValueError("Invalid request card contract: cards_data must be list")

        page_meta = response_data.get("page_meta") or {}
        if not isinstance(page_meta, dict):
            raise ValueError("Invalid request card contract: page_meta must be dict")

        request_cards: list[RequestCardSchema] = []
        for card_data in cards_data:
            if not isinstance(card_data, dict):
                raise ValueError("Invalid request card contract: item must be dict")
            request_cards.append(RequestCardSchema.from_dict(card_data))

        return cls(
            request_cards=request_cards,
            current_page=int(page_meta.get("current_page", 1)),
            total_pages=int(page_meta.get("total_pages", 1)),
        )

    @property
    def has_prev(self) -> bool:
        return self.current_page > 1

    @property
    def has_next(self) -> bool:
        return self.current_page < self.total_pages

Editor is loading...
Leave a Comment