Ревью кодовой базы: 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>
323 lines
11 KiB
Python
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)
|