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

1043 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Напоминалки: пауза и часовой пояс — план реализации
> **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 <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`** — пуш делает пользователь
сам после собственного тестирования.