# План: 018 — корректность и согласованность (по ревью 2026-09-27) ## Context Ревью `docs/reviews/2026-09-27-codebase-review.md`, раздел «Корректность и согласованность», пункты 5–7: - **п. 5** — одиночное удаление бэкапа в UI (`POST /backups/delete`, `app/ui/routes.py::backup_delete`) вызывает `s3.delete_object` напрямую. Метаданные (`backups.deleted_at`) не обновляются, событие `backup.deleted` не пишется — в отличие от группового удаления и API, которые идут через `backups.delete_many`. - **п. 6** — две системы миграций: `app/db.py::_migrate` (ручные `ALTER TABLE devices ADD COLUMN …` без версии) и `app/migrations.py` (`PRAGMA user_version`). Нужна одна точка с версией схемы. - **п. 7** — групповая смена канала (`PUT /api/v1/batch/channel`, `POST /ui/batch/channel`) выполняется последовательно внутри HTTP-запроса: на большом парке упирается в таймаут, сбой одного устройства прерывает остальные. Решения пользователя: - п. 7 — **фоновые задачи**: новый тип задачи `set_channel`; `PUT /api/v1/batch/channel` меняет контракт на `202 {"job_ids": [...]}` (как `/batch/backup`). - Стенд — существующий контейнер `ros_control-ros_control-1` (compose-проект в корне репозитория). **В томе боевые данные: удалять существующие устройства, группы, бэкапы, задачи и записи журнала нельзя.** Для проверок можно создавать новые объекты; созданное проверкой убирается только штатными средствами, существующее не трогается. ## Изменения ### п. 5 — одиночное удаление через общий сервис (`app/ui/routes.py`) - `backup_delete`: вместо `s3.key_allowed` + `s3.delete_object` вызвать `backups.delete_many([key])` (проверка ключа, удаление, `sync_rows` с событием уже внутри). Недопустимый ключ — прежний `ValueError` → 400. - Ответ — редирект на `/backups?deleted=N&failed=M`, как у `backups_delete_many` (страница уже показывает итог по этим параметрам). - API `DELETE /api/v1/backups` уже использует `delete_many` — не меняется. ### п. 6 — единая система миграций (`app/db.py`, `app/migrations.py`) - Удалить `db._migrate` и его вызов из `init_db`. - В `migrations.py` добавить `_add_legacy_columns(con)`: те же три `ALTER` (`use_tls BOOLEAN NOT NULL DEFAULT 1`, `group_id VARCHAR(40)`, `note TEXT`), идемпотентно по `PRAGMA table_info(devices)`. - Вызов в `migrations.run` **при `version < 1` до `_is_legacy`/`_to_v1`** — `_to_v1` читает `use_tls`, `group_id`, `note` из старой таблицы, поэтому колонки должны появиться раньше. `SCHEMA_VERSION` остаётся 2 (схема не меняется). - Ветка `:memory:` (тесты) не меняется: `create_all` создаёт полную схему. - Боевая БД уже на `user_version = 2` — для неё изменение не выполняет никаких действий. ### п. 7 — групповая смена канала через задачи (`app/services/jobs.py`, `app/services/ops.py`, `app/api/v1.py`, `app/ui/routes.py`, `app/ui/templates/dashboard.html`) - `ops.run_set_channel(device_id, channel) -> str`: **переиспользовать** `ops.set_channel` (установка канала + `refresh_status`); возвращает сообщение «Канал установлен». - `jobs.JOB_TYPES["set_channel"] = ops.run_set_channel`. `jobs.start_jobs(job_type, device_ids, params: dict | None = None)`: `params` передаются раннеру как именованные аргументы (`await JOB_TYPES[t](device_id, **params)`) и пишутся в `data` события `job.created`. Колонку в `jobs` не добавляем: незавершённые задачи после перезапуска всё равно помечаются проваленными (`fail_stale_jobs`), повторно параметры не нужны. - Проверка канала — до создания задач: `channel not in CHANNELS` → `ValueError` (400); в API и так `Literal[CHANNELS]`. - API: `PUT /api/v1/batch/channel` → `status_code=202`, ответ `{"job_ids": jobs.start_jobs("set_channel", body.resolve(), {"channel": body.channel})}`. `PUT /api/v1/devices/{id}/update/channel` (одно устройство) — **без изменений**, синхронный. - UI: `POST /ui/batch/channel` — создаёт задачи и возвращает `_jobs.html`, как остальные групповые действия; в `dashboard.html` у кнопок меню «Канал» `hx-target="#jobs"`, `hx-include="[name=device_ids]:checked"` (как у «Обновление»). Смена канала у одного устройства из меню строки (`/ui/devices/{id}/channel`) — без изменений. - Таблица устройств обновится автоопросом (`/ui/devices?poll=1`) после завершения задач — `refresh_status` уже записывает статус. - Отображение типа задачи `set_channel` в панели «Задачи» и журнале — проверить подписи типов (если есть словарь подписей — добавить «Смена канала»). ## Тесты (минимально) - В существующий тест группы/устройств или новый короткий: `PUT /api/v1/batch/channel` → 202 и `job_ids` по числу устройств; раннер подменён (`monkeypatch` на `ops.set_channel`), задача завершается `done`, в `job.created` есть `{"channel": …}`. - `test_migration_replaces_numeric_ids`: убедиться, что тест по-прежнему покрывает старую схему без колонок `use_tls/group_id/note` (если его фикстура создаёт их сама — добавить вариант без них). - `test_bulk_delete_backups`: одна проверка `POST /backups/delete` → редирект с `deleted=1`, вызов идёт через `delete_many`. ## Документация - README: таблица API (`PUT /api/v1/batch/channel` → 202 `{"job_ids"}`), типы задач, строка 018 в «История изменений»; число тестов в разделе «Разработка». - `summary.md` — оркестратор после проверки. ## Исполнение - Код, тесты, пересборка стенда — исполнитель (Sonnet). Тесты не запускает, не коммитит, `summary.md` не создаёт. - Оркестратор: ревью диффа, полный прогон тестов, проверки на стенде на **новых** тестовых объектах без удаления существующих данных. - Пользователь: ручная проверка UI. ## Проверка - `./venv/bin/python -m pytest -q` — все зелёные. - Стенд: `docker compose up -d --build --force-recreate`, контейнер `ros_control-ros_control-1` запущен, `docker exec … grep -c set_channel /srv/app/services/jobs.py` ≥ 1, `PRAGMA user_version` = 2, число строк в таблицах до и после совпадает. - Сценарии на стенде (новое тестовое устройство с недоступным адресом, например `192.0.2.1` из TEST-NET): `PUT /api/v1/batch/channel` → 202, задача `set_channel` завершается `failed` с ошибкой соединения, остальные задачи не затронуты; неверный канал → 422; тестовое устройство затем удаляется через API (только оно). - Миграция старой схемы — тестом на временной БД (боевую не трогаем). - Ручная проверка UI — пользователь: меню «Канал» при выбранных устройствах создаёт задачи в панели «Задачи»; удаление одного файла на «Бэкапах» показывает итог.