Расписания рассылок переехали из кода в админку

В админке появился раздел «Расписания»: время, дни недели, таймзона, шаблон и вкл/выкл, с историей изменений и удалением. Бот раз в минуту читает активные строки из БД, поэтому правки применяются без перезапуска; при сбое базы он переживает его на последнем прочитанном наборе или на расписании из кода. Стартовые рассылки перенесены миграцией V16, так что поведение не меняется. Заодно в зависимости добавлен tzdata: в slim-образах без него падает ZoneInfo на Europe/Moscow.
This commit is contained in:
MpakobecEx 2026-10-19 03:41:07 +05:00
parent 1e4e905f24
commit 8fb7fe5dd9
19 changed files with 1142 additions and 25 deletions

View File

@ -62,6 +62,10 @@ APP_ENV=prod python -m bot # prod
правка применяется без перезапуска; при недоступной БД бот использует тексты из кода. правка применяется без перезапуска; при недоступной БД бот использует тексты из кода.
Предпросмотр использует тот же `render()`, что и бот. Предпросмотр использует тот же `render()`, что и бот.
Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели,
таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если
база недоступна — использует расписание из кода (совпадает с сидом `V16`).
## Аналитика (ClickHouse) ## Аналитика (ClickHouse)
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`. Все значимые действия бота пишутся в 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`). - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.

View File

@ -9,7 +9,7 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.templating import Jinja2Templates 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 import __version__
from common.config import AdminSettings, get_admin_settings from common.config import AdminSettings, get_admin_settings
from common.db.pool import close_pool, init_pool 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(health.router)
app.include_router(pages.router) app.include_router(pages.router)
app.include_router(messages.router) app.include_router(messages.router)
app.include_router(schedules.router)
return app return app

98
admin/forms.py Normal file
View File

@ -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(),
},
[],
)

View File

