Яички снова стали пасхальным событием, а не круглогодичной валютой
CI / lint-test (push) Failing after 11s Details
CI / build-push (push) Has been skipped Details

Окно теперь считается само: с 1 апреля по православную Пасху (в 2026 году — 12 апреля) вместо зашитых дат 2025 года, из-за которых команда не работала вовсе. Вне окна /яички отвечает «рано» или «событие завершилось», а строка про яички не показывается ни в /инфо, ни в карточке админки. Если понадобится другое окно, даты фиксируются через EGG_EVENT_START/EGG_EVENT_END.
This commit is contained in:
MpakobecEx 2026-10-19 04:03:22 +05:00
parent 107a79d05e
commit 6b15d8ae04
19 changed files with 171 additions and 16 deletions

View File

@ -53,6 +53,11 @@ ANALYTICS_BUFFER_MAX=5000
# Правка в админке применяется без перезапуска в течение этого времени. # Правка в админке применяется без перезапуска в течение этого времени.
TEMPLATE_CACHE_TTL=45 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. # Админка (FastAPI, Блок 4). Порт на loopback хоста для docker compose.
ADMIN_HTTP_PORT=8000 ADMIN_HTTP_PORT=8000
ADMIN_BIND_IP=127.0.0.1 ADMIN_BIND_IP=127.0.0.1

View File

@ -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`). - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. - Выпуск: изменить `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 git push origin master --tags
``` ```
## Пасхальное событие «Яички»
`/яички` работает только в сезон: по умолчанию с **1 апреля по православную Пасху**
(в 2026 году — 12 апреля). Вне окна бот отвечает, что ещё рано, а строка про яички
не показывается ни в `/инфо`, ни в карточке пользователя в админке.
Даты можно зафиксировать переменными `EGG_EVENT_START`/`EGG_EVENT_END` (ISO,
например `2027-04-01` и `2027-05-02`).
## Команды бота ## Команды бота
`/ник`, `/список`, `/кто`, `/инфо`, `/помощь`, `/очищение`, `/яички`, `/ник`, `/список`, `/кто`, `/инфо`, `/помощь`, `/очищение`, `/яички`,

View File

@ -21,6 +21,7 @@ SAMPLE_CONTEXT: dict[str, Any] = {
"dt": "01.01.2026", "dt": "01.01.2026",
"msg_count": 42, "msg_count": 42,
"eggs": 2, "eggs": 2,
"eggs_line": "<b>🥚 Количество яичек:</b> 2",
"h": "12", "h": "12",
"m": "05", "m": "05",
"s": "09", "s": "09",

View File

@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from admin.security import check_csrf from admin.security import check_csrf
from common import __version__ from common import __version__
from common.eggs import is_egg_event_active
from common.repositories import users_admin_repo from common.repositories import users_admin_repo
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@ -44,6 +45,7 @@ async def _card_response(
"history": await users_admin_repo.user_history(user_id), "history": await users_admin_repo.user_history(user_id),
"errors": errors or [], "errors": errors or [],
"saved": saved, "saved": saved,
"eggs_visible": is_egg_event_active(request.app.state.settings),
"max_nick_length": users_admin_repo.MAX_NICK_LENGTH, "max_nick_length": users_admin_repo.MAX_NICK_LENGTH,
"max_comment_length": users_admin_repo.MAX_COMMENT_LENGTH, "max_comment_length": users_admin_repo.MAX_COMMENT_LENGTH,
}, },

View File

@ -58,10 +58,12 @@
<div class="text-xs uppercase tracking-wide text-slate-500">Баланс DE</div> <div class="text-xs uppercase tracking-wide text-slate-500">Баланс DE</div>
<div class="mt-1 text-xl font-semibold">{{ user.balance }}</div> <div class="mt-1 text-xl font-semibold">{{ user.balance }}</div>
</div> </div>
{% if eggs_visible %}
<div class="rounded-lg border border-slate-200 bg-white p-4"> <div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Яички</div> <div class="text-xs uppercase tracking-wide text-slate-500">Яички</div>
<div class="mt-1 text-xl font-semibold">{{ user.eggs }}</div> <div class="mt-1 text-xl font-semibold">{{ user.eggs }}</div>
</div> </div>
{% endif %}
<div class="rounded-lg border border-slate-200 bg-white p-4"> <div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Сообщения</div> <div class="text-xs uppercase tracking-wide text-slate-500">Сообщения</div>
<div class="mt-1 text-xl font-semibold">{{ user.msg_count }}</div> <div class="mt-1 text-xl font-semibold">{{ user.msg_count }}</div>

