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 %}
+
+ {% for error in errors %}- {{ error }}
{% endfor %}
+
+{% endif %}
+
+
+
+{% 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 %}
+
+ | {{ "%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 %}
+ |
+
+
+ |
+
+ {% else %}
+
+ | Расписаний пока нет. |
+
+ {% endfor %}
+
+
+
+{% 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"