@ -19,7 +19,7 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
{ {
"title": "Расписания", "title": "Расписания",
"description": "Время рассылок, дни недели и шаблон", "description": "Время рассылок, дни недели и шаблон",
"url": None, "url": "/schedules",
}, },
{ {
"title": "Пользователи", "title": "Пользователи",

190
admin/routes/schedules.py Normal file
View File

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

View File

@ -14,6 +14,7 @@
<nav class="flex items-center gap-4 text-sm"> <nav class="flex items-center gap-4 text-sm">
<a href="/" class="text-slate-600 hover:text-slate-900">Главная</a> <a href="/" class="text-slate-600 hover:text-slate-900">Главная</a>
<a href="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a> <a href="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a>
<a href="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a>
<span class="text-slate-500">админка · v{{ version }}</span> <span class="text-slate-500">админка · v{{ version }}</span>
</nav> </nav>
</div> </div>

View File

@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block title %}{% if schedule.is_new %}Новое расписание{% else %}Расписание #{{ schedule.id }}{% endif %}{% endblock %}
{% block content %}
<p class="text-sm text-slate-500"><a href="/schedules" class="hover:underline">← Расписания</a></p>
<h1 class="mt-1 text-2xl font-semibold">
{% if schedule.is_new %}Новое расписание{% else %}Расписание #{{ schedule.id }}{% endif %}
</h1>
{% if errors %}
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
<form method="post" action="{{ schedule.action }}" class="mt-6 max-w-3xl space-y-5">
<div>
<label for="template_key" class="text-sm font-medium text-slate-700">Шаблон сообщения</label>
<select
id="template_key"
name="template_key"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
>
{% for template in message_templates %}
<option value="{{ template.key }}" {% if template.key == schedule.template_key %}selected{% endif %}>
{{ template.key }} — {{ template.description }}
</option>
{% endfor %}
</select>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="hour" class="text-sm font-medium text-slate-700">Час (0–23)</label>
<input
id="hour"
name="hour"
type="number"
min="0"
max="23"
value="{{ schedule.hour }}"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
<div>
<label for="minute" class="text-sm font-medium text-slate-700">Минуты (0–59)</label>
<input
id="minute"
name="minute"
type="number"
min="0"
max="59"
value="{{ schedule.minute }}"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
</div>
<fieldset>
<legend class="text-sm font-medium text-slate-700">Дни недели</legend>
<p class="text-xs text-slate-500">Ничего не выбрано — каждый день.</p>
<div class="mt-2 flex flex-wrap gap-4">
{% for day, name in weekday_names.items() %}
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="weekdays" value="{{ day }}" {% if day in schedule.weekdays %}checked{% endif %} />
{{ name }}
</label>
{% endfor %}
</div>
</fieldset>
<div>
<label for="timezone" class="text-sm font-medium text-slate-700">Таймзона</label>
<input
id="timezone"
name="timezone"
type="text"
value="{{ schedule.timezone }}"
list="timezones"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 font-mono text-sm focus:border-sky-500 focus:outline-none"
/>
<datalist id="timezones">
<option value="Europe/Moscow"></option>
<option value="Europe/Berlin"></option>
<option value="UTC"></option>
</datalist>
<p class="mt-1 text-xs text-slate-500">Время указывается в этой таймзоне (IANA).</p>
</div>
<div>
<label for="description" class="text-sm font-medium text-slate-700">Описание (для себя)</label>
<input
id="description"
name="description"
type="text"
value="{{ schedule.description }}"
maxlength="200"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="enabled" {% if schedule.enabled %}checked{% endif %} />
Включено
</label>
<div class="flex items-center gap-3">
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Сохранить
</button>
<a href="/schedules" class="text-sm text-slate-600 hover:underline">Отмена</a>
</div>
</form>
{% if not schedule.is_new %}
<div class="mt-10">
<h2 class="text-lg font-medium">История изменений</h2>
{% if history %}
<ul class="mt-3 space-y-2">
{% for item in history %}
<li class="rounded-lg border border-slate-200 bg-white p-3 text-sm">
<div class="flex items-center justify-between text-slate-500">
<span>{{ item.changed_at.strftime("%d.%m.%Y %H:%M") }}</span>
<span>редактор: {{ item.editor_id or "—" }}</span>
</div>
<details class="mt-2">
<summary class="cursor-pointer text-slate-600">старое → новое</summary>
<div class="mt-2 grid gap-3 sm:grid-cols-2">
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs">{{ item.old_data | tojson(indent=2) if item.old_data else "—" }}</pre>
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs">{{ item.new_data | tojson(indent=2) if item.new_data else "—" }}</pre>
</div>
</details>
</li>
{% endfor %}
</ul>
{% else %}
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
{% endif %}
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}Расписания{% endblock %}
{% block content %}
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Расписания</h1>
<a href="/schedules/new" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Новое расписание
</a>
</div>
<p class="mt-2 text-slate-600">
Рассылки исполняет бот: он читает активные расписания из БД раз в минуту,
поэтому правки применяются без перезапуска.
</p>
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-slate-600">
<tr>
<th class="px-4 py-3 font-medium">Время</th>
<th class="px-4 py-3 font-medium">Дни</th>
<th class="px-4 py-3 font-medium">Шаблон</th>
<th class="px-4 py-3 font-medium">Таймзона</th>
<th class="px-4 py-3 font-medium">Статус</th>
<th class="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for schedule in schedules %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-mono">{{ "%02d:%02d" | format(schedule.hour, schedule.minute) }}</td>
<td class="px-4 py-3">{{ format_weekdays(schedule.weekdays) }}</td>
<td class="px-4 py-3">
<div class="font-mono text-sky-700">{{ schedule.template_key }}</div>
<div class="text-xs text-slate-500">{{ schedule.description or schedule.template_description }}</div>
</td>
<td class="px-4 py-3 text-slate-600">{{ schedule.timezone }}</td>
<td class="px-4 py-3">
{% if schedule.enabled %}
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
{% else %}
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключено</span>
{% endif %}
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-2">
<a href="/schedules/{{ schedule.id }}" class="text-sky-700 hover:underline">Изменить</a>
<form method="post" action="/schedules/{{ schedule.id }}/toggle">
<button type="submit" class="text-slate-600 hover:underline">
{% if schedule.enabled %}Выключить{% else %}Включить{% endif %}
</button>
</form>
<form method="post" action="/schedules/{{ schedule.id }}/delete" onsubmit="return confirm('Удалить расписание?');">
<button type="submit" class="text-red-600 hover:underline">Удалить</button>
</form>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-4 py-6 text-center text-slate-500">Расписаний пока нет.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,22 +1,25 @@
"""Планировщик рассылок. """Планировщик рассылок.
Исполняется внутри процесса бота. Время и дни недели пока заданы в коде Расписания читаются из БД (таблица ``bot.schedules``, редактируется в админке).
(данные, а не магические числа); в Блоке 4 они переедут в таблицу ``schedules`` Если база недоступна, используется расписание по умолчанию из кода и последний
и будут редактироваться через админку. Сами тексты уже берутся из БД успешно прочитанный набор. Тексты берутся из ``bot.message_templates`` через
(``bot.message_templates``) через ``common.texts.store``. ``common.texts.store`` (TTL-кеш, правки без перезапуска).
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
from dataclasses import dataclass, field import time
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo
from aiogram import Bot from aiogram import Bot
from bot.services import users as users_service from bot.services import users as users_service
from common.config import get_settings from common.config import get_settings
from common.repositories import schedules_repo
from common.texts import format_usernames, render from common.texts import format_usernames, render
from common.texts.store import get_text from common.texts.store import get_text
@ -25,34 +28,76 @@ logger = logging.getLogger(__name__)
#: Размер порции упоминаний в одном сообщении. #: Размер порции упоминаний в одном сообщении.
BATCH_SIZE = 10 BATCH_SIZE = 10
#: Как часто писать в лог, если БД с расписаниями недоступна (секунды).
WARNING_INTERVAL = 300.0
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ScheduledMessage: class ScheduledMessage:
"""Одна запланированная рассылка. """Одна запланированная рассылка из БД (или из значений по умолчанию).
:param weekdays: дни недели (0=Пн … 6=Вс). None — ежедневно. :param weekdays: дни недели по ISO (1=Пн … 7=Вс). Пусто — ежедневно.
:param timezone: таймзона расписания (IANA), время в ней.
""" """
hour: int hour: int
minute: int minute: int
template_key: str template_key: str
weekdays: tuple[int, ...] | None = field(default=None) weekdays: tuple[int, ...] = ()
timezone: str = "Europe/Moscow"
def matches(self, now: datetime) -> bool: 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 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, 30, "notify.blago_30"),
ScheduledMessage(16, 45, "notify.blago_45"), ScheduledMessage(16, 45, "notify.blago_45"),
ScheduledMessage(16, 55, "notify.blago_55"), ScheduledMessage(16, 55, "notify.blago_55"),
ScheduledMessage(17, 0, "notify.blago"), ScheduledMessage(17, 0, "notify.blago"),
ScheduledMessage(15, 0, "notify.vklad", weekdays=(4,)), # пятница ScheduledMessage(15, 0, "notify.vklad", weekdays=(5,)), # пятница
ScheduledMessage(14, 0, "notify.vklad", weekdays=(6,)), # воскресенье 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: 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 [[]] 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: async def _tick(bot: Bot, now: datetime) -> None:
for schedule in SCHEDULES: for schedule in await _load_schedules():
if schedule.matches(now): if not schedule.matches(now):
subscribers = await users_service.list_subscribers() continue
template = await get_text(schedule.template_key) template = await get_text(schedule.template_key)
await _send_broadcast(bot, template, subscribers) 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: async def run_notifications(bot: Bot) -> None:

