Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions src/_dependencies/common/geo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
"""Shared geography / region-selection primitives across all messengers.

Centralises:

1. **Federal district constants** — single source of truth for labels
and normalised names used by VK and MAX (and eventually Telegram).
2. **Region name compaction** — converts verbose subtype suffixes
(e.g. `` – Активные поиски``) to short emoji markers (e.g. ``🔍``).
3. **Navigation button labels** — ``← назад``, ``ещё →``, ``Завершить``,
``в начало``, etc.
4. **Callback payload schema** — ``RegionCallbackPayload`` dataclass
with JSON serialisation, shared by VK and MAX inline keyboards.
5. **Pagination helpers** — split a region list into pages, compute
navigation metadata.
6. **Common text messages** — prompts shown during region selection.
7. **Emoji legend** — explaining what each emoji marker means.

Usage::

from _dependencies.common.geo import (
FEDERAL_DISTRICTS,
compact_region_name,
REGION_EMOJI_LEGEND,
RegionCallbackPayload,
paginate_regions,
NavButton,
)
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import NamedTuple

# ═══════════════════════════════════════════════════════════════════════════════
# Navigation button labels
# ═══════════════════════════════════════════════════════════════════════════════


class NavButton:
"""Shared navigation button labels used by region-selection keyboards."""

BACK: str = '← назад'
NEXT: str = 'ещё →'
FINISH: str = 'Завершить'
BACK_TO_START: str = 'в начало'


# ═══════════════════════════════════════════════════════════════════════════════
# Federal districts — single source of truth
# ═══════════════════════════════════════════════════════════════════════════════

#: (display_label, normalised_name) pairs.
#: ``normalised_name`` is used internally for DB lookups and callback payloads.
FEDERAL_DISTRICTS: tuple[tuple[str, str], ...] = (
('Центральный ФО', 'Центральный'),
('Северо-Западный ФО', 'Северо-Западный'),
('Южный ФО', 'Южный'),
('Северо-Кавказский ФО', 'Северо-Кавказский'),
('Приволжский ФО', 'Приволжский'),
('Уральский ФО', 'Уральский'),
('Сибирский ФО', 'Сибирский'),
('Дальневосточный ФО', 'Дальневосточный'),
('Прочие поиски по РФ', 'Прочие поиски по РФ'),
)

# Normalised names by label (for lookup)
_FED_DISTRICT_BY_LABEL: dict[str, str] = {label: norm for label, norm in FEDERAL_DISTRICTS}
# Labels by normalised name (for reverse lookup)
_FED_DISTRICT_LABEL: dict[str, str] = {norm: label for label, norm in FEDERAL_DISTRICTS}


def get_fed_district_normalised(label: str) -> str | None:
"""Return the normalised district name for a display label, or *None*.

Example: ``'Центральный ФО'`` → ``'Центральный'``.
"""
return _FED_DISTRICT_BY_LABEL.get(label.strip())


def get_fed_district_label(normalised: str) -> str | None:
"""Return the display label for a normalised district name, or *None*.

Example: ``'Центральный'`` → ``'Центральный ФО'``.
"""
return _FED_DISTRICT_LABEL.get(normalised.strip())


# ═══════════════════════════════════════════════════════════════════════════════
# Region name compaction (emoji suffixes)
# ═══════════════════════════════════════════════════════════════════════════════

_SUFFIX_TO_EMOJI: dict[str, str] = {
' – Активные поиски': '🔍',
' – Инфо поддержка': 'ℹ️',
' – Мероприятия': '📅',
}
# Build a regex once for efficient matching
_COMPACT_REGION_RE = re.compile('(' + '|'.join(re.escape(s) for s in _SUFFIX_TO_EMOJI) + ')$')

# Emoji legend shown above region selection keyboards so users understand
# what each emoji marker means.
REGION_EMOJI_LEGEND: str = '🔍 — активные поиски\n' 'ℹ️ — инфорг / поддержка\n' '📅 — мероприятия'


def compact_region_name(name: str) -> str:
"""Replace verbose subtype suffix with a short emoji marker at the beginning.

``"Москва – Активные поиски"`` → ``"🔍 Москва"``

If no suffix matches, the original name is returned unchanged.
"""
match = _COMPACT_REGION_RE.search(name)
if match:
suffix = match.group(1)
region_part = name[: match.start()].strip()
return _SUFFIX_TO_EMOJI[suffix] + ' ' + region_part
return name


# ═══════════════════════════════════════════════════════════════════════════════
# Callback payload — shared by VK and MAX inline keyboards
# ═══════════════════════════════════════════════════════════════════════════════

# All possible ``cmd`` values used in region-selection callbacks.
CMD_DISTRICT_SELECT: str = 'district_select'
CMD_PAGINATE_TOGGLE: str = 'paginate_toggle'
CMD_PAGINATE_NAV: str = 'paginate_nav'
CMD_PAGINATE_FINISH: str = 'paginate_finish'


@dataclass(frozen=True)
class RegionCallbackPayload:
"""Structured payload for inline region-selection callbacks.