View File

@ -12,8 +12,9 @@ from aiogram.types import Message
from bot.services import access, stats, subscriptions from bot.services import access, stats, subscriptions
from bot.services import users as users_service from bot.services import users as users_service
from common.config import get_settings from common.config import get_settings
from common.eggs import is_egg_event_active
from common.texts import system 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 from common.texts.store import get_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -108,6 +109,9 @@ async def info_handler(message: Message) -> None:
"dt": user_info.dt, "dt": user_info.dt,
"msg_count": user_info.msg_count, "msg_count": user_info.msg_count,
"eggs": user_info.eggs, "eggs": user_info.eggs,
"eggs_line": (
f"<b>🥚 Количество яичек:</b> {escape(user_info.eggs)}" if is_egg_event_active(get_settings()) else ""
),
} }
await message.answer(render(await get_text("info.profile"), context)) await message.answer(render(await get_text("info.profile"), context))

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import random import random
from datetime import date, datetime from datetime import datetime
from aiogram import Router from aiogram import Router
from aiogram.filters import Command from aiogram.filters import Command
@ -12,16 +12,13 @@ from aiogram.types import Message
from bot.services import access, alerts from bot.services import access, alerts
from bot.services import economy as economy_service from bot.services import economy as economy_service
from common.config import get_settings 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 import system
from common.texts.render import render from common.texts.render import render
from common.texts.store import get_text from common.texts.store import get_text
router = Router(name="economy") router = Router(name="economy")
#: Окно сезонного события «яички».
EGG_EVENT_START = date(2025, 4, 1)
EGG_EVENT_END = date(2025, 4, 20)
#: Шанс выпадения монетки на сообщение. #: Шанс выпадения монетки на сообщение.
COIN_CHANCE = 27 # из 100 COIN_CHANCE = 27 # из 100
@ -48,9 +45,11 @@ async def purification_handler(message: Message) -> None:
@router.message(Command("яички", ignore_case=True)) @router.message(Command("яички", ignore_case=True))
async def eggs_handler(message: Message) -> None: async def eggs_handler(message: Message) -> None:
today = datetime.now(get_settings().tzinfo).date() settings = get_settings()
if not (EGG_EVENT_START <= today <= EGG_EVENT_END): if not is_egg_event_active(settings):
await message.answer(system.MSG_EGGS_TOO_EARLY) 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 return
if not await access.check_user(message): if not await access.check_user(message):

View File

@ -6,6 +6,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import date
from functools import lru_cache from functools import lru_cache
from typing import Annotated from typing import Annotated
from urllib.parse import quote_plus from urllib.parse import quote_plus
@ -70,6 +71,10 @@ class Settings(BaseSettings):
#: применяются без перезапуска). 0 — читать БД при каждом обращении. #: применяются без перезапуска). 0 — читать БД при каждом обращении.
template_cache_ttl: float = 45.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") @field_validator("chat_id", "alert_chat_id", mode="before")
@classmethod @classmethod
def _empty_int_to_zero(cls, value: object) -> object: def _empty_int_to_zero(cls, value: object) -> object:
@ -78,6 +83,14 @@ class Settings(BaseSettings):
return 0 return 0
return value 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") @field_validator("admin_ids", mode="before")
@classmethod @classmethod
def _parse_admin_ids(cls, value: object) -> object: def _parse_admin_ids(cls, value: object) -> object:

46
common/eggs.py Normal file
View File

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

View File

