Files
bot_vk_ikp_prodagi/docs/superpowers/plans/2026-09-05-reminders-pause-timezone.md

48 KiB
Raw Permalink Blame History

Напоминалки: пауза и часовой пояс — план реализации

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Добавить в уже работающую фичу «Напоминалки» (см. docs/superpowers/plans/2026-09-02-admin-reminders.md) паузу напоминания без удаления и корректный учёт часового пояса того админа, который настраивает напоминание.

Architecture: Часовой пояс каждого админа хранится один раз в новой таблице admin_settings (запрашивается гейтом при первом входе в раздел «Напоминалки»). Дата/время конвертируются в серверное время только один раз — при создании (вход → сервер) и при показе списка (сервер → тот, кто сейчас смотрит); сам фоновый планировщик и расчёт периодичности (compute_next_occurrence) работают как прежде, в серверном времени, без изменений. Пауза — это просто булев флаг paused в таблице reminders, который планировщик учитывает одним доп. условием в SQL; возобновление не требует пересчёта дат — этим уже занимается существующая логика «пропущенных периодов» (Task 5 первого плана).

Tech Stack: Python, SQLite (sqlite3 через DatabaseManager), vk_api, pytest.

Важно: НЕ пушить в git до полного прогона тестов и ручной проверки в работающем боте (см. Task 7). Локальные коммиты — можно и нужно.


Task 1: reminders.py — конвертация между часовыми поясами

Files:

  • Modify: reminders.py

  • Test: tests/test_reminders.py

  • Step 1: Написать падающие тесты

В tests/test_reminders.py найти строку импорта в самом начале файла:

from datetime import datetime
from reminders import parse_reminder_datetime, compute_next_occurrence

Заменить на:

from datetime import datetime
from reminders import parse_reminder_datetime, compute_next_occurrence, \
    server_utc_offset_hours, convert_time

Добавить в конец tests/test_reminders.py:

def test_server_utc_offset_hours_in_sane_range():
    """Собственное смещение сервера от UTC — разумное число часов (реальные пояса Земли)."""
    offset = server_utc_offset_hours()
    assert -12 <= offset <= 14


def test_convert_time_to_utc():
    dt = datetime(2027, 3, 15, 9, 0)  # 09:00 по времени автора (UTC+3, Москва)
    result = convert_time(dt, from_offset=3, to_offset=0)  # переводим в UTC
    assert result == datetime(2027, 3, 15, 6, 0)


def test_convert_time_crosses_midnight_backwards():
    dt = datetime(2027, 3, 15, 1, 0)  # 01:00 по времени автора (UTC+5, Екатеринбург)
    result = convert_time(dt, from_offset=5, to_offset=-2)
    assert result == datetime(2027, 3, 14, 18, 0)  # переходит на предыдущие сутки


def test_convert_time_same_offset_is_noop():
    dt = datetime(2027, 1, 1, 12, 0)
    assert convert_time(dt, from_offset=3, to_offset=3) == dt
  • Step 2: Убедиться, что тесты падают

Run: python -m pytest tests/test_reminders.py -v -k "utc_offset or convert_time" Expected: FAIL с ImportError: cannot import name 'server_utc_offset_hours'

  • Step 3: Реализовать функции

В reminders.py в самый конец файла (после compute_next_occurrence) добавить:

def server_utc_offset_hours() -> float:
    """Собственное смещение сервера от UTC в часах. Часовой пояс сервера нигде
    явно не настроен (может быть любым в зависимости от хостинга), поэтому
    вычисляется на лету через локальную таймзону ОС."""
    offset = datetime.now().astimezone().utcoffset()
    return offset.total_seconds() / 3600 if offset else 0.0


def convert_time(dt: datetime, from_offset: float, to_offset: float) -> datetime:
    """Переводит наивный datetime из одного часового пояса (смещение от UTC
    в часах) в другой — просто сдвигает время на разницу смещений."""
    return dt + timedelta(hours=(to_offset - from_offset))

(timedelta и datetime уже импортированы в начале reminders.py — дополнительных импортов не требуется.)

  • Step 4: Прогнать тесты

Run: python -m pytest tests/test_reminders.py -v Expected: PASS (11 passed — 7 старых + 4 новых)

  • Step 5: Коммит
git add reminders.py tests/test_reminders.py
git commit -m "Напоминалки: конвертация между часовыми поясами (reminders.py)"

Task 2: database.py — часовой пояс админов и пауза напоминаний

Files:

  • Modify: database.py

  • Test: tests/test_database.py

  • Step 1: Написать падающие тесты

Добавить в конец tests/test_database.py:

def test_admin_tz_offset(tmp_db):
    assert tmp_db.get_admin_tz_offset(999) is None
    tmp_db.set_admin_tz_offset(999, 3)
    assert tmp_db.get_admin_tz_offset(999) == 3
    tmp_db.set_admin_tz_offset(999, -5)  # перезапись существующего значения
    assert tmp_db.get_admin_tz_offset(999) == -5


