Files
tech-service-portal/app/routers/external.py
T

178 lines
8.9 KiB
Python

"""Инциденты извне: публичная форма без входа (/report) и API для внешних систем по ключу доступа.
Публичная форма: место → машины этого места (и вложенных мест) → описание и контакты. Ограничение частоты по адресу
и ловушка для ботов. Включается/выключается администратором (настройка public_incidents).
API: заголовок X-API-Key; ключи выпускает администратор, в БД хранится только хэш."""
import hashlib
import secrets
import time
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import config, push, schemas as S, settings_store as ss
from ..db import get_db
from ..models import ApiKey, Equipment, Incident, Location, User
from ..security import is_engineer, is_manager, require_portal_admin as require_admin
from .catalog import commit, get_or_404
from .incidents import inc_out
router = APIRouter(tags=["external"])
PUBLIC_LIMIT = 5 # отправок с одного адреса
PUBLIC_WINDOW = 600 # за 10 минут
PUBLIC_GLOBAL = 200 # всего публичных инцидентов в час (защита от массового спама)
_hits: dict[str, list[float]] = {}
def _rate(key: str, limit: int, window: int):
t = time.time()
lst = [x for x in _hits.get(key, []) if t - x < window]
if len(lst) >= limit:
raise HTTPException(429, "Слишком много обращений, попробуйте позже", headers={"Retry-After": str(window)})
lst.append(t)
_hits[key] = lst
if len(_hits) > 5000: # предел памяти
for k in [k for k, v in _hits.items() if not v or t - v[-1] > PUBLIC_WINDOW][:2500]:
_hits.pop(k, None)
def public_enabled(db: Session) -> bool:
return ss.get(db, "public_incidents") not in ("0", "false", "False")
def descendants(db: Session, loc_id: int) -> set[int]:
"""Место и все вложенные в него места."""
children: dict[int | None, list[int]] = {}
for i, p in db.execute(select(Location.id, Location.parent_id)):
children.setdefault(p, []).append(i)
out, stack = set(), [loc_id]
while stack:
x = stack.pop()
if x in out:
continue
out.add(x)
stack.extend(children.get(x, []))
return out
def notify_staff(db: Session, eq: Equipment, i: Incident, who: str):
from .incidents import notify
notify(db, eq, i, who)
def short_title(text: str) -> str:
first = text.strip().splitlines()[0] if text.strip() else "Инцидент"
return first[:120] + ("…" if len(first) > 120 else "")
# ---------- публичная форма ----------
@router.get("/public/report-options")
def report_options(db: Session = Depends(get_db)):
"""Места и активные машины для публичной формы (только названия — без внутренних данных)."""
if not public_enabled(db):
return {"enabled": False, "locations": [], "equipment": []}
return {
"enabled": True,
"locations": [{"id": i, "name": n, "parent_id": p} for i, n, p in db.execute(select(Location.id, Location.name, Location.parent_id).order_by(Location.name))],
"equipment": [{"id": e.id, "name": e.name, "inventory_no": e.inventory_no, "location_id": e.location_id}
for e in db.scalars(select(Equipment).where(Equipment.is_active.is_(True)).order_by(Equipment.name))],
}
@router.post("/public/incidents", status_code=201)
def public_incident(data: S.PublicIncidentIn, request: Request, db: Session = Depends(get_db)):
if not public_enabled(db):
raise HTTPException(403, "Приём обращений через форму отключён")
ip = request.client.host if request.client else "?"
_rate("ip:" + ip, PUBLIC_LIMIT, PUBLIC_WINDOW)
_rate("all", PUBLIC_GLOBAL, 3600)
if data.website: # ловушка заполнена — бот; отвечаем как обычно, но ничего не сохраняем
return {"ok": True, "number": None}
loc = get_or_404(db, Location, data.location_id)
eq = get_or_404(db, Equipment, data.equipment_id)
if not eq.is_active or eq.location_id not in descendants(db, loc.id):
raise HTTPException(422, "Эта машина не относится к выбранному месту")
title = data.title.strip() or short_title(data.description)
i = Incident(kind=data.kind, equipment_id=eq.id, occurred_at=config.now(), title=title, description=data.description.strip(), hours_at_open=eq.meter_hours,
source="public", reporter_name=data.reporter_name.strip(), reporter_contact=data.reporter_contact.strip())
db.add(i)
commit(db)
notify_staff(db, eq, i, f"Обращение: {i.reporter_name}")
return {"ok": True, "number": i.id}
# ---------- API для внешних систем ----------
def _hash(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
def api_key(x_api_key: str = Header(default="", alias="X-API-Key"), db: Session = Depends(get_db)) -> ApiKey:
k = db.scalar(select(ApiKey).where(ApiKey.key_hash == _hash(x_api_key))) if x_api_key else None
if not k or not k.is_active:
raise HTTPException(401, "Нужен действующий ключ доступа (заголовок X-API-Key)")
k.last_used_at = config.now()
return k
@router.get("/external/equipment")
def external_equipment(db: Session = Depends(get_db), k: ApiKey = Depends(api_key)):
"""Справочник машин для сопоставления: id, название, инвентарный номер, место."""
locs = dict(db.execute(select(Location.id, Location.name)).all())
out = [{"id": e.id, "name": e.name, "inventory_no": e.inventory_no, "location": locs.get(e.location_id, "")}
for e in db.scalars(select(Equipment).where(Equipment.is_active.is_(True)).order_by(Equipment.name))]
commit(db)
return out
@router.post("/external/incidents", response_model=S.IncidentOut, status_code=201)
def external_incident(data: S.ExternalIncidentIn, db: Session = Depends(get_db), k: ApiKey = Depends(api_key)):
"""Завести инцидент (kind=incident) или сообщение о неисправности (kind=fault) из внешней системы."""
if data.equipment_id:
eq = get_or_404(db, Equipment, data.equipment_id)
elif data.inventory_no and data.inventory_no.strip():
eq = db.scalar(select(Equipment).where(Equipment.inventory_no == data.inventory_no.strip()))
if not eq:
raise HTTPException(404, "Машина с таким инвентарным номером не найдена")
else:
raise HTTPException(422, "Укажите equipment_id или inventory_no")
if data.external_id: # повтор той же заявки не создаёт дубликат
dup = db.scalar(select(Incident).where(Incident.api_key_id == k.id, Incident.external_id == data.external_id))
if dup:
return inc_out(db, dup)
at = data.occurred_at or config.now()
if at > config.now():
raise HTTPException(422, "Время инцидента не может быть в будущем")
i = Incident(kind=data.kind, equipment_id=eq.id, occurred_at=at, title=data.title.strip(), description=data.description.strip(), source="api", hours_at_open=eq.meter_hours,
reporter_name=data.reporter_name.strip() or k.name, reporter_contact=data.reporter_contact.strip(),
external_id=data.external_id, api_key_id=k.id)
db.add(i)
commit(db)
notify_staff(db, eq, i, k.name)
return inc_out(db, i)
# ---------- управление ключами (администратор) ----------
@router.get("/api-keys", response_model=list[S.ApiKeyOut])
def list_keys(db: Session = Depends(get_db), _: User = Depends(require_admin)):
return db.scalars(select(ApiKey).order_by(ApiKey.id)).all()
@router.post("/api-keys", response_model=S.ApiKeyOut, status_code=201)
def create_key(data: S.ApiKeyIn, db: Session = Depends(get_db), me: User = Depends(require_admin)):
raw = "cl_" + secrets.token_urlsafe(32)
k = ApiKey(name=data.name.strip(), key_hash=_hash(raw), prefix=raw[:10], created_by_id=me.id)
db.add(k)
commit(db)
out = S.ApiKeyOut.model_validate(k)
out.key = raw # показывается один раз
return out
@router.delete("/api-keys/{id}", status_code=204)
def revoke_key(id: int, db: Session = Depends(get_db), _: User = Depends(require_admin)):
k = get_or_404(db, ApiKey, id)
k.is_active = False # инциденты сохраняют ссылку на ключ
commit(db)