Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
"""Инциденты извне: публичная форма без входа (/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)
|
||||
Reference in New Issue
Block a user