121fbdafd3
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>
200 lines
6.3 KiB
Python
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],
|
|
)
|