Files
sejeteralo/backend/app/routers/households.py
Yvv 5dc42af33e Add interactive citizen page with sidebar, display settings, and adaptive CSS
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>
2026-02-23 21:00:22 +01:00

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()