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:
Yvv
2026-02-21 15:26:02 +01:00
commit b30e54a8f7
67 changed files with 16723 additions and 0 deletions

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