Initial commit: SejeteralO water tarification platform
Full-stack app for participatory water pricing using Bezier curves. - Backend: FastAPI + SQLAlchemy + SQLite with JWT auth - Frontend: Nuxt 4 + TypeScript with interactive SVG editor - Math engine: cubic Bezier tarification with Cardano solver - Admin: commune management, household import, vote monitoring, CMS - Citizen: interactive curve editor, vote submission - Docker-compose deployment ready Includes fixes for: - Impact table snake_case/camelCase property mismatch - CMS content backend API + frontend editor (was stub) - Admin route protection middleware - Public content display on commune page - Vote confirmation page link fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
117
backend/app/routers/households.py
Normal file
117
backend/app/routers/households.py
Normal file
@@ -0,0 +1,117 @@
|
||||
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, 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/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")
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@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(...),
|
||||
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)
|
||||
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()
|
||||
Reference in New Issue
Block a user