def test_reminder_pause_excludes_from_due_but_stays_visible(tmp_db):
    rid = tmp_db.add_reminder("Продлить домен", "2020-01-01 09:00:00", "once", "all", created_by=1)
    assert tmp_db.get_due_reminders("2020-01-01 09:00:00")  # просрочено — должно найтись

    tmp_db.set_reminder_paused(rid, True)
    assert tmp_db.get_due_reminders("2020-01-01 09:00:00") == []  # на паузе — не должно
    row = next(r for r in tmp_db.get_active_reminders() if r["id"] == rid)
    assert row["paused"] == 1  # но в общем списке всё ещё видно

    tmp_db.set_reminder_paused(rid, False)
    assert tmp_db.get_due_reminders("2020-01-01 09:00:00")  # сняли паузу — снова попадает в due
    row = next(r for r in tmp_db.get_active_reminders() if r["id"] == rid)
    assert row["paused"] == 0
  • Step 2: Убедиться, что тесты падают

Run: python -m pytest tests/test_database.py -v -k "tz_offset or pause" Expected: FAIL с AttributeError: 'DatabaseManager' object has no attribute 'get_admin_tz_offset'

  • Step 3: Добавить таблицу admin_settings и колонку paused

В database.py найти блок создания таблицы reminders в _init_db:

            # Table for admin reminders (настраиваемые напоминания: дата, текст, периодичность)
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS reminders (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    text TEXT NOT NULL,
                    remind_at TEXT NOT NULL,   -- якорная дата первого срабатывания, не меняется
                    next_at TEXT NOT NULL,     -- следующее срабатывание, двигается вперёд
                    recurrence TEXT NOT NULL,  -- 'once' | 'weekly' | 'monthly' | 'yearly'
                    recipients TEXT NOT NULL,  -- JSON: список vk_id, либо строка "all"
                    created_by INTEGER NOT NULL,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    active INTEGER NOT NULL DEFAULT 1
                )
            """)

            conn.commit()

Заменить на (добавлена колонка paused в CREATE TABLE + миграция для уже существующих БД + новая таблица admin_settings):

            # Table for admin reminders (настраиваемые напоминания: дата, текст, периодичность)
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS reminders (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    text TEXT NOT NULL,
                    remind_at TEXT NOT NULL,   -- якорная дата первого срабатывания, не меняется
                    next_at TEXT NOT NULL,     -- следующее срабатывание, двигается вперёд
                    recurrence TEXT NOT NULL,  -- 'once' | 'weekly' | 'monthly' | 'yearly'
                    recipients TEXT NOT NULL,  -- JSON: список vk_id, либо строка "all"
                    created_by INTEGER NOT NULL,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    active INTEGER NOT NULL DEFAULT 1,
                    paused INTEGER NOT NULL DEFAULT 0
                )
            """)
            # Миграция: колонка paused для уже существующих БД (созданных до этой фичи)
            try:
                cursor.execute("ALTER TABLE reminders ADD COLUMN paused INTEGER NOT NULL DEFAULT 0")
            except sqlite3.OperationalError:
                pass

            # Table for per-admin settings (пока только часовой пояс для напоминалок)
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS admin_settings (
                    user_id INTEGER PRIMARY KEY,
                    tz_offset INTEGER NOT NULL,  -- смещение от UTC в часах, напр. 3 для Москвы
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)

            conn.commit()
  • Step 4: Добавить CRUD-методы часового пояса и паузы

В database.py найти конец блока напоминалок (метод delete_reminder):

    def delete_reminder(self, reminder_id: int) -> bool:
        with self._get_connection() as conn:
            cursor = conn.cursor()
            cursor.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
            conn.commit()
            return cursor.rowcount > 0

Сразу после него добавить:

    def set_reminder_paused(self, reminder_id: int, paused: bool):
        with self._get_connection() as conn:
            cursor = conn.cursor()
            cursor.execute("UPDATE reminders SET paused = ? WHERE id = ?",
                           (1 if paused else 0, reminder_id))
            conn.commit()

    # --- Часовой пояс админов (для корректного показа/ввода времени напоминалок) ---
    def get_admin_tz_offset(self, user_id: int) -> Optional[int]:
        """Смещение от UTC в часах, которое задал себе админ, или None, если ещё не задавал."""
        with self._get_connection() as conn:
            cursor = conn.cursor()
            cursor.execute("SELECT tz_offset FROM admin_settings WHERE user_id = ?", (user_id,))
            row = cursor.fetchone()
            return row[0] if row else None

    def set_admin_tz_offset(self, user_id: int, tz_offset: int):
        with self._get_connection() as conn:
            cursor = conn.cursor()
            cursor.execute(
                "INSERT INTO admin_settings (user_id, tz_offset) VALUES (?, ?) "
                "ON CONFLICT(user_id) DO UPDATE SET tz_offset = excluded.tz_offset, "
                "updated_at = CURRENT_TIMESTAMP",
                (user_id, tz_offset)
            )
            conn.commit()
  • Step 5: Обновить get_active_reminders/get_due_reminders

