From 8fb7fe5dd9ce18efcb855da87805fca0bd2af44a Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Mon, 19 Oct 2026 03:41:07 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=80=D0=B0=D1=81=D1=81=D1=8B=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B5=D1=85=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=BA=D0=BE=D0=B4=D0=B0=20=D0=B2=20=D0=B0?= =?UTF-8?q?=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В админке появился раздел «Расписания»: время, дни недели, таймзона, шаблон и вкл/выкл, с историей изменений и удалением. Бот раз в минуту читает активные строки из БД, поэтому правки применяются без перезапуска; при сбое базы он переживает его на последнем прочитанном наборе или на расписании из кода. Стартовые рассылки перенесены миграцией V16, так что поведение не меняется. Заодно в зависимости добавлен tzdata: в slim-образах без него падает ZoneInfo на Europe/Moscow. --- README.md | 6 +- admin/app.py | 3 +- admin/forms.py | 98 +++++++++++++ admin/routes/pages.py | 2 +- admin/routes/schedules.py | 190 +++++++++++++++++++++++++ admin/templates/base.html | 1 + admin/templates/schedules/form.html | 142 +++++++++++++++++++ admin/templates/schedules/list.html | 69 +++++++++ bot/services/notifications.py | 85 ++++++++--- common/repositories/schedules_repo.py | 183 ++++++++++++++++++++++++ common/version.py | 2 +- requirements-admin.txt | 3 + requirements.txt | 3 + sql/V14__admin_tables.sql | 5 +- sql/V16__seed_schedules.sql | 12 ++ tests/test_admin_schedules.py | 196 ++++++++++++++++++++++++++ tests/test_notifications_schedule.py | 68 +++++++++ tests/test_schedule_form.py | 97 +++++++++++++ tests/test_version.py | 2 +- 19 files changed, 1142 insertions(+), 25 deletions(-) create mode 100644 admin/forms.py create mode 100644 admin/routes/schedules.py create mode 100644 admin/templates/schedules/form.html create mode 100644 admin/templates/schedules/list.html create mode 100644 common/repositories/schedules_repo.py create mode 100644 sql/V16__seed_schedules.sql create mode 100644 tests/test_admin_schedules.py create mode 100644 tests/test_notifications_schedule.py create mode 100644 tests/test_schedule_form.py diff --git a/README.md b/README.md index fb7c7f8..c0986f6 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,10 @@ APP_ENV=prod python -m bot # prod правка применяется без перезапуска; при недоступной БД бот использует тексты из кода. Предпросмотр использует тот же `render()`, что и бот. +Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели, +таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если +база недоступна — использует расписание из кода (совпадает с сидом `V16`). + ## Аналитика (ClickHouse) Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`. @@ -122,7 +126,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.2`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.3`. - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. diff --git a/admin/app.py b/admin/app.py index c4bed2c..eebbf1e 100644 --- a/admin/app.py +++ b/admin/app.py @@ -9,7 +9,7 @@ from pathlib import Path from fastapi import FastAPI from fastapi.templating import Jinja2Templates -from admin.routes import health, messages, pages +from admin.routes import health, messages, pages, schedules from common import __version__ from common.config import AdminSettings, get_admin_settings from common.db.pool import close_pool, init_pool @@ -53,4 +53,5 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) - app.include_router(health.router) app.include_router(pages.router) app.include_router(messages.router) + app.include_router(schedules.router) return app diff --git a/admin/forms.py b/admin/forms.py new file mode 100644 index 0000000..9a2e411 --- /dev/null +++ b/admin/forms.py @@ -0,0 +1,98 @@ +"""Проверка и разбор форм админки (чистые функции — без HTTP и БД).""" + +from __future__ import annotations + +from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +WEEKDAY_NAMES: dict[int, str] = { + 1: "Пн", + 2: "Вт", + 3: "Ср", + 4: "Чт", + 5: "Пт", + 6: "Сб", + 7: "Вс", +} + +MAX_DESCRIPTION_LENGTH = 200 + + +def format_weekdays(days: list[int] | tuple[int, ...] | None) -> str: + """Человеческое описание дней ISO: ``1,3,5`` -> ``Пн, Ср, Пт``.""" + if not days: + return "каждый день" + return ", ".join(WEEKDAY_NAMES[day] for day in sorted(set(days)) if day in WEEKDAY_NAMES) + + +def parse_schedule_fields( + *, + template_key: str, + hour: str, + minute: str, + weekdays: list[str], + timezone: str, + description: str, + enabled: bool, +) -> tuple[dict[str, Any] | None, list[str]]: + """Собрать данные расписания из полей формы. + + Возвращает ``(data, errors)``: при ошибках ``data is None`` и заполнен ``errors``. + """ + errors: list[str] = [] + + try: + hour_value = int(hour) + except (TypeError, ValueError): + hour_value = -1 + errors.append("Час должен быть числом") + try: + minute_value = int(minute) + except (TypeError, ValueError): + minute_value = -1 + errors.append("Минуты должны быть числом") + + if not 0 <= hour_value <= 23 and "Час должен быть числом" not in errors: + errors.append("Час должен быть от 0 до 23") + if not 0 <= minute_value <= 59 and "Минуты должны быть числом" not in errors: + errors.append("Минуты должны быть от 0 до 59") + + days: list[int] = [] + for raw in weekdays: + try: + day = int(raw) + except (TypeError, ValueError): + errors.append(f"Некорректный день недели: {raw}") + continue + if 1 <= day <= 7: + days.append(day) + else: + errors.append("День недели должен быть от 1 (Пн) до 7 (Вс)") + + tz_name = (timezone or "").strip() or "Europe/Moscow" + try: + ZoneInfo(tz_name) + except (ZoneInfoNotFoundError, ValueError): + errors.append(f"Неизвестная таймзона: {tz_name}") + + if not template_key.strip(): + errors.append("Выберите шаблон сообщения") + + if len(description) > MAX_DESCRIPTION_LENGTH: + errors.append(f"Описание длиннее {MAX_DESCRIPTION_LENGTH} символов") + + if errors: + return None, errors + + return ( + { + "template_key": template_key, + "hour": hour_value, + "minute": minute_value, + "weekdays": sorted(set(days)), + "timezone": tz_name, + "enabled": enabled, + "description": description.strip(), + }, + [], + ) diff --git a/admin/routes/pages.py b/admin/routes/pages.py index 552f727..62c9507 100644 --- a/admin/routes/pages.py +++ b/admin/routes/pages.py @@ -19,7 +19,7 @@ SECTIONS: tuple[dict[str, str | None], ...] = ( { "title": "Расписания", "description": "Время рассылок, дни недели и шаблон", - "url": None, + "url": "/schedules", }, { "title": "Пользователи", diff --git a/admin/routes/schedules.py b/admin/routes/schedules.py new file mode 100644 index 0000000..008079c --- /dev/null +++ b/admin/routes/schedules.py @@ -0,0 +1,190 @@ +"""Раздел «Расписания»: CRUD рассылок, которые исполняет бот.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import HTMLResponse, RedirectResponse + +from admin.forms import format_weekdays, parse_schedule_fields +from common import __version__ +from common.repositories import schedules_repo, templates_repo + +router = APIRouter(prefix="/schedules", tags=["schedules"]) + + +def _raw_from_form(form: Any) -> dict[str, Any]: + return { + "template_key": str(form.get("template_key", "")), + "hour": str(form.get("hour", "")), + "minute": str(form.get("minute", "")), + "weekdays": [str(day) for day in form.getlist("weekdays")], + "timezone": str(form.get("timezone", "")), + "description": str(form.get("description", "")), + "enabled": form.get("enabled") is not None, + } + + +def _view(values: Mapping[str, Any], *, is_new: bool) -> dict[str, Any]: + """Привести значения (из БД или формы) к виду, удобному шаблону.""" + days: list[int] = [] + for day in values.get("weekdays") or (): + try: + days.append(int(day)) + except (TypeError, ValueError): + continue + return { + "template_key": values.get("template_key") or "", + "hour": values.get("hour", 16), + "minute": values.get("minute", 0), + "weekdays": sorted(set(days)), + "timezone": values.get("timezone") or "Europe/Moscow", + "description": values.get("description") or "", + "enabled": bool(values.get("enabled", True)), + "id": values.get("id"), + "is_new": is_new, + "action": "/schedules" if is_new else f"/schedules/{values.get('id')}", + } + + +def _form_response( + request: Request, + schedule: dict[str, Any], + message_templates: list[dict[str, Any]], + *, + errors: list[str] | None = None, + history: list[dict[str, Any]] | None = None, + status_code: int = 200, +) -> Response: + return request.app.state.templates.TemplateResponse( + request=request, + name="schedules/form.html", + context={ + "version": __version__, + "schedule": schedule, + "message_templates": message_templates, + "errors": errors or [], + "history": history or [], + "weekday_names": {1: "Пн", 2: "Вт", 3: "Ср", 4: "Чт", 5: "Пт", 6: "Сб", 7: "Вс"}, + }, + status_code=status_code, + ) + + +@router.get("", response_class=HTMLResponse) +async def list_schedules(request: Request) -> HTMLResponse: + """Список расписаний: время, дни, шаблон, таймзона и статус.""" + return request.app.state.templates.TemplateResponse( + request=request, + name="schedules/list.html", + context={ + "version": __version__, + "schedules": await schedules_repo.list_schedules(), + "format_weekdays": format_weekdays, + }, + ) + + +@router.get("/new", response_class=HTMLResponse) +async def new_schedule(request: Request) -> HTMLResponse: + """Форма создания расписания.""" + message_templates = await templates_repo.list_templates() + defaults = { + "template_key": message_templates[0]["key"] if message_templates else "", + "hour": 16, + "minute": 0, + "weekdays": [], + "timezone": "Europe/Moscow", + "description": "", + "enabled": True, + } + return _form_response(request, _view(defaults, is_new=True), message_templates) + + +@router.post("") +async def create_schedule(request: Request) -> Response: + """Создать расписание (с проверкой полей и существования шаблона).""" + form = await request.form() + raw = _raw_from_form(form) + data, errors = parse_schedule_fields(**raw) + message_templates = await templates_repo.list_templates() + + if data is not None and await templates_repo.get_template(data["template_key"]) is None: + errors.append("Шаблон не найден") + + if errors: + return _form_response( + request, + _view(raw, is_new=True), + message_templates, + errors=errors, + status_code=400, + ) + + await schedules_repo.create_schedule(data) + return RedirectResponse(url="/schedules", status_code=303) + + +@router.get("/{schedule_id}", response_class=HTMLResponse) +async def edit_schedule(request: Request, schedule_id: int) -> HTMLResponse: + """Форма редактирования и история изменений.""" + schedule = await schedules_repo.get_schedule(schedule_id) + if schedule is None: + raise HTTPException(status_code=404, detail="Расписание не найдено") + + return _form_response( + request, + _view(schedule, is_new=False), + await templates_repo.list_templates(), + history=await schedules_repo.schedule_history(schedule_id), + ) + + +@router.post("/{schedule_id}") +async def update_schedule(request: Request, schedule_id: int) -> Response: + """Сохранить изменения расписания.""" + if await schedules_repo.get_schedule(schedule_id) is None: + raise HTTPException(status_code=404, detail="Расписание не найдено") + + form = await request.form() + raw = _raw_from_form(form) + data, errors = parse_schedule_fields(**raw) + message_templates = await templates_repo.list_templates() + + if data is not None and await templates_repo.get_template(data["template_key"]) is None: + errors.append("Шаблон не найден") + + if errors: + view = _view({**raw, "id": schedule_id}, is_new=False) + return _form_response( + request, + view, + message_templates, + errors=errors, + history=await schedules_repo.schedule_history(schedule_id), + status_code=400, + ) + + await schedules_repo.update_schedule(schedule_id, data) + return RedirectResponse(url="/schedules", status_code=303) + + +@router.post("/{schedule_id}/toggle") +async def toggle_schedule(schedule_id: int) -> Response: + """Включить или выключить расписание.""" + schedule = await schedules_repo.get_schedule(schedule_id) + if schedule is None: + raise HTTPException(status_code=404, detail="Расписание не найдено") + + await schedules_repo.set_enabled(schedule_id, not schedule["enabled"]) + return RedirectResponse(url="/schedules", status_code=303) + + +@router.post("/{schedule_id}/delete") +async def delete_schedule(schedule_id: int) -> Response: + """Удалить расписание (история при этом сохраняется).""" + if not await schedules_repo.delete_schedule(schedule_id): + raise HTTPException(status_code=404, detail="Расписание не найдено") + return RedirectResponse(url="/schedules", status_code=303) diff --git a/admin/templates/base.html b/admin/templates/base.html index 9f43ae1..b4b2f0c 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -14,6 +14,7 @@ diff --git a/admin/templates/schedules/form.html b/admin/templates/schedules/form.html new file mode 100644 index 0000000..8666d80 --- /dev/null +++ b/admin/templates/schedules/form.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} + +{% block title %}{% if schedule.is_new %}Новое расписание{% else %}Расписание #{{ schedule.id }}{% endif %}{% endblock %} + +{% block content %} +

