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] class DowngradeIn(BaseModel): target_version: str = Field(min_length=1) class BatchDowngradeIn(BatchIn): target_version: str = Field(min_length=1) # --- устройства --- @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("/devices/{device_id}/firmware/downgrade", status_code=202) async def downgrade_firmware(device_id: str, body: DowngradeIn): """Изменение 027: осознанный откат прошивки RouterBOARD до версии, встроенной в установленную ROS (устройство перезагрузится); версия проверяется на устройстве в задаче — как и /update/downgrade.""" return {"job_ids": await jobs.start_jobs("fw_downgrade", [device_id], {"target_version": body.target_version})} @router.post("/devices/{device_id}/update/downgrade", status_code=202) async def downgrade_update(device_id: str, body: DowngradeIn): """Осознанный откат ROS до версии канала (устройство перезагрузится); версия проверяется на устройстве в задаче — если она не совпала с подтверждённой, задача завершается ошибкой.""" return {"job_ids": await jobs.start_jobs("ros_downgrade", [device_id], {"target_version": body.target_version})} @router.post("/batch/ros_downgrade", status_code=202) async def batch_ros_downgrade(body: BatchDowngradeIn): """Групповой откат ROS — отдельный эндпоинт с обязательной target_version (не входит в /batch/{action}); регистрируется раньше /batch/{action}, иначе Starlette отдаст этот путь туда (action="ros_downgrade" не в Literal).""" device_ids = await asyncio.to_thread(body.resolve) return {"job_ids": await jobs.start_jobs("ros_downgrade", device_ids, {"target_version": body.target_version})} @router.post("/batch/fw_downgrade", status_code=202) async def batch_fw_downgrade(body: BatchDowngradeIn): """Изменение 027: групповой откат прошивки — отдельный эндпоинт с обязательной target_version, по образцу /batch/ros_downgrade; регистрируется раньше /batch/{action}.""" device_ids = await asyncio.to_thread(body.resolve) return {"job_ids": await jobs.start_jobs("fw_downgrade", device_ids, {"target_version": body.target_version})} @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=.""" 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)