В database.py найти:

    def get_active_reminders(self) -> list:
        """Активные напоминания для отображения в админке, по next_at."""
        with self._get_connection() as conn:
            conn.row_factory = sqlite3.Row
            cursor = conn.cursor()
            cursor.execute(
                "SELECT id, text, remind_at, next_at, recurrence, recipients, created_by "
                "FROM reminders WHERE active = 1 ORDER BY next_at"
            )
            return self._rows_with_parsed_recipients(cursor)

    def get_due_reminders(self, now_str: str) -> list:
        """Активные напоминания, чьё время (next_at) уже наступило."""
        with self._get_connection() as conn:
            conn.row_factory = sqlite3.Row
            cursor = conn.cursor()
            cursor.execute(
                "SELECT id, text, remind_at, next_at, recurrence, recipients, created_by "
                "FROM reminders WHERE active = 1 AND next_at <= ?", (now_str,)
            )
            return self._rows_with_parsed_recipients(cursor)

Заменить на (добавлена колонка paused в SELECT + фильтр paused = 0 только в get_due_reminders — в списке для админки напоминания на паузе должны быть видны, а вот планировщику их брать нельзя):

    def get_active_reminders(self) -> list:
        """Активные напоминания для отображения в админке, по next_at."""
        with self._get_connection() as conn:
            conn.row_factory = sqlite3.Row
            cursor = conn.cursor()
            cursor.execute(
                "SELECT id, text, remind_at, next_at, recurrence, recipients, created_by, paused "
                "FROM reminders WHERE active = 1 ORDER BY next_at"
            )
            return self._rows_with_parsed_recipients(cursor)

    def get_due_reminders(self, now_str: str) -> list:
        """Активные напоминания (не на паузе), чьё время (next_at) уже наступило."""
        with self._get_connection() as conn:
            conn.row_factory = sqlite3.Row
            cursor = conn.cursor()
            cursor.execute(
                "SELECT id, text, remind_at, next_at, recurrence, recipients, created_by, paused "
                "FROM reminders WHERE active = 1 AND paused = 0 AND next_at <= ?", (now_str,)
            )
            return self._rows_with_parsed_recipients(cursor)
  • Step 6: Прогнать тесты

Run: python -m pytest tests/test_database.py -v Expected: PASS (все тесты, включая старые)

  • Step 7: Коммит
git add database.py tests/test_database.py
git commit -m "Напоминалки: часовой пояс админов и пауза в database.py"

Task 3: keyboards.py — кнопка паузы

Files:

  • Modify: keyboards.py

  • Step 1: Добавить кнопку в клавиатуру раздела

В keyboards.py найти:

def get_reminders_admin_keyboard() -> str:
    """Клавиатура раздела «Напоминалки». Обычная (не инлайн) клавиатура — важно:
    она заменяет собой нижнюю клавиатуру предыдущего шага мастера (например,
    «Разово / Каждую неделю / …»), иначе старые кнопки остаются висеть после
    завершения мастера."""
    keyboard = VkKeyboard(one_time=False)
    keyboard.add_button(" Добавить", color=VkKeyboardColor.PRIMARY)
    keyboard.add_line()
    keyboard.add_button("🗑 Удалить напоминание", color=VkKeyboardColor.NEGATIVE)
    keyboard.add_line()
    keyboard.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY)
    return keyboard.get_keyboard()

Заменить на (добавлена кнопка паузы отдельной строкой перед «В админку»):

def get_reminders_admin_keyboard() -> str:
    """Клавиатура раздела «Напоминалки». Обычная (не инлайн) клавиатура — важно:
    она заменяет собой нижнюю клавиатуру предыдущего шага мастера (например,
    «Разово / Каждую неделю / …»), иначе старые кнопки остаются висеть после
    завершения мастера."""
    keyboard = VkKeyboard(one_time=False)
    keyboard.add_button(" Добавить", color=VkKeyboardColor.PRIMARY)
    keyboard.add_line()
    keyboard.add_button("🗑 Удалить напоминание", color=VkKeyboardColor.NEGATIVE)
    keyboard.add_line()
    keyboard.add_button("⏸/▶ Пауза", color=VkKeyboardColor.SECONDARY)
    keyboard.add_line()
    keyboard.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY)
    return keyboard.get_keyboard()
  • Step 2: Проверить регрессию

Run: python -m pytest tests/ -v Expected: PASS (keyboards.py не имеет собственных тестов — проверяем, что ничего не сломалось в остальном наборе)

  • Step 3: Коммит
git add keyboards.py
git commit -m "Напоминалки: кнопка паузы в клавиатуре раздела"

