From 123b5abdfc90bb9684834d373cda67fcfad68e5b Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Sun, 27 Sep 2026 21:23:34 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=B1=D0=B0=D0=B7=D1=8B=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=20=D0=BF=D0=BE=20=D0=B5=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=B8=D1=82=D0=BE=D0=B3=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью кодовой базы: docs/reviews/2026-09-27-codebase-review.md. Корректность и согласованность, пункты 5–7 ревью (docs/changes/018): - одиночное удаление бэкапа в UI идёт через общий delete_many: пометка deleted_at и событие backup.deleted, как у группового удаления и API; - единая система миграций: ручные ALTER из db._migrate перенесены в migrations.run (при user_version < 1, до замены ID); - групповая смена канала выполняется фоновыми задачами set_channel; PUT /api/v1/batch/channel → 202 {"job_ids": [...]} (ломающее изменение API), меню «Канал» в UI выводит задачи в панель «Задачи». Тесты: 22 из 22. Стенд проверен на порту 8001 (8000 занят посторонним процессом), боевые данные не изменены. Ручная проверка UI пользователем на момент коммита не подтверждена. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- app/api/v1.py | 10 +-- app/db.py | 13 --- app/migrations.py | 18 ++++- app/services/jobs.py | 15 ++-- app/services/ops.py | 6 ++ app/ui/routes.py | 16 ++-- app/ui/templates/_jobs.html | 2 +- app/ui/templates/dashboard.html | 2 +- .../018-correctness-consistency/plan.md | 80 +++++++++++++++++++ .../018-correctness-consistency/summary.md | 29 +++++++ docs/reviews/2026-09-27-codebase-review.md | 78 ++++++++++++++++++ tests/test_app.py | 67 ++++++++++++++++ 13 files changed, 303 insertions(+), 41 deletions(-) create mode 100644 docs/changes/018-correctness-consistency/plan.md create mode 100644 docs/changes/018-correctness-consistency/summary.md create mode 100644 docs/reviews/2026-09-27-codebase-review.md diff --git a/README.md b/README.md index 93b0c62..8f93c3d 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ docker compose up -d --build # UI: http://localhost:8000, OpenAPI: /docs python3 -m venv venv && ./venv/bin/pip install -r requirements.txt set -a; . ./.env; set +a ./venv/bin/uvicorn app.main:app --reload -./venv/bin/python -m pytest # 20 тестов, фоновый опрос в тестах выключен +./venv/bin/python -m pytest # 22 теста, фоновый опрос в тестах выключен ``` ## API v1 @@ -113,8 +113,8 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic | POST | `/api/v1/devices/{id}/firmware/upgrade` | обновление FW (задача) | | GET/POST | `/api/v1/groups` | группы (с числом устройств) / создать | | PATCH/DELETE | `/api/v1/groups/{id}` | переименовать / удалить (устройства остаются без группы) | -| POST | `/api/v1/batch/{backup\|ros_update\|fw_update}` | `{"device_ids": [...]}` и/или `{"group_id": N}` — групповая операция | -| PUT | `/api/v1/batch/channel` | `{"device_ids": [...] или "group_id": N, "channel": "..."}` | +| POST | `/api/v1/batch/{backup\|ros_update\|fw_update}` | `{"device_ids": [...]}` и/или `{"group_id": N}` — групповая операция (задача) | +| PUT | `/api/v1/batch/channel` | `{"device_ids": [...] или "group_id": N, "channel": "..."}` — групповая смена канала (задача `set_channel`) → 202 `{"job_ids": [...]}` | | GET/DELETE | `/api/v1/backups`, `/backups/download?key=` | бэкапы в бакете (фильтры `device_id`, `group`, `kind`, `date_from`, `date_to`, `q`) | | POST | `/api/v1/backups/delete` | групповое удаление файлов: `{"keys": [...]}` → `{"deleted": N, "failed": M}` | | GET | `/api/v1/jobs`, `/jobs/{id}` | состояние задач | @@ -131,6 +131,7 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic - `app/services` — устройства и фильтры, группы, бэкапы, операции, задачи, фоновый опрос (`poller.py`) - `app/api` — JSON API; `app/ui` — WEB UI (Jinja2 + HTMX, `static/` со стилями, скриптом и шрифтами) - `tests` — минимальный набор; `docs/changes` — планы и итоги каждого изменения +- `docs/reviews` — ревью кодовой базы (замечания и приоритеты исправлений) ## История изменений @@ -152,3 +153,4 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic - `015-immutable-device-name` — имя устройства задаётся только при создании. - `016-unique-ids-and-event-log` — глобально уникальные ID (префикс + UUIDv7) для всех сущностей, журнал событий в БД, миграция числовых ID. - `017-events-ui-rotation` — страница «Журнал» в UI, настройки ротации, очистка журнала с подтверждением паролем. +- `018-correctness-consistency` — одиночное удаление бэкапа в UI через общий сервис `delete_many`, единая система миграций (колонки старой схемы — в `migrations.run` до миграции ID), групповая смена канала фоновыми задачами (`set_channel`). diff --git a/app/api/v1.py b/app/api/v1.py index 4af97c2..078a7c0 100644 --- a/app/api/v1.py +++ b/app/api/v1.py @@ -208,14 +208,10 @@ async def batch(action: Literal["backup", "ros_update", "fw_update"], body: Batc return {"job_ids": jobs.start_jobs(action, body.resolve())} -@router.put("/batch/channel", response_model=list[DeviceOut]) +@router.put("/batch/channel", status_code=202) async def batch_channel(body: BatchChannelIn): - targets = body.resolve() - for i in targets: - devices.get_device(i) - for i in targets: - await ops.set_channel(i, body.channel) - return [DeviceOut.of(devices.get_device(i)) for i in targets] + """Групповая смена канала — фоновыми задачами (как /batch/{backup|ros_update|fw_update}).""" + return {"job_ids": jobs.start_jobs("set_channel", body.resolve(), {"channel": body.channel})} # --- резервные копии в S3 --- diff --git a/app/db.py b/app/db.py index 9fe6f4d..d34873d 100644 --- a/app/db.py +++ b/app/db.py @@ -26,7 +26,6 @@ def init_db(url: str | None = None) -> None: from app import models # noqa: F401 (регистрация моделей) Base.metadata.create_all(_engine) # у новой БД — все таблицы, у старой — только недостающие (events) - _migrate() from app import migrations @@ -34,18 +33,6 @@ def init_db(url: str | None = None) -> None: migrations.run(_engine, db_file) -def _migrate() -> None: - """create_all не добавляет колонки в существующие таблицы — докидываем вручную.""" - with _engine.begin() as conn: - cols = {r[1] for r in conn.exec_driver_sql("PRAGMA table_info(devices)")} - if "use_tls" not in cols: - conn.exec_driver_sql("ALTER TABLE devices ADD COLUMN use_tls BOOLEAN NOT NULL DEFAULT 1") - if "group_id" not in cols: - conn.exec_driver_sql("ALTER TABLE devices ADD COLUMN group_id VARCHAR(40)") - if "note" not in cols: - conn.exec_driver_sql("ALTER TABLE devices ADD COLUMN note TEXT") - - @contextmanager def session_scope(): """Короткая транзакция: commit при успехе, rollback при ошибке.""" diff --git a/app/migrations.py b/app/migrations.py index c9be774..34d872a 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -41,6 +41,18 @@ def _is_legacy(con: sqlite3.Connection) -> bool: return bool(cols) and cols.get("id", "").startswith("INT") +def _add_legacy_columns(con: sqlite3.Connection) -> None: + """Колонки devices, которых не было в самой старой схеме (create_all их не добавляет в существующую + таблицу) — докидываем идемпотентно, до чтения старой таблицы в _to_v1.""" + cols = {r[1] for r in con.execute("PRAGMA table_info(devices)")} + if "use_tls" not in cols: + con.execute("ALTER TABLE devices ADD COLUMN use_tls BOOLEAN NOT NULL DEFAULT 1") + if "group_id" not in cols: + con.execute("ALTER TABLE devices ADD COLUMN group_id VARCHAR(40)") + if "note" not in cols: + con.execute("ALTER TABLE devices ADD COLUMN note TEXT") + + def run(engine: Engine, db_path: Path | None) -> None: """Приводит БД к текущей версии схемы. Идемпотентна.""" if db_path is None: # не файловая БД (:memory:) — старых данных быть не может @@ -53,8 +65,10 @@ def run(engine: Engine, db_path: Path | None) -> None: version = con.execute("PRAGMA user_version").fetchone()[0] if version >= SCHEMA_VERSION: return - if version < 1 and _is_legacy(con): - _to_v1(con, db_path) + if version < 1: + _add_legacy_columns(con) # _to_v1 читает use_tls/group_id/note из старой таблицы + if _is_legacy(con): + _to_v1(con, db_path) con.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") finally: con.close() diff --git a/app/services/jobs.py b/app/services/jobs.py index 2bf0fc6..1f65c9f 100644 --- a/app/services/jobs.py +++ b/app/services/jobs.py @@ -11,6 +11,7 @@ JOB_TYPES = { "backup": ops.run_backup, "ros_update": ops.run_ros_update, "fw_update": ops.run_fw_update, + "set_channel": ops.run_set_channel, } _tasks: set[asyncio.Task] = set() @@ -35,20 +36,22 @@ def _finish(job_id: str, status: str, message: str) -> None: job_id=job_id, data={"type": j.type, "status": status}, s=s) -async def _run(job_id: str, job_type: str, device_id: str) -> None: +async def _run(job_id: str, job_type: str, device_id: str, params: dict) -> None: events.set_job(job_id) # события и бэкап, созданные внутри задачи, ссылаются на её ID async with _semaphore(): _finish(job_id, "running", "") try: - _finish(job_id, "done", await JOB_TYPES[job_type](device_id)) + _finish(job_id, "done", await JOB_TYPES[job_type](device_id, **params)) except Exception as e: # noqa: BLE001 — любой сбой фиксируем в задаче _finish(job_id, "failed", str(e)) -def start_jobs(job_type: str, device_ids: list[str]) -> list[str]: - """Создаёт задачи для списка устройств и запускает их в фоне. Возвращает ID задач.""" +def start_jobs(job_type: str, device_ids: list[str], params: dict | None = None) -> list[str]: + """Создаёт задачи для списка устройств и запускает их в фоне. params передаются раннеру именованными + аргументами (device_id, **params) и попадают в data события job.created. Возвращает ID задач.""" if job_type not in JOB_TYPES: raise ValueError(f"Неизвестный тип задачи: {job_type}") + params = params or {} for did in device_ids: ids.check(did, "dev") started = [] @@ -61,10 +64,10 @@ def start_jobs(job_type: str, device_ids: list[str]) -> list[str]: s.add(j) s.flush() events.record("job.created", "job", j.id, f"Задача {job_type} для {d.name} создана", device_id=did, - job_id=j.id, data={"type": job_type}, s=s) + job_id=j.id, data={"type": job_type, **params}, s=s) started.append((j.id, did)) for jid, did in started: - task = asyncio.create_task(_run(jid, job_type, did)) # наследует контекст (актор) + task = asyncio.create_task(_run(jid, job_type, did, params)) # наследует контекст (актор) _tasks.add(task) task.add_done_callback(_tasks.discard) return [jid for jid, _ in started] diff --git a/app/services/ops.py b/app/services/ops.py index f174742..dc4f902 100644 --- a/app/services/ops.py +++ b/app/services/ops.py @@ -127,6 +127,12 @@ async def set_channel(device_id: str, channel: str) -> None: await refresh_status(device_id) +async def run_set_channel(device_id: str, channel: str) -> str: + """Раннер задачи set_channel: та же смена канала, что и для одного устройства.""" + await set_channel(device_id, channel) + return f"Канал {channel} установлен" + + async def run_ros_update(device_id: str) -> str: async with devices.open_client(devices.get_conn(device_id)) as c: return await ros.install_ros_update(c) diff --git a/app/ui/routes.py b/app/ui/routes.py index dd490a3..de4ff7f 100644 --- a/app/ui/routes.py +++ b/app/ui/routes.py @@ -191,10 +191,11 @@ async def batch(request: Request, action: str, device_ids: list[str] = Form(defa if not device_ids: return _render(request, "_jobs.html", jobs=jobs.list_jobs(15), flash="Выберите устройства") if action == "channel": - for i in device_ids: - await ops.set_channel(i, channel) - return _render(request, "_devices.html", oob=True, **_devices_ctx(_flt(await request.form()))) - jobs.start_jobs(action, device_ids) + if channel not in CHANNELS: + raise ValueError(f"Неизвестный канал: {channel}") + jobs.start_jobs("set_channel", device_ids, {"channel": channel}) + else: + jobs.start_jobs(action, device_ids) return _render(request, "_jobs.html", jobs=jobs.list_jobs(15)) @@ -411,10 +412,9 @@ async def backup_download(key: str): @router.post("/backups/delete", dependencies=[Depends(require_login)]) async def backup_delete(key: str = Form()): - if not s3.key_allowed(key): - raise ValueError("Недопустимый ключ") - await s3.delete_object(key) - return RedirectResponse("/backups", status_code=303) + """Одиночное удаление — тот же сервис, что и групповое: метаданные и событие backup.deleted не теряются.""" + deleted, failed = await backups.delete_many([key]) + return RedirectResponse(f"/backups?deleted={deleted}&failed={failed}", status_code=303) # --- журнал событий --- diff --git a/app/ui/templates/_jobs.html b/app/ui/templates/_jobs.html index 344eb71..67c176b 100644 --- a/app/ui/templates/_jobs.html +++ b/app/ui/templates/_jobs.html @@ -18,7 +18,7 @@ {{ j.id|short_id }} {{ j.device_name }} - {{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW"}.get(j.type, j.type) }} + {{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW", "set_channel": "Смена канала"}.get(j.type, j.type) }} {{ {"pending": "В очереди", "running": "Выполняется", "done": "Готово", "failed": "Ошибка"}.get(j.status, j.status) }} {{ j.message }} {{ j.created_at|dt }} diff --git a/app/ui/templates/dashboard.html b/app/ui/templates/dashboard.html index d4a6e28..8a1c043 100644 --- a/app/ui/templates/dashboard.html +++ b/app/ui/templates/dashboard.html @@ -63,7 +63,7 @@