← Расписания

+

+ {% if schedule.is_new %}Новое расписание{% else %}Расписание #{{ schedule.id }}{% endif %} +

+ +{% if errors %} + +{% endif %} + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ Дни недели +

Ничего не выбрано — каждый день.

+
+ {% for day, name in weekday_names.items() %} + + {% endfor %} +
+
+ +
+ + + + + + + +

Время указывается в этой таймзоне (IANA).

+
+ +
+ + +
+ + + +
+ + Отмена +
+
+ +{% if not schedule.is_new %} +
+

История изменений

+ {% if history %} + + {% else %} +

Изменений ещё не было.

+ {% endif %} +
+{% endif %} +{% endblock %} diff --git a/admin/templates/schedules/list.html b/admin/templates/schedules/list.html new file mode 100644 index 0000000..499c231 --- /dev/null +++ b/admin/templates/schedules/list.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} + +{% block title %}Расписания{% endblock %} + +{% block content %} +
+

Расписания

+ + Новое расписание + +
+ +

+ Рассылки исполняет бот: он читает активные расписания из БД раз в минуту, + поэтому правки применяются без перезапуска. +

+ +
+ + + + + + + + + + + + + {% for schedule in schedules %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
ВремяДниШаблонТаймзонаСтатус
{{ "%02d:%02d" | format(schedule.hour, schedule.minute) }}{{ format_weekdays(schedule.weekdays) }} +
{{ schedule.template_key }}
+
{{ schedule.description or schedule.template_description }}
+
{{ schedule.timezone }} + {% if schedule.enabled %} + включено + {% else %} + выключено + {% endif %} + +
+ Изменить +
+ +
+
+ +
+
+
Расписаний пока нет.
+
+{% endblock %} diff --git a/bot/services/notifications.py b/bot/services/notifications.py index 27851d1..bf29986 100644 --- a/bot/services/notifications.py +++ b/bot/services/notifications.py @@ -1,22 +1,25 @@ """Планировщик рассылок. -Исполняется внутри процесса бота. Время и дни недели пока заданы в коде -(данные, а не магические числа); в Блоке 4 они переедут в таблицу ``schedules`` -и будут редактироваться через админку. Сами тексты уже берутся из БД -(``bot.message_templates``) через ``common.texts.store``. +Расписания читаются из БД (таблица ``bot.schedules``, редактируется в админке). +Если база недоступна, используется расписание по умолчанию из кода и последний +успешно прочитанный набор. Тексты берутся из ``bot.message_templates`` через +``common.texts.store`` (TTL-кеш, правки без перезапуска). """ from __future__ import annotations import asyncio import logging -from dataclasses import dataclass, field +import time +from dataclasses import dataclass from datetime import datetime +from zoneinfo import ZoneInfo from aiogram import Bot from bot.services import users as users_service from common.config import get_settings +from common.repositories import schedules_repo from common.texts import format_usernames, render from common.texts.store import get_text @@ -25,34 +28,76 @@ logger = logging.getLogger(__name__) #: Размер порции упоминаний в одном сообщении. BATCH_SIZE = 10 +#: Как часто писать в лог, если БД с расписаниями недоступна (секунды). +WARNING_INTERVAL = 300.0 + @dataclass(frozen=True, slots=True) class ScheduledMessage: - """Одна запланированная рассылка. + """Одна запланированная рассылка из БД (или из значений по умолчанию). - :param weekdays: дни недели (0=Пн … 6=Вс). None — ежедневно. + :param weekdays: дни недели по ISO (1=Пн … 7=Вс). Пусто — ежедневно. + :param timezone: таймзона расписания (IANA), время в ней. """ hour: int minute: int template_key: str - weekdays: tuple[int, ...] | None = field(default=None) + weekdays: tuple[int, ...] = () + timezone: str = "Europe/Moscow" def matches(self, now: datetime) -> bool: - if self.hour != now.hour or self.minute != now.minute: + """Совпадает ли момент ``now`` (aware) с этим расписанием.""" + local = now.astimezone(ZoneInfo(self.timezone)) + if self.hour != local.hour or self.minute != local.minute: return False - return self.weekdays is None or now.weekday() in self.weekdays + return not self.weekdays or local.isoweekday() in self.weekdays -SCHEDULES: tuple[ScheduledMessage, ...] = ( +#: Расписание по умолчанию — на случай недоступной БД (совпадает с V16-сидом). +DEFAULT_SCHEDULES: tuple[ScheduledMessage, ...] = ( ScheduledMessage(16, 30, "notify.blago_30"), ScheduledMessage(16, 45, "notify.blago_45"), ScheduledMessage(16, 55, "notify.blago_55"), ScheduledMessage(17, 0, "notify.blago"), - ScheduledMessage(15, 0, "notify.vklad", weekdays=(4,)), # пятница - ScheduledMessage(14, 0, "notify.vklad", weekdays=(6,)), # воскресенье + ScheduledMessage(15, 0, "notify.vklad", weekdays=(5,)), # пятница + ScheduledMessage(14, 0, "notify.vklad", weekdays=(7,)), # воскресенье ) +#: Последний успешно прочитанный набор — чтобы пережить временный сбой БД. +_last_good: tuple[ScheduledMessage, ...] | None = None +_last_warning_at = 0.0 + + +def _from_row(row: dict) -> ScheduledMessage: + return ScheduledMessage( + hour=int(row["hour"]), + minute=int(row["minute"]), + template_key=str(row["template_key"]), + weekdays=tuple(int(day) for day in (row["weekdays"] or ())), + timezone=str(row["timezone"] or "Europe/Moscow"), + ) + + +async def _load_schedules() -> tuple[ScheduledMessage, ...]: + """Прочитать активные расписания; при сбое — последние известные или дефолт.""" + global _last_good, _last_warning_at + + try: + rows = await schedules_repo.list_active() + except Exception: + now = time.monotonic() + if now - _last_warning_at > WARNING_INTERVAL: + logger.warning( + "Не удалось прочитать расписания из БД — использую последние известные", + exc_info=True, + ) + _last_warning_at = now + return _last_good if _last_good is not None else DEFAULT_SCHEDULES + + _last_good = tuple(_from_row(row) for row in rows) + return _last_good + async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> None: chunks = [subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)] or [[]] @@ -62,11 +107,15 @@ async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> N async def _tick(bot: Bot, now: datetime) -> None: - for schedule in SCHEDULES: - if schedule.matches(now): - subscribers = await users_service.list_subscribers() - template = await get_text(schedule.template_key) - await _send_broadcast(bot, template, subscribers) + for schedule in await _load_schedules(): + if not schedule.matches(now): + continue + template = await get_text(schedule.template_key) + if not template: + logger.warning("Шаблон %s пуст или не найден — рассылка пропущена", schedule.template_key) + continue + subscribers = await users_service.list_subscribers() + await _send_broadcast(bot, template, subscribers) async def run_notifications(bot: Bot) -> None: diff --git a/common/repositories/schedules_repo.py b/common/repositories/schedules_repo.py new file mode 100644 index 0000000..648c754 --- /dev/null +++ b/common/repositories/schedules_repo.py @@ -0,0 +1,183 @@ +"""Репозиторий расписаний рассылок (Блок 4.5). + +Редактирует админка, исполняет бот: он читает активные строки раз в минуту. +Каждое изменение пишет снимок строки в ``schedules_history`` (аудит). +""" + +from __future__ import annotations + +from typing import Any + +from psycopg.types.json import Jsonb + +from common.db.pool import get_pool + +#: Поля, которые попадают в историю и форму (без служебных отметок времени). +SNAPSHOT_FIELDS = ("template_key", "hour", "minute", "weekdays", "timezone", "enabled", "description") + + +def _snapshot(row: dict[str, Any]) -> dict[str, Any]: + return {field: row.get(field) for field in SNAPSHOT_FIELDS} + + +async def list_schedules() -> list[dict[str, Any]]: + """Все расписания с описанием шаблона (для таблицы в админке).""" + async with get_pool().connection() as conn: + cur = await conn.execute( + """ + SELECT s.id, s.template_key, t.description AS template_description, + s.hour, s.minute, s.weekdays, s.timezone, s.enabled, + s.description, s.updated_at, s.updated_by + FROM bot.schedules AS s + JOIN bot.message_templates AS t ON t.key = s.template_key + ORDER BY s.hour, s.minute, s.id + """ + ) + return await cur.fetchall() + + +async def list_active() -> list[dict[str, Any]]: + """Активные расписания — для планировщика бота.""" + async with get_pool().connection() as conn: + cur = await conn.execute( + """ + SELECT id, template_key, hour, minute, weekdays, timezone + FROM bot.schedules + WHERE enabled + ORDER BY hour, minute, id + """ + ) + return await cur.fetchall() + + +async def get_schedule(schedule_id: int) -> dict[str, Any] | None: + async with get_pool().connection() as conn: + cur = await conn.execute( + """ + SELECT id, template_key, hour, minute, weekdays, timezone, enabled, + description, updated_at, updated_by + FROM bot.schedules + WHERE id = %s + """, + (schedule_id,), + ) + return await cur.fetchone() + + +async def create_schedule(data: dict[str, Any], *, editor_id: int | None = None) -> int: + """Создать расписание и записать его в историю. Возвращает id.""" + async with get_pool().connection() as conn: + cur = await conn.execute( + """ + INSERT INTO bot.schedules + (template_key, hour, minute, weekdays, timezone, enabled, description, updated_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + data["template_key"], + data["hour"], + data["minute"], + data["weekdays"], + data["timezone"], + data["enabled"], + data["description"], + editor_id, + ), + ) + row = await cur.fetchone() + schedule_id = int(row["id"]) + await conn.execute( + """ + INSERT INTO bot.schedules_history (schedule_id, old_data, new_data, editor_id) + VALUES (%s, NULL, %s, %s) + """, + (schedule_id, Jsonb(data), editor_id), + ) + return schedule_id + + +async def update_schedule(schedule_id: int, data: dict[str, Any], *, editor_id: int | None = None) -> bool: + """Обновить расписание и записать старое/новое значение. False — если записи нет.""" + async with get_pool().connection() as conn: + cur = await conn.execute( + "SELECT * FROM bot.schedules WHERE id = %s FOR UPDATE", + (schedule_id,), + ) + row = await cur.fetchone() + if row is None: + return False + + old = _snapshot(row) + await conn.execute( + """ + UPDATE bot.schedules + SET template_key = %s, hour = %s, minute = %s, weekdays = %s, + timezone = %s, enabled = %s, description = %s, + updated_at = now(), updated_by = %s + WHERE id = %s + """, + ( + data["template_key"], + data["hour"], + data["minute"], + data["weekdays"], + data["timezone"], + data["enabled"], + data["description"], + editor_id, + schedule_id, + ), + ) + await conn.execute( + """ + INSERT INTO bot.schedules_history (schedule_id, old_data, new_data, editor_id) + VALUES (%s, %s, %s, %s) + """, + (schedule_id, Jsonb(old), Jsonb(data), editor_id), + ) + return True + + +async def set_enabled(schedule_id: int, enabled: bool, *, editor_id: int | None = None) -> bool: + """Включить/выключить расписание (тоже с записью в историю).""" + schedule = await get_schedule(schedule_id) + if schedule is None: + return False + data = _snapshot(schedule) + data["enabled"] = enabled + return await update_schedule(schedule_id, data, editor_id=editor_id) + + +async def delete_schedule(schedule_id: int, *, editor_id: int | None = None) -> bool: + """Удалить расписание, сохранив последний снимок в истории.""" + async with get_pool().connection() as conn: + cur = await conn.execute("SELECT * FROM bot.schedules WHERE id = %s FOR UPDATE", (schedule_id,)) + row = await cur.fetchone() + if row is None: + return False + + await conn.execute( + """ + INSERT INTO bot.schedules_history (schedule_id, old_data, new_data, editor_id) + VALUES (%s, %s, NULL, %s) + """, + (schedule_id, Jsonb(_snapshot(row)), editor_id), + ) + await conn.execute("DELETE FROM bot.schedules WHERE id = %s", (schedule_id,)) + return True + + +async def schedule_history(schedule_id: int, *, limit: int = 10) -> list[dict[str, Any]]: + async with get_pool().connection() as conn: + cur = await conn.execute( + """ + SELECT id, old_data, new_data, editor_id, changed_at + FROM bot.schedules_history + WHERE schedule_id = %s + ORDER BY changed_at DESC + LIMIT %s + """, + (schedule_id, limit), + ) + return await cur.fetchall() diff --git a/common/version.py b/common/version.py index 984dc40..c1ca057 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.4.2" +__version__ = "0.4.3" diff --git a/requirements-admin.txt b/requirements-admin.txt index 325c192..f76f775 100644 --- a/requirements-admin.txt +++ b/requirements-admin.txt @@ -8,6 +8,9 @@ jinja2>=3.1,<4.0 pydantic>=2.7,<3.0 pydantic-settings>=2.3,<3.0 +# Таймзоны для проверки расписаний (в slim-образах нет системного tzdata). +tzdata>=2024.1 + # Postgres (async psycopg v3) — понадобится разделам шаблонов и пользователей. psycopg[binary,pool]>=3.2,<4.0 diff --git a/requirements.txt b/requirements.txt index dfd864f..ab2f463 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,9 @@ aiogram>=3.7,<4.0 pydantic>=2.7,<3.0 pydantic-settings>=2.3,<3.0 python-dotenv>=1.0,<2.0 +# База таймзон для zoneinfo: в slim-образах нет системного tzdata, +# без пакета ZoneInfo("Europe/Moscow") падает (расписания и настройка TIMEZONE). +tzdata>=2024.1 psycopg[binary,pool]>=3.2,<4.0 # ClickHouse (аналитика событий, Блок 3). Extras [async] — нативный asyncio-клиент. diff --git a/sql/V14__admin_tables.sql b/sql/V14__admin_tables.sql index 532fc69..d704790 100644 --- a/sql/V14__admin_tables.sql +++ b/sql/V14__admin_tables.sql @@ -48,11 +48,12 @@ COMMENT ON TABLE bot.schedules IS 'Расписания рассылок (исп COMMENT ON COLUMN bot.schedules.weekdays IS 'Дни недели 1..7 (ISO, 1 = понедельник); пусто — каждый день'; -- История изменений расписаний: снимок строки до и после правки. +-- FK с SET NULL: история должна пережить удаление самого расписания. CREATE TABLE IF NOT EXISTS bot.schedules_history ( id bigserial PRIMARY KEY, - schedule_id bigint NOT NULL REFERENCES bot.schedules (id) ON DELETE CASCADE, + schedule_id bigint REFERENCES bot.schedules (id) ON DELETE SET NULL, old_data jsonb, - new_data jsonb NOT NULL, + new_data jsonb, editor_id bigint, changed_at timestamptz NOT NULL DEFAULT now() ); diff --git a/sql/V16__seed_schedules.sql b/sql/V16__seed_schedules.sql new file mode 100644 index 0000000..c197771 --- /dev/null +++ b/sql/V16__seed_schedules.sql @@ -0,0 +1,12 @@ +-- Стартовые расписания рассылок: переносят текущее поведение из кода бота. +-- Дни недели — ISO (1 = понедельник … 7 = воскресенье), пусто — каждый день. +-- Время — в таймзоне расписания (Europe/Moscow). + +INSERT INTO bot.schedules (template_key, hour, minute, weekdays, timezone, description) +VALUES + ('notify.blago_30', 16, 30, '{}', 'Europe/Moscow', 'Благо: за 30 минут'), + ('notify.blago_45', 16, 45, '{}', 'Europe/Moscow', 'Благо: за 15 минут'), + ('notify.blago_55', 16, 55, '{}', 'Europe/Moscow', 'Благо: за 5 минут'), + ('notify.blago', 17, 0, '{}', 'Europe/Moscow', 'Благо: начало'), + ('notify.vklad', 15, 0, '{5}', 'Europe/Moscow', 'Вклад: пятница'), + ('notify.vklad', 14, 0, '{7}', 'Europe/Moscow', 'Вклад: воскресенье'); diff --git a/tests/test_admin_schedules.py b/tests/test_admin_schedules.py new file mode 100644 index 0000000..256ee9c --- /dev/null +++ b/tests/test_admin_schedules.py @@ -0,0 +1,196 @@ +"""Тесты раздела «Расписания» (без БД — репозитории подменяются).""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from admin.app import create_app +from common.config import AdminSettings +from common.repositories import schedules_repo, templates_repo + +TEMPLATE: dict[str, Any] = { + "key": "notify.blago", + "text": "Благо через 5 минут {usernames}", + "description": "Благо: начало", + "enabled": True, + "updated_at": datetime(2026, 1, 1, 12, 0, tzinfo=UTC), + "updated_by": None, +} + +SCHEDULE: dict[str, Any] = { + "id": 1, + "template_key": "notify.blago", + "template_description": "Благо: начало", + "hour": 17, + "minute": 0, + "weekdays": [1, 5], + "timezone": "Europe/Moscow", + "enabled": True, + "description": "Благо", + "updated_at": datetime(2026, 1, 1, 12, 0, tzinfo=UTC), + "updated_by": None, +} + + +def _client() -> TestClient: + settings = AdminSettings(_env_file=None, bot_token="", db_password="test", app_env="dev") + return TestClient(create_app(settings, init_db=False)) + + +@pytest.fixture +def fake_repos(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + state: dict[str, Any] = {} + + async def list_schedules() -> list[dict[str, Any]]: + return [SCHEDULE] + + async def get_schedule(schedule_id: int) -> dict[str, Any] | None: + return SCHEDULE if schedule_id == 1 else None + + async def schedule_history(schedule_id: int, *, limit: int = 10) -> list[dict[str, Any]]: + return [] + + async def create_schedule(data: dict[str, Any], *, editor_id: int | None = None) -> int: + state["created"] = data + return 2 + + async def update_schedule(schedule_id: int, data: dict[str, Any], *, editor_id: int | None = None) -> bool: + state["updated"] = (schedule_id, data) + return True + + async def delete_schedule(schedule_id: int, *, editor_id: int | None = None) -> bool: + state["deleted"] = schedule_id + return schedule_id == 1 + + async def set_enabled(schedule_id: int, enabled: bool, *, editor_id: int | None = None) -> bool: + state["enabled"] = (schedule_id, enabled) + return True + + async def list_templates() -> list[dict[str, Any]]: + return [TEMPLATE] + + async def get_template(key: str) -> dict[str, Any] | None: + return TEMPLATE if key == TEMPLATE["key"] else None + + monkeypatch.setattr(schedules_repo, "list_schedules", list_schedules) + monkeypatch.setattr(schedules_repo, "get_schedule", get_schedule) + monkeypatch.setattr(schedules_repo, "schedule_history", schedule_history) + monkeypatch.setattr(schedules_repo, "create_schedule", create_schedule) + monkeypatch.setattr(schedules_repo, "update_schedule", update_schedule) + monkeypatch.setattr(schedules_repo, "delete_schedule", delete_schedule) + monkeypatch.setattr(schedules_repo, "set_enabled", set_enabled) + monkeypatch.setattr(templates_repo, "list_templates", list_templates) + monkeypatch.setattr(templates_repo, "get_template", get_template) + return state + + +def test_schedules_list(fake_repos: dict[str, Any]) -> None: + response = _client().get("/schedules") + + assert response.status_code == 200 + assert "17:00" in response.text + assert "Пн, Пт" in response.text + assert "включено" in response.text + + +def test_new_schedule_form(fake_repos: dict[str, Any]) -> None: + response = _client().get("/schedules/new") + + assert response.status_code == 200 + assert "Новое расписание" in response.text + assert "notify.blago" in response.text + + +def test_create_schedule(fake_repos: dict[str, Any]) -> None: + response = _client().post( + "/schedules", + data={ + "template_key": "notify.blago", + "hour": "17", + "minute": "0", + "weekdays": ["1", "5"], + "timezone": "Europe/Moscow", + "description": "Благо", + "enabled": "on", + }, + ) + + assert response.status_code == 200 + assert fake_repos["created"] == { + "template_key": "notify.blago", + "hour": 17, + "minute": 0, + "weekdays": [1, 5], + "timezone": "Europe/Moscow", + "enabled": True, + "description": "Благо", + } + + +def test_create_schedule_rejects_bad_hour(fake_repos: dict[str, Any]) -> None: + response = _client().post( + "/schedules", + data={"template_key": "notify.blago", "hour": "25", "minute": "0", "timezone": "Europe/Moscow"}, + ) + + assert response.status_code == 400 + assert "Час должен быть от 0 до 23" in response.text + assert "created" not in fake_repos + + +def test_create_schedule_rejects_unknown_template(fake_repos: dict[str, Any]) -> None: + response = _client().post( + "/schedules", + data={"template_key": "unknown.key", "hour": "17", "minute": "0", "timezone": "Europe/Moscow"}, + ) + + assert response.status_code == 400 + assert "Шаблон не найден" in response.text + + +def test_edit_schedule(fake_repos: dict[str, Any]) -> None: + response = _client().get("/schedules/1") + + assert response.status_code == 200 + assert "Расписание #1" in response.text + + +def test_edit_missing_schedule_404(fake_repos: dict[str, Any]) -> None: + assert _client().get("/schedules/99").status_code == 404 + + +def test_update_schedule(fake_repos: dict[str, Any]) -> None: + response = _client().post( + "/schedules/1", + data={ + "template_key": "notify.blago", + "hour": "18", + "minute": "30", + "weekdays": [], + "timezone": "Europe/Moscow", + "description": "", + "enabled": "on", + }, + ) + + assert response.status_code == 200 + schedule_id, data = fake_repos["updated"] + assert schedule_id == 1 + assert data["hour"] == 18 + assert data["minute"] == 30 + + +def test_toggle_schedule(fake_repos: dict[str, Any]) -> None: + _client().post("/schedules/1/toggle") + + assert fake_repos["enabled"] == (1, False) + + +def test_delete_schedule(fake_repos: dict[str, Any]) -> None: + _client().post("/schedules/1/delete") + + assert fake_repos["deleted"] == 1 diff --git a/tests/test_notifications_schedule.py b/tests/test_notifications_schedule.py new file mode 100644 index 0000000..a0e2da7 --- /dev/null +++ b/tests/test_notifications_schedule.py @@ -0,0 +1,68 @@ +"""Тесты расписаний бота: совпадение времени, таймзоны и сид V16.""" + +from __future__ import annotations + +import re +from datetime import UTC, datetime +from pathlib import Path + +from bot.services.notifications import DEFAULT_SCHEDULES, ScheduledMessage, _from_row + +SEED_FILE = Path(__file__).resolve().parents[1] / "sql" / "V16__seed_schedules.sql" +_ROW_RE = re.compile(r"\('([^']+)',\s*(\d+),\s*(\d+),\s*'\{([^}]*)\}',\s*'([^']+)'") + + +def test_matches_daily_in_schedule_timezone() -> None: + schedule = ScheduledMessage(16, 30, "notify.blago_30") + + # 13:30 UTC = 16:30 МСК + assert schedule.matches(datetime(2026, 10, 19, 13, 30, tzinfo=UTC)) + assert not schedule.matches(datetime(2026, 10, 19, 13, 31, tzinfo=UTC)) + + +def test_matches_respects_weekdays_iso() -> None: + friday = ScheduledMessage(15, 0, "notify.vklad", weekdays=(5,)) + + # 2026-10-16 — пятница, 12:00 UTC = 15:00 МСК + assert friday.matches(datetime(2026, 10, 16, 12, 0, tzinfo=UTC)) + # 2026-10-15 — четверг + assert not friday.matches(datetime(2026, 10, 15, 12, 0, tzinfo=UTC)) + + +def test_matches_uses_schedule_timezone() -> None: + berlin = ScheduledMessage(16, 30, "notify.blago_30", timezone="Europe/Berlin") + + # Зимой Берлин = UTC+1: 15:30 UTC = 16:30 + assert berlin.matches(datetime(2026, 1, 15, 15, 30, tzinfo=UTC)) + + +def test_from_row_converts_fields() -> None: + schedule = _from_row( + { + "hour": 14, + "minute": 5, + "template_key": "notify.vklad", + "weekdays": [7], + "timezone": "Europe/Moscow", + } + ) + + assert schedule == ScheduledMessage(14, 5, "notify.vklad", weekdays=(7,), timezone="Europe/Moscow") + + +def test_default_schedules_match_seed() -> None: + """Расписание-фолбэк в коде не должно расходиться с сидом V16.""" + seeded = [] + for key, hour, minute, days, timezone in _ROW_RE.findall(SEED_FILE.read_text(encoding="utf-8")): + weekdays = tuple(int(day) for day in days.split(",") if day.strip()) + seeded.append( + ScheduledMessage( + hour=int(hour), + minute=int(minute), + template_key=key, + weekdays=weekdays, + timezone=timezone, + ) + ) + + assert seeded == list(DEFAULT_SCHEDULES) diff --git a/tests/test_schedule_form.py b/tests/test_schedule_form.py new file mode 100644 index 0000000..5646389 --- /dev/null +++ b/tests/test_schedule_form.py @@ -0,0 +1,97 @@ +"""Тесты разбора формы расписаний (без HTTP и БД).""" + +from __future__ import annotations + +from admin.forms import format_weekdays, parse_schedule_fields + + +def _parse(**overrides: object) -> tuple[dict | None, list[str]]: + fields: dict[str, object] = { + "template_key": "notify.blago", + "hour": "17", + "minute": "0", + "weekdays": [], + "timezone": "Europe/Moscow", + "description": "Благо", + "enabled": True, + } + fields.update(overrides) + return parse_schedule_fields(**fields) # type: ignore[arg-type] + + +def test_format_weekdays() -> None: + assert format_weekdays([]) == "каждый день" + assert format_weekdays((5, 1, 3, 3)) == "Пн, Ср, Пт" + + +def test_parse_valid_daily_schedule() -> None: + data, errors = _parse() + + assert errors == [] + assert data == { + "template_key": "notify.blago", + "hour": 17, + "minute": 0, + "weekdays": [], + "timezone": "Europe/Moscow", + "enabled": True, + "description": "Благо", + } + + +def test_parse_weekdays_are_sorted_and_deduplicated() -> None: + data, errors = _parse(weekdays=["5", "1", "5"]) + + assert errors == [] + assert data is not None + assert data["weekdays"] == [1, 5] + + +def test_parse_default_timezone() -> None: + data, errors = _parse(timezone="") + + assert errors == [] + assert data is not None + assert data["timezone"] == "Europe/Moscow" + + +def test_parse_rejects_bad_hour() -> None: + data, errors = _parse(hour="25") + + assert data is None + assert "Час должен быть от 0 до 23" in errors + + +def test_parse_rejects_non_numeric_minute() -> None: + data, errors = _parse(minute="abc") + + assert data is None + assert "Минуты должны быть числом" in errors + + +def test_parse_rejects_bad_weekday() -> None: + data, errors = _parse(weekdays=["9"]) + + assert data is None + assert any("День недели" in error for error in errors) + + +def test_parse_rejects_unknown_timezone() -> None: + data, errors = _parse(timezone="Mars/Olympus") + + assert data is None + assert any("таймзона" in error for error in errors) + + +def test_parse_rejects_empty_template() -> None: + data, errors = _parse(template_key="") + + assert data is None + assert "Выберите шаблон сообщения" in errors + + +def test_parse_rejects_long_description() -> None: + data, errors = _parse(description="x" * 201) + + assert data is None + assert any("Описание длиннее" in error for error in errors) diff --git a/tests/test_version.py b/tests/test_version.py index 5585db3..7934f22 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.2" + assert __version__ == "0.4.3"