Files
ros_control/app/api/v1.py
T
ayurishchevandClaude Opus 5.5 123b5abdfc Ревью кодовой базы и исправления корректности по его итогам
Ревью кодовой базы: 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 <noreply@anthropic.com>
2026-09-27 21:23:34 +03:00

323 lines
11 KiB
Python

import json
from datetime import date, datetime
from typing import Literal
from fastapi import APIRouter, Depends
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field, field_validator
from app import ids, s3
from app.ros.operations import CHANNELS
from app.security import require_api_token
from app.services import backups, devices, events, groups, jobs, ops, settings
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: str | None = None # grp_…
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: str | None = None # grp_…; явный null — открепить от группы
note: str | None = None # пустая строка или null — очистить примечание
class DeviceOut(BaseModel):
"""Пароль устройства в ответы не попадает."""
id: str # dev_…
name: str
host: str
port: int
username: str
verify_tls: bool
use_tls: bool
group_id: str | 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: str # job_…
device_id: str | 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: str # grp_…
name: str
device_count: int = 0
class BatchIn(BaseModel):
"""Устройства, над которыми выполняется операция: список id и/или целая группа."""
device_ids: list[str] = Field(default_factory=list) # dev_…
group_id: str | None = None # grp_…
def resolve(self) -> list[str]:
found = list(self.device_ids)
if self.group_id is not None:
groups.get_group(self.group_id) # 404, если группы нет
found += [d.id for d in devices.list_devices() if d.group_id == self.group_id]
found = list(dict.fromkeys(found))
if not found:
raise ValueError("Не найдено ни одного устройства: укажите device_ids или непустую group_id")
return found
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 группы (grp_…)
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: str, 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: str):
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: str):
return DeviceOut.of(devices.get_device(device_id))
@router.patch("/devices/{device_id}", response_model=DeviceOut)
async def patch_device(device_id: str, 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: str):
devices.delete_device(device_id)
@router.post("/devices/{device_id}/refresh", response_model=DeviceOut)
async def refresh_device(device_id: str):
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: str):
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: str, 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: str):
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: str):
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", status_code=202)
async def batch_channel(body: BatchChannelIn):
"""Групповая смена канала — фоновыми задачами (как /batch/{backup|ros_update|fw_update})."""
return {"job_ids": jobs.start_jobs("set_channel", body.resolve(), {"channel": body.channel})}
# --- резервные копии в S3 ---
@router.get("/backups")
async def list_backups(
device_id: str | None = None,
group: str = "", # "" — все, "none" — без группы, иначе ID группы (grp_…)
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):
deleted, failed = await backups.delete_many([key])
if failed:
raise RuntimeError("Не удалось удалить файл из бакета")
# --- журнал событий ---
class EventOut(BaseModel):
id: str # evt_…
ts: datetime
type: str
entity_type: str
entity_id: str | None
device_id: str | None
job_id: str | None
actor: str
message: str
data: dict | None = None
model_config = {"from_attributes": True}
@field_validator("data", mode="before")
@classmethod
def _parse(cls, v):
return json.loads(v) if isinstance(v, str) else v
class JournalSettingsIn(BaseModel):
retention_days: int # 0 — без ограничения по сроку
max_rows: int # 0 — без ограничения по числу записей
@router.get("/events/settings")
async def get_journal_settings():
"""Настройки ротации журнала и сводка (число записей, самая старая запись)."""
return {**settings.journal(), **events.stats()}
@router.put("/events/settings")
async def put_journal_settings(body: JournalSettingsIn):
"""Сохраняет настройки ротации и сразу применяет ротацию. Очистки журнала через API нет — она выполняется
только в UI через окно с вводом пароля пользователя."""
return {**settings.save_journal(body.retention_days, body.max_rows), **events.stats()}
@router.get("/events", response_model=list[EventOut])
async def list_events(entity_id: str | None = None, type: str | None = None, device_id: str | None = None,
job_id: str | None = None, actor: str | None = None, date_from: date | None = None,
date_to: date | None = None, q: str | None = None, before: str | None = None, limit: int = 100):
"""Журнал событий, новые сверху. type — точный тип (device.created) или группа (device, job, backup);
actor — ui, api, poller, system или anonymous. Постранично: before=<ID последней записи предыдущей страницы>."""
return events.list_events(entity_id=entity_id, type_=type, device_id=device_id, job_id=job_id, actor=actor,
date_from=date_from, date_to=date_to, text=q, before=before, limit=limit)
@router.get("/events/{event_id}", response_model=EventOut)
async def get_event(event_id: str):
ids.check(event_id, "evt")
return events.get_event(event_id)
# --- задачи ---
@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: str):
return jobs.get_job(job_id)