Files
ros_control/app/api/v1.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

270 lines
8.2 KiB
Python

from datetime import date, datetime
from typing import Literal
from fastapi import APIRouter, Depends
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field
from app import s3
from app.ros.operations import CHANNELS
from app.security import require_api_token
from app.services import backups, devices, groups, jobs, ops
router = APIRouter(prefix="/api/v1", dependencies=[Depends(require_api_token)], tags=["api"])
class DeviceIn(BaseModel):
name: str
host: str
port: int = 443
username: str
password: str
verify_tls: bool = False
use_tls: bool = True # False = HTTP (сервис www, порт 80): пароль идёт открытым текстом
group_id: int | None = None
note: str | None = None
class DevicePatch(BaseModel):
name: str | None = None
host: str | None = None
port: int | None = None
username: str | None = None
password: str | None = None
verify_tls: bool | None = None
use_tls: bool | None = None
group_id: int | None = None # явный null — открепить от группы
note: str | None = None # пустая строка или null — очистить примечание
class DeviceOut(BaseModel):
"""Пароль устройства в ответы не попадает."""
id: int
name: str
host: str
port: int
username: str
verify_tls: bool
use_tls: bool
group_id: int | None
note: str | None
online: bool | None
status: dict
status_at: datetime | None
last_error: str | None
last_backup_requested_at: datetime | None
@classmethod
def of(cls, d) -> "DeviceOut":
return cls(**{k: getattr(d, k) for k in cls.model_fields})
class JobOut(BaseModel):
id: int
device_id: int | None
device_name: str
type: str
status: str
message: str
created_at: datetime
finished_at: datetime | None
model_config = {"from_attributes": True}
class ChannelIn(BaseModel):
channel: Literal[CHANNELS] # type: ignore[valid-type]
class GroupIn(BaseModel):
name: str
class GroupOut(BaseModel):
id: int
name: str
device_count: int = 0
class BatchIn(BaseModel):
"""Устройства, над которыми выполняется операция: список id и/или целая группа."""
device_ids: list[int] = Field(default_factory=list)
group_id: int | None = None
def resolve(self) -> list[int]:
ids = list(self.device_ids)
if self.group_id is not None:
groups.get_group(self.group_id) # 404, если группы нет
ids += [d.id for d in devices.list_devices() if d.group_id == self.group_id]
ids = list(dict.fromkeys(ids))
if not ids:
raise ValueError("Не найдено ни одного устройства: укажите device_ids или непустую group_id")
return ids
class BatchChannelIn(BatchIn):
channel: Literal[CHANNELS] # type: ignore[valid-type]
# --- устройства ---
@router.get("/devices", response_model=list[DeviceOut])
async def list_devices(
group: str = "", # "" — все, "none" — без группы, иначе id группы
q: str = "",
status: Literal["", "online", "offline"] = "",
updates: Literal["", "ros", "fw", "any", "none"] = "",
channel: str = "",
):
found = devices.filter_devices(devices.list_devices(), group, q, status, updates, channel)
return [DeviceOut.of(d) for d in found]
# --- группы ---
@router.get("/groups", response_model=list[GroupOut])
async def list_groups():
return groups.list_groups()
@router.post("/groups", response_model=GroupOut, status_code=201)
async def create_group(body: GroupIn):
g = groups.create_group(body.name)
return GroupOut(id=g.id, name=g.name)
@router.patch("/groups/{group_id}", response_model=GroupOut)
async def rename_group(group_id: int, body: GroupIn):
groups.rename_group(group_id, body.name)
return next(g for g in groups.list_groups() if g["id"] == group_id)
@router.delete("/groups/{group_id}", status_code=204)
async def delete_group(group_id: int):
groups.delete_group(group_id)
@router.post("/devices", response_model=DeviceOut, status_code=201)
async def create_device(body: DeviceIn):
return DeviceOut.of(devices.create_device(**body.model_dump()))
@router.get("/devices/{device_id}", response_model=DeviceOut)
async def get_device(device_id: int):
return DeviceOut.of(devices.get_device(device_id))
@router.patch("/devices/{device_id}", response_model=DeviceOut)
async def patch_device(device_id: int, body: DevicePatch):
return DeviceOut.of(devices.update_device(device_id, **body.model_dump(exclude_unset=True)))
@router.delete("/devices/{device_id}", status_code=204)
async def delete_device(device_id: int):
devices.delete_device(device_id)
@router.post("/devices/{device_id}/refresh", response_model=DeviceOut)
async def refresh_device(device_id: int):
devices.get_device(device_id)
await ops.refresh_status(device_id)
return DeviceOut.of(devices.get_device(device_id))
@router.post("/devices/refresh", response_model=list[DeviceOut])
async def refresh_all():
await ops.refresh_many()
return [DeviceOut.of(d) for d in devices.list_devices()]
# --- операции (долгие — через jobs) ---
@router.post("/devices/{device_id}/backups", status_code=202)
async def create_backup(device_id: int):
return {"job_ids": jobs.start_jobs("backup", [device_id])}
@router.put("/devices/{device_id}/update/channel", response_model=DeviceOut)
async def put_channel(device_id: int, body: ChannelIn):
devices.get_device(device_id)
await ops.set_channel(device_id, body.channel)
return DeviceOut.of(devices.get_device(device_id))
@router.post("/devices/{device_id}/update/install", status_code=202)
async def install_update(device_id: int):
return {"job_ids": jobs.start_jobs("ros_update", [device_id])}
@router.post("/devices/{device_id}/firmware/upgrade", status_code=202)
async def upgrade_firmware(device_id: int):
return {"job_ids": jobs.start_jobs("fw_update", [device_id])}
@router.post("/batch/{action}", status_code=202)
async def batch(action: Literal["backup", "ros_update", "fw_update"], body: BatchIn):
"""Групповая операция над списком устройств."""
return {"job_ids": jobs.start_jobs(action, body.resolve())}
@router.put("/batch/channel", response_model=list[DeviceOut])
async def batch_channel(body: BatchChannelIn):
ids = body.resolve()
for i in ids:
devices.get_device(i)
for i in ids:
await ops.set_channel(i, body.channel)
return [DeviceOut.of(devices.get_device(i)) for i in ids]
# --- резервные копии в S3 ---
@router.get("/backups")
async def list_backups(
device_id: int | None = None,
group: str = "", # "" — все, "none" — без группы, иначе id группы
kind: Literal["", "backup", "rsc"] = "",
date_from: date | None = None,
date_to: date | None = None,
q: str = "",
):
name = devices.get_device(device_id).name if device_id else ""
return await backups.list_backups(name, group, kind, date_from, date_to, q)
@router.get("/backups/download")
async def download_backup(key: str):
if not s3.key_allowed(key):
raise ValueError("Недопустимый ключ")
return RedirectResponse(s3.presign_get(key))
class KeysIn(BaseModel):
keys: list[str] = Field(min_length=1)
@router.post("/backups/delete")
async def delete_backups(body: KeysIn):
"""Групповое удаление файлов из бакета: {"keys": [...]}."""
deleted, failed = await backups.delete_many(body.keys)
return {"deleted": deleted, "failed": failed}
@router.delete("/backups", status_code=204)
async def delete_backup(key: str):
if not s3.key_allowed(key):
raise ValueError("Недопустимый ключ")
await s3.delete_object(key)
# --- задачи ---
@router.get("/jobs", response_model=list[JobOut])
async def list_jobs():
return jobs.list_jobs()
@router.get("/jobs/{job_id}", response_model=JobOut)
async def get_job(job_id: int):
return jobs.get_job(job_id)