Files
ros_control/app/api/v1.py
T
ayurishchevandClaude Opus 5.5 0bda0038d0 Остаток п. 9 ревью: синхронная БД вне event loop, повторное ревью
Повторное ревью кодовой базы: docs/reviews/2026-09-28-1243-codebase-review.md
(статус 12 замечаний, новые замечания 13–16).

Остаток п. 9 (docs/changes/022):
- 14 async-функций API, UI и сервисов больше не обращаются к SQLite
  напрямую — через asyncio.to_thread; jobs.start_jobs стал async (БД в
  потоке, create_task в event loop); ops._conn для подключения к устройству;
- тест-линтер по AST: в async def нет прямых вызовов функций с
  session_scope — защита от регресса.

Тесты: 29 из 29. Стенд: задачи и актор событий в порядке, параллельные
запросы не ждут медленного устройства, боевые данные не изменены.
Ручная проверка UI пользователем на момент коммита не подтверждена.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:12:44 +03:00

327 lines
11 KiB
Python

import asyncio
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])
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])
def list_groups():
return groups.list_groups()
@router.post("/groups", response_model=GroupOut, status_code=201)
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)
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)
def delete_group(group_id: str):
groups.delete_group(group_id)
@router.post("/devices", response_model=DeviceOut, status_code=201)
def create_device(body: DeviceIn):
return DeviceOut.of(devices.create_device(**body.model_dump()))
@router.get("/devices/{device_id}", response_model=DeviceOut)
def get_device(device_id: str):
return DeviceOut.of(devices.get_device(device_id))
@router.patch("/devices/{device_id}", response_model=DeviceOut)
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)
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):
await asyncio.to_thread(devices.get_device, device_id)
await ops.refresh_status(device_id)
return DeviceOut.of(await asyncio.to_thread(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 await asyncio.to_thread(devices.list_devices)]
# --- операции (долгие — через jobs) ---
@router.post("/devices/{device_id}/backups", status_code=202)
async def create_backup(device_id: str):
return {"job_ids": await 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):
await asyncio.to_thread(devices.get_device, device_id)
await ops.set_channel(device_id, body.channel)
return DeviceOut.of(await asyncio.to_thread(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": await 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": await 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):
"""Групповая операция над списком устройств."""
device_ids = await asyncio.to_thread(body.resolve)
return {"job_ids": await jobs.start_jobs(action, device_ids)}
@router.put("/batch/channel", status_code=202)
async def batch_channel(body: BatchChannelIn):
"""Групповая смена канала — фоновыми задачами (как /batch/{backup|ros_update|fw_update})."""
device_ids = await asyncio.to_thread(body.resolve)
return {"job_ids": await jobs.start_jobs("set_channel", device_ids, {"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 = "",
refresh: bool = False, # принудительно перечитать бакет, минуя кэш (BACKUPS_CACHE_TTL)
):
name = (await asyncio.to_thread(devices.get_device, device_id)).name if device_id else ""
return await backups.list_backups(name, group, kind, date_from, date_to, q, refresh=refresh)
@router.get("/backups/download")
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")
def get_journal_settings():
"""Настройки ротации журнала и сводка (число записей, самая старая запись)."""
return {**settings.journal(), **events.stats()}
@router.put("/events/settings")
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])
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)
def get_event(event_id: str):
ids.check(event_id, "evt")
return events.get_event(event_id)
# --- задачи ---
@router.get("/jobs", response_model=list[JobOut])
def list_jobs():
return jobs.list_jobs()
@router.get("/jobs/{job_id}", response_model=JobOut)
def get_job(job_id: str):
return jobs.get_job(job_id)