Групповое удаление бэкапов, поддержка CHR, строгое сравнение версий ROS

Групповое удаление бэкапов (docs/changes/013):
- страница «Резервные копии»: чекбоксы, «выбрать все», панель «Выбрано: N /
  Удалить / Снять выбор» вместо фильтров, подтверждение, итог удаления;
- backups.delete_many: все ключи проверяются до удаления, удаление параллельное;
  UI POST /backups/delete-many, API POST /api/v1/backups/delete;
- выбор строк в app.js обобщён (data-select) для устройств и бэкапов.

Поддержка CHR (docs/changes/014):
- у CHR нет /system/routerboard (HTTP 400): устройство больше не считается
  недоступным, версия FW не показывается, обновление FW пропускается;
- «есть обновление ROS» определяется строгим сравнением версий (на канале
  long-term последняя версия может быть старше установленной);
- в таблице указана причина недоступности: авторизация / ошибка ответа /
  нет соединения.

Тесты: 11 из 11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-19 14:11:32 +03:00
1 parent 4c1841b61b
commit 0914209bf8
16 files changed
+194 -20

No files matched your search

+45
View File
@@ -229,3 +229,48 @@ async def test_poll_marks_offline_and_recovers(monkeypatch):
await ops.poll_device(d.id) # онлайн -> лёгкий опрос обновляет uptime, остальное сохраняется
dev = devices.get_device(d.id)
assert dev.status["uptime"] == "2m" and dev.status["ros_latest"] == "7.24.4"
def test_bulk_delete_backups(monkeypatch):
"""Групповое удаление: чужой ключ отклоняет всю операцию; допустимые — удаляются (UI и API)."""
removed = []
async def fake_delete(key):
removed.append(key)
monkeypatch.setattr(s3, "delete_object", fake_delete)
ok = ["backups/a/1.backup", "backups/a/1.rsc"]
with TestClient(create_app()) as c:
c.post("/login", data={"username": "admin", "password": "pw"})
# UI: ключ вне префикса бэкапов -> ошибка, ничего не удалено
r = c.post("/backups/delete-many", data={"key": ok + ["other/secret.txt"]}, follow_redirects=False)
assert r.status_code == 400 and removed == []
# UI: допустимые ключи -> возврат на страницу с итогом
r = c.post("/backups/delete-many", data={"key": ok, "next": "/backups?group=1"}, follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/backups?group=1&deleted=2&failed=0"
assert sorted(removed) == ok
# API
removed.clear()
h = {"Authorization": "Bearer test-token"}
assert c.post("/api/v1/backups/delete", headers=h, json={"keys": ok}).json() == {"deleted": 2, "failed": 0}
assert c.post("/api/v1/backups/delete", headers=h, json={"keys": ["x/../y"]}).status_code == 400
@pytest.mark.asyncio
async def test_chr_status_and_version_compare():
"""CHR: раздела system/routerboard нет (HTTP 400) — это не сбой; «последняя» версия старше установленной — не обновление."""
def handler(r: httpx.Request) -> httpx.Response:
if r.url.path == "/rest/system/routerboard":
return httpx.Response(400, json={"detail": "no such command or directory (routerboard)", "error": 400})
data = {
"/rest/system/resource": {"board-name": "CHR Yandex epyc-9654", "version": "7.24 (stable)", "uptime": "1d"},
"/rest/system/package/update": {"channel": "long-term", "installed-version": "7.24", "latest-version": "7.23.7"},
}
return httpx.Response(200, json=data.get(r.url.path, []))
async with ros_client(handler) as c:
st = await ros.get_status(c)
assert await ros.upgrade_firmware(c) == "Устройство без RouterBOARD firmware (например, CHR) — пропущено"
assert st["model"].startswith("CHR") and st["fw_current"] is None
assert not ros.version_newer(st["ros_latest"], st["ros_installed"]) # 7.23.7 старше 7.24
assert ros.version_newer("7.24.1", "7.24") and ros.version_newer("7.25", "7.25rc1") and not ros.version_newer("7.25rc1", "7.25")