Files
ros_control/app/services/backups.py
T
ayurishchevandClaude Sonnet 5 0914209bf8 Групповое удаление бэкапов, поддержка 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>
2026-09-19 14:11:32 +03:00

78 lines
3.7 KiB
Python

"""Список бэкапов из бакета с фильтрами (устройство, группа, тип, даты, текст)."""
import asyncio
from datetime import date
from app import s3
from app.services import devices, groups
def _kind(key: str) -> str:
return "backup" if key.endswith(".backup") else "rsc" if key.endswith(".rsc") else "other"
async def search(device: str = "", group: str = "", kind: str = "", date_from: date | None = None,
date_to: date | None = None, q: str = "") -> tuple[list[dict], dict]:
"""Отфильтрованные бэкапы и итоги по всему бакету ({"total": N, "size": байты}).
Ключи вида backups/<устройство>/<файл>: устройство берётся из ключа, группа — по имени устройства.
group: "" — все, "none" — без группы (в т.ч. бэкапы удалённых устройств), иначе id группы.
Даты — по времени изменения объекта (UTC), включительно."""
group_names = {g["id"]: g["name"] for g in groups.list_groups()}
device_group = {d.name: group_names.get(d.group_id) for d in devices.list_devices()}
want_group = group_names.get(int(group)) if group.isdigit() else None
everything = await s3.list_backups(None)
stats = {"total": len(everything), "size": sum(i["size"] for i in everything)}
q = q.strip().lower()
out = []
for item in everything:
parts = item["key"].split("/")
name = parts[1] if len(parts) >= 3 else ""
item = {**item, "device": name, "group": device_group.get(name), "kind": _kind(item["key"])}
if device and name != device:
continue
if group == "none" and item["group"] is not None:
continue
if group.isdigit() and (want_group is None or item["group"] != want_group):
continue
if kind and item["kind"] != kind:
continue
day = item["last_modified"].date()
if (date_from and day < date_from) or (date_to and day > date_to):
continue
if q and q not in item["key"].lower():
continue
out.append(item)
return out, stats
async def list_backups(device: str = "", group: str = "", kind: str = "", date_from: date | None = None,
date_to: date | None = None, q: str = "") -> list[dict]:
return (await search(device, group, kind, date_from, date_to, q))[0]
MAX_DELETE = 500
async def delete_many(keys: list[str]) -> tuple[int, int]:
"""Групповое удаление файлов из бакета. Все ключи проверяются до начала удаления
(чужой ключ — ValueError, ничего не удаляется). Возвращает (удалено, не удалось)."""
keys = list(dict.fromkeys(keys))
if not keys:
raise ValueError("Не выбрано ни одного файла")
if len(keys) > MAX_DELETE:
raise ValueError(f"За один раз можно удалить не более {MAX_DELETE} файлов")
if not all(s3.key_allowed(k) for k in keys):
raise ValueError("Недопустимый ключ")
sem = asyncio.Semaphore(8)
async def one(key: str) -> bool:
async with sem:
try:
await s3.delete_object(key)
return True
except Exception: # noqa: BLE001 — считаем неудачей одного файла, остальные удаляем
return False
results = await asyncio.gather(*(one(k) for k in keys))
return sum(results), len(results) - sum(results)