# Напоминалки: пауза и часовой пояс — план реализации > **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`** — пуш делает пользователь сам после собственного тестирования.