Access permissions, seed permissions, security, session, api/session improved handling + speed across the site/UX improvements
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"""Dashboard summary endpoint.
|
||||
|
||||
Returns only the aggregates the homepage actually renders — counts, top items,
|
||||
totals, and a trend-chart series. Replaces a Dashboard load that previously
|
||||
fetched five full collections (raw materials, mixes, all product cost
|
||||
breakdowns, scenarios, data-quality) and only used summaries from each.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import AuthSession, require_client_session
|
||||
from app.db.session import get_db
|
||||
from app.models.mix import Mix
|
||||
from app.models.product import Product
|
||||
from app.models.raw_material import RawMaterial
|
||||
from app.services.client_access_service import has_access_level
|
||||
from app.services.costing_engine import (
|
||||
calculate_mix_cost,
|
||||
calculate_product_cost,
|
||||
get_active_price,
|
||||
calculate_raw_material_cost,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
def _can(session: AuthSession, module_key: str) -> bool:
|
||||
permissions = session.module_permissions or {}
|
||||
return has_access_level(permissions.get(module_key), "view")
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def dashboard_summary(
|
||||
session: AuthSession = Depends(require_client_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
raw_materials_summary: dict | None = None
|
||||
mixes_summary: dict | None = None
|
||||
products_summary: dict | None = None
|
||||
raw_series: list[float] = []
|
||||
mix_series: list[float] = []
|
||||
product_series: list[float] = []
|
||||
|
||||
if _can(session, "raw_materials") or _can(session, "dashboard"):
|
||||
materials = db.scalars(
|
||||
select(RawMaterial)
|
||||
.where(RawMaterial.tenant_id == session.tenant_id)
|
||||
.options(selectinload(RawMaterial.price_versions))
|
||||
).all()
|
||||
|
||||
total_market_value = 0.0
|
||||
latest = None
|
||||
latest_date = None
|
||||
for material in materials:
|
||||
active = get_active_price(material)
|
||||
if active is None:
|
||||
continue
|
||||
comp = calculate_raw_material_cost(material, active)
|
||||
raw_series.append(comp.cost_per_kg)
|
||||
total_market_value += active.market_value
|
||||
if latest_date is None or active.effective_date > latest_date:
|
||||
latest_date = active.effective_date
|
||||
latest = {
|
||||
"id": material.id,
|
||||
"name": material.name,
|
||||
"market_value": active.market_value,
|
||||
"cost_per_kg": comp.cost_per_kg,
|
||||
"effective_date": active.effective_date.isoformat() if active.effective_date else None,
|
||||
}
|
||||
|
||||
raw_materials_summary = {
|
||||
"count": len(materials),
|
||||
"total_market_value": round(total_market_value, 4),
|
||||
"latest": latest,
|
||||
}
|
||||
|
||||
if _can(session, "mix_master") or _can(session, "dashboard"):
|
||||
mix_rows = db.scalars(
|
||||
select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)
|
||||
).all()
|
||||
cost_sum = 0.0
|
||||
cost_count = 0
|
||||
top_mix: dict | None = None
|
||||
for mix in mix_rows:
|
||||
result = calculate_mix_cost(db, mix.id)
|
||||
cost_per_kg = result.get("mix_cost_per_kg")
|
||||
if cost_per_kg is not None:
|
||||
mix_series.append(cost_per_kg)
|
||||
cost_sum += cost_per_kg
|
||||
cost_count += 1
|
||||
if top_mix is None or (cost_per_kg or 0) > (top_mix.get("mix_cost_per_kg") or 0):
|
||||
top_mix = {
|
||||
"id": result["id"],
|
||||
"name": result["name"],
|
||||
"client_name": result["client_name"],
|
||||
"ingredients_count": len(result["ingredients"]),
|
||||
"total_mix_kg": result["total_mix_kg"],
|
||||
"total_mix_cost": result["total_mix_cost"],
|
||||
"mix_cost_per_kg": cost_per_kg,
|
||||
"warnings": result["warnings"],
|
||||
}
|
||||
|
||||
mixes_summary = {
|
||||
"count": len(mix_rows),
|
||||
"average_cost_per_kg": round(cost_sum / cost_count, 4) if cost_count else 0.0,
|
||||
"top": top_mix,
|
||||
}
|
||||
|
||||
if _can(session, "products") or _can(session, "dashboard"):
|
||||
products = db.scalars(
|
||||
select(Product).where(Product.tenant_id == session.tenant_id)
|
||||
).all()
|
||||
rows: list[dict] = []
|
||||
for product in products:
|
||||
result = calculate_product_cost(db, product.id)
|
||||
finished = result.get("finished_product_delivered") or 0.0
|
||||
product_series.append(finished)
|
||||
rows.append(
|
||||
{
|
||||
"id": product.id,
|
||||
"product_name": result["product_name"],
|
||||
"client_name": result["client_name"],
|
||||
"finished_product_delivered": finished,
|
||||
"warnings": result["warnings"],
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(key=lambda row: row["finished_product_delivered"], reverse=True)
|
||||
products_summary = {
|
||||
"count": len(products),
|
||||
"top": rows[0] if rows else None,
|
||||
"top_products": rows[:4],
|
||||
}
|
||||
|
||||
return {
|
||||
"raw_materials": raw_materials_summary,
|
||||
"mixes": mixes_summary,
|
||||
"products": products_summary,
|
||||
# Pre-computed numeric series for the homepage trend chart so the
|
||||
# client doesn't need full collections to draw it.
|
||||
"trend_seeds": {
|
||||
"raw_material_cost_per_kg": raw_series,
|
||||
"mix_cost_per_kg": mix_series,
|
||||
"product_finished_delivered": product_series,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user