Fix API regressions and refresh project docs

This commit is contained in:
Artem Kokos
2026-05-15 23:12:28 +07:00
parent 654f64bb90
commit 13fba2fa44
19 changed files with 3258 additions and 964 deletions

View File

@@ -4,10 +4,10 @@ from sqlalchemy import select
from app.core.database import async_session
from app.models.api_key import ApiKeyModel
from app.api.deps import require_admin, AuthContext
from app.api.deps import require_master
# Все операции с ключами -- только для админов (мастер-ключ)
router = APIRouter(dependencies=[Depends(require_admin)])
# Все операции с ключами доступны только мастер-ключу.
router = APIRouter(dependencies=[Depends(require_master)])
class KeyActionRequest(BaseModel):
@@ -16,16 +16,41 @@ class KeyActionRequest(BaseModel):
key: str
async def _find_key_by_secret_or_public_id(
session, key_or_id: str
) -> ApiKeyModel | None:
result = await session.execute(
select(ApiKeyModel).where(ApiKeyModel.key == key_or_id)
)
api_key = result.scalar_one_or_none()
if api_key:
return api_key
result = await session.execute(select(ApiKeyModel))
for candidate in result.scalars().all():
if candidate.public_id == key_or_id:
return candidate
return None
@router.get("")
async def list_keys():
"""Список всех гостевых ключей."""
"""
Список всех гостевых ключей.
В ответе поле `key` содержит публичный идентификатор, а не сам секрет.
Это сохраняет совместимость с текущим UI и не раскрывает токены повторно.
"""
async with async_session() as session:
result = await session.execute(select(ApiKeyModel))
keys = result.scalars().all()
return [
{
"key": k.key,
"key": k.public_id,
"key_id": k.public_id,
"display_key": k.preview,
"name": k.name,
"is_admin": k.is_admin,
"active": k.active,
@@ -50,6 +75,8 @@ async def create_key(name: str, is_admin: bool = False):
return {
"key": new_key.key,
"key_id": new_key.public_id,
"display_key": new_key.preview,
"name": new_key.name,
"is_admin": new_key.is_admin,
"message": "Сохраните ключ -- он больше не будет показан полностью",
@@ -60,10 +87,7 @@ async def create_key(name: str, is_admin: bool = False):
async def revoke_key(body: KeyActionRequest):
"""Деактивировать (отозвать) гостевой ключ. Ключ передаётся в body, не в URL."""
async with async_session() as session:
result = await session.execute(
select(ApiKeyModel).where(ApiKeyModel.key == body.key)
)
api_key = result.scalar_one_or_none()
api_key = await _find_key_by_secret_or_public_id(session, body.key)
if not api_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
@@ -71,17 +95,14 @@ async def revoke_key(body: KeyActionRequest):
session.add(api_key)
await session.commit()
return {"status": "revoked", "name": api_key.name}
return {"status": "revoked", "name": api_key.name, "key_id": api_key.public_id}
@router.post("/activate")
async def activate_key(body: KeyActionRequest):
"""Повторно активировать ключ. Ключ передаётся в body, не в URL."""
async with async_session() as session:
result = await session.execute(
select(ApiKeyModel).where(ApiKeyModel.key == body.key)
)
api_key = result.scalar_one_or_none()
api_key = await _find_key_by_secret_or_public_id(session, body.key)
if not api_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
@@ -89,4 +110,4 @@ async def activate_key(body: KeyActionRequest):
session.add(api_key)
await session.commit()
return {"status": "activated", "name": api_key.name}
return {"status": "activated", "name": api_key.name, "key_id": api_key.public_id}

View File

@@ -1,12 +1,14 @@
import asyncio
import json
import logging
from typing import Optional
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from app.core.state import state_manager
from app.core.database import async_session
from app.drivers.wiz import WizDriver
from app.api.deps import verify_token, AuthContext
from app.core.database import async_session
from app.core.state import state_manager
from app.drivers.wiz import WizDriver, WizResponse
from app.models.event_log import EventLog
logger = logging.getLogger(__name__)
@@ -34,21 +36,171 @@ async def _log_event(
logger.error(f"Ошибка записи в лог: {e}")
async def log_toggle(auth: AuthContext, target_type: str, target_id: str, params: dict):
"""Логирует toggle_on/toggle_off если в params есть state."""
def _build_command_params(
state: Optional[bool],
brightness: Optional[int],
scene: Optional[str],
temp: Optional[int],
r: Optional[int],
g: Optional[int],
b: Optional[int],
) -> dict[str, Any]:
params: dict[str, Any] = {}
if state is not None:
params["state"] = state
if brightness is not None:
params["dimming"] = brightness
if scene is not None:
if scene not in wiz.SCENES:
raise HTTPException(status_code=400, detail="Неизвестная сцена")
params["sceneId"] = wiz.SCENES[scene]
elif temp is not None:
params["temp"] = temp
elif any(v is not None for v in [r, g, b]):
params["r"], params["g"], params["b"] = r or 0, g or 0, b or 0
return params
def _resolve_action_name(params: dict[str, Any]) -> str:
if "state" in params:
action = "toggle_on" if params["state"] else "toggle_off"
await _log_event(auth, action, target_type, target_id, params)
return "toggle_on" if params["state"] else "toggle_off"
if "sceneId" in params:
return "scene"
if any(channel in params for channel in ("r", "g", "b")):
return "color"
if "temp" in params:
return "temperature"
if "dimming" in params:
return "brightness"
return "command"
async def log_toggle_by_name(
key_name: str, target_type: str, target_id: str, params: dict
def _build_event_payload(
params: dict[str, Any],
*,
success_count: int | None = None,
failure_count: int | None = None,
target_count: int | None = None,
status: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {"command": params}
outcome = {
"status": status,
"success_count": success_count,
"failure_count": failure_count,
"target_count": target_count,
}
payload["outcome"] = {k: v for k, v in outcome.items() if v is not None}
return payload
async def log_command_result(
auth: AuthContext,
target_type: str,
target_id: str,
params: dict[str, Any],
*,
success_count: int,
failure_count: int,
target_count: int,
):
"""Логирует toggle из контекста без AuthContext (для планировщика)."""
if "state" in params:
auth = AuthContext(is_master=False, is_admin=False, key_name=key_name)
action = "toggle_on" if params["state"] else "toggle_off"
await _log_event(auth, action, target_type, target_id, params)
action = _resolve_action_name(params)
await _log_event(
auth,
f"{action}_requested",
target_type,
target_id,
_build_event_payload(params, target_count=target_count, status="requested"),
)
if success_count == 0:
await _log_event(
auth,
f"{action}_failed",
target_type,
target_id,
_build_event_payload(
params,
success_count=0,
failure_count=failure_count,
target_count=target_count,
status="failed",
),
)
return
outcome_status = "ok" if failure_count == 0 else "partial"
await _log_event(
auth,
action,
target_type,
target_id,
_build_event_payload(
params,
success_count=success_count,
failure_count=failure_count,
target_count=target_count,
status=outcome_status,
),
)
async def log_command_result_by_name(
key_name: str,
target_type: str,
target_id: str,
params: dict,
*,
success_count: int = 1,
failure_count: int = 0,
target_count: int = 1,
):
"""Логирует результат команды из контекста без AuthContext (для планировщика)."""
auth = AuthContext(is_master=False, is_admin=False, key_name=key_name)
await log_command_result(
auth,
target_type,
target_id,
params,
success_count=success_count,
failure_count=failure_count,
target_count=target_count,
)
def _response_error_status(result: WizResponse) -> int:
if result.kind == "timeout":
return 504
return 502
def _response_error_detail(result: WizResponse, *, prefix: str) -> str:
if result.message:
return f"{prefix}: {result.message}"
if result.kind == "timeout":
return f"{prefix}: таймаут ответа"
return f"{prefix}: ошибка обмена с лампой"
def _serialize_wiz_result(ip: str, result: WizResponse) -> dict[str, Any]:
payload: dict[str, Any] = {
"ip": ip,
"ok": result.ok,
"kind": result.kind,
}
if result.ok:
payload["result"] = result.result
else:
payload["error"] = result.message or result.kind
return payload
def _summarize_group_results(results: list[WizResponse]) -> tuple[int, int]:
success_count = sum(1 for item in results if item.ok)
failure_count = len(results) - success_count
return success_count, failure_count
@router.post("/device/{device_id}")
@@ -67,27 +219,42 @@ async def control_device(
if not device:
raise HTTPException(status_code=404, detail="Лампа не в сети")
params = {}
if state is not None:
params["state"] = state
if brightness is not None:
params["dimming"] = brightness
if scene and scene in wiz.SCENES:
params["sceneId"] = wiz.SCENES[scene]
elif temp is not None:
params["temp"] = temp
elif any(v is not None for v in [r, g, b]):
params["r"], params["g"], params["b"] = r or 0, g or 0, b or 0
params = _build_command_params(state, brightness, scene, temp, r, g, b)
if not params:
raise HTTPException(status_code=400, detail="Никаких команд не передано")
result = await wiz.set_pilot(device.ip, params)
if not result.ok:
await log_command_result(
auth,
"device",
device_id,
params,
success_count=0,
failure_count=1,
target_count=1,
)
raise HTTPException(
status_code=_response_error_status(result),
detail=_response_error_detail(result, prefix="Команда лампе не доставлена"),
)
await log_toggle(auth, "device", device_id, params)
await log_command_result(
auth,
"device",
device_id,
params,
success_count=1,
failure_count=0,
target_count=1,
)
return {"device_id": device_id, "applied": params, "result": result}
return {
"device_id": device_id,
"applied": params,
"result": result.payload,
"status": "ok",
}
@router.post("/group/{group_id}")
@@ -106,25 +273,44 @@ async def control_group(
if not ips:
raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн")
params = {}
if state is not None:
params["state"] = state
if brightness is not None:
params["dimming"] = brightness
if scene and scene in wiz.SCENES:
params["sceneId"] = wiz.SCENES[scene]
elif temp is not None:
params["temp"] = temp
elif any(v is not None for v in [r, g, b]):
params["r"], params["g"], params["b"] = r or 0, g or 0, b or 0
params = _build_command_params(state, brightness, scene, temp, r, g, b)
if not params:
raise HTTPException(status_code=400, detail="Никаких команд не передано")
tasks = [wiz.set_pilot(ip, params) for ip in ips]
await asyncio.gather(*tasks, return_exceptions=True)
results = await asyncio.gather(*tasks)
success_count, failure_count = _summarize_group_results(results)
await log_toggle(auth, "group", group_id, params)
await log_command_result(
auth,
"group",
group_id,
params,
success_count=success_count,
failure_count=failure_count,
target_count=len(results),
)
return {"status": "ok", "applied": params, "sent_to": ips}
if success_count == 0:
first_failure = next(item for item in results if not item.ok)
raise HTTPException(
status_code=_response_error_status(first_failure),
detail=_response_error_detail(
first_failure, prefix="Команда группе не доставлена"
),
)
group_status = "ok" if failure_count == 0 else "partial"
return {
"status": group_status,
"applied": params,
"sent_to": ips,
"success_count": success_count,
"failure_count": failure_count,
"results": [
_serialize_wiz_result(ip, result) for ip, result in zip(ips, results)
],
}
@router.post("/device/{device_id}/blink")
@@ -133,16 +319,36 @@ async def blink_device(device_id: str, _auth: AuthContext = Depends(verify_token
if not device:
raise HTTPException(status_code=404, detail="Лампа оффлайн")
try:
current = await wiz.get_pilot(device.ip)
original_state = current.get("result", {}).get("state", False)
await wiz.set_pilot(device.ip, {"state": not original_state})
await asyncio.sleep(0.5)
await wiz.set_pilot(device.ip, {"state": original_state})
return {"status": "blink_done", "original": original_state}
except Exception as e:
logger.error(f"Blink error: {e}")
raise HTTPException(status_code=500, detail="Ошибка связи с лампой")
current = await wiz.get_pilot(device.ip)
if not current.ok:
raise HTTPException(
status_code=_response_error_status(current),
detail=_response_error_detail(
current, prefix="Не удалось получить текущее состояние лампы"
),
)
original_state = current.result.get("state", False)
first_toggle = await wiz.set_pilot(device.ip, {"state": not original_state})
if not first_toggle.ok:
raise HTTPException(
status_code=_response_error_status(first_toggle),
detail=_response_error_detail(
first_toggle, prefix="Не удалось выполнить первую фазу blink"
),
)
await asyncio.sleep(0.5)
second_toggle = await wiz.set_pilot(device.ip, {"state": original_state})
if not second_toggle.ok:
raise HTTPException(
status_code=_response_error_status(second_toggle),
detail=_response_error_detail(
second_toggle, prefix="Не удалось восстановить исходное состояние"
),
)
return {"status": "blink_done", "original": original_state}
@router.get("/device/{device_id}/status")
@@ -152,11 +358,14 @@ async def get_device_status(device_id: str, _auth: AuthContext = Depends(verify_
if not device:
raise HTTPException(status_code=404, detail="Лампа оффлайн или не найдена")
try:
status = await wiz.get_pilot(device.ip)
return {"device_id": device_id, "status": status.get("result", {})}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка опроса лампы: {e}")
status = await wiz.get_pilot(device.ip)
if not status.ok:
raise HTTPException(
status_code=_response_error_status(status),
detail=_response_error_detail(status, prefix="Ошибка опроса лампы"),
)
return {"device_id": device_id, "status": status.result}
@router.get("/group/{group_id}/status")
@@ -166,14 +375,20 @@ async def get_group_status(group_id: str, _auth: AuthContext = Depends(verify_to
if not ips:
raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн")
tasks = [wiz.get_pilot(ip) for ip in ips]
results = await asyncio.gather(*tasks, return_exceptions=True)
results = await asyncio.gather(*[wiz.get_pilot(ip) for ip in ips])
status_report = []
for ip, res in zip(ips, results):
if isinstance(res, Exception):
status_report.append({"ip": ip, "error": str(res)})
else:
status_report.append({"ip": ip, "status": res.get("result", {})})
if res.ok:
status_report.append({"ip": ip, "status": res.result})
continue
status_report.append(
{
"ip": ip,
"error": res.message or res.kind,
"kind": res.kind,
}
)
return {"group_id": group_id, "results": status_report}

View File

@@ -1,28 +1,75 @@
import logging
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from typing import Any, Optional
from app.core.scheduler import app_tz, scheduler
from fastapi import APIRouter, Depends, HTTPException
from app.api.deps import require_admin
from app.core.scheduler import (
app_tz,
create_schedule_task,
delete_schedule_task,
is_internal_job_id,
list_schedule_tasks,
)
from app.core.state import state_manager
from app.drivers.wiz import WizDriver
from app.api.deps import require_admin
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_admin)])
# Префиксы служебных задач -- не показываем на фронте
_INTERNAL_JOB_PREFIXES = ("cleanup_",)
def _validate_target(target_id: str, is_group: bool):
if is_group:
if target_id not in state_manager.groups:
raise HTTPException(status_code=404, detail="Группа не найдена")
return "group"
if target_id not in state_manager.devices:
raise HTTPException(status_code=404, detail="Устройство не найдено")
return "device"
def _build_action_params(
state: Optional[bool],
brightness: Optional[int],
scene: Optional[str],
temp: Optional[int],
r: Optional[int],
g: Optional[int],
b: Optional[int],
) -> dict[str, Any]:
params: dict[str, Any] = {}
if state is not None:
params["state"] = state
if brightness is not None:
params["dimming"] = brightness
if scene is not None:
if scene not in WizDriver.SCENES:
raise HTTPException(status_code=400, detail="Неизвестная сцена")
params["sceneId"] = WizDriver.SCENES[scene]
elif temp is not None:
params["temp"] = temp
elif any(channel is not None for channel in (r, g, b)):
params["r"] = r or 0
params["g"] = g or 0
params["b"] = b or 0
if not params:
raise HTTPException(status_code=400, detail="Никаких команд не передано")
return params
async def run_group_command(target_id: str, is_group: bool, params: dict):
"""
Универсальное выполнение команды по расписанию.
IP резолвится в момент выполнения, а не создания задачи --
корректно работает при смене IP (DHCP) и изменении состава группы.
Сигнатура специально сохранена совместимой со старым persisted jobstore,
чтобы legacy APScheduler jobs можно было безопасно мигрировать.
"""
if is_group:
ips = state_manager.get_group_ips(target_id)
@@ -35,61 +82,89 @@ async def run_group_command(target_id: str, is_group: bool, params: dict):
return
local_wiz = WizDriver()
success_count = 0
failure_count = 0
for ip in ips:
try:
await local_wiz.set_pilot(ip, params)
logger.info(f"Расписание: {target_id} -> {ip}: {params}")
result = await local_wiz.set_pilot(ip, params)
if result.ok:
success_count += 1
logger.info(f"Расписание: {target_id} -> {ip}: {params}")
else:
failure_count += 1
logger.error(
f"Расписание: ошибка {ip}: {result.message or result.kind}"
)
except Exception as e:
failure_count += 1
logger.error(f"Расписание: ошибка {ip}: {e}")
# Логируем toggle в event_log
# Импорт здесь, чтобы избежать циклической зависимости
from app.api.routes.control import log_toggle_by_name
from app.api.routes.control import log_command_result_by_name
target_type = "group" if is_group else "device"
await log_toggle_by_name("scheduler", target_type, target_id, params)
await log_command_result_by_name(
"scheduler",
target_type,
target_id,
params,
success_count=success_count,
failure_count=failure_count,
target_count=len(ips),
)
@router.post("/once")
async def schedule_once(
target_id: str,
state: bool,
state: Optional[bool] = None,
run_at: Optional[datetime] = None,
hours_from_now: Optional[int] = None,
is_group: bool = True,
brightness: Optional[int] = None,
scene: Optional[str] = None,
temp: Optional[int] = None,
r: Optional[int] = None,
g: Optional[int] = None,
b: Optional[int] = None,
):
# 1. Определяем время запуска в правильной таймзоне
if hours_from_now is not None:
if hours_from_now < 0:
raise HTTPException(
status_code=400, detail="hours_from_now не может быть отрицательным"
)
exec_time = datetime.now(app_tz) + timedelta(hours=hours_from_now)
elif run_at:
if run_at.tzinfo is None:
exec_time = app_tz.localize(run_at)
else:
exec_time = run_at
exec_time = run_at.astimezone(app_tz)
else:
raise HTTPException(status_code=400, detail="Нужно время или отступ в часах")
# 2. Проверяем что цель существует (но IP резолвится при выполнении)
if is_group:
if target_id not in state_manager.groups:
raise HTTPException(status_code=404, detail="Группа не найдена")
else:
if target_id not in state_manager.devices:
raise HTTPException(status_code=404, detail="Устройство не найдено")
if exec_time <= datetime.now(app_tz):
raise HTTPException(
status_code=400, detail="Время запуска должно быть в будущем"
)
# 3. Регаем задачу
job_id = f"once_{target_id}_{int(exec_time.timestamp())}"
target_type = _validate_target(target_id, is_group)
action_params = _build_action_params(state, brightness, scene, temp, r, g, b)
scheduler.add_job(
run_group_command,
trigger=DateTrigger(run_date=exec_time, timezone=app_tz),
args=[target_id, is_group, {"state": state}],
id=job_id,
name=f"Once: {target_id} | {state}",
replace_existing=True,
)
try:
task = await create_schedule_task(
trigger_type="once",
target_id=target_id,
target_type=target_type,
trigger_args={"run_at": exec_time.isoformat()},
action_params=action_params,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"status": "scheduled", "run_at": exec_time.isoformat()}
return {
"status": "scheduled",
"job_id": task.job_id,
"run_at": exec_time.isoformat(),
}
@router.post("/cron")
@@ -99,84 +174,50 @@ async def add_cron_task(
minute: str,
day_of_week: str = "*",
is_group: bool = True,
state: bool = True,
state: Optional[bool] = None,
brightness: Optional[int] = None,
scene: Optional[str] = None,
temp: Optional[int] = None,
r: Optional[int] = None,
g: Optional[int] = None,
b: Optional[int] = None,
):
# Проверяем что цель существует
if is_group:
if target_id not in state_manager.groups:
raise HTTPException(status_code=404, detail="Группа не найдена")
else:
if target_id not in state_manager.devices:
raise HTTPException(status_code=404, detail="Устройство не найдено")
target_type = _validate_target(target_id, is_group)
action_params = _build_action_params(state, brightness, scene, temp, r, g, b)
# Одна задача на всю группу -- IP резолвятся при каждом срабатывании
trigger = CronTrigger(
hour=hour, minute=minute, day_of_week=day_of_week, timezone=app_tz
)
trigger_args = {
"hour": hour,
"minute": minute,
"day_of_week": day_of_week,
}
try:
task = await create_schedule_task(
trigger_type="cron",
target_id=target_id,
target_type=target_type,
trigger_args=trigger_args,
action_params=action_params,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
job_id = f"cron_{target_id}_{hour}_{minute}"
scheduler.add_job(
run_group_command,
trigger,
args=[target_id, is_group, {"state": state}],
id=job_id,
name=f"CRON: {target_id} | {hour}:{minute} | {state}",
replace_existing=True,
)
return {"status": "cron_scheduled", "job_id": job_id}
return {"status": "cron_scheduled", "job_id": task.job_id}
@router.get("/tasks")
async def get_all_tasks():
jobs = []
for job in scheduler.get_jobs():
# Пропускаем служебные задачи
if any(job.id.startswith(prefix) for prefix in _INTERNAL_JOB_PREFIXES):
continue
# Парсим имя
name_parts = job.name.split("|")
target = name_parts[0].replace("CRON:", "").replace("Once:", "").strip()
is_on = "True" in job.name or "true" in job.name.lower()
h, m = None, None
next_run_str = None
if job.next_run_time:
# Переводим из UTC в локальную таймзону для вывода
local_time = job.next_run_time.astimezone(app_tz)
h = str(local_time.hour).zfill(2)
m = str(local_time.minute).zfill(2)
next_run_str = local_time.isoformat()
# Если это крон, подтягиваем значения из триггера
if hasattr(job.trigger, "fields"):
h = str(job.trigger.fields[5])
m = str(job.trigger.fields[6])
jobs.append(
{
"id": job.id,
"target_id": target,
"state": is_on,
"next_run": next_run_str,
"hour": h,
"minute": m,
}
)
return {"tasks": jobs}
return {"tasks": await list_schedule_tasks()}
@router.delete("/{job_id}")
async def cancel_task(job_id: str):
# Запрещаем удалять служебные задачи через API
if any(job_id.startswith(prefix) for prefix in _INTERNAL_JOB_PREFIXES):
if is_internal_job_id(job_id):
raise HTTPException(status_code=403, detail="Нельзя удалить служебную задачу")
try:
scheduler.remove_job(job_id)
await delete_schedule_task(job_id)
return {"status": "deleted"}
except Exception:
except KeyError:
raise HTTPException(status_code=404, detail="Задача не найдена")
except ValueError:
raise HTTPException(status_code=403, detail="Нельзя удалить служебную задачу")

View File

@@ -8,6 +8,10 @@ from app.api.deps import require_admin
router = APIRouter(dependencies=[Depends(require_admin)])
def _is_summary_command_event(action: str) -> bool:
return not action.endswith("_requested")
@router.get("/summary")
async def get_summary(days: int = Query(default=7, ge=1, le=365)):
"""
@@ -33,6 +37,9 @@ async def get_summary(days: int = Query(default=7, ge=1, le=365)):
last_on = {}
for ev in events:
if not _is_summary_command_event(ev.action):
continue
tid = ev.target_id
if tid not in stats:
stats[tid] = {