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)) @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)