Яички снова стали пасхальным событием, а не круглогодичной валютой
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
# Пасхальное событие «Яички». Пусто — автоматически 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

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`).
- 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`).
## Команды бота
`/ник`, `/список`, `/кто`, `/инфо`, `/помощь`, `/очищение`, `/яички`,

View File

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

View File

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

View File

@ -58,10 +58,12 @@
<div class="text-xs uppercase tracking-wide text-slate-500">Баланс DE</div>
<div class="mt-1 text-xl font-semibold">{{ user.balance }}</div>
</div>
{% if eggs_visible %}
<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="mt-1 text-xl font-semibold">{{ user.eggs }}</div>
</div>
{% endif %}
<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="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 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"<b>🥚 Количество яичек:</b> {escape(user_info.eggs)}" if is_egg_event_active(get_settings()) else ""
),
}
await message.answer(render(await get_text("info.profile"), context))

View File

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

View File

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

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> {dt}
<b>📊 Количество сообщений в чате:</b> {msg_count}
<b>🥚 Количество яичек:</b> {eggs}
{eggs_line}
"""
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_]*)\}")
#: Теги, чьи значения подставляются «как есть» (формируются 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": "Секунды до следующего действия",

View File

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

View File

@ -7,4 +7,4 @@
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 кеша бота (правки из админки без перезапуска).
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 кеша бота (правки из админки без перезапуска).
TEMPLATE_CACHE_TTL=45
# Пасхальное событие «Яички»: пусто — 1 апреля → Православная Пасха.
EGG_EVENT_START=
EGG_EVENT_END=

View File

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

View File

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

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():
assert __version__
assert __version__ == "0.4.8"
assert __version__ == "0.4.9"