178 lines
8.9 KiB
Python
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)
|