Serialised as JSON for VK and MAX callback systems.
"""

cmd: str
district: str | None = None
region: str | None = None
page: int | None = None

def to_dict(self) -> dict[str, object]:
d: dict[str, object] = {'cmd': self.cmd}
if self.district is not None:
d['district'] = self.district
if self.region is not None:
d['region'] = self.region
if self.page is not None:
d['page'] = self.page
return d


# ═══════════════════════════════════════════════════════════════════════════════
# Pagination helpers
# ═══════════════════════════════════════════════════════════════════════════════


class PageRange(NamedTuple):
"""Start (inclusive) and end (exclusive) indices for a page slice."""

start: int
end: int


class PageInfo(NamedTuple):
"""Metadata for a single pagination page."""

items: list[str]
page: int
total_pages: int
has_prev: bool
has_next: bool
slice: PageRange


def paginate_regions(regions: list[str], page: int, page_size: int = 6) -> PageInfo:
"""Split *regions* into pages and return metadata for *page* (zero-based).

Args:
regions: Full list of region display names.
page: Zero-based page index.
page_size: Number of items per page.

Returns:
A :class:`PageInfo` with the slice of items for the requested page.
"""
total_pages = max(1, (len(regions) + page_size - 1) // page_size)
start = page * page_size
end = min(start + page_size, len(regions))
return PageInfo(
items=regions[start:end],
page=page,
total_pages=total_pages,
has_prev=page > 0,
has_next=page < total_pages - 1,
slice=PageRange(start, end),
)


# ═══════════════════════════════════════════════════════════════════════════════
# Common text messages
# ═══════════════════════════════════════════════════════════════════════════════

REGION_SELECTION_DONE: str = '✅ Выбор региона завершён.'
REGION_CANNOT_REMOVE_LAST: str = 'Нельзя удалить последний регион.'
REGION_TOGGLED_ON: str = 'Регион добавлен!'
REGION_TOGGLED_OFF: str = 'Регион удалён.'
REGION_TOGGLED_ON_FMT: str = 'Регион "{}" добавлен!'
REGION_TOGGLED_OFF_FMT: str = 'Регион "{}" удален.'
REGION_NOT_FOUND: str = 'Регион не найден.'
REGION_ERROR: str = 'Произошла ошибка. Попробуйте позже.'
14 changes: 10 additions & 4 deletions src/max_bot/_utils/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@

from _dependencies.bot.users_management import ManageUserAction
from _dependencies.common.commons import Messenger
from _dependencies.common.geo import (
CMD_DISTRICT_SELECT,
CMD_PAGINATE_FINISH,
CMD_PAGINATE_NAV,
CMD_PAGINATE_TOGGLE,
)
from _dependencies.models import DialogState
from _dependencies.user_repository import UserRepository

Expand Down Expand Up @@ -333,7 +339,7 @@ async def on_enable_notifications(event: MessageCallback) -> None:
# ─── Region Selection: Federal Districts ─────────────────────────────────


@router.message_callback(PayloadCmd('district_select'))
@router.message_callback(PayloadCmd(CMD_DISTRICT_SELECT))
async def on_district_select(event: MessageCallback) -> None:
"""Show paginated regions for the selected federal district."""
payload = _parse_payload(event.callback.payload)
Expand Down Expand Up @@ -367,7 +373,7 @@ async def on_district_select(event: MessageCallback) -> None:
# ─── Region Selection: Pagination ────────────────────────────────────────


@router.message_callback(PayloadCmd('paginate_nav'))
@router.message_callback(PayloadCmd(CMD_PAGINATE_NAV))
async def on_paginate_nav(event: MessageCallback) -> None:
"""Navigate between pages of regions."""
payload = _parse_payload(event.callback.payload)
Expand Down Expand Up @@ -398,7 +404,7 @@ async def on_paginate_nav(event: MessageCallback) -> None:
# ─── Region Selection: Toggle Subscribe ──────────────────────────────────


@router.message_callback(PayloadCmd('paginate_toggle'))
@router.message_callback(PayloadCmd(CMD_PAGINATE_TOGGLE))
async def on_paginate_toggle(event: MessageCallback) -> None:
"""Toggle subscription for a region."""
payload = _parse_payload(event.callback.payload)
Expand Down Expand Up @@ -461,7 +467,7 @@ async def on_paginate_toggle(event: MessageCallback) -> None:
)


@router.message_callback(PayloadCmd('paginate_finish'))
@router.message_callback(PayloadCmd(CMD_PAGINATE_FINISH))
async def on_paginate_finish(event: MessageCallback) -> None:
"""Finish region selection and return to main menu."""
await event.ack(notification=REGION_SELECTION_DONE)
Expand Down
Loading
Loading