"""Репозиторий расписаний рассылок (Блок 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 claim_run(schedule_id: int, run_at: Any) -> bool: """Атомарно отметить минуту как отработанную. ``True`` — эту минуту ещё никто не отправлял; ``False`` — уже была отправка (например, до перезапуска бота). """ async with get_pool().connection() as conn: cur = await conn.execute( """ INSERT INTO bot.schedule_runs (schedule_id, run_at) VALUES (%s, %s) ON CONFLICT DO NOTHING """, (schedule_id, run_at), ) claimed = bool(cur.rowcount) if claimed: await conn.execute("DELETE FROM bot.schedule_runs WHERE run_at < now() - interval '7 days'") return claimed 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()