5efc0aca8d
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1043 lines
48 KiB
Markdown
1043 lines
48 KiB
Markdown
# Напоминалки: пауза и часовой пояс — план реализации
|
||
|
||
> **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`** — пуш делает пользователь
|
||
сам после собственного тестирования.
|