Major rework of the citizen-facing page: - Chart + sidebar layout (auth/vote/countdown in right sidebar) - DisplaySettings component (font size, chart density, color palettes) - Adaptive CSS with clamp() throughout, responsive breakpoints at 480/768/1024 - Baseline charts zoomed on first tier for small consumption detail - Marginal price chart with dual Y-axes (foyers left, €/m³ right) - Key metrics banner (5 columns: recettes, palier, prix palier, prix médian, mon prix) - Client-side p0/impacts computation, draggable median price bar - Household dots toggle, vote overlay curves - Auth returns volume_m3, vote captures submitted_at - Cleaned header nav (removed Accueil/Super Admin for public visitors) - Terminology: foyer for bills, électeur for votes - 600m³ added to impact reference volumes - Realistic seed votes (50 votes, 3 profiles) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
196 lines
7.0 KiB
Python
196 lines
7.0 KiB
Python
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func
|
|
import io
|
|
import numpy as np
|
|
|
|
from app.database import get_db
|
|
from app.models import Commune, Household, TariffParams, AdminUser
|
|
from app.schemas import HouseholdOut, HouseholdStats, ImportPreview, ImportResult
|
|
from app.services.auth_service import get_current_admin
|
|
from app.services.import_service import parse_import_file, import_households, generate_template_csv
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/communes/{slug}/households/distribution")
|
|
async def household_distribution(
|
|
slug: str,
|
|
bucket_size: int = 50,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Public: returns consumption distribution as histogram buckets."""
|
|
result = await db.execute(select(Commune).where(Commune.slug == slug))
|
|
commune = result.scalar_one_or_none()
|
|
if not commune:
|
|
raise HTTPException(status_code=404, detail="Commune introuvable")
|
|
|
|
hh_result = await db.execute(
|
|
select(Household).where(Household.commune_id == commune.id)
|
|
)
|
|
households = hh_result.scalars().all()
|
|
|
|
if not households:
|
|
return {"buckets": [], "total": 0, "bucket_size": bucket_size}
|
|
|
|
volumes = [h.volume_m3 for h in households]
|
|
max_vol = max(volumes)
|
|
num_buckets = int(max_vol // bucket_size) + 1
|
|
|
|
buckets = []
|
|
for i in range(num_buckets):
|
|
low = i * bucket_size
|
|
high = (i + 1) * bucket_size
|
|
count = sum(1 for v in volumes if low <= v < high)
|
|
if count > 0 or i < num_buckets:
|
|
buckets.append({"low": low, "high": high, "count": count})
|
|
|
|
return {"buckets": buckets, "total": len(households), "bucket_size": bucket_size}
|
|
|
|
|
|
@router.get("/communes/{slug}/households/volumes")
|
|
async def household_volumes(
|
|
slug: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Public: returns anonymous list of household volumes and statuses for chart display."""
|
|
result = await db.execute(select(Commune).where(Commune.slug == slug))
|
|
commune = result.scalar_one_or_none()
|
|
if not commune:
|
|
raise HTTPException(status_code=404, detail="Commune introuvable")
|
|
|
|
hh_result = await db.execute(
|
|
select(Household.volume_m3, Household.status).where(Household.commune_id == commune.id)
|
|
)
|
|
rows = hh_result.all()
|
|
return [{"volume_m3": r.volume_m3, "status": r.status} for r in rows]
|
|
|
|
|
|
@router.get("/communes/{slug}/households/template")
|
|
async def download_template():
|
|
content = generate_template_csv()
|
|
return StreamingResponse(
|
|
io.BytesIO(content),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": "attachment; filename=template_foyers.csv"},
|
|
)
|
|
|
|
|
|
@router.get("/communes/{slug}/households/stats", response_model=HouseholdStats)
|
|
async def household_stats(slug: str, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Commune).where(Commune.slug == slug))
|
|
commune = result.scalar_one_or_none()
|
|
if not commune:
|
|
raise HTTPException(status_code=404, detail="Commune introuvable")
|
|
|
|
# Load tariff params for data_year
|
|
params_result = await db.execute(
|
|
select(TariffParams).where(TariffParams.commune_id == commune.id)
|
|
)
|
|
params = params_result.scalar_one_or_none()
|
|
|
|
hh_result = await db.execute(
|
|
select(Household).where(Household.commune_id == commune.id)
|
|
)
|
|
households = hh_result.scalars().all()
|
|
|
|
if not households:
|
|
return HouseholdStats(
|
|
total=0, rs_count=0, rp_count=0, pro_count=0,
|
|
total_volume=0, avg_volume=0, median_volume=0, voted_count=0,
|
|
data_year=params.data_year if params else None,
|
|
data_imported_at=params.data_imported_at if params else None,
|
|
)
|
|
|
|
volumes = [h.volume_m3 for h in households]
|
|
return HouseholdStats(
|
|
total=len(households),
|
|
rs_count=sum(1 for h in households if h.status == "RS"),
|
|
rp_count=sum(1 for h in households if h.status == "RP"),
|
|
pro_count=sum(1 for h in households if h.status == "PRO"),
|
|
total_volume=sum(volumes),
|
|
avg_volume=float(np.mean(volumes)),
|
|
median_volume=float(np.median(volumes)),
|
|
voted_count=sum(1 for h in households if h.has_voted),
|
|
data_year=params.data_year if params else None,
|
|
data_imported_at=params.data_imported_at if params else None,
|
|
)
|
|
|
|
|
|
@router.post("/communes/{slug}/households/import/preview", response_model=ImportPreview)
|
|
async def preview_import(
|
|
slug: str,
|
|
file: UploadFile = File(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
admin: AdminUser = Depends(get_current_admin),
|
|
):
|
|
result = await db.execute(select(Commune).where(Commune.slug == slug))
|
|
commune = result.scalar_one_or_none()
|
|
if not commune:
|
|
raise HTTPException(status_code=404, detail="Commune introuvable")
|
|
|
|
content = await file.read()
|
|
df, errors = parse_import_file(content, file.filename)
|
|
|
|
if df is None:
|
|
return ImportPreview(valid_rows=0, errors=errors, sample=[])
|
|
|
|
valid_rows = len(df) - len(errors)
|
|
sample = df.head(5).to_dict(orient="records")
|
|
return ImportPreview(valid_rows=valid_rows, errors=errors, sample=sample)
|
|
|
|
|
|
@router.post("/communes/{slug}/households/import", response_model=ImportResult)
|
|
async def do_import(
|
|
slug: str,
|
|
file: UploadFile = File(...),
|
|
data_year: int | None = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
admin: AdminUser = Depends(get_current_admin),
|
|
):
|
|
result = await db.execute(select(Commune).where(Commune.slug == slug))
|
|
commune = result.scalar_one_or_none()
|
|
if not commune:
|
|
raise HTTPException(status_code=404, detail="Commune introuvable")
|
|
|
|
content = await file.read()
|
|
df, parse_errors = parse_import_file(content, file.filename)
|
|
|
|
if df is None or parse_errors:
|
|
raise HTTPException(status_code=400, detail={"errors": parse_errors})
|
|
|
|
created, import_errors = await import_households(db, commune.id, df)
|
|
|
|
# Update data_imported_at and optional data_year on tariff params
|
|
params_result = await db.execute(
|
|
select(TariffParams).where(TariffParams.commune_id == commune.id)
|
|
)
|
|
params = params_result.scalar_one_or_none()
|
|
if params:
|
|
params.data_imported_at = datetime.utcnow()
|
|
if data_year is not None:
|
|
params.data_year = data_year
|
|
await db.commit()
|
|
|
|
return ImportResult(created=created, errors=import_errors)
|
|
|
|
|
|
@router.get("/communes/{slug}/households", response_model=list[HouseholdOut])
|
|
async def list_households(
|
|
slug: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
admin: AdminUser = Depends(get_current_admin),
|
|
):
|
|
result = await db.execute(select(Commune).where(Commune.slug == slug))
|
|
commune = result.scalar_one_or_none()
|
|
if not commune:
|
|
raise HTTPException(status_code=404, detail="Commune introuvable")
|
|
|
|
hh_result = await db.execute(
|
|
select(Household).where(Household.commune_id == commune.id)
|
|
)
|
|
return hh_result.scalars().all()
|