v0.1.27
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
This commit is contained in:
@@ -13,6 +13,8 @@ from app.schemas.editor import (
|
||||
EditorIngredientRow,
|
||||
EditorIngredientUpdate,
|
||||
EditorMixFormulaRead,
|
||||
EditorMixCreate,
|
||||
EditorMixFormulaReplace,
|
||||
EditorMixIngredientCreate,
|
||||
EditorMixIngredientUpdate,
|
||||
EditorMixRow,
|
||||
@@ -22,9 +24,11 @@ from app.schemas.editor import (
|
||||
EditorProductIngredientUpdate,
|
||||
EditorProductRow,
|
||||
EditorProductUpdate,
|
||||
EditorResolvedMixFormula,
|
||||
)
|
||||
from app.services.client_access_service import has_access_level
|
||||
from app.services.costing_engine import calculate_raw_material_cost, get_active_price
|
||||
from app.services.mix_calculator_service import resolve_editor_mix_formula, resolve_representative_product
|
||||
|
||||
router = APIRouter(prefix="/api/editor", tags=["editor"])
|
||||
|
||||
@@ -250,6 +254,25 @@ def list_editor_mixes(
|
||||
]
|
||||
|
||||
|
||||
@router.post("/mixes", response_model=EditorMixRow, status_code=201)
|
||||
def create_editor_mix(
|
||||
payload: EditorMixCreate,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
mix = Mix(
|
||||
tenant_id=session.tenant_id or "",
|
||||
client_name=payload.client_name.strip(),
|
||||
name=payload.name.strip(),
|
||||
notes=payload.notes,
|
||||
)
|
||||
db.add(mix)
|
||||
db.commit()
|
||||
db.refresh(mix)
|
||||
# A brand-new mix has no products yet, so it reads as Inactive (no visible products).
|
||||
return _serialize_mix_row(mix, visible_count=0, product_count=0)
|
||||
|
||||
|
||||
@router.patch("/mixes/{mix_id}", response_model=EditorMixRow)
|
||||
def update_editor_mix(
|
||||
mix_id: int,
|
||||
@@ -377,6 +400,94 @@ def delete_editor_mix_ingredient(
|
||||
return _serialize_mix_formula(mix)
|
||||
|
||||
|
||||
@router.get("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
|
||||
def get_editor_mix_resolved_formula(
|
||||
mix_id: int,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""The mix formula as the Mix Calculator reads it (product-first resolution).
|
||||
|
||||
This is what the Mix Editor displays, so the two surfaces show identical
|
||||
ingredients and quantities. See `resolve_editor_mix_formula`.
|
||||
"""
|
||||
tenant_id = session.tenant_id or ""
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
|
||||
if mix is None:
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
|
||||
|
||||
|
||||
@router.put("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
|
||||
def replace_editor_mix_formula(
|
||||
mix_id: int,
|
||||
payload: EditorMixFormulaReplace,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Replace a mix's whole formula in one save.
|
||||
|
||||
Writes back to the *same source* the Mix Calculator reads: the representative
|
||||
product's own formula (`ProductIngredient`) when it has one, otherwise the
|
||||
shared mix master (`MixIngredient`). Either way the calculator immediately
|
||||
reflects the edit.
|
||||
"""
|
||||
tenant_id = session.tenant_id or ""
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
|
||||
if mix is None:
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
|
||||
raw_ids = [row.raw_material_id for row in payload.rows]
|
||||
if len(set(raw_ids)) != len(raw_ids):
|
||||
raise HTTPException(status_code=400, detail="Each raw material can only appear once in a mix")
|
||||
existing_ids = set(
|
||||
db.scalars(
|
||||
select(RawMaterial.id).where(RawMaterial.tenant_id == tenant_id, RawMaterial.id.in_(raw_ids))
|
||||
).all()
|
||||
)
|
||||
missing = [raw_id for raw_id in raw_ids if raw_id not in existing_ids]
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail="Raw material not found")
|
||||
|
||||
product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix_id)
|
||||
if product is not None and product.ingredients:
|
||||
# Replace the representative product's own formula.
|
||||
for ingredient in list(product.ingredients):
|
||||
db.delete(ingredient)
|
||||
db.flush()
|
||||
for sort_order, row in enumerate(payload.rows, start=1):
|
||||
db.add(
|
||||
ProductIngredient(
|
||||
tenant_id=tenant_id,
|
||||
product_id=product.id,
|
||||
raw_material_id=row.raw_material_id,
|
||||
quantity_kg=row.quantity_kg,
|
||||
sort_order=sort_order,
|
||||
notes=row.notes,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# No product-specific formula in play: edit the shared mix master, which
|
||||
# is what the calculator falls back to for this mix.
|
||||
for ingredient in list(mix.ingredients):
|
||||
db.delete(ingredient)
|
||||
db.flush()
|
||||
for row in payload.rows:
|
||||
db.add(
|
||||
MixIngredient(
|
||||
tenant_id=tenant_id,
|
||||
mix_id=mix.id,
|
||||
raw_material_id=row.raw_material_id,
|
||||
quantity_kg=row.quantity_kg,
|
||||
notes=row.notes,
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
|
||||
return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
|
||||
def get_editor_product_ingredients(
|
||||
product_id: int,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""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()]
|
||||
@@ -58,6 +58,12 @@ class Settings:
|
||||
login_rate_limit_window_seconds: int
|
||||
trusted_hosts: tuple[str, ...]
|
||||
docs_enabled: bool
|
||||
# Static API key for the read-only Power BI / external data API (`/api/v1`).
|
||||
# Blank disables the API entirely (every request returns 503).
|
||||
powerbi_api_key: str
|
||||
# Tenant the Power BI API reads from. Defaults to the costing client tenant
|
||||
# (where internal staff store throughput), so a single key serves Irwin.
|
||||
powerbi_tenant_id: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
@@ -98,6 +104,8 @@ class Settings:
|
||||
login_rate_limit_window_seconds=int(os.getenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "300")),
|
||||
trusted_hosts=_parse_csv_env(os.getenv("TRUSTED_HOSTS", "localhost,127.0.0.1,testserver")),
|
||||
docs_enabled=_env_flag("DOCS_ENABLED", default=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")).lower() != "production"),
|
||||
powerbi_api_key=os.getenv("POWERBI_API_KEY", "").strip(),
|
||||
powerbi_tenant_id=os.getenv("POWERBI_TENANT_ID", os.getenv("CLIENT_TENANT_ID", "hunter-premium-produce")).strip(),
|
||||
)
|
||||
settings._validate()
|
||||
return settings
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.api.ordering_admin import router as ordering_admin_router
|
||||
from app.api.powerbi import router as powerbi_router
|
||||
from app.api.product_costing import router as product_costing_router
|
||||
from app.api.products import router as products_router
|
||||
from app.api.public_v1 import router as public_v1_router
|
||||
from app.api.raw_materials import router as raw_materials_router
|
||||
from app.api.scenarios import router as scenarios_router
|
||||
from app.api.throughput import router as throughput_router
|
||||
@@ -209,6 +210,7 @@ app.include_router(throughput_router)
|
||||
app.include_router(ordering_router)
|
||||
app.include_router(ordering_admin_router)
|
||||
app.include_router(powerbi_router)
|
||||
app.include_router(public_v1_router)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -270,6 +272,11 @@ async def enforce_request_limits_and_csrf(request: Request, call_next):
|
||||
"script-src 'self'; "
|
||||
"font-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
# PDF previews/printing load a same-origin blob: URL into an iframe.
|
||||
# Without an explicit frame-src/child-src these fall back to default-src
|
||||
# ('self'), which blocks blob: and breaks the in-app print dialog.
|
||||
"frame-src 'self' blob:; "
|
||||
"child-src 'self' blob:; "
|
||||
"frame-ancestors 'self'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
@@ -315,6 +322,7 @@ def root():
|
||||
"scenarios": "/api/scenarios",
|
||||
"operations_throughput": "/api/throughput",
|
||||
"client_access": "/api/client-access",
|
||||
"powerbi_throughput": "/api/v1/throughput",
|
||||
"docs": "/docs",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@ class EditorProductUpdate(BaseModel):
|
||||
notes: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class EditorMixCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
client_name: str = Field(min_length=1, max_length=255)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
notes: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class EditorMixUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -71,6 +79,55 @@ class EditorMixFormulaRead(BaseModel):
|
||||
total_kg: float
|
||||
|
||||
|
||||
class EditorResolvedMixIngredient(BaseModel):
|
||||
raw_material_id: int
|
||||
raw_material_name: str
|
||||
quantity_kg: float
|
||||
# This row's share of the mix total, matching the Mix Calculator.
|
||||
mix_percentage: float
|
||||
unit: str
|
||||
notes: str | None
|
||||
|
||||
|
||||
class EditorResolvedMixFormula(BaseModel):
|
||||
"""A mix formula resolved the way the Mix Calculator reads it.
|
||||
|
||||
`source` is `product` when the numbers come from a representative product's
|
||||
own formula, or `mix` when they come from the shared mix master fallback.
|
||||
The PUT endpoint writes back to whichever source produced these rows.
|
||||
"""
|
||||
|
||||
id: int
|
||||
tenant_id: str
|
||||
client_name: str
|
||||
name: str
|
||||
source: str
|
||||
product_id: int | None
|
||||
ingredients: list[EditorResolvedMixIngredient]
|
||||
total_kg: float
|
||||
|
||||
|
||||
class EditorMixFormulaRowInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
raw_material_id: int
|
||||
quantity_kg: float = Field(gt=0)
|
||||
notes: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class EditorMixFormulaReplace(BaseModel):
|
||||
"""Full replacement of a mix's formula in one save.
|
||||
|
||||
The frontend keeps kilograms as the canonical value (percentages are an
|
||||
entry aid that resolve back to kg against the total), so the API only needs
|
||||
the resolved kg per row.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
rows: list[EditorMixFormulaRowInput] = Field(min_length=1)
|
||||
|
||||
|
||||
class EditorMixIngredientCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -93,6 +93,96 @@ def _mix_calculator_option_rank(product: Product) -> tuple[int, int, float, int]
|
||||
)
|
||||
|
||||
|
||||
def resolve_representative_product(db: Session, *, tenant_id: str, mix_id: int) -> Product | None:
|
||||
"""The single product the Mix Calculator surfaces for a given mix.
|
||||
|
||||
The calculator lists one representative product per (client, mix) and reads
|
||||
its formula. The Mix Editor reuses this so it edits exactly what the
|
||||
calculator shows. Preference order mirrors `build_mix_calculator_options`:
|
||||
visible products that already have a product-specific formula, ranked by
|
||||
`_mix_calculator_option_rank`; then any visible product; then any product.
|
||||
"""
|
||||
products = db.scalars(
|
||||
select(Product)
|
||||
.where(Product.tenant_id == tenant_id, Product.mix_id == mix_id)
|
||||
.options(
|
||||
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
|
||||
selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material),
|
||||
)
|
||||
).all()
|
||||
if not products:
|
||||
return None
|
||||
with_formula = [product for product in products if product.visible and product.ingredients]
|
||||
pool = with_formula or [product for product in products if product.visible] or list(products)
|
||||
return min(pool, key=_mix_calculator_option_rank)
|
||||
|
||||
|
||||
def resolve_editor_mix_formula(db: Session, *, tenant_id: str, mix: Mix) -> dict:
|
||||
"""Resolve a mix's formula the way the calculator does, for the editor.
|
||||
|
||||
Returns the resolved ingredient rows (with each row's share of the total as
|
||||
`mix_percentage`), the total kg, and where the formula lives:
|
||||
`source='product'` (a representative product's own formula) or `source='mix'`
|
||||
(the shared mix master fallback). `product_id` names the product that owns the
|
||||
formula when `source='product'`. The save path writes back to that same source.
|
||||
"""
|
||||
product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix.id)
|
||||
if product is not None and product.ingredients:
|
||||
rows, total_kg = _resolved_formula_rows(product)
|
||||
# Carry each ingredient's note through so saving doesn't wipe it.
|
||||
notes_by_raw_material = {
|
||||
ingredient.raw_material_id: ingredient.notes for ingredient in product.ingredients
|
||||
}
|
||||
for row in rows:
|
||||
row["notes"] = notes_by_raw_material.get(row["raw_material_id"])
|
||||
source = "product"
|
||||
product_id = product.id
|
||||
else:
|
||||
# No product-specific formula: the calculator reads the shared mix master,
|
||||
# so the editor shows and edits that.
|
||||
rows = [
|
||||
{
|
||||
"raw_material_id": ingredient.raw_material_id,
|
||||
"raw_material_name": ingredient.raw_material.name
|
||||
if ingredient.raw_material is not None
|
||||
else f"Raw material {ingredient.raw_material_id}",
|
||||
"quantity_kg": ingredient.quantity_kg,
|
||||
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
|
||||
"sort_order": index,
|
||||
"notes": ingredient.notes,
|
||||
}
|
||||
for index, ingredient in enumerate(
|
||||
sorted(mix.ingredients, key=lambda item: item.raw_material.name if item.raw_material else ""),
|
||||
start=1,
|
||||
)
|
||||
]
|
||||
total_kg = round(sum(row["quantity_kg"] for row in rows), 4)
|
||||
source = "mix"
|
||||
product_id = product.id if product is not None else None
|
||||
|
||||
ingredients = [
|
||||
{
|
||||
"raw_material_id": row["raw_material_id"],
|
||||
"raw_material_name": row["raw_material_name"],
|
||||
"quantity_kg": round(row["quantity_kg"], 4),
|
||||
"mix_percentage": round((row["quantity_kg"] / total_kg) * 100, 4) if total_kg > 0 else 0.0,
|
||||
"unit": row["unit"],
|
||||
"notes": row.get("notes"),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
return {
|
||||
"id": mix.id,
|
||||
"tenant_id": mix.tenant_id,
|
||||
"client_name": mix.client_name,
|
||||
"name": mix.name,
|
||||
"source": source,
|
||||
"product_id": product_id,
|
||||
"ingredients": ingredients,
|
||||
"total_kg": total_kg,
|
||||
}
|
||||
|
||||
|
||||
def calculate_mix_calculator_preview(
|
||||
db: Session,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""The Mix Editor must resolve and edit the SAME formula the Mix Calculator reads.
|
||||
|
||||
These cover `resolve_editor_mix_formula` / `resolve_representative_product`: a mix
|
||||
whose product carries its own formula resolves to that product (not the shared
|
||||
mix master), percentages are computed against the total, and the representative
|
||||
product is chosen the way the calculator chooses it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.db.session import Base
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.product import Product, ProductIngredient
|
||||
from app.models.raw_material import RawMaterial
|
||||
from app.services.mix_calculator_service import (
|
||||
resolve_editor_mix_formula,
|
||||
resolve_representative_product,
|
||||
)
|
||||
|
||||
TENANT = "hunter-premium-produce"
|
||||
|
||||
|
||||
def _session() -> Session:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, expire_on_commit=False)()
|
||||
|
||||
|
||||
def _raw(db: Session, name: str) -> RawMaterial:
|
||||
material = RawMaterial(tenant_id=TENANT, name=name, unit_of_measure="kg", kg_per_unit=1, status="active")
|
||||
db.add(material)
|
||||
db.flush()
|
||||
return material
|
||||
|
||||
|
||||
def test_resolves_product_formula_not_mix_master():
|
||||
db = _session()
|
||||
bayley = _raw(db, "Bayley")
|
||||
filler = _raw(db, "Filler")
|
||||
|
||||
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Pigeon Mix")
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
|
||||
# Shared mix master says something different from the product formula.
|
||||
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=bayley.id, quantity_kg=100))
|
||||
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=filler.id, quantity_kg=100))
|
||||
|
||||
product = Product(tenant_id=TENANT, client_name="Hunter", name="Pigeon 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
# The calculator's real numbers live here: 787.5 / 1320.41 ~ 59.6%.
|
||||
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=787.5, sort_order=1))
|
||||
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=532.91, sort_order=2))
|
||||
db.commit()
|
||||
|
||||
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
|
||||
|
||||
assert formula["source"] == "product"
|
||||
assert formula["product_id"] == product.id
|
||||
assert formula["total_kg"] == 1320.41
|
||||
by_name = {row["raw_material_name"]: row for row in formula["ingredients"]}
|
||||
assert by_name["Bayley"]["quantity_kg"] == 787.5
|
||||
# Percentage matches the worked example (787.5 / 1320.41 * 100).
|
||||
assert abs(by_name["Bayley"]["mix_percentage"] - 59.6406) < 0.001
|
||||
|
||||
|
||||
def test_falls_back_to_mix_master_when_no_product_formula():
|
||||
db = _session()
|
||||
maize = _raw(db, "Maize")
|
||||
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50))
|
||||
db.commit()
|
||||
|
||||
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
|
||||
assert formula["source"] == "mix"
|
||||
assert formula["total_kg"] == 50
|
||||
assert formula["ingredients"][0]["mix_percentage"] == 100.0
|
||||
|
||||
|
||||
def test_representative_product_prefers_20kg_bag():
|
||||
db = _session()
|
||||
maize = _raw(db, "Maize")
|
||||
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Dual Mix")
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
|
||||
bulka = Product(tenant_id=TENANT, client_name="Hunter", name="Dual Bulka", mix_id=mix.id, unit_of_measure="500kg bulka", visible=True)
|
||||
bag = Product(tenant_id=TENANT, client_name="Hunter", name="Dual 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
|
||||
db.add_all([bulka, bag])
|
||||
db.flush()
|
||||
for product in (bulka, bag):
|
||||
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=maize.id, quantity_kg=20, sort_order=1))
|
||||
db.commit()
|
||||
|
||||
representative = resolve_representative_product(db, tenant_id=TENANT, mix_id=mix.id)
|
||||
assert representative is not None
|
||||
assert representative.unit_of_measure == "20kg bag"
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for the read-only external data API (`/api/v1`) used by Power BI.
|
||||
|
||||
Drives the real FastAPI app via TestClient against an in-memory database, and
|
||||
swaps the configured API key in by replacing the module-level `settings` object
|
||||
(the real one is a frozen dataclass).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api import public_v1
|
||||
from app.core.config import settings
|
||||
from app.db.session import Base, get_db
|
||||
from app.main import app
|
||||
from app.models.throughput import ProductionThroughput
|
||||
|
||||
API_KEY = "test-powerbi-key"
|
||||
TENANT = "hunter-premium-produce"
|
||||
|
||||
|
||||
def _seed_entry(db, *, tenant_id: str, product: str, quantity: float, when: date) -> None:
|
||||
db.add(
|
||||
ProductionThroughput(
|
||||
tenant_id=tenant_id,
|
||||
production_date=when,
|
||||
product_name_snapshot=product,
|
||||
bag_size=20,
|
||||
quantity=quantity,
|
||||
quantity_type="bags",
|
||||
calculated_kg=quantity * 20,
|
||||
created_by="test",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_factory():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
def override_get_db():
|
||||
db = TestingSession()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
yield TestingSession
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _make_client(monkeypatch, *, api_key: str) -> TestClient:
|
||||
# Replace the whole settings object the router reads (frozen dataclass).
|
||||
monkeypatch.setattr(public_v1, "settings", replace(settings, powerbi_api_key=api_key, powerbi_tenant_id=TENANT))
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_disabled_when_key_unset(monkeypatch, db_factory):
|
||||
client = _make_client(monkeypatch, api_key="")
|
||||
response = client.get("/api/v1/throughput", headers={"X-API-Key": "anything"})
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def test_rejects_missing_and_wrong_key(monkeypatch, db_factory):
|
||||
client = _make_client(monkeypatch, api_key=API_KEY)
|
||||
assert client.get("/api/v1/throughput").status_code == 401
|
||||
assert client.get("/api/v1/throughput", headers={"X-API-Key": "nope"}).status_code == 401
|
||||
|
||||
|
||||
def test_returns_entries_for_tenant(monkeypatch, db_factory):
|
||||
db = db_factory()
|
||||
_seed_entry(db, tenant_id=TENANT, product="Maize", quantity=10, when=date(2026, 1, 5))
|
||||
_seed_entry(db, tenant_id=TENANT, product="Barley", quantity=5, when=date(2026, 1, 6))
|
||||
# An entry in another tenant must never leak through.
|
||||
_seed_entry(db, tenant_id="someone-else", product="Secret", quantity=99, when=date(2026, 1, 7))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
client = _make_client(monkeypatch, api_key=API_KEY)
|
||||
|
||||
# Header auth.
|
||||
response = client.get("/api/v1/throughput", headers={"X-API-Key": API_KEY})
|
||||
assert response.status_code == 200
|
||||
rows = response.json()
|
||||
names = [row["product_name_snapshot"] for row in rows]
|
||||
assert names == ["Maize", "Barley"] # oldest first, other tenant excluded
|
||||
assert rows[0]["calculated_kg"] == 200.0
|
||||
|
||||
# Query-string auth works too (Power BI Web connector convenience).
|
||||
assert client.get(f"/api/v1/throughput?api_key={API_KEY}").status_code == 200
|
||||
|
||||
|
||||
def test_date_filter(monkeypatch, db_factory):
|
||||
db = db_factory()
|
||||
_seed_entry(db, tenant_id=TENANT, product="Old", quantity=1, when=date(2026, 1, 1))
|
||||
_seed_entry(db, tenant_id=TENANT, product="New", quantity=1, when=date(2026, 2, 1))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
client = _make_client(monkeypatch, api_key=API_KEY)
|
||||
response = client.get("/api/v1/throughput?date_from=2026-01-15", headers={"X-API-Key": API_KEY})
|
||||
assert [row["product_name_snapshot"] for row in response.json()] == ["New"]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Regression guards for the print/PDF Content-Security-Policy.
|
||||
|
||||
The in-app print dialog loads a generated PDF as a same-origin ``blob:`` URL into
|
||||
an iframe and calls ``contentWindow.print()``. If the CSP omits ``frame-src`` /
|
||||
``child-src`` for ``blob:`` the directive falls back to ``default-src 'self'``,
|
||||
which silently blocks the frame and breaks printing for every user (regardless of
|
||||
role). These tests pin the policy on both layers that emit it:
|
||||
|
||||
* the FastAPI security middleware (covers every API response), and
|
||||
* the production nginx config (the source of the *document* CSP that actually
|
||||
governs ``frame-src`` in the browser).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import issue_token
|
||||
from app.main import app
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
NGINX_CONFIGS = [REPO_ROOT / "deploy" / "nginx" / "clients.lean-101.conf"]
|
||||
|
||||
# Directives the print flow depends on. blob: must be framable, and that must not
|
||||
# come at the cost of dropping the same-origin baseline.
|
||||
REQUIRED_FRAME_SOURCES = {"'self'", "blob:"}
|
||||
|
||||
|
||||
def _parse_csp(header: str) -> dict[str, set[str]]:
|
||||
"""Parse a CSP header string into ``{directive: {sources}}``."""
|
||||
directives: dict[str, set[str]] = {}
|
||||
for part in header.split(";"):
|
||||
tokens = part.split()
|
||||
if not tokens:
|
||||
continue
|
||||
directives[tokens[0].lower()] = set(tokens[1:])
|
||||
return directives
|
||||
|
||||
|
||||
def _assert_blob_framing(header: str) -> None:
|
||||
csp = _parse_csp(header)
|
||||
# frame-src must exist and allow self + blob (no falling back to default-src).
|
||||
assert "frame-src" in csp, f"frame-src missing from CSP: {header!r}"
|
||||
assert REQUIRED_FRAME_SOURCES <= csp["frame-src"], (
|
||||
f"frame-src must allow {REQUIRED_FRAME_SOURCES}, got {csp['frame-src']}"
|
||||
)
|
||||
# child-src is the Safari fallback for frame-src; keep it aligned.
|
||||
assert "child-src" in csp, f"child-src missing from CSP: {header!r}"
|
||||
assert "blob:" in csp["child-src"], f"child-src must allow blob:, got {csp['child-src']}"
|
||||
# We only widened framing: the same-origin default must stay intact.
|
||||
assert csp.get("default-src") == {"'self'"}, f"default-src weakened: {csp.get('default-src')}"
|
||||
|
||||
|
||||
# --- Backend middleware policy ------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client() -> TestClient:
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def test_backend_csp_allows_blob_frames(client: TestClient) -> None:
|
||||
response = client.get("/health")
|
||||
assert "content-security-policy" in response.headers
|
||||
_assert_blob_framing(response.headers["content-security-policy"])
|
||||
|
||||
|
||||
def test_backend_csp_present_for_all_users(client: TestClient) -> None:
|
||||
"""The policy is identical for anonymous, authenticated, and rejected (401)
|
||||
requests, so printing can never depend on who is signed in."""
|
||||
admin_token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"})
|
||||
|
||||
responses = [
|
||||
client.get("/health"), # anonymous
|
||||
client.get("/api/access/me"), # the endpoint that 401s for warehouse users
|
||||
client.get("/api/access/me", headers={"Authorization": f"Bearer {admin_token}"}),
|
||||
]
|
||||
|
||||
policies = set()
|
||||
for response in responses:
|
||||
header = response.headers.get("content-security-policy")
|
||||
assert header is not None, f"CSP missing on {response.request.url} ({response.status_code})"
|
||||
_assert_blob_framing(header)
|
||||
policies.add(header)
|
||||
|
||||
assert len(policies) == 1, "CSP must not vary by authentication state"
|
||||
|
||||
|
||||
# --- Production document policy (nginx) ---------------------------------------
|
||||
|
||||
|
||||
def test_nginx_csp_allows_blob_frames() -> None:
|
||||
"""Every CSP the production nginx emits must allow blob framing. This guards
|
||||
the *document* policy, which is what the browser enforces for the print iframe."""
|
||||
csp_line = re.compile(r'Content-Security-Policy\s+"([^"]+)"', re.IGNORECASE)
|
||||
|
||||
for config_path in NGINX_CONFIGS:
|
||||
assert config_path.exists(), f"missing nginx config: {config_path}"
|
||||
text = config_path.read_text(encoding="utf-8")
|
||||
policies = csp_line.findall(text)
|
||||
assert policies, f"no Content-Security-Policy header found in {config_path}"
|
||||
for policy in policies:
|
||||
_assert_blob_framing(policy)
|
||||
Reference in New Issue
Block a user