Яички снова стали пасхальным событием, а не круглогодичной валютой
Окно теперь считается само: с 1 апреля по православную Пасху (в 2026 году — 12 апреля) вместо зашитых дат 2025 года, из-за которых команда не работала вовсе. Вне окна /яички отвечает «рано» или «событие завершилось», а строка про яички не показывается ни в /инфо, ни в карточке админки. Если понадобится другое окно, даты фиксируются через EGG_EVENT_START/EGG_EVENT_END.
This commit is contained in:
parent
107a79d05e
commit
6b15d8ae04
|
|
@ -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
|
||||
|
|
|
|||
10
README.md
10
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`).
|
||||
|
||||
## Команды бота
|
||||
|
||||
`/ник`, `/список`, `/кто`, `/инфо`, `/помощь`, `/очищение`, `/яички`,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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": "Секунды до следующего действия",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ MSG_PURCHASE_FAIL = "Не получилось купить :с"
|
|||
MSG_DONAT_PROMO = "Назови этот код админам!\n`{promo}`"
|
||||
|
||||
MSG_EGGS_TOO_EARLY = "Слишком рано..."
|
||||
MSG_EGGS_EVENT_OVER = "Событие с яичками уже завершилось — увидимся на следующую Пасху."
|
||||
|
||||
MSG_BOT_ADDED = "Я пришёл! Требую админские права!"
|
||||
|
||||
|
|
|
|||
|
|
@ -7,4 +7,4 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.4.8"
|
||||
__version__ = "0.4.9"
|
||||
|
|
|
|||
|
|
@ -54,3 +54,7 @@ ADMIN_BIND_IP=127.0.0.1
|
|||
|
||||
# Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска).
|
||||
TEMPLATE_CACHE_TTL=45
|
||||
|
||||
# Пасхальное событие «Яички»: пусто — 1 апреля → Православная Пасха.
|
||||
EGG_EVENT_START=
|
||||
EGG_EVENT_END=
|
||||
|
|
|
|||
|
|
@ -52,3 +52,7 @@ ADMIN_BIND_IP=127.0.0.1
|
|||
|
||||
# Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска).
|
||||
TEMPLATE_CACHE_TTL=45
|
||||
|
||||
# Пасхальное событие «Яички»: пусто — 1 апреля → Православная Пасха.
|
||||
EGG_EVENT_START=
|
||||
EGG_EVENT_END=
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue