Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live. Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator). Fix: Security headers on all API responses (hardening) Add: New mix button available on the Mix Editor. Add: New ingredient button available on the Ingredient Editor
79 lines
3.3 KiB
Python
79 lines
3.3 KiB
Python
"""Read-only external data API (`/api/v1`).
|
|
|
|
A deliberately simple, API-key authenticated surface for Power BI (and any other
|
|
external reporting tool). It is intentionally separate from the cookie/JWT
|
|
session model used by the operator frontend: external tools cannot hold a
|
|
browser session, so they present a single static key instead.
|
|
|
|
Authentication: send the key either as an ``X-API-Key`` request header or an
|
|
``api_key`` query-string parameter (Power BI's Web connector supports both).
|
|
The key is configured via the ``POWERBI_API_KEY`` environment variable; when it
|
|
is blank the whole API is disabled and every request returns 503.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
from datetime import date
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import settings
|
|
from app.core.security_logging import log_security_event
|
|
from app.db.session import get_db
|
|
from app.models.throughput import ProductionThroughput
|
|
from app.services.throughput_service import serialize_entry
|
|
|
|
router = APIRouter(prefix="/api/v1", tags=["public-v1"])
|
|
|
|
_API_KEY_HEADER = "X-API-Key"
|
|
|
|
|
|
def require_powerbi_api_key(request: Request) -> str:
|
|
"""Authorize an external request via the static Power BI API key.
|
|
|
|
Returns the tenant the caller may read. Raises 503 when the API is not
|
|
configured, or 401 when the key is missing/incorrect.
|
|
"""
|
|
configured = settings.powerbi_api_key
|
|
if not configured:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="The data API is not configured.",
|
|
)
|
|
|
|
presented = request.headers.get(_API_KEY_HEADER) or request.query_params.get("api_key") or ""
|
|
# Constant-time comparison so the endpoint does not leak key length/contents
|
|
# through response timing.
|
|
if not presented or not secrets.compare_digest(presented, configured):
|
|
log_security_event("authz.denied", role="powerbi", reason="invalid_api_key")
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key")
|
|
|
|
return settings.powerbi_tenant_id
|
|
|
|
|
|
@router.get("/throughput")
|
|
def list_throughput(
|
|
date_from: date | None = Query(default=None, description="Only entries on/after this production date (YYYY-MM-DD)."),
|
|
date_to: date | None = Query(default=None, description="Only entries on/before this production date (YYYY-MM-DD)."),
|
|
limit: int = Query(default=5000, ge=1, le=50000),
|
|
tenant_id: str = Depends(require_powerbi_api_key),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Flat list of production throughput entries for Power BI.
|
|
|
|
One row per packing run, oldest first so incremental refreshes append
|
|
naturally. Each row carries the same fields the operator UI shows
|
|
(date, product, bag size, quantity, calculated kg, QA flags, staff, notes).
|
|
"""
|
|
stmt = select(ProductionThroughput).where(ProductionThroughput.tenant_id == tenant_id)
|
|
if date_from is not None:
|
|
stmt = stmt.where(ProductionThroughput.production_date >= date_from)
|
|
if date_to is not None:
|
|
stmt = stmt.where(ProductionThroughput.production_date <= date_to)
|
|
stmt = stmt.order_by(ProductionThroughput.production_date.asc(), ProductionThroughput.id.asc()).limit(limit)
|
|
|
|
return [serialize_entry(entry) for entry in db.scalars(stmt).all()]
|