@ -9,7 +9,7 @@ MSG_INFO = """<b>👤Информация о пользователе {username}
<b>💰 Деньги:</b> {money} <b>💰 Деньги:</b> {money}
<b>📈 Начало активности в боте:</b> {dt} <b>📈 Начало активности в боте:</b> {dt}
<b>📊 Количество сообщений в чате:</b> {msg_count} <b>📊 Количество сообщений в чате:</b> {msg_count}
<b>🥚 Количество яичек:</b> {eggs} {eggs_line}
""" """
MSG_HELP = """<b><u>Список команд бота</u></b> MSG_HELP = """<b><u>Список команд бота</u></b>

View File

@ -18,7 +18,7 @@ from typing import Any
_TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}") _TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}")
#: Теги, чьи значения подставляются «как есть» (формируются helpers-функциями ниже). #: Теги, чьи значения подставляются «как есть» (формируются 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] = { TAG_REGISTRY: dict[str, str] = {
@ -32,6 +32,7 @@ TAG_REGISTRY: dict[str, str] = {
"dt": "Дата начала активности", "dt": "Дата начала активности",
"msg_count": "Количество сообщений", "msg_count": "Количество сообщений",
"eggs": "Количество яичек", "eggs": "Количество яичек",
"eggs_line": "Готовая строка про яички (пусто вне пасхального события)",
"h": "Часы до следующего действия", "h": "Часы до следующего действия",
"m": "Минуты до следующего действия", "m": "Минуты до следующего действия",
"s": "Секунды до следующего действия", "s": "Секунды до следующего действия",

View File

@ -25,6 +25,7 @@ MSG_PURCHASE_FAIL = "Не получилось купить :с"
MSG_DONAT_PROMO = "Назови этот код админам!\n`{promo}`" MSG_DONAT_PROMO = "Назови этот код админам!\n`{promo}`"
MSG_EGGS_TOO_EARLY = "Слишком рано..." MSG_EGGS_TOO_EARLY = "Слишком рано..."
MSG_EGGS_EVENT_OVER = "Событие с яичками уже завершилось — увидимся на следующую Пасху."
MSG_BOT_ADDED = "Я пришёл! Требую админские права!" MSG_BOT_ADDED = "Я пришёл! Требую админские права!"

View File

@ -7,4 +7,4 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.4.8" __version__ = "0.4.9"

View File

@ -54,3 +54,7 @@ ADMIN_BIND_IP=127.0.0.1
# Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска). # Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска).
TEMPLATE_CACHE_TTL=45 TEMPLATE_CACHE_TTL=45
# Пасхальное событие «Яички»: пусто — 1 апреля → Православная Пасха.
EGG_EVENT_START=
EGG_EVENT_END=

View File

@ -52,3 +52,7 @@ ADMIN_BIND_IP=127.0.0.1
# Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска). # Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска).
TEMPLATE_CACHE_TTL=45 TEMPLATE_CACHE_TTL=45
# Пасхальное событие «Яички»: пусто — 1 апреля → Православная Пасха.
EGG_EVENT_START=
EGG_EVENT_END=

View File

@ -41,7 +41,7 @@ VALUES
<b>💰 Деньги:</b> {money} <b>💰 Деньги:</b> {money}
<b>📈 Начало активности в боте:</b> {dt} <b>📈 Начало активности в боте:</b> {dt}
<b>📊 Количество сообщений в чате:</b> {msg_count} <b>📊 Количество сообщений в чате:</b> {msg_count}
<b>🥚 Количество яичек:</b> {eggs} {eggs_line}
$tpl$, 'Профиль (/инфо)'), $tpl$, 'Профиль (/инфо)'),
('info.help', $tpl$<b><u>Список команд бота</u></b> ('info.help', $tpl$<b><u>Список команд бота</u></b>

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, date, datetime
from typing import Any from typing import Any
import pytest 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 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: def test_update_nick(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory() client: TestClient = client_factory()
token = _csrf(client, csrf_token) token = _csrf(client, csrf_token)

51
tests/test_eggs.py Normal file
View File

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

View File

@ -7,4 +7,4 @@ from common import __version__
def test_version_is_set(): def test_version_is_set():
assert __version__ assert __version__
assert __version__ == "0.4.8" assert __version__ == "0.4.9"