Files
Hartmut 121fbdafd3 refactor(phase3): remove dead services + STL remnant cleanup
Phase 3.2 — Delete orphaned service directories:
  - blender-renderer/ (HTTP microservice replaced by render-worker subprocess)
  - threejs-renderer/ (replaced by render-worker)
  - flamenco/ (removed in migration 032, directory still existed on disk)

Phase 3.2 — Remove STL workflow remnants:
  - analytics.py: remove avg_stl_s from RenderTimeBreakdown schema (always None)
  - kpi_service.py: remove avg_stl_s from return dicts + update docstring
  - frontend/src/api/analytics.ts: remove avg_stl_s from RenderTimeBreakdown interface
  - admin.py: remove dead blender-renderer HTTP configure call (service gone)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:30:52 +01:00

200 lines
6.3 KiB
Python

"""Analytics router — KPI dashboard endpoints."""
from datetime import date, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.database import get_db
from app.utils.auth import require_admin_or_pm
from app.models.user import User
from app.services import kpi_service
router = APIRouter(prefix="/analytics", tags=["analytics"])
# ── Response models ────────────────────────────────────────────────────────────
class ThroughputPoint(BaseModel):
week: str
count: int
completed: int
class RevenuePoint(BaseModel):
month: str
revenue: float
order_count: int
class ProcessingTimeStats(BaseModel):
avg_submit_to_complete_s: Optional[float]
avg_submit_to_processing_s: Optional[float]
p50_s: Optional[float]
p95_s: Optional[float]
class ItemStatusBreakdown(BaseModel):
pending: int
approved: int
rejected: int
class RenderTimeBreakdown(BaseModel):
avg_render_s: Optional[float]
avg_total_s: Optional[float]
sample_count: int
class TopLevelSummary(BaseModel):
total_orders: int
completed_orders: int
total_revenue: float
total_rendering_items: int
class CategoryCount(BaseModel):
category: str
count: int
class ProductCategoryStats(BaseModel):
unique_products_rendered: int
total_products: int
products_with_cad: int
products_by_category: list[CategoryCount]
class OutputTypeUsagePoint(BaseModel):
output_type: str
count: int
class RenderStatusDistribution(BaseModel):
pending: int
processing: int
completed: int
failed: int
class RendererUsagePoint(BaseModel):
renderer: str
count: int
class TopProductEntry(BaseModel):
pim_id: str
product_name: Optional[str]
category: str
order_count: int
class CategoryRevenueEntry(BaseModel):
category: str
order_count: int
revenue: float
class RenderBackendStatsEntry(BaseModel):
backend: str
total: int
completed: int
failed: int
avg_render_s: float | None
p50_render_s: float | None
class RenderTimeByOutputType(BaseModel):
output_type: str
job_count: int
avg_render_s: float | None
min_render_s: float | None
max_render_s: float | None
p50_render_s: float | None
class OrdersByUserEntry(BaseModel):
full_name: str
email: str
role: str
order_count: int
revenue: float
class DashboardKPIs(BaseModel):
summary: TopLevelSummary
throughput: list[ThroughputPoint]
revenue: list[RevenuePoint]
processing_times: ProcessingTimeStats
item_status: ItemStatusBreakdown
render_times: RenderTimeBreakdown
product_stats: ProductCategoryStats
output_type_usage: list[OutputTypeUsagePoint]
render_status: RenderStatusDistribution
renderer_usage: list[RendererUsagePoint]
top_products: list[TopProductEntry]
orders_by_user: list[OrdersByUserEntry]
category_revenue: list[CategoryRevenueEntry]
render_backend_stats: list[RenderBackendStatsEntry]
render_time_by_output_type: list[RenderTimeByOutputType]
# ── Endpoints ──────────────────────────────────────────────────────────────────
@router.get("/dashboard", response_model=DashboardKPIs)
async def get_dashboard_kpis(
date_from: date | None = Query(None, description="Start date (ISO), e.g. 2025-01-01"),
date_to: date | None = Query(None, description="End date (ISO), e.g. 2025-06-30"),
_user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
) -> DashboardKPIs:
"""Aggregate KPI data for the analytics dashboard."""
# Default: last 12 weeks
if date_to is None:
date_to = date.today()
if date_from is None:
date_from = date_to - timedelta(weeks=12)
df = date_from.isoformat()
dt = date_to.isoformat()
summary_data, throughput_data, revenue_data, proc_data, item_data, render_data = (
await kpi_service.top_level_summary(db, df, dt),
await kpi_service.order_throughput_by_week(db, df, dt),
await kpi_service.revenue_overview(db, df, dt),
await kpi_service.processing_time_stats(db, df, dt),
await kpi_service.item_status_breakdown(db, df, dt),
await kpi_service.render_time_breakdown(db, df, dt),
)
product_stats_data = await kpi_service.product_and_category_stats(db, df, dt)
ot_usage_data = await kpi_service.output_type_usage(db, df, dt)
render_status_data, renderer_usage_data = await kpi_service.render_status_distribution(db, df, dt)
top_products_data = await kpi_service.top_products(db, df, dt)
cat_revenue_data = await kpi_service.category_revenue(db, df, dt)
users_data = await kpi_service.orders_by_user(db, df, dt)
backend_stats_data = await kpi_service.render_backend_stats(db, df, dt)
render_time_ot_data = await kpi_service.render_time_by_output_type(db, df, dt)
return DashboardKPIs(
summary=TopLevelSummary(**summary_data),
throughput=[ThroughputPoint(**p) for p in throughput_data],
revenue=[RevenuePoint(**p) for p in revenue_data],
processing_times=ProcessingTimeStats(**proc_data),
item_status=ItemStatusBreakdown(**item_data),
render_times=RenderTimeBreakdown(**render_data),
product_stats=ProductCategoryStats(
**{**product_stats_data, "products_by_category": [
CategoryCount(**c) for c in product_stats_data["products_by_category"]
]}
),
output_type_usage=[OutputTypeUsagePoint(**p) for p in ot_usage_data],
render_status=RenderStatusDistribution(**render_status_data),
renderer_usage=[RendererUsagePoint(**p) for p in renderer_usage_data],
top_products=[TopProductEntry(**p) for p in top_products_data],
orders_by_user=[OrdersByUserEntry(**p) for p in users_data],
category_revenue=[CategoryRevenueEntry(**p) for p in cat_revenue_data],
render_backend_stats=[RenderBackendStatsEntry(**p) for p in backend_stats_data],
render_time_by_output_type=[RenderTimeByOutputType(**p) for p in render_time_ot_data],
)