diff --git a/.env.example b/.env.example index 579ce5f..f0d9736 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,11 @@ ANALYTICS_BUFFER_MAX=5000 # Правка в админке применяется без перезапуска в течение этого времени. TEMPLATE_CACHE_TTL=45 +# Пасхальное событие «Яички». Пусто — автоматически 1 апреля → Православная Пасха. +# Можно задать вручную (ISO): EGG_EVENT_START=2027-04-01, EGG_EVENT_END=2027-05-02. +EGG_EVENT_START= +EGG_EVENT_END= + # Админка (FastAPI, Блок 4). Порт на loopback хоста для docker compose. ADMIN_HTTP_PORT=8000 ADMIN_BIND_IP=127.0.0.1 diff --git a/README.md b/README.md index 130b701..e4e85a8 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.8`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.9`. - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. @@ -174,6 +174,14 @@ git tag -a vX.Y -m "Release vX.Y" git push origin master --tags ``` +## Пасхальное событие «Яички» + +`/яички` работает только в сезон: по умолчанию с **1 апреля по православную Пасху** +(в 2026 году — 12 апреля). Вне окна бот отвечает, что ещё рано, а строка про яички +не показывается ни в `/инфо`, ни в карточке пользователя в админке. +Даты можно зафиксировать переменными `EGG_EVENT_START`/`EGG_EVENT_END` (ISO, +например `2027-04-01` и `2027-05-02`). + ## Команды бота `/ник`, `/список`, `/кто`, `/инфо`, `/помощь`, `/очищение`, `/яички`, diff --git a/admin/preview.py b/admin/preview.py index 2feef6a..23a336e 100644 --- a/admin/preview.py +++ b/admin/preview.py @@ -21,6 +21,7 @@ SAMPLE_CONTEXT: dict[str, Any] = { "dt": "01.01.2026", "msg_count": 42, "eggs": 2, + "eggs_line": "🥚 Количество яичек: 2", "h": "12", "m": "05", "s": "09", diff --git a/admin/routes/users.py b/admin/routes/users.py index 658861a..2a2e10a 100644 --- a/admin/routes/users.py +++ b/admin/routes/users.py @@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from admin.security import check_csrf from common import __version__ +from common.eggs import is_egg_event_active from common.repositories import users_admin_repo router = APIRouter(prefix="/users", tags=["users"]) @@ -44,6 +45,7 @@ async def _card_response( "history": await users_admin_repo.user_history(user_id), "errors": errors or [], "saved": saved, + "eggs_visible": is_egg_event_active(request.app.state.settings), "max_nick_length": users_admin_repo.MAX_NICK_LENGTH, "max_comment_length": users_admin_repo.MAX_COMMENT_LENGTH, }, diff --git a/admin/templates/users/card.html b/admin/templates/users/card.html index 88f2b49..2b49de3 100644 --- a/admin/templates/users/card.html +++ b/admin/templates/users/card.html @@ -58,10 +58,12 @@
Баланс DE
{{ user.balance }}
+ {% if eggs_visible %}
Яички
{{ user.eggs }}
+ {% endif %}
Сообщения
{{ user.msg_count }}
diff --git a/bot/handlers/commands.py b/bot/handlers/commands.py index cb4b4e2..81436a6 100644 --- a/bot/handlers/commands.py +++ b/bot/handlers/commands.py @@ -12,8 +12,9 @@ from aiogram.types import Message from bot.services import access, stats, subscriptions from bot.services import users as users_service from common.config import get_settings +from common.eggs import is_egg_event_active from common.texts import system -from common.texts.render import format_tops, format_user_list, render +from common.texts.render import escape, format_tops, format_user_list, render from common.texts.store import get_text logger = logging.getLogger(__name__) @@ -108,6 +109,9 @@ async def info_handler(message: Message) -> None: "dt": user_info.dt, "msg_count": user_info.msg_count, "eggs": user_info.eggs, + "eggs_line": ( + f"🥚 Количество яичек: {escape(user_info.eggs)}" if is_egg_event_active(get_settings()) else "" + ), } await message.answer(render(await get_text("info.profile"), context)) diff --git a/bot/handlers/economy.py b/bot/handlers/economy.py index d6bbf32..75d64c4 100644 --- a/bot/handlers/economy.py +++ b/bot/handlers/economy.py @@ -3,7 +3,7 @@ from __future__ import annotations import random -from datetime import date, datetime +from datetime import datetime from aiogram import Router from aiogram.filters import Command @@ -12,16 +12,13 @@ from aiogram.types import Message from bot.services import access, alerts from bot.services import economy as economy_service from common.config import get_settings +from common.eggs import egg_event_period, is_egg_event_active from common.texts import system from common.texts.render import render from common.texts.store import get_text router = Router(name="economy") -#: Окно сезонного события «яички». -EGG_EVENT_START = date(2025, 4, 1) -EGG_EVENT_END = date(2025, 4, 20) - #: Шанс выпадения монетки на сообщение. COIN_CHANCE = 27 # из 100 @@ -48,9 +45,11 @@ async def purification_handler(message: Message) -> None: @router.message(Command("яички", ignore_case=True)) async def eggs_handler(message: Message) -> None: - today = datetime.now(get_settings().tzinfo).date() - if not (EGG_EVENT_START <= today <= EGG_EVENT_END): - await message.answer(system.MSG_EGGS_TOO_EARLY) + settings = get_settings() + if not is_egg_event_active(settings): + today = datetime.now(settings.tzinfo).date() + start, _ = egg_event_period(settings, today) + await message.answer(system.MSG_EGGS_TOO_EARLY if today < start else system.MSG_EGGS_EVENT_OVER) return if not await access.check_user(message): diff --git a/common/config.py b/common/config.py index 931078f..7f738bb 100644 --- a/common/config.py +++ b/common/config.py @@ -6,6 +6,7 @@ from __future__ import annotations +from datetime import date from functools import lru_cache from typing import Annotated from urllib.parse import quote_plus @@ -70,6 +71,10 @@ class Settings(BaseSettings): #: применяются без перезапуска). 0 — читать БД при каждом обращении. template_cache_ttl: float = 45.0 + #: Окно пасхального события «Яички». Пусто — автоматически 1 апреля → Православная Пасха. + egg_event_start: date | None = None + egg_event_end: date | None = None + @field_validator("chat_id", "alert_chat_id", mode="before") @classmethod def _empty_int_to_zero(cls, value: object) -> object: @@ -78,6 +83,14 @@ class Settings(BaseSettings): return 0 return value + @field_validator("egg_event_start", "egg_event_end", mode="before") + @classmethod + def _empty_date_to_none(cls, value: object) -> object: + """Пустое значение в .env — «даты не заданы» (работает авто-расчёт).""" + if value is None or value == "": + return None + return value + @field_validator("admin_ids", mode="before") @classmethod def _parse_admin_ids(cls, value: object) -> object: diff --git a/common/eggs.py b/common/eggs.py new file mode 100644 index 0000000..d95b4e2 --- /dev/null +++ b/common/eggs.py @@ -0,0 +1,46 @@ +"""Пасхальное событие «Яички» (Блок 4: сезонность). + +По умолчанию окно — с 1 апреля по православную Пасху включительно (как было +в исходном боте: 01.04–20.04.2025). Даты можно переопределить переменными +``EGG_EVENT_START``/``EGG_EVENT_END`` (например, для другого года или правила). +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta + +from common.config import Settings + +#: Разница между юлианским и григорианским календарями для 1900–2099. +_JULIAN_TO_GREGORIAN_DAYS = 13 + + +def orthodox_easter(year: int) -> date: + """Дата православной Пасхи (по юлианской пасхалии, переведённая в григорианский стиль).""" + a = year % 4 + b = year % 7 + c = year % 19 + d = (19 * c + 15) % 30 + e = (2 * a + 4 * b - d + 34) % 7 + month = (d + e + 114) // 31 + day = ((d + e + 114) % 31) + 1 + return date(year, month, day) + timedelta(days=_JULIAN_TO_GREGORIAN_DAYS) + + +def egg_event_period(settings: Settings, today: date) -> tuple[date, date]: + """Начало и конец окна «яичек» (включительно). + + Если в настройках заданы обе даты — берём их, иначе 1 апреля → православная + Пасха года ``today``. + """ + if settings.egg_event_start and settings.egg_event_end: + return settings.egg_event_start, settings.egg_event_end + return date(today.year, 4, 1), orthodox_easter(today.year) + + +def is_egg_event_active(settings: Settings, today: date | None = None) -> bool: + """Идёт ли сейчас пасхальное событие (в таймзоне настроек).""" + if today is None: + today = datetime.now(settings.tzinfo).date() + start, end = egg_event_period(settings, today) + return start <= today <= end diff --git a/common/texts/info.py b/common/texts/info.py index 7c82653..b696f8f 100644 --- a/common/texts/info.py +++ b/common/texts/info.py @@ -9,7 +9,7 @@ MSG_INFO = """👤Информация о пользователе {username} 💰 Деньги: {money} 📈 Начало активности в боте: {dt} 📊 Количество сообщений в чате: {msg_count} -🥚 Количество яичек: {eggs} +{eggs_line} """ MSG_HELP = """Список команд бота diff --git a/common/texts/render.py b/common/texts/render.py index a02cc0e..253508d 100644 --- a/common/texts/render.py +++ b/common/texts/render.py @@ -18,7 +18,7 @@ from typing import Any _TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}") #: Теги, чьи значения подставляются «как есть» (формируются helpers-функциями ниже). -RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_count", "tops"}) +RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_count", "tops", "eggs_line"}) #: Полный реестр поддерживаемых тегов: имя -> короткое описание. TAG_REGISTRY: dict[str, str] = { @@ -32,6 +32,7 @@ TAG_REGISTRY: dict[str, str] = { "dt": "Дата начала активности", "msg_count": "Количество сообщений", "eggs": "Количество яичек", + "eggs_line": "Готовая строка про яички (пусто вне пасхального события)", "h": "Часы до следующего действия", "m": "Минуты до следующего действия", "s": "Секунды до следующего действия", diff --git a/common/texts/system.py b/common/texts/system.py index 35c5633..6361f5d 100644 --- a/common/texts/system.py +++ b/common/texts/system.py @@ -25,6 +25,7 @@ MSG_PURCHASE_FAIL = "Не получилось купить :с" MSG_DONAT_PROMO = "Назови этот код админам!\n`{promo}`" MSG_EGGS_TOO_EARLY = "Слишком рано..." +MSG_EGGS_EVENT_OVER = "Событие с яичками уже завершилось — увидимся на следующую Пасху." MSG_BOT_ADDED = "Я пришёл! Требую админские права!" diff --git a/common/version.py b/common/version.py index d6ff408..76424af 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.4.8" +__version__ = "0.4.9" diff --git a/env/prod.env.example b/env/prod.env.example index ed5bdfd..cb70602 100644 --- a/env/prod.env.example +++ b/env/prod.env.example @@ -54,3 +54,7 @@ ADMIN_BIND_IP=127.0.0.1 # Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска). TEMPLATE_CACHE_TTL=45 + +# Пасхальное событие «Яички»: пусто — 1 апреля → Православная Пасха. +EGG_EVENT_START= +EGG_EVENT_END= diff --git a/env/test.env.example b/env/test.env.example index 2491441..3b5dca4 100644 --- a/env/test.env.example +++ b/env/test.env.example @@ -52,3 +52,7 @@ ADMIN_BIND_IP=127.0.0.1 # Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска). TEMPLATE_CACHE_TTL=45 + +# Пасхальное событие «Яички»: пусто — 1 апреля → Православная Пасха. +EGG_EVENT_START= +EGG_EVENT_END= diff --git a/sql/V15__seed_message_templates.sql b/sql/V15__seed_message_templates.sql index 877a732..31fcc21 100644 --- a/sql/V15__seed_message_templates.sql +++ b/sql/V15__seed_message_templates.sql @@ -41,7 +41,7 @@ VALUES 💰 Деньги: {money} 📈 Начало активности в боте: {dt} 📊 Количество сообщений в чате: {msg_count} -🥚 Количество яичек: {eggs} +{eggs_line} $tpl$, 'Профиль (/инфо)'), ('info.help', $tpl$Список команд бота diff --git a/tests/test_admin_users.py b/tests/test_admin_users.py index 0a6e448..cc5c293 100644 --- a/tests/test_admin_users.py +++ b/tests/test_admin_users.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, date, datetime from typing import Any import pytest @@ -157,6 +157,20 @@ def test_user_card_404(fake_repo: dict[str, Any], client_factory) -> None: assert client_factory().get("/users/99").status_code == 404 +def test_eggs_hidden_outside_event(fake_repo: dict[str, Any], client_factory) -> None: + response = client_factory().get("/users/42") + + assert response.status_code == 200 + assert "Яички" not in response.text + + +def test_eggs_visible_during_event(fake_repo: dict[str, Any], client_factory, settings_factory) -> None: + today = date.today() + client = client_factory(settings=settings_factory(egg_event_start=today, egg_event_end=today)) + + assert "Яички" in client.get("/users/42").text + + def test_update_nick(fake_repo: dict[str, Any], client_factory, csrf_token) -> None: client: TestClient = client_factory() token = _csrf(client, csrf_token) diff --git a/tests/test_eggs.py b/tests/test_eggs.py new file mode 100644 index 0000000..15599b8 --- /dev/null +++ b/tests/test_eggs.py @@ -0,0 +1,51 @@ +"""Тесты сезонности «яичек»: Пасха, окно события и переопределение дат.""" + +from __future__ import annotations + +from datetime import date + +from common.config import Settings +from common.eggs import egg_event_period, is_egg_event_active, orthodox_easter + + +def _settings(**env: str) -> Settings: + return Settings(_env_file=None, **{"BOT_TOKEN": "1:a", **env}) + + +def test_orthodox_easter_known_dates() -> None: + assert orthodox_easter(2024) == date(2024, 5, 5) + assert orthodox_easter(2025) == date(2025, 4, 20) + assert orthodox_easter(2026) == date(2026, 4, 12) + assert orthodox_easter(2027) == date(2027, 5, 2) + + +def test_default_period_is_april_first_to_easter() -> None: + settings = _settings() + + assert egg_event_period(settings, date(2026, 2, 1)) == (date(2026, 4, 1), date(2026, 4, 12)) + + +def test_event_boundaries() -> None: + settings = _settings() + + assert not is_egg_event_active(settings, date(2026, 3, 31)) + assert is_egg_event_active(settings, date(2026, 4, 1)) + assert is_egg_event_active(settings, date(2026, 4, 12)) + assert not is_egg_event_active(settings, date(2026, 4, 13)) + + +def test_event_dates_can_be_overridden() -> None: + settings = _settings(EGG_EVENT_START="2026-04-05", EGG_EVENT_END="2026-04-10") + + assert egg_event_period(settings, date(2026, 4, 1)) == (date(2026, 4, 5), date(2026, 4, 10)) + assert not is_egg_event_active(settings, date(2026, 4, 4)) + assert is_egg_event_active(settings, date(2026, 4, 6)) + assert not is_egg_event_active(settings, date(2026, 4, 11)) + + +def test_empty_dates_mean_auto() -> None: + settings = _settings(EGG_EVENT_START="", EGG_EVENT_END="") + + assert settings.egg_event_start is None + assert settings.egg_event_end is None + assert is_egg_event_active(settings, date(2026, 4, 2)) diff --git a/tests/test_version.py b/tests/test_version.py index 8c9c74b..e1b7ff6 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,4 +7,4 @@ from common import __version__ def test_version_is_set(): assert __version__ - assert __version__ == "0.4.8" + assert __version__ == "0.4.9"