Task 4: bot.py — гейт часового пояса и конвертация при создании/показе

Files:

  • Modify: bot.py

  • Step 1: Импорт новых функций из reminders.py

В bot.py найти:

from reminders import parse_reminder_datetime, compute_next_occurrence

Заменить на:

from reminders import parse_reminder_datetime, compute_next_occurrence, \
    server_utc_offset_hours, convert_time
  • Step 2: Добавить хелперы _parse_tz_offset и _require_reminder_tz

В bot.py найти конец метода _admin_display (сразу после него идёт _send_reminders_admin):

    def _admin_display(self, uid: int) -> str:
        """ФИО из VK + ссылка, если имя удалось получить, иначе только ссылка
        (используется в мастере напоминалок, чтобы не кликать по каждому id)."""
        name = self._vk_name(uid)
        return f"{name} (vk.com/id{uid})" if name else f"vk.com/id{uid}"

    def _send_reminders_admin(self, user_id: int):

Заменить на (добавлены два новых метода между ними):

    def _admin_display(self, uid: int) -> str:
        """ФИО из VK + ссылка, если имя удалось получить, иначе только ссылка
        (используется в мастере напоминалок, чтобы не кликать по каждому id)."""
        name = self._vk_name(uid)
        return f"{name} (vk.com/id{uid})" if name else f"vk.com/id{uid}"

    def _parse_tz_offset(self, raw: str) -> Optional[int]:
        """Парсит смещение от UTC вида '+3', '-5', '0'. None, если не число
        или вне диапазона реальных часовых поясов (-12..+14)."""
        raw = raw.strip()
        if not re.match(r"^[+-]?\d{1,2}$", raw):
            return None
        val = int(raw)
        return val if -12 <= val <= 14 else None

    def _require_reminder_tz(self, user_id: int) -> bool:
        """Гейт часового пояса: если у админа он ещё не задан — переводит его
        в мастер ввода пояса и возвращает False (вызывающий должен просто
        return). Если пояс уже задан — возвращает True, можно продолжать."""
        if db.get_admin_tz_offset(user_id) is not None:
            return True
        db.update_user_state(user_id, "ADMIN_REMINDER_TZ_SETUP")
        self.send_message(
            user_id,
            "🌐 Прежде чем пользоваться напоминалками, укажите свой часовой пояс "
            "относительно UTC.\nНапример: +3 (Москва), +5 (Екатеринбург), 0 (UTC).",
            keyboard=get_cancel_admin_keyboard()
        )
        return False

    def _handle_set_timezone(self, user_id: int, text: str):
        """Команда 'часовой пояс <±N>' — сменить уже заданный пояс в любой момент."""
        raw = text[len("часовой пояс"):].strip()
        offset = self._parse_tz_offset(raw)
        if offset is None:
            self.send_message(user_id, "⚠️ Формат: часовой пояс +3 (число от -12 до +14).")
            return
        db.set_admin_tz_offset(user_id, offset)
        self.send_message(user_id, f"✅ Часовой пояс сохранён: UTC{offset:+d}.")

    def _send_reminders_admin(self, user_id: int):
  • Step 3: Обновить _send_reminders_admin — показ времени в поясе зрителя + пометка паузы

В bot.py найти:

    def _send_reminders_admin(self, user_id: int):
        """Раздел «Напоминалки»: список активных + инструкции."""
        rows = db.get_active_reminders()
        listing = ""
        for r in rows:
            dt = datetime.strptime(r["next_at"], "%Y-%m-%d %H:%M:%S")
            who = "всем админам" if r["recipients"] == "all" else \
                ", ".join(self._admin_display(uid) for uid in r["recipients"])
            period = REMINDER_PERIOD_LABELS.get(r["recurrence"], r["recurrence"])
            short = (r["text"][:50] + "…") if len(r["text"]) > 50 else r["text"]
            listing += (f"🔹 [{r['id']}] {dt.strftime('%d.%m.%Y %H:%M')} ({period}) → {who}\n"
                        f"   «{short}»\n")
        if not listing:
            listing = "Активных напоминаний нет.\n"
        self.send_message(
            user_id,
            "⏰ Напоминалки\n\n"
            f"{listing}\n"
            "Управляйте кнопками ниже: ➕ добавить • 🗑 удалить",
            keyboard=get_reminders_admin_keyboard()
        )

Заменить на:

    def _send_reminders_admin(self, user_id: int):
        """Раздел «Напоминалки»: список активных + инструкции.
        Вызывается только после прохождения гейта часового пояса
        (см. _require_reminder_tz) — тут он гарантированно уже задан."""
        rows = db.get_active_reminders()
        viewer_offset = db.get_admin_tz_offset(user_id)
        if viewer_offset is None:
            viewer_offset = 0
        server_offset = server_utc_offset_hours()
        listing = ""
        for r in rows:
            server_dt = datetime.strptime(r["next_at"], "%Y-%m-%d %H:%M:%S")
            viewer_dt = convert_time(server_dt, server_offset, viewer_offset)
            who = "всем админам" if r["recipients"] == "all" else \
                ", ".join(self._admin_display(uid) for uid in r["recipients"])
            period = REMINDER_PERIOD_LABELS.get(r["recurrence"], r["recurrence"])
            short = (r["text"][:50] + "…") if len(r["text"]) > 50 else r["text"]
            pause_mark = "⏸ (на паузе) " if r["paused"] else ""
            listing += (f"🔹 [{r['id']}] {pause_mark}{viewer_dt.strftime('%d.%m.%Y %H:%M')} "
                        f"(UTC{viewer_offset:+d}, {period}) → {who}\n"
                        f"   «{short}»\n")
        if not listing:
            listing = "Активных напоминаний нет.\n"
        self.send_message(
            user_id,
            "⏰ Напоминалки\n\n"
            f"{listing}\n"
            "ℹ️ Чтобы время в списке отображалось верно у всех, каждому нужно один раз "
            "зайти сюда и указать свой часовой пояс.\n\n"
            "Управляйте кнопками ниже: ➕ добавить • 🗑 удалить • ⏸/▶ пауза",
            keyboard=get_reminders_admin_keyboard()
        )
  • Step 4: Подключить гейт и команду смены пояса в роутере handle_message

В bot.py найти:

            elif text_lower in ["⏰ напоминалки", "напоминалки"]:
                self._send_reminders_admin(user_id)
                return
            elif text_lower == " добавить":
                db.update_user_state(user_id, "ADMIN_REMINDER_DATE")
                self.send_message(
                    user_id,
                    "⏰ Новое напоминание.\nВведите дату и время: ДД.ММ.ГГГГ ЧЧ:ММ\n"
                    "Например: 15.03.2027 09:00",
                    keyboard=get_cancel_admin_keyboard()
                )
                return
            elif text_lower.startswith("напоминалка удалить"):
                self._handle_reminder_delete(user_id, text_clean)
                return
            elif text_lower in ["🗑 удалить напоминание", "удалить напоминание"]:
                rows = db.get_active_reminders()
                if not rows:
                    self.send_message(user_id, "Активных напоминаний нет.")
                    self._send_reminders_admin(user_id)
                    return
                items = [(r["id"], r["text"]) for r in rows]
                db.update_user_state(user_id, "ADMIN_REMINDER_DEL_PICK")
                self.send_message(user_id, "🗑 Какое напоминание удалить? Выберите:",
                                  keyboard=get_pick_keyboard(items))
                return

Заменить на (добавлены гейт в трёх местах, новая кнопка паузы и команда смены пояса):

            elif text_lower in ["⏰ напоминалки", "напоминалки"]:
                if self._require_reminder_tz(user_id):
                    self._send_reminders_admin(user_id)
                return
            elif text_lower == " добавить":
                if not self._require_reminder_tz(user_id):
                    return
                db.update_user_state(user_id, "ADMIN_REMINDER_DATE")
                self.send_message(
                    user_id,
                    "⏰ Новое напоминание.\nВведите дату и время: ДД.ММ.ГГГГ ЧЧ:ММ\n"
                    "Например: 15.03.2027 09:00",
                    keyboard=get_cancel_admin_keyboard()
                )
                return
            elif text_lower.startswith("напоминалка удалить"):
                self._handle_reminder_delete(user_id, text_clean)
                return
            elif text_lower in ["🗑 удалить напоминание", "удалить напоминание"]:
                if not self._require_reminder_tz(user_id):
                    return
                rows = db.get_active_reminders()
                if not rows:
                    self.send_message(user_id, "Активных напоминаний нет.")
                    self._send_reminders_admin(user_id)
                    return
                items = [(r["id"], r["text"]) for r in rows]
                db.update_user_state(user_id, "ADMIN_REMINDER_DEL_PICK")
                self.send_message(user_id, "🗑 Какое напоминание удалить? Выберите:",
                                  keyboard=get_pick_keyboard(items))
                return
            elif text_lower in ["⏸/▶ пауза", "пауза"]:
                if not self._require_reminder_tz(user_id):
                    return
                rows = db.get_active_reminders()
                if not rows:
                    self.send_message(user_id, "Активных напоминаний нет.")
                    self._send_reminders_admin(user_id)
                    return
                items = [
                    (r["id"], ("▶ Возобновить: " if r["paused"] else "⏸ На паузу: ") + r["text"])
                    for r in rows
                ]
                db.update_user_state(user_id, "ADMIN_REMINDER_PAUSE_PICK")
                self.send_message(user_id, "⏸/▶ Какое напоминание переключить? Выберите:",
                                  keyboard=get_pick_keyboard(items))
                return
            elif text_lower.startswith("часовой пояс"):
                self._handle_set_timezone(user_id, text_clean)
                return
  • Step 5: Добавить состояние ADMIN_REMINDER_TZ_SETUP и конвертацию в ADMIN_REMINDER_DATE

В bot.py найти:

        # ---------- Напоминалки ----------
        if state == "ADMIN_REMINDER_DEL_PICK":
            rid = pick_id(text)
            if not rid.isdigit() or not db.delete_reminder(int(rid)):
                self.send_message(user_id, "⚠️ Выберите напоминание кнопкой ниже.")
                return
            db.update_user_state(user_id, "FREE_CHAT")
            self.send_message(user_id, f"✅ Напоминание №{rid} удалено.")
            self._send_reminders_admin(user_id)
            return

        if state == "ADMIN_REMINDER_DATE":
            dt = parse_reminder_datetime(text)
            if not dt:
                self.send_message(user_id, "⚠️ Формат: ДД.ММ.ГГГГ ЧЧ:ММ (например, 15.03.2027 09:00)",
                                  keyboard=get_cancel_admin_keyboard())
                return
            if dt <= datetime.now():
                self.send_message(user_id, "⚠️ Дата и время должны быть в будущем.",
                                  keyboard=get_cancel_admin_keyboard())
                return
            self._admin_ctx[user_id] = {"remind_at": dt.strftime("%Y-%m-%d %H:%M:%S")}
            db.update_user_state(user_id, "ADMIN_REMINDER_TEXT")
            self.send_message(user_id, "✏️ Введите текст напоминания:", keyboard=get_cancel_admin_keyboard())
            return

Заменить на (добавлено состояние ADMIN_REMINDER_TZ_SETUP и ADMIN_REMINDER_PAUSE_PICK перед блоком дат, и конвертация в ADMIN_REMINDER_DATE):

        # ---------- Напоминалки ----------
        if state == "ADMIN_REMINDER_TZ_SETUP":
            offset = self._parse_tz_offset(text)
            if offset is None:
                self.send_message(user_id, "⚠️ Формат: число от -12 до +14, например +3 или -5.",
                                  keyboard=get_cancel_admin_keyboard())
                return
            db.set_admin_tz_offset(user_id, offset)
            db.update_user_state(user_id, "FREE_CHAT")
            self.send_message(user_id, f"✅ Часовой пояс сохранён: UTC{offset:+d}.")
            self._send_reminders_admin(user_id)
            return

        if state == "ADMIN_REMINDER_DEL_PICK":
            rid = pick_id(text)
            if not rid.isdigit() or not db.delete_reminder(int(rid)):
                self.send_message(user_id, "⚠️ Выберите напоминание кнопкой ниже.")
                return
            db.update_user_state(user_id, "FREE_CHAT")
            self.send_message(user_id, f"✅ Напоминание №{rid} удалено.")
            self._send_reminders_admin(user_id)
            return

        if state == "ADMIN_REMINDER_PAUSE_PICK":
            rid = pick_id(text)
            rows_by_id = {r["id"]: r for r in db.get_active_reminders()}
            if not rid.isdigit() or int(rid) not in rows_by_id:
                self.send_message(user_id, "⚠️ Выберите напоминание кнопкой ниже.")
                return
            new_paused = not rows_by_id[int(rid)]["paused"]
            db.set_reminder_paused(int(rid), new_paused)
            db.update_user_state(user_id, "FREE_CHAT")
            self.send_message(
                user_id,
                f"✅ Напоминание №{rid} " +
                ("поставлено на паузу." if new_paused else "снято с паузы.")
            )
            self._send_reminders_admin(user_id)
            return

        if state == "ADMIN_REMINDER_DATE":
            dt = parse_reminder_datetime(text)
            if not dt:
                self.send_message(user_id, "⚠️ Формат: ДД.ММ.ГГГГ ЧЧ:ММ (например, 15.03.2027 09:00)",
                                  keyboard=get_cancel_admin_keyboard())
                return
            admin_offset = db.get_admin_tz_offset(user_id)
            if admin_offset is None:
                admin_offset = 0
            server_dt = convert_time(dt, admin_offset, server_utc_offset_hours())
            if server_dt <= datetime.now():
                self.send_message(user_id, "⚠️ Дата и время должны быть в будущем.",
                                  keyboard=get_cancel_admin_keyboard())
                return
            self._admin_ctx[user_id] = {"remind_at": server_dt.strftime("%Y-%m-%d %H:%M:%S")}
            db.update_user_state(user_id, "ADMIN_REMINDER_TEXT")
            self.send_message(user_id, "✏️ Введите текст напоминания:", keyboard=get_cancel_admin_keyboard())
            return
  • Step 6: Прогнать полный набор тестов

Run: python -m pytest tests/ -v Expected: PASS (сквозные тесты добавим в Task 5; ничего не должно сломаться из существующего — но обратите внимание: старые тесты мастера создания напоминания из первого плана, например test_admin_reminder_wizard_creates_reminder, теперь упрутся в гейт часового пояса и, скорее всего, упадут — это ожидаемо и будет исправлено в Task 5, где эти тесты обновляются)

  • Step 7: Коммит
git add bot.py
git commit -m "Напоминалки: гейт часового пояса, конвертация времени, состояние паузы"

Task 5: Обновить старые тесты и добавить сквозные тесты новых сценариев

Files:

  • Modify: tests/test_flow.py

Task 4 подключил обязательный гейт часового пояса перед разделом «Напоминалки» — это меняет поведение уже существующих тестов мастера создания напоминания (test_admin_reminder_wizard_creates_reminder, test_admin_reminder_wizard_specific_recipients, test_admin_reminder_delete_via_picker_button), которые открывают раздел без предварительно заданного часового пояса.

  • Step 1: Обновить существующие тесты, чтобы часовой пояс был задан заранее

В tests/test_flow.py найти:

def test_admin_reminder_wizard_creates_reminder(app, monkeypatch):
    admin = 80001
    monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])

    app.handle_message(admin, "Админка ⚙️")

Заменить на (добавлена строка с заданием часового пояса перед началом мастера):

def test_admin_reminder_wizard_creates_reminder(app, monkeypatch):
    admin = 80001
    monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
    bot.db.set_admin_tz_offset(admin, 3)  # пояс уже задан — гейт не должен мешать

    app.handle_message(admin, "Админка ⚙️")

Найти:

def test_admin_reminder_wizard_specific_recipients(app, monkeypatch):
    admin, other = 80003, 80004
    monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin, other])

    app.handle_message(admin, "⏰ Напоминалки")

Заменить на:

def test_admin_reminder_wizard_specific_recipients(app, monkeypatch):
    admin, other = 80003, 80004
    monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin, other])
    bot.db.set_admin_tz_offset(admin, 0)

    app.handle_message(admin, "⏰ Напоминалки")

Найти:

def test_admin_reminder_delete(app, monkeypatch):
    admin = 80002
    monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
    rid = bot.db.add_reminder("Тест", "2099-01-01 09:00:00", "once", [admin], created_by=admin)

Заменить на:

def test_admin_reminder_delete(app, monkeypatch):
    admin = 80002
    monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
    bot.db.set_admin_tz_offset(admin, 0)
    rid = bot.db.add_reminder("Тест", "2099-01-01 09:00:00", "once", [admin], created_by=admin)

Найти:

def test_admin_reminder_delete_via_picker_button(app, monkeypatch):
    """Удаление через кнопку «🗑 Удалить напоминание» + выбор из списка (без ввода id вручную)."""
    admin = 80005
    monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
    rid = bot.db.add_reminder("Продлить хостинг", "2099-01-01 09:00:00", "once", [admin], created_by=admin)

Заменить на:

def test_admin_reminder_delete_via_picker_button(app, monkeypatch):
    """Удаление через кнопку «🗑 Удалить напоминание» + выбор из списка (без ввода id вручную)."""
    admin = 80005
    monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
    bot.db.set_admin_tz_offset(admin, 0)
    rid = bot.db.add_reminder("Продлить хостинг", "2099-01-01 09:00:00", "once", [admin], created_by=admin)
  • Step 2: Прогнать обновлённые тесты

Run: python -m pytest tests/test_flow.py -v -k reminder Expected: PASS (4 старых теста снова зелёные)

  • Step 3: Написать тесты новых сценариев

Добавить в конец tests/test_flow.py:

def test_reminder_tz_gate_before_list(app, monkeypatch):
    """Без заданного часового пояса раздел не открывается — сначала гейт."""
    admin = 80006
    monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])

    app.handle_message(admin, "⏰ Напоминалки")
    assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP"

    app.handle_message(admin, "не число")  # невалидный формат — остаёмся на шаге
    assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP"

    app.handle_message(admin, "+3")
    assert _state(admin) == "FREE_CHAT"
    assert bot.db.get_admin_tz_offset(admin) == 3

    # повторный вход — пояс уже задан, гейт больше не срабатывает
    app.handle_message(admin, "⏰ Напоминалки")
    assert _state(admin) == "FREE_CHAT"


def test_reminder_creation_converts_author_local_time_to_server(app, monkeypatch):
    admin = 80007
    monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
    bot.db.set_admin_tz_offset(admin, 3)  # пояс уже задан — гейт не мешает

    app.handle_message(admin, "⏰ Напоминалки")
    app.handle_message(admin, " Добавить")
    app.handle_message(admin, "01.06.2099 09:00")  # 09:00 по времени автора (UTC+3)
    app.handle_message(admin, "Тест конвертации")
    app.handle_message(admin, "0")
    app.handle_message(admin, "Разово")

    rows = bot.db.get_active_reminders()
    stored = datetime.strptime(rows[0]["remind_at"], "%Y-%m-%d %H:%M:%S")
    server_offset = reminders.server_utc_offset_hours()
    expected = datetime(2099, 6, 1, 9, 0) + timedelta(hours=(server_offset - 3))
    assert stored == expected


def test_reminder_pause_and_resume_via_picker(app, monkeypatch):
    admin = 80008
    monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
    bot.db.set_admin_tz_offset(admin, 0)
    rid = bot.db.add_reminder("Продлить хостинг", "2020-01-01 09:00:00", "once", [admin], created_by=admin)

    app.handle_message(admin, "⏰ Напоминалки")
    app.handle_message(admin, "⏸/▶ Пауза")
    assert _state(admin) == "ADMIN_REMINDER_PAUSE_PICK"
    app.handle_message(admin, f"{rid}. Продлить хостинг")
    assert _state(admin) == "FREE_CHAT"
    row = next(r for r in bot.db.get_active_reminders() if r["id"] == rid)
    assert row["paused"] == 1

    # напоминание давно просрочено, но на паузе — планировщик не должен его отправить
    app._sent.clear()
    app._check_due_reminders()
    assert not app._sent
    assert bot.db.get_active_reminders()  # всё ещё активно (не деактивировалось)

    # возобновляем через тот же пикер
    app.handle_message(admin, "⏸/▶ Пауза")
    app.handle_message(admin, f"{rid}. Продлить хостинг")
    row = next(r for r in bot.db.get_active_reminders() if r["id"] == rid)
    assert row["paused"] == 0

    # теперь просроченное разовое напоминание должно уйти при следующей проверке
    app._check_due_reminders()
    assert app._sent
    assert not bot.db.get_active_reminders()  # once — деактивировалось после отправки


def test_timezone_change_command(app, monkeypatch):
    admin = 80009
    monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
    bot.db.set_admin_tz_offset(admin, 3)

    app.handle_message(admin, "часовой пояс -5")
    assert bot.db.get_admin_tz_offset(admin) == -5

    app.handle_message(admin, "часовой пояс много")
    assert bot.db.get_admin_tz_offset(admin) == -5  # невалидный ввод не поменял значение

test_reminder_creation_converts_author_local_time_to_server использует datetime, timedelta и модуль reminders — убедиться, что они импортированы в начале tests/test_flow.py. Найти самую первую строку файла:

import bot

Заменить на:

import bot
import reminders
from datetime import datetime, timedelta
  • Step 4: Прогнать новые тесты

Run: python -m pytest tests/test_flow.py -v -k "tz_gate or converts_author or pause_and_resume or timezone_change" Expected: PASS (4 passed)

  • Step 5: Прогнать весь набор тестов проекта

Run: python -m pytest tests/ -v Expected: PASS (все тесты проекта)

  • Step 6: Коммит
git add tests/test_flow.py
git commit -m "Напоминалки: тесты гейта часового пояса, конвертации и паузы"

Task 6: Ручная проверка в работающем боте (перед push)

Files: нет изменений кода — только проверка. Бот в этом проекте уже запущен в реальном режиме VK (см. предыдущую сессию) — потребуется его перезапустить, чтобы подхватить новый код.

  • Step 1: Перезапустить бота

Найти PID процесса и перезапустить:

wmic process where "commandline like '%bot.py%' and name='python.exe'" get processid

Затем (подставив найденный PID):

taskkill //F //PID <PID>

И запустить заново из корня проекта:

python bot.py

(или в фоне: nohup python bot.py > bot_manual_run.log 2>&1 &)

  • Step 2: Пройти гейт и мастер вручную

В личке сообщества (админ, у которого ещё не задан часовой пояс):

Админка ⚙️
⏰ Напоминалки

Expected: бот просит указать часовой пояс, а не показывает список.

+3

Expected: « Часовой пояс сохранён: UTC+3.», затем сразу показывается список напоминаний (пустой или как есть).

 Добавить
15.03.2027 09:00
Проверка часового пояса
0
Разово

Expected: напоминание создано; в списке видно время 15.03.2027 09:00 (то же самое, что ввели — потому что автор и зритель здесь один и тот же человек с одним и тем же поясом UTC+3).

  • Step 3: Проверить паузу
⏸/▶ Пауза

Expected: список с пунктом «⏸ На паузу: Проверка часового пояса». Выбрать его.

Expected: « Напоминание №N поставлено на паузу.», в списке у напоминания появляется пометка «⏸ (на паузе)».

Повторить ⏸/▶ Пауза и выбрать то же напоминание — должно вернуться в активное состояние (« Напоминание №N снято с паузы.», пометка исчезает).

  • Step 4: Убедиться, что ничего не сломалось

Run: python -m pytest tests/ -v Expected: PASS — полный прогон перед завершением работы.

  • Step 5: Сообщить пользователю результат

Только после успешного прохождения Step 1–4 — сообщить, что фича готова к проверке пользователем. Не выполнять git push — пуш делает пользователь сам после собственного тестирования.