View File

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

View File

@ -7,4 +7,4 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.4.2" __version__ = "0.4.3"

View File

@ -8,6 +8,9 @@ jinja2>=3.1,<4.0
pydantic>=2.7,<3.0 pydantic>=2.7,<3.0
pydantic-settings>=2.3,<3.0 pydantic-settings>=2.3,<3.0
# Таймзоны для проверки расписаний (в slim-образах нет системного tzdata).
tzdata>=2024.1
# Postgres (async psycopg v3) — понадобится разделам шаблонов и пользователей. # Postgres (async psycopg v3) — понадобится разделам шаблонов и пользователей.
psycopg[binary,pool]>=3.2,<4.0 psycopg[binary,pool]>=3.2,<4.0

View File

@ -5,6 +5,9 @@ aiogram>=3.7,<4.0
pydantic>=2.7,<3.0 pydantic>=2.7,<3.0
pydantic-settings>=2.3,<3.0 pydantic-settings>=2.3,<3.0
python-dotenv>=1.0,<2.0 python-dotenv>=1.0,<2.0
# База таймзон для zoneinfo: в slim-образах нет системного tzdata,
# без пакета ZoneInfo("Europe/Moscow") падает (расписания и настройка TIMEZONE).
tzdata>=2024.1
psycopg[binary,pool]>=3.2,<4.0 psycopg[binary,pool]>=3.2,<4.0
# ClickHouse (аналитика событий, Блок 3). Extras [async] — нативный asyncio-клиент. # ClickHouse (аналитика событий, Блок 3). Extras [async] — нативный asyncio-клиент.

View File

@ -48,11 +48,12 @@ COMMENT ON TABLE bot.schedules IS 'Расписания рассылок (исп
COMMENT ON COLUMN bot.schedules.weekdays IS 'Дни недели 1..7 (ISO, 1 = понедельник); пусто — каждый день'; COMMENT ON COLUMN bot.schedules.weekdays IS 'Дни недели 1..7 (ISO, 1 = понедельник); пусто — каждый день';
-- История изменений расписаний: снимок строки до и после правки. -- История изменений расписаний: снимок строки до и после правки.
-- FK с SET NULL: история должна пережить удаление самого расписания.
CREATE TABLE IF NOT EXISTS bot.schedules_history ( CREATE TABLE IF NOT EXISTS bot.schedules_history (
id bigserial PRIMARY KEY, 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, old_data jsonb,
new_data jsonb NOT NULL, new_data jsonb,
editor_id bigint, editor_id bigint,
changed_at timestamptz NOT NULL DEFAULT now() changed_at timestamptz NOT NULL DEFAULT now()
); );

View File

@ -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', 'Вклад: воскресенье');

View File

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

View File

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

View File

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

View File

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