Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
48 KiB
Напоминалки: пауза и часовой пояс — план реализации
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Добавить в уже работающую фичу «Напоминалки» (см.
docs/superpowers/plans/2026-09-02-admin-reminders.md) паузу напоминания без
удаления и корректный учёт часового пояса того админа, который настраивает
напоминание.
Architecture: Часовой пояс каждого админа хранится один раз в новой таблице
admin_settings (запрашивается гейтом при первом входе в раздел «Напоминалки»).
Дата/время конвертируются в серверное время только один раз — при создании
(вход → сервер) и при показе списка (сервер → тот, кто сейчас смотрит); сам
фоновый планировщик и расчёт периодичности (compute_next_occurrence) работают
как прежде, в серверном времени, без изменений. Пауза — это просто булев флаг
paused в таблице reminders, который планировщик учитывает одним доп.
условием в SQL; возобновление не требует пересчёта дат — этим уже занимается
существующая логика «пропущенных периодов» (Task 5 первого плана).
Tech Stack: Python, SQLite (sqlite3 через DatabaseManager), vk_api,
pytest.
Важно: НЕ пушить в git до полного прогона тестов и ручной проверки в работающем боте (см. Task 7). Локальные коммиты — можно и нужно.
Task 1: reminders.py — конвертация между часовыми поясами
Files:
-
Modify:
reminders.py -
Test:
tests/test_reminders.py -
Step 1: Написать падающие тесты
В tests/test_reminders.py найти строку импорта в самом начале файла:
from datetime import datetime
from reminders import parse_reminder_datetime, compute_next_occurrence
Заменить на:
from datetime import datetime
from reminders import parse_reminder_datetime, compute_next_occurrence, \
server_utc_offset_hours, convert_time
Добавить в конец tests/test_reminders.py:
def test_server_utc_offset_hours_in_sane_range():
"""Собственное смещение сервера от UTC — разумное число часов (реальные пояса Земли)."""
offset = server_utc_offset_hours()
assert -12 <= offset <= 14
def test_convert_time_to_utc():
dt = datetime(2027, 3, 15, 9, 0) # 09:00 по времени автора (UTC+3, Москва)
result = convert_time(dt, from_offset=3, to_offset=0) # переводим в UTC
assert result == datetime(2027, 3, 15, 6, 0)
def test_convert_time_crosses_midnight_backwards():
dt = datetime(2027, 3, 15, 1, 0) # 01:00 по времени автора (UTC+5, Екатеринбург)
result = convert_time(dt, from_offset=5, to_offset=-2)
assert result == datetime(2027, 3, 14, 18, 0) # переходит на предыдущие сутки
def test_convert_time_same_offset_is_noop():
dt = datetime(2027, 1, 1, 12, 0)
assert convert_time(dt, from_offset=3, to_offset=3) == dt
- Step 2: Убедиться, что тесты падают
Run: python -m pytest tests/test_reminders.py -v -k "utc_offset or convert_time"
Expected: FAIL с ImportError: cannot import name 'server_utc_offset_hours'
- Step 3: Реализовать функции
В reminders.py в самый конец файла (после compute_next_occurrence) добавить:
def server_utc_offset_hours() -> float:
"""Собственное смещение сервера от UTC в часах. Часовой пояс сервера нигде
явно не настроен (может быть любым в зависимости от хостинга), поэтому
вычисляется на лету через локальную таймзону ОС."""
offset = datetime.now().astimezone().utcoffset()
return offset.total_seconds() / 3600 if offset else 0.0
def convert_time(dt: datetime, from_offset: float, to_offset: float) -> datetime:
"""Переводит наивный datetime из одного часового пояса (смещение от UTC
в часах) в другой — просто сдвигает время на разницу смещений."""
return dt + timedelta(hours=(to_offset - from_offset))
(timedelta и datetime уже импортированы в начале reminders.py — дополнительных
импортов не требуется.)
- Step 4: Прогнать тесты
Run: python -m pytest tests/test_reminders.py -v
Expected: PASS (11 passed — 7 старых + 4 новых)
- Step 5: Коммит
git add reminders.py tests/test_reminders.py
git commit -m "Напоминалки: конвертация между часовыми поясами (reminders.py)"
Task 2: database.py — часовой пояс админов и пауза напоминаний
Files:
-
Modify:
database.py -
Test:
tests/test_database.py -
Step 1: Написать падающие тесты
Добавить в конец tests/test_database.py:
def test_admin_tz_offset(tmp_db):
assert tmp_db.get_admin_tz_offset(999) is None
tmp_db.set_admin_tz_offset(999, 3)
assert tmp_db.get_admin_tz_offset(999) == 3
tmp_db.set_admin_tz_offset(999, -5) # перезапись существующего значения
assert tmp_db.get_admin_tz_offset(999) == -5
def test_reminder_pause_excludes_from_due_but_stays_visible(tmp_db):
rid = tmp_db.add_reminder("Продлить домен", "2020-01-01 09:00:00", "once", "all", created_by=1)
assert tmp_db.get_due_reminders("2020-01-01 09:00:00") # просрочено — должно найтись
tmp_db.set_reminder_paused(rid, True)
assert tmp_db.get_due_reminders("2020-01-01 09:00:00") == [] # на паузе — не должно
row = next(r for r in tmp_db.get_active_reminders() if r["id"] == rid)
assert row["paused"] == 1 # но в общем списке всё ещё видно
tmp_db.set_reminder_paused(rid, False)
assert tmp_db.get_due_reminders("2020-01-01 09:00:00") # сняли паузу — снова попадает в due
row = next(r for r in tmp_db.get_active_reminders() if r["id"] == rid)
assert row["paused"] == 0
- Step 2: Убедиться, что тесты падают
Run: python -m pytest tests/test_database.py -v -k "tz_offset or pause"
Expected: FAIL с AttributeError: 'DatabaseManager' object has no attribute 'get_admin_tz_offset'
- Step 3: Добавить таблицу
admin_settingsи колонкуpaused
В database.py найти блок создания таблицы reminders в _init_db:
# Table for admin reminders (настраиваемые напоминания: дата, текст, периодичность)
cursor.execute("""
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
remind_at TEXT NOT NULL, -- якорная дата первого срабатывания, не меняется
next_at TEXT NOT NULL, -- следующее срабатывание, двигается вперёд
recurrence TEXT NOT NULL, -- 'once' | 'weekly' | 'monthly' | 'yearly'
recipients TEXT NOT NULL, -- JSON: список vk_id, либо строка "all"
created_by INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
active INTEGER NOT NULL DEFAULT 1
)
""")
conn.commit()
Заменить на (добавлена колонка paused в CREATE TABLE + миграция для уже
существующих БД + новая таблица admin_settings):
# Table for admin reminders (настраиваемые напоминания: дата, текст, периодичность)
cursor.execute("""
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
remind_at TEXT NOT NULL, -- якорная дата первого срабатывания, не меняется
next_at TEXT NOT NULL, -- следующее срабатывание, двигается вперёд
recurrence TEXT NOT NULL, -- 'once' | 'weekly' | 'monthly' | 'yearly'
recipients TEXT NOT NULL, -- JSON: список vk_id, либо строка "all"
created_by INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
active INTEGER NOT NULL DEFAULT 1,
paused INTEGER NOT NULL DEFAULT 0
)
""")
# Миграция: колонка paused для уже существующих БД (созданных до этой фичи)
try:
cursor.execute("ALTER TABLE reminders ADD COLUMN paused INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# Table for per-admin settings (пока только часовой пояс для напоминалок)
cursor.execute("""
CREATE TABLE IF NOT EXISTS admin_settings (
user_id INTEGER PRIMARY KEY,
tz_offset INTEGER NOT NULL, -- смещение от UTC в часах, напр. 3 для Москвы
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
- Step 4: Добавить CRUD-методы часового пояса и паузы
В database.py найти конец блока напоминалок (метод delete_reminder):
def delete_reminder(self, reminder_id: int) -> bool:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
conn.commit()
return cursor.rowcount > 0
Сразу после него добавить:
def set_reminder_paused(self, reminder_id: int, paused: bool):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE reminders SET paused = ? WHERE id = ?",
(1 if paused else 0, reminder_id))
conn.commit()
# --- Часовой пояс админов (для корректного показа/ввода времени напоминалок) ---
def get_admin_tz_offset(self, user_id: int) -> Optional[int]:
"""Смещение от UTC в часах, которое задал себе админ, или None, если ещё не задавал."""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT tz_offset FROM admin_settings WHERE user_id = ?", (user_id,))
row = cursor.fetchone()
return row[0] if row else None
def set_admin_tz_offset(self, user_id: int, tz_offset: int):
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO admin_settings (user_id, tz_offset) VALUES (?, ?) "
"ON CONFLICT(user_id) DO UPDATE SET tz_offset = excluded.tz_offset, "
"updated_at = CURRENT_TIMESTAMP",
(user_id, tz_offset)
)
conn.commit()
- Step 5: Обновить
get_active_reminders/get_due_reminders
В database.py найти:
def get_active_reminders(self) -> list:
"""Активные напоминания для отображения в админке, по next_at."""
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"SELECT id, text, remind_at, next_at, recurrence, recipients, created_by "
"FROM reminders WHERE active = 1 ORDER BY next_at"
)
return self._rows_with_parsed_recipients(cursor)
def get_due_reminders(self, now_str: str) -> list:
"""Активные напоминания, чьё время (next_at) уже наступило."""
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"SELECT id, text, remind_at, next_at, recurrence, recipients, created_by "
"FROM reminders WHERE active = 1 AND next_at <= ?", (now_str,)
)
return self._rows_with_parsed_recipients(cursor)
Заменить на (добавлена колонка paused в SELECT + фильтр paused = 0 только
в get_due_reminders — в списке для админки напоминания на паузе должны быть
видны, а вот планировщику их брать нельзя):
def get_active_reminders(self) -> list:
"""Активные напоминания для отображения в админке, по next_at."""
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"SELECT id, text, remind_at, next_at, recurrence, recipients, created_by, paused "
"FROM reminders WHERE active = 1 ORDER BY next_at"
)
return self._rows_with_parsed_recipients(cursor)
def get_due_reminders(self, now_str: str) -> list:
"""Активные напоминания (не на паузе), чьё время (next_at) уже наступило."""
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"SELECT id, text, remind_at, next_at, recurrence, recipients, created_by, paused "
"FROM reminders WHERE active = 1 AND paused = 0 AND next_at <= ?", (now_str,)
)
return self._rows_with_parsed_recipients(cursor)
- Step 6: Прогнать тесты
Run: python -m pytest tests/test_database.py -v
Expected: PASS (все тесты, включая старые)
- Step 7: Коммит
git add database.py tests/test_database.py
git commit -m "Напоминалки: часовой пояс админов и пауза в database.py"
Task 3: keyboards.py — кнопка паузы
Files:
-
Modify:
keyboards.py -
Step 1: Добавить кнопку в клавиатуру раздела
В keyboards.py найти:
def get_reminders_admin_keyboard() -> str:
"""Клавиатура раздела «Напоминалки». Обычная (не инлайн) клавиатура — важно:
она заменяет собой нижнюю клавиатуру предыдущего шага мастера (например,
«Разово / Каждую неделю / …»), иначе старые кнопки остаются висеть после
завершения мастера."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("➕ Добавить", color=VkKeyboardColor.PRIMARY)
keyboard.add_line()
keyboard.add_button("🗑 Удалить напоминание", color=VkKeyboardColor.NEGATIVE)
keyboard.add_line()
keyboard.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY)
return keyboard.get_keyboard()
Заменить на (добавлена кнопка паузы отдельной строкой перед «В админку»):
def get_reminders_admin_keyboard() -> str:
"""Клавиатура раздела «Напоминалки». Обычная (не инлайн) клавиатура — важно:
она заменяет собой нижнюю клавиатуру предыдущего шага мастера (например,
«Разово / Каждую неделю / …»), иначе старые кнопки остаются висеть после
завершения мастера."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("➕ Добавить", color=VkKeyboardColor.PRIMARY)
keyboard.add_line()
keyboard.add_button("🗑 Удалить напоминание", color=VkKeyboardColor.NEGATIVE)
keyboard.add_line()
keyboard.add_button("⏸/▶ Пауза", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY)
return keyboard.get_keyboard()
- Step 2: Проверить регрессию
Run: python -m pytest tests/ -v
Expected: PASS (keyboards.py не имеет собственных тестов — проверяем, что
ничего не сломалось в остальном наборе)
- Step 3: Коммит
git add keyboards.py
git commit -m "Напоминалки: кнопка паузы в клавиатуре раздела"
Task 4: bot.py — гейт часового пояса и конвертация при создании/показе
Files:
-
Modify:
bot.py -
Step 1: Импорт новых функций из
reminders.py
В bot.py найти:
from reminders import parse_reminder_datetime, compute_next_occurrence
Заменить на:
from reminders import parse_reminder_datetime, compute_next_occurrence, \
server_utc_offset_hours, convert_time
- Step 2: Добавить хелперы
_parse_tz_offsetи_require_reminder_tz
В bot.py найти конец метода _admin_display (сразу после него идёт
_send_reminders_admin):
def _admin_display(self, uid: int) -> str:
"""ФИО из VK + ссылка, если имя удалось получить, иначе только ссылка
(используется в мастере напоминалок, чтобы не кликать по каждому id)."""
name = self._vk_name(uid)
return f"{name} (vk.com/id{uid})" if name else f"vk.com/id{uid}"
def _send_reminders_admin(self, user_id: int):
Заменить на (добавлены два новых метода между ними):
def _admin_display(self, uid: int) -> str:
"""ФИО из VK + ссылка, если имя удалось получить, иначе только ссылка
(используется в мастере напоминалок, чтобы не кликать по каждому id)."""
name = self._vk_name(uid)
return f"{name} (vk.com/id{uid})" if name else f"vk.com/id{uid}"
def _parse_tz_offset(self, raw: str) -> Optional[int]:
"""Парсит смещение от UTC вида '+3', '-5', '0'. None, если не число
или вне диапазона реальных часовых поясов (-12..+14)."""
raw = raw.strip()
if not re.match(r"^[+-]?\d{1,2}$", raw):
return None
val = int(raw)
return val if -12 <= val <= 14 else None
def _require_reminder_tz(self, user_id: int) -> bool:
"""Гейт часового пояса: если у админа он ещё не задан — переводит его
в мастер ввода пояса и возвращает False (вызывающий должен просто
return). Если пояс уже задан — возвращает True, можно продолжать."""
if db.get_admin_tz_offset(user_id) is not None:
return True
db.update_user_state(user_id, "ADMIN_REMINDER_TZ_SETUP")
self.send_message(
user_id,
"🌐 Прежде чем пользоваться напоминалками, укажите свой часовой пояс "
"относительно UTC.\nНапример: +3 (Москва), +5 (Екатеринбург), 0 (UTC).",
keyboard=get_cancel_admin_keyboard()
)
return False
def _handle_set_timezone(self, user_id: int, text: str):
"""Команда 'часовой пояс <±N>' — сменить уже заданный пояс в любой момент."""
raw = text[len("часовой пояс"):].strip()
offset = self._parse_tz_offset(raw)
if offset is None:
self.send_message(user_id, "⚠️ Формат: часовой пояс +3 (число от -12 до +14).")
return
db.set_admin_tz_offset(user_id, offset)
self.send_message(user_id, f"✅ Часовой пояс сохранён: UTC{offset:+d}.")
def _send_reminders_admin(self, user_id: int):
- Step 3: Обновить
_send_reminders_admin— показ времени в поясе зрителя + пометка паузы
В bot.py найти:
def _send_reminders_admin(self, user_id: int):
"""Раздел «Напоминалки»: список активных + инструкции."""
rows = db.get_active_reminders()
listing = ""
for r in rows:
dt = datetime.strptime(r["next_at"], "%Y-%m-%d %H:%M:%S")
who = "всем админам" if r["recipients"] == "all" else \
", ".join(self._admin_display(uid) for uid in r["recipients"])
period = REMINDER_PERIOD_LABELS.get(r["recurrence"], r["recurrence"])
short = (r["text"][:50] + "…") if len(r["text"]) > 50 else r["text"]
listing += (f"🔹 [{r['id']}] {dt.strftime('%d.%m.%Y %H:%M')} ({period}) → {who}\n"
f" «{short}»\n")
if not listing:
listing = "Активных напоминаний нет.\n"
self.send_message(
user_id,
"⏰ Напоминалки\n\n"
f"{listing}\n"
"Управляйте кнопками ниже: ➕ добавить • 🗑 удалить",
keyboard=get_reminders_admin_keyboard()
)
Заменить на:
def _send_reminders_admin(self, user_id: int):
"""Раздел «Напоминалки»: список активных + инструкции.
Вызывается только после прохождения гейта часового пояса
(см. _require_reminder_tz) — тут он гарантированно уже задан."""
rows = db.get_active_reminders()
viewer_offset = db.get_admin_tz_offset(user_id)
if viewer_offset is None:
viewer_offset = 0
server_offset = server_utc_offset_hours()
listing = ""
for r in rows:
server_dt = datetime.strptime(r["next_at"], "%Y-%m-%d %H:%M:%S")
viewer_dt = convert_time(server_dt, server_offset, viewer_offset)
who = "всем админам" if r["recipients"] == "all" else \
", ".join(self._admin_display(uid) for uid in r["recipients"])
period = REMINDER_PERIOD_LABELS.get(r["recurrence"], r["recurrence"])
short = (r["text"][:50] + "…") if len(r["text"]) > 50 else r["text"]
pause_mark = "⏸ (на паузе) " if r["paused"] else ""
listing += (f"🔹 [{r['id']}] {pause_mark}{viewer_dt.strftime('%d.%m.%Y %H:%M')} "
f"(UTC{viewer_offset:+d}, {period}) → {who}\n"
f" «{short}»\n")
if not listing:
listing = "Активных напоминаний нет.\n"
self.send_message(
user_id,
"⏰ Напоминалки\n\n"
f"{listing}\n"
"ℹ️ Чтобы время в списке отображалось верно у всех, каждому нужно один раз "
"зайти сюда и указать свой часовой пояс.\n\n"
"Управляйте кнопками ниже: ➕ добавить • 🗑 удалить • ⏸/▶ пауза",
keyboard=get_reminders_admin_keyboard()
)
- Step 4: Подключить гейт и команду смены пояса в роутере
handle_message
В bot.py найти:
elif text_lower in ["⏰ напоминалки", "напоминалки"]:
self._send_reminders_admin(user_id)
return
elif text_lower == "➕ добавить":
db.update_user_state(user_id, "ADMIN_REMINDER_DATE")
self.send_message(
user_id,
"⏰ Новое напоминание.\nВведите дату и время: ДД.ММ.ГГГГ ЧЧ:ММ\n"
"Например: 15.03.2027 09:00",
keyboard=get_cancel_admin_keyboard()
)
return
elif text_lower.startswith("напоминалка удалить"):
self._handle_reminder_delete(user_id, text_clean)
return
elif text_lower in ["🗑 удалить напоминание", "удалить напоминание"]:
rows = db.get_active_reminders()
if not rows:
self.send_message(user_id, "Активных напоминаний нет.")
self._send_reminders_admin(user_id)
return
items = [(r["id"], r["text"]) for r in rows]
db.update_user_state(user_id, "ADMIN_REMINDER_DEL_PICK")
self.send_message(user_id, "🗑 Какое напоминание удалить? Выберите:",
keyboard=get_pick_keyboard(items))
return
Заменить на (добавлены гейт в трёх местах, новая кнопка паузы и команда смены пояса):
elif text_lower in ["⏰ напоминалки", "напоминалки"]:
if self._require_reminder_tz(user_id):
self._send_reminders_admin(user_id)
return
elif text_lower == "➕ добавить":
if not self._require_reminder_tz(user_id):
return
db.update_user_state(user_id, "ADMIN_REMINDER_DATE")
self.send_message(
user_id,
"⏰ Новое напоминание.\nВведите дату и время: ДД.ММ.ГГГГ ЧЧ:ММ\n"
"Например: 15.03.2027 09:00",
keyboard=get_cancel_admin_keyboard()
)
return
elif text_lower.startswith("напоминалка удалить"):
self._handle_reminder_delete(user_id, text_clean)
return
elif text_lower in ["🗑 удалить напоминание", "удалить напоминание"]:
if not self._require_reminder_tz(user_id):
return
rows = db.get_active_reminders()
if not rows:
self.send_message(user_id, "Активных напоминаний нет.")
self._send_reminders_admin(user_id)
return
items = [(r["id"], r["text"]) for r in rows]
db.update_user_state(user_id, "ADMIN_REMINDER_DEL_PICK")
self.send_message(user_id, "🗑 Какое напоминание удалить? Выберите:",
keyboard=get_pick_keyboard(items))
return
elif text_lower in ["⏸/▶ пауза", "пауза"]:
if not self._require_reminder_tz(user_id):
return
rows = db.get_active_reminders()
if not rows:
self.send_message(user_id, "Активных напоминаний нет.")
self._send_reminders_admin(user_id)
return
items = [
(r["id"], ("▶ Возобновить: " if r["paused"] else "⏸ На паузу: ") + r["text"])
for r in rows
]
db.update_user_state(user_id, "ADMIN_REMINDER_PAUSE_PICK")
self.send_message(user_id, "⏸/▶ Какое напоминание переключить? Выберите:",
keyboard=get_pick_keyboard(items))
return
elif text_lower.startswith("часовой пояс"):
self._handle_set_timezone(user_id, text_clean)
return
- Step 5: Добавить состояние
ADMIN_REMINDER_TZ_SETUPи конвертацию вADMIN_REMINDER_DATE
В bot.py найти:
# ---------- Напоминалки ----------
if state == "ADMIN_REMINDER_DEL_PICK":
rid = pick_id(text)
if not rid.isdigit() or not db.delete_reminder(int(rid)):
self.send_message(user_id, "⚠️ Выберите напоминание кнопкой ниже.")
return
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(user_id, f"✅ Напоминание №{rid} удалено.")
self._send_reminders_admin(user_id)
return
if state == "ADMIN_REMINDER_DATE":
dt = parse_reminder_datetime(text)
if not dt:
self.send_message(user_id, "⚠️ Формат: ДД.ММ.ГГГГ ЧЧ:ММ (например, 15.03.2027 09:00)",
keyboard=get_cancel_admin_keyboard())
return
if dt <= datetime.now():
self.send_message(user_id, "⚠️ Дата и время должны быть в будущем.",
keyboard=get_cancel_admin_keyboard())
return
self._admin_ctx[user_id] = {"remind_at": dt.strftime("%Y-%m-%d %H:%M:%S")}
db.update_user_state(user_id, "ADMIN_REMINDER_TEXT")
self.send_message(user_id, "✏️ Введите текст напоминания:", keyboard=get_cancel_admin_keyboard())
return
Заменить на (добавлено состояние ADMIN_REMINDER_TZ_SETUP и ADMIN_REMINDER_PAUSE_PICK
перед блоком дат, и конвертация в ADMIN_REMINDER_DATE):
# ---------- Напоминалки ----------
if state == "ADMIN_REMINDER_TZ_SETUP":
offset = self._parse_tz_offset(text)
if offset is None:
self.send_message(user_id, "⚠️ Формат: число от -12 до +14, например +3 или -5.",
keyboard=get_cancel_admin_keyboard())
return
db.set_admin_tz_offset(user_id, offset)
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(user_id, f"✅ Часовой пояс сохранён: UTC{offset:+d}.")
self._send_reminders_admin(user_id)
return
if state == "ADMIN_REMINDER_DEL_PICK":
rid = pick_id(text)
if not rid.isdigit() or not db.delete_reminder(int(rid)):
self.send_message(user_id, "⚠️ Выберите напоминание кнопкой ниже.")
return
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(user_id, f"✅ Напоминание №{rid} удалено.")
self._send_reminders_admin(user_id)
return
if state == "ADMIN_REMINDER_PAUSE_PICK":
rid = pick_id(text)
rows_by_id = {r["id"]: r for r in db.get_active_reminders()}
if not rid.isdigit() or int(rid) not in rows_by_id:
self.send_message(user_id, "⚠️ Выберите напоминание кнопкой ниже.")
return
new_paused = not rows_by_id[int(rid)]["paused"]
db.set_reminder_paused(int(rid), new_paused)
db.update_user_state(user_id, "FREE_CHAT")
self.send_message(
user_id,
f"✅ Напоминание №{rid} " +
("поставлено на паузу." if new_paused else "снято с паузы.")
)
self._send_reminders_admin(user_id)
return
if state == "ADMIN_REMINDER_DATE":
dt = parse_reminder_datetime(text)
if not dt:
self.send_message(user_id, "⚠️ Формат: ДД.ММ.ГГГГ ЧЧ:ММ (например, 15.03.2027 09:00)",
keyboard=get_cancel_admin_keyboard())
return
admin_offset = db.get_admin_tz_offset(user_id)
if admin_offset is None:
admin_offset = 0
server_dt = convert_time(dt, admin_offset, server_utc_offset_hours())
if server_dt <= datetime.now():
self.send_message(user_id, "⚠️ Дата и время должны быть в будущем.",
keyboard=get_cancel_admin_keyboard())
return
self._admin_ctx[user_id] = {"remind_at": server_dt.strftime("%Y-%m-%d %H:%M:%S")}
db.update_user_state(user_id, "ADMIN_REMINDER_TEXT")
self.send_message(user_id, "✏️ Введите текст напоминания:", keyboard=get_cancel_admin_keyboard())
return
- Step 6: Прогнать полный набор тестов
Run: python -m pytest tests/ -v
Expected: PASS (сквозные тесты добавим в Task 5; ничего не должно сломаться из
существующего — но обратите внимание: старые тесты мастера создания напоминания
из первого плана, например test_admin_reminder_wizard_creates_reminder, теперь
упрутся в гейт часового пояса и, скорее всего, упадут — это ожидаемо и будет
исправлено в Task 5, где эти тесты обновляются)
- Step 7: Коммит
git add bot.py
git commit -m "Напоминалки: гейт часового пояса, конвертация времени, состояние паузы"
Task 5: Обновить старые тесты и добавить сквозные тесты новых сценариев
Files:
- Modify:
tests/test_flow.py
Task 4 подключил обязательный гейт часового пояса перед разделом «Напоминалки» —
это меняет поведение уже существующих тестов мастера создания напоминания
(test_admin_reminder_wizard_creates_reminder, test_admin_reminder_wizard_specific_recipients,
test_admin_reminder_delete_via_picker_button), которые открывают раздел
без предварительно заданного часового пояса.
- Step 1: Обновить существующие тесты, чтобы часовой пояс был задан заранее
В tests/test_flow.py найти:
def test_admin_reminder_wizard_creates_reminder(app, monkeypatch):
admin = 80001
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
app.handle_message(admin, "Админка ⚙️")
Заменить на (добавлена строка с заданием часового пояса перед началом мастера):
def test_admin_reminder_wizard_creates_reminder(app, monkeypatch):
admin = 80001
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
bot.db.set_admin_tz_offset(admin, 3) # пояс уже задан — гейт не должен мешать
app.handle_message(admin, "Админка ⚙️")
Найти:
def test_admin_reminder_wizard_specific_recipients(app, monkeypatch):
admin, other = 80003, 80004
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin, other])
app.handle_message(admin, "⏰ Напоминалки")
Заменить на:
def test_admin_reminder_wizard_specific_recipients(app, monkeypatch):
admin, other = 80003, 80004
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin, other])
bot.db.set_admin_tz_offset(admin, 0)
app.handle_message(admin, "⏰ Напоминалки")
Найти:
def test_admin_reminder_delete(app, monkeypatch):
admin = 80002
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
rid = bot.db.add_reminder("Тест", "2099-01-01 09:00:00", "once", [admin], created_by=admin)
Заменить на:
def test_admin_reminder_delete(app, monkeypatch):
admin = 80002
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
bot.db.set_admin_tz_offset(admin, 0)
rid = bot.db.add_reminder("Тест", "2099-01-01 09:00:00", "once", [admin], created_by=admin)
Найти:
def test_admin_reminder_delete_via_picker_button(app, monkeypatch):
"""Удаление через кнопку «🗑 Удалить напоминание» + выбор из списка (без ввода id вручную)."""
admin = 80005
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
rid = bot.db.add_reminder("Продлить хостинг", "2099-01-01 09:00:00", "once", [admin], created_by=admin)
Заменить на:
def test_admin_reminder_delete_via_picker_button(app, monkeypatch):
"""Удаление через кнопку «🗑 Удалить напоминание» + выбор из списка (без ввода id вручную)."""
admin = 80005
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
bot.db.set_admin_tz_offset(admin, 0)
rid = bot.db.add_reminder("Продлить хостинг", "2099-01-01 09:00:00", "once", [admin], created_by=admin)
- Step 2: Прогнать обновлённые тесты
Run: python -m pytest tests/test_flow.py -v -k reminder
Expected: PASS (4 старых теста снова зелёные)
- Step 3: Написать тесты новых сценариев
Добавить в конец tests/test_flow.py:
def test_reminder_tz_gate_before_list(app, monkeypatch):
"""Без заданного часового пояса раздел не открывается — сначала гейт."""
admin = 80006
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
app.handle_message(admin, "⏰ Напоминалки")
assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP"
app.handle_message(admin, "не число") # невалидный формат — остаёмся на шаге
assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP"
app.handle_message(admin, "+3")
assert _state(admin) == "FREE_CHAT"
assert bot.db.get_admin_tz_offset(admin) == 3
# повторный вход — пояс уже задан, гейт больше не срабатывает
app.handle_message(admin, "⏰ Напоминалки")
assert _state(admin) == "FREE_CHAT"
def test_reminder_creation_converts_author_local_time_to_server(app, monkeypatch):
admin = 80007
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
bot.db.set_admin_tz_offset(admin, 3) # пояс уже задан — гейт не мешает
app.handle_message(admin, "⏰ Напоминалки")
app.handle_message(admin, "➕ Добавить")
app.handle_message(admin, "01.06.2099 09:00") # 09:00 по времени автора (UTC+3)
app.handle_message(admin, "Тест конвертации")
app.handle_message(admin, "0")
app.handle_message(admin, "Разово")
rows = bot.db.get_active_reminders()
stored = datetime.strptime(rows[0]["remind_at"], "%Y-%m-%d %H:%M:%S")
server_offset = reminders.server_utc_offset_hours()
expected = datetime(2099, 6, 1, 9, 0) + timedelta(hours=(server_offset - 3))
assert stored == expected
def test_reminder_pause_and_resume_via_picker(app, monkeypatch):
admin = 80008
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
bot.db.set_admin_tz_offset(admin, 0)
rid = bot.db.add_reminder("Продлить хостинг", "2020-01-01 09:00:00", "once", [admin], created_by=admin)
app.handle_message(admin, "⏰ Напоминалки")
app.handle_message(admin, "⏸/▶ Пауза")
assert _state(admin) == "ADMIN_REMINDER_PAUSE_PICK"
app.handle_message(admin, f"{rid}. Продлить хостинг")
assert _state(admin) == "FREE_CHAT"
row = next(r for r in bot.db.get_active_reminders() if r["id"] == rid)
assert row["paused"] == 1
# напоминание давно просрочено, но на паузе — планировщик не должен его отправить
app._sent.clear()
app._check_due_reminders()
assert not app._sent
assert bot.db.get_active_reminders() # всё ещё активно (не деактивировалось)
# возобновляем через тот же пикер
app.handle_message(admin, "⏸/▶ Пауза")
app.handle_message(admin, f"{rid}. Продлить хостинг")
row = next(r for r in bot.db.get_active_reminders() if r["id"] == rid)
assert row["paused"] == 0
# теперь просроченное разовое напоминание должно уйти при следующей проверке
app._check_due_reminders()
assert app._sent
assert not bot.db.get_active_reminders() # once — деактивировалось после отправки
def test_timezone_change_command(app, monkeypatch):
admin = 80009
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
bot.db.set_admin_tz_offset(admin, 3)
app.handle_message(admin, "часовой пояс -5")
assert bot.db.get_admin_tz_offset(admin) == -5
app.handle_message(admin, "часовой пояс много")
assert bot.db.get_admin_tz_offset(admin) == -5 # невалидный ввод не поменял значение
test_reminder_creation_converts_author_local_time_to_server использует
datetime, timedelta и модуль reminders — убедиться, что они импортированы
в начале tests/test_flow.py. Найти самую первую строку файла:
import bot
Заменить на:
import bot
import reminders
from datetime import datetime, timedelta
- Step 4: Прогнать новые тесты
Run: python -m pytest tests/test_flow.py -v -k "tz_gate or converts_author or pause_and_resume or timezone_change"
Expected: PASS (4 passed)
- Step 5: Прогнать весь набор тестов проекта
Run: python -m pytest tests/ -v
Expected: PASS (все тесты проекта)
- Step 6: Коммит
git add tests/test_flow.py
git commit -m "Напоминалки: тесты гейта часового пояса, конвертации и паузы"
Task 6: Ручная проверка в работающем боте (перед push)
Files: нет изменений кода — только проверка. Бот в этом проекте уже запущен в реальном режиме VK (см. предыдущую сессию) — потребуется его перезапустить, чтобы подхватить новый код.
- Step 1: Перезапустить бота
Найти PID процесса и перезапустить:
wmic process where "commandline like '%bot.py%' and name='python.exe'" get processid
Затем (подставив найденный PID):
taskkill //F //PID <PID>
И запустить заново из корня проекта:
python bot.py
(или в фоне: nohup python bot.py > bot_manual_run.log 2>&1 &)
- Step 2: Пройти гейт и мастер вручную
В личке сообщества (админ, у которого ещё не задан часовой пояс):
Админка ⚙️
⏰ Напоминалки
Expected: бот просит указать часовой пояс, а не показывает список.
+3
Expected: «✅ Часовой пояс сохранён: UTC+3.», затем сразу показывается список напоминаний (пустой или как есть).
➕ Добавить
15.03.2027 09:00
Проверка часового пояса
0
Разово
Expected: напоминание создано; в списке видно время 15.03.2027 09:00 (то же самое, что ввели — потому что автор и зритель здесь один и тот же человек с одним и тем же поясом UTC+3).
- Step 3: Проверить паузу
⏸/▶ Пауза
Expected: список с пунктом «⏸ На паузу: Проверка часового пояса». Выбрать его.
Expected: «✅ Напоминание №N поставлено на паузу.», в списке у напоминания появляется пометка «⏸ (на паузе)».
Повторить ⏸/▶ Пауза и выбрать то же напоминание — должно вернуться в
активное состояние («✅ Напоминание №N снято с паузы.», пометка исчезает).
- Step 4: Убедиться, что ничего не сломалось
Run: python -m pytest tests/ -v
Expected: PASS — полный прогон перед завершением работы.
- Step 5: Сообщить пользователю результат
Только после успешного прохождения Step 1–4 — сообщить, что фича готова к
проверке пользователем. Не выполнять git push — пуш делает пользователь
сам после собственного тестирования.