Расписания рассылок переехали из кода в админку
В админке появился раздел «Расписания»: время, дни недели, таймзона, шаблон и вкл/выкл, с историей изменений и удалением. Бот раз в минуту читает активные строки из БД, поэтому правки применяются без перезапуска; при сбое базы он переживает его на последнем прочитанном наборе или на расписании из кода. Стартовые рассылки перенесены миграцией V16, так что поведение не меняется. Заодно в зависимости добавлен tzdata: в slim-образах без него падает ZoneInfo на Europe/Moscow.
This commit is contained in:
parent
1e4e905f24
commit
8fb7fe5dd9
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
@ -19,7 +19,7 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
|
||||||
{
|
{
|
||||||
"title": "Расписания",
|
"title": "Расписания",
|
||||||
"description": "Время рассылок, дни недели и шаблон",
|
"description": "Время рассылок, дни недели и шаблон",
|
||||||
"url": None,
|
"url": "/schedules",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Пользователи",
|
"title": "Пользователи",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.4.2"
|
__version__ = "0.4.3"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-клиент.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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', 'Вклад: воскресенье');
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue