From 5efc0aca8d806292ed950a4ee719b04c69ca1626 Mon Sep 17 00:00:00 2001 From: Kenik Date: Sat, 5 Sep 2026 14:35:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=B0=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D0=BA=D0=B8:=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=83=D0=B7=D1=8B=20=D0=B8=20=D1=87=D0=B0=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE=D1=8F=D1=81=D0=B0=20(do?= =?UTF-8?q?cs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- .../2026-09-05-reminders-pause-timezone.md | 1042 +++++++++++++++++ 1 file changed, 1042 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-05-reminders-pause-timezone.md diff --git a/docs/superpowers/plans/2026-09-05-reminders-pause-timezone.md b/docs/superpowers/plans/2026-09-05-reminders-pause-timezone.md new file mode 100644 index 0000000..a434e56 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-reminders-pause-timezone.md @@ -0,0 +1,1042 @@ +# Напоминалки: пауза и часовой пояс — план реализации + +> **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` найти строку импорта в самом начале файла: + +```python +from datetime import datetime +from reminders import parse_reminder_datetime, compute_next_occurrence +``` + +Заменить на: + +```python +from datetime import datetime +from reminders import parse_reminder_datetime, compute_next_occurrence, \ + server_utc_offset_hours, convert_time +``` + +Добавить в конец `tests/test_reminders.py`: + +```python +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`) добавить: + +```python +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: Коммит** + +```bash +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`: + +```python +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`: + +```python + # 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`): + +```python + # 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`): + +```python + 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 +``` + +Сразу после него добавить: + +```python + 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` найти: + +```python + 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` — в списке для админки напоминания на паузе должны быть +видны, а вот планировщику их брать нельзя): + +```python + 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: Коммит** + +```bash +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` найти: + +```python +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() +``` + +Заменить на (добавлена кнопка паузы отдельной строкой перед «В админку»): + +```python +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: Коммит** + +```bash +git add keyboards.py +git commit -m "Напоминалки: кнопка паузы в клавиатуре раздела" +``` + +--- + +## Task 4: `bot.py` — гейт часового пояса и конвертация при создании/показе + +**Files:** +- Modify: `bot.py` + +- [ ] **Step 1: Импорт новых функций из `reminders.py`** + +В `bot.py` найти: + +```python +from reminders import parse_reminder_datetime, compute_next_occurrence +``` + +Заменить на: + +```python +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`): + +```python + 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): +``` + +Заменить на (добавлены два новых метода между ними): + +```python + 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` найти: + +```python + 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() + ) +``` + +Заменить на: + +```python + 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` найти: + +```python + 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 +``` + +Заменить на (добавлены гейт в трёх местах, новая кнопка паузы и команда смены +пояса): + +```python + 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` найти: + +```python + # ---------- Напоминалки ---------- + 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`): + +```python + # ---------- Напоминалки ---------- + 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: Коммит** + +```bash +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` найти: + +```python +def test_admin_reminder_wizard_creates_reminder(app, monkeypatch): + admin = 80001 + monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin]) + + app.handle_message(admin, "Админка ⚙️") +``` + +Заменить на (добавлена строка с заданием часового пояса перед началом мастера): + +```python +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, "Админка ⚙️") +``` + +Найти: + +```python +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, "⏰ Напоминалки") +``` + +Заменить на: + +```python +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, "⏰ Напоминалки") +``` + +Найти: + +```python +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) +``` + +Заменить на: + +```python +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) +``` + +Найти: + +```python +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) +``` + +Заменить на: + +```python +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`: + +```python +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`. Найти самую первую строку файла: + +```python +import bot +``` + +Заменить на: + +```python +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: Коммит** + +```bash +git add tests/test_flow.py +git commit -m "Напоминалки: тесты гейта часового пояса, конвертации и паузы" +``` + +--- + +## Task 6: Ручная проверка в работающем боте (перед push) + +**Files:** нет изменений кода — только проверка. Бот в этом проекте уже +запущен в реальном режиме VK (см. предыдущую сессию) — потребуется его +перезапустить, чтобы подхватить новый код. + +- [ ] **Step 1: Перезапустить бота** + +Найти PID процесса и перезапустить: + +```bash +wmic process where "commandline like '%bot.py%' and name='python.exe'" get processid +``` + +Затем (подставив найденный PID): + +```bash +taskkill //F //PID +``` + +И запустить заново из корня проекта: + +```bash +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`** — пуш делает пользователь +сам после собственного тестирования.