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

View File

@@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models import AdminUser, Household, Commune
from app.schemas import AdminLogin, CitizenVerify, Token, AdminUserCreate, AdminUserOut
from app.services.auth_service import (
verify_password, create_admin_token, create_citizen_token,
hash_password, require_super_admin,
)
router = APIRouter()
@router.post("/admin/login", response_model=Token)
async def admin_login(data: AdminLogin, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(AdminUser)
.options(selectinload(AdminUser.communes))
.where(AdminUser.email == data.email)
)
admin = result.scalar_one_or_none()
if not admin or not verify_password(data.password, admin.hashed_password):
raise HTTPException(status_code=401, detail="Identifiants invalides")
# For commune_admin, include their first commune slug
commune_slug = None
if admin.communes:
commune_slug = admin.communes[0].slug
return Token(
access_token=create_admin_token(admin),
role=admin.role,
commune_slug=commune_slug,
)
@router.post("/citizen/verify", response_model=Token)
async def citizen_verify(data: CitizenVerify, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Household)
.join(Commune)
.where(Commune.slug == data.commune_slug, Household.auth_code == data.auth_code)
)
household = result.scalar_one_or_none()
if not household:
raise HTTPException(status_code=401, detail="Code invalide ou commune introuvable")
return Token(
access_token=create_citizen_token(household, data.commune_slug),
role="citizen",
commune_slug=data.commune_slug,
)
@router.post("/admin/create", response_model=AdminUserOut)
async def create_admin(
data: AdminUserCreate,
db: AsyncSession = Depends(get_db),
current: AdminUser = Depends(require_super_admin),
):
existing = await db.execute(select(AdminUser).where(AdminUser.email == data.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email déjà utilisé")
admin = AdminUser(
email=data.email,
hashed_password=hash_password(data.password),
full_name=data.full_name,
role=data.role,
)
if data.commune_slugs:
for slug in data.commune_slugs:
result = await db.execute(select(Commune).where(Commune.slug == slug))
commune = result.scalar_one_or_none()
if commune:
admin.communes.append(commune)
db.add(admin)
await db.commit()
await db.refresh(admin)
return admin

View File

@@ -0,0 +1,128 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from app.database import get_db
from app.models import Commune, TariffParams, Household, Vote, CommuneContent, AdminUser, admin_commune_table
from app.schemas import (
CommuneCreate, CommuneUpdate, CommuneOut,
TariffParamsUpdate, TariffParamsOut,
)
from app.services.auth_service import get_current_admin, require_super_admin
router = APIRouter()
@router.get("/", response_model=list[CommuneOut])
async def list_communes(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Commune).where(Commune.is_active == True))
return result.scalars().all()
@router.post("/", response_model=CommuneOut)
async def create_commune(
data: CommuneCreate,
db: AsyncSession = Depends(get_db),
admin: AdminUser = Depends(require_super_admin),
):
existing = await db.execute(select(Commune).where(Commune.slug == data.slug))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Slug déjà utilisé")
commune = Commune(name=data.name, slug=data.slug, description=data.description)
db.add(commune)
await db.flush()
params = TariffParams(commune_id=commune.id)
db.add(params)
await db.commit()
await db.refresh(commune)
return commune
@router.get("/{slug}", response_model=CommuneOut)
async def get_commune(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")
return commune
@router.put("/{slug}", response_model=CommuneOut)
async def update_commune(
slug: str,
data: CommuneUpdate,
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")
if data.name is not None:
commune.name = data.name
if data.description is not None:
commune.description = data.description
if data.is_active is not None:
commune.is_active = data.is_active
await db.commit()
await db.refresh(commune)
return commune
@router.delete("/{slug}")
async def delete_commune(
slug: str,
db: AsyncSession = Depends(get_db),
admin: AdminUser = Depends(require_super_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")
# Delete related data in order
await db.execute(delete(Vote).where(Vote.commune_id == commune.id))
await db.execute(delete(Household).where(Household.commune_id == commune.id))
await db.execute(delete(TariffParams).where(TariffParams.commune_id == commune.id))
await db.execute(delete(CommuneContent).where(CommuneContent.commune_id == commune.id))
await db.execute(delete(admin_commune_table).where(admin_commune_table.c.commune_id == commune.id))
await db.delete(commune)
await db.commit()
return {"detail": f"Commune '{slug}' supprimée"}
@router.get("/{slug}/params", response_model=TariffParamsOut)
async def get_params(slug: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(TariffParams).join(Commune).where(Commune.slug == slug)
)
params = result.scalar_one_or_none()
if not params:
raise HTTPException(status_code=404, detail="Paramètres introuvables")
return params
@router.put("/{slug}/params", response_model=TariffParamsOut)
async def update_params(
slug: str,
data: TariffParamsUpdate,
db: AsyncSession = Depends(get_db),
admin: AdminUser = Depends(get_current_admin),
):
result = await db.execute(
select(TariffParams).join(Commune).where(Commune.slug == slug)
)
params = result.scalar_one_or_none()
if not params:
raise HTTPException(status_code=404, detail="Paramètres introuvables")
for field, value in data.model_dump(exclude_unset=True).items():
setattr(params, field, value)
await db.commit()
await db.refresh(params)
return params

View File

@@ -0,0 +1,102 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models import Commune, CommuneContent, AdminUser
from app.schemas import ContentUpdate, ContentOut
from app.services.auth_service import get_current_admin
router = APIRouter()
async def _get_commune(slug: str, db: AsyncSession) -> Commune:
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")
return commune
@router.get("/{slug}/content", response_model=list[ContentOut])
async def list_content(slug: str, db: AsyncSession = Depends(get_db)):
"""List all content pages for a commune (public)."""
commune = await _get_commune(slug, db)
result = await db.execute(
select(CommuneContent)
.where(CommuneContent.commune_id == commune.id)
.order_by(CommuneContent.slug)
)
return result.scalars().all()
@router.get("/{slug}/content/{page_slug}", response_model=ContentOut)
async def get_content(slug: str, page_slug: str, db: AsyncSession = Depends(get_db)):
"""Get a specific content page (public)."""
commune = await _get_commune(slug, db)
result = await db.execute(
select(CommuneContent)
.where(CommuneContent.commune_id == commune.id)
.where(CommuneContent.slug == page_slug)
)
content = result.scalar_one_or_none()
if not content:
raise HTTPException(status_code=404, detail="Page introuvable")
return content
@router.put("/{slug}/content/{page_slug}", response_model=ContentOut)
async def upsert_content(
slug: str,
page_slug: str,
data: ContentUpdate,
db: AsyncSession = Depends(get_db),
admin: AdminUser = Depends(get_current_admin),
):
"""Create or update a content page (admin only)."""
commune = await _get_commune(slug, db)
result = await db.execute(
select(CommuneContent)
.where(CommuneContent.commune_id == commune.id)
.where(CommuneContent.slug == page_slug)
)
content = result.scalar_one_or_none()
if content:
content.title = data.title
content.body_markdown = data.body_markdown
else:
content = CommuneContent(
commune_id=commune.id,
slug=page_slug,
title=data.title,
body_markdown=data.body_markdown,
)
db.add(content)
await db.commit()
await db.refresh(content)
return content
@router.delete("/{slug}/content/{page_slug}")
async def delete_content(
slug: str,
page_slug: str,
db: AsyncSession = Depends(get_db),
admin: AdminUser = Depends(get_current_admin),
):
"""Delete a content page (admin only)."""
commune = await _get_commune(slug, db)
result = await db.execute(
select(CommuneContent)
.where(CommuneContent.commune_id == commune.id)
.where(CommuneContent.slug == page_slug)
)
content = result.scalar_one_or_none()
if not content:
raise HTTPException(status_code=404, detail="Page introuvable")
await db.delete(content)
await db.commit()
return {"detail": f"Page '{page_slug}' supprimée"}

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

View File

@@ -0,0 +1,96 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models import Commune, TariffParams, Household
from app.schemas import TariffComputeRequest, TariffComputeResponse, ImpactRowOut
from app.engine.pricing import HouseholdData, compute_tariff, compute_impacts
router = APIRouter()
async def _load_commune_data(
slug: str, db: AsyncSession
) -> tuple[list[HouseholdData], TariffParams]:
"""Load households and tariff params for a commune."""
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")
params_result = await db.execute(
select(TariffParams).where(TariffParams.commune_id == commune.id)
)
params = params_result.scalar_one_or_none()
if not params:
raise HTTPException(status_code=404, detail="Paramètres tarifs manquants")
hh_result = await db.execute(
select(Household).where(Household.commune_id == commune.id)
)
households_db = hh_result.scalars().all()
if not households_db:
raise HTTPException(status_code=400, detail="Aucun foyer importé pour cette commune")
households = [
HouseholdData(
volume_m3=h.volume_m3,
status=h.status,
price_paid_eur=h.price_paid_eur,
)
for h in households_db
]
return households, params
@router.post("/compute", response_model=TariffComputeResponse)
async def compute(data: TariffComputeRequest, db: AsyncSession = Depends(get_db)):
households, params = await _load_commune_data(data.commune_slug, db)
result = compute_tariff(
households,
recettes=params.recettes,
abop=params.abop,
abos=params.abos,
vinf=data.vinf,
vmax=params.vmax,
pmax=params.pmax,
a=data.a,
b=data.b,
c=data.c,
d=data.d,
e=data.e,
)
p0, impacts = compute_impacts(
households,
recettes=params.recettes,
abop=params.abop,
abos=params.abos,
vinf=data.vinf,
vmax=params.vmax,
pmax=params.pmax,
a=data.a,
b=data.b,
c=data.c,
d=data.d,
e=data.e,
)
return TariffComputeResponse(
p0=result.p0,
curve_volumes=result.curve_volumes,
curve_prices_m3=result.curve_prices_m3,
curve_bills_rp=result.curve_bills_rp,
curve_bills_rs=result.curve_bills_rs,
impacts=[
ImpactRowOut(
volume=imp.volume,
old_price=imp.old_price,
new_price_rp=imp.new_price_rp,
new_price_rs=imp.new_price_rs,
)
for imp in impacts
],
)

View File

@@ -0,0 +1,287 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from app.database import get_db
from app.models import Commune, Household, Vote, TariffParams, AdminUser
from app.schemas import VoteCreate, VoteOut, MedianOut, TariffComputeResponse, ImpactRowOut
from app.services.auth_service import get_current_citizen, get_current_admin
from app.engine.pricing import HouseholdData, compute_p0, compute_tariff, compute_impacts
from app.engine.current_model import compute_linear_tariff
from app.engine.median import VoteParams, compute_median
router = APIRouter()
async def _get_commune_by_slug(slug: str, db: AsyncSession) -> Commune:
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")
return commune
async def _load_commune_context(commune_id: int, db: AsyncSession):
"""Load tariff params and households for a commune."""
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_db = hh_result.scalars().all()
households = [
HouseholdData(volume_m3=h.volume_m3, status=h.status, price_paid_eur=h.price_paid_eur)
for h in households_db
]
return params, households
# ── Public endpoint: current median curve for citizens ──
@router.get("/communes/{slug}/votes/current")
async def current_curve(slug: str, db: AsyncSession = Depends(get_db)):
"""
Public endpoint: returns the current median curve + baseline linear model.
No auth required — this is what citizens see when they visit a commune page.
Always returns the baseline linear model.
Returns the median Bézier curve only if votes exist.
"""
commune = await _get_commune_by_slug(slug, db)
params, households = await _load_commune_context(commune.id, db)
if not params or not households:
return {"has_votes": False, "vote_count": 0}
# Always compute the baseline linear model
baseline = compute_linear_tariff(
households, recettes=params.recettes,
abop=params.abop, abos=params.abos, vmax=params.vmax,
)
baseline_data = {
"p0_linear": baseline.p0,
"baseline_volumes": baseline.curve_volumes,
"baseline_bills_rp": baseline.curve_bills_rp,
"baseline_bills_rs": baseline.curve_bills_rs,
"baseline_price_m3_rp": baseline.curve_price_m3_rp,
"baseline_price_m3_rs": baseline.curve_price_m3_rs,
}
# Tariff params for the frontend
tariff_params = {
"recettes": params.recettes,
"abop": params.abop,
"abos": params.abos,
"pmax": params.pmax,
"vmax": params.vmax,
}
# Get active votes
result = await db.execute(
select(Vote).where(Vote.commune_id == commune.id, Vote.is_active == True)
)
votes = result.scalars().all()
if not votes:
# Return default Bézier curve (a=b=c=d=e=0.5, vinf=vmax/2)
default_vinf = params.vmax / 2
default_tariff = compute_tariff(
households,
recettes=params.recettes, abop=params.abop, abos=params.abos,
vinf=default_vinf, vmax=params.vmax, pmax=params.pmax,
a=0.5, b=0.5, c=0.5, d=0.5, e=0.5,
)
_, default_impacts = compute_impacts(
households,
recettes=params.recettes, abop=params.abop, abos=params.abos,
vinf=default_vinf, vmax=params.vmax, pmax=params.pmax,
a=0.5, b=0.5, c=0.5, d=0.5, e=0.5,
)
return {
"has_votes": False,
"vote_count": 0,
"params": tariff_params,
"median": {
"vinf": default_vinf, "a": 0.5, "b": 0.5,
"c": 0.5, "d": 0.5, "e": 0.5,
},
"p0": default_tariff.p0,
"curve_volumes": default_tariff.curve_volumes,
"curve_prices_m3": default_tariff.curve_prices_m3,
"curve_bills_rp": default_tariff.curve_bills_rp,
"curve_bills_rs": default_tariff.curve_bills_rs,
"impacts": [
{"volume": imp.volume, "old_price": imp.old_price,
"new_price_rp": imp.new_price_rp, "new_price_rs": imp.new_price_rs}
for imp in default_impacts
],
**baseline_data,
}
# Compute median
vote_params = [
VoteParams(vinf=v.vinf, a=v.a, b=v.b, c=v.c, d=v.d, e=v.e)
for v in votes
]
median = compute_median(vote_params)
# Compute full tariff for the median
tariff = compute_tariff(
households,
recettes=params.recettes, abop=params.abop, abos=params.abos,
vinf=median.vinf, vmax=params.vmax, pmax=params.pmax,
a=median.a, b=median.b, c=median.c, d=median.d, e=median.e,
)
_, impacts = compute_impacts(
households,
recettes=params.recettes, abop=params.abop, abos=params.abos,
vinf=median.vinf, vmax=params.vmax, pmax=params.pmax,
a=median.a, b=median.b, c=median.c, d=median.d, e=median.e,
)
return {
"has_votes": True,
"vote_count": len(votes),
"params": tariff_params,
"median": {
"vinf": median.vinf,
"a": median.a,
"b": median.b,
"c": median.c,
"d": median.d,
"e": median.e,
},
"p0": tariff.p0,
"curve_volumes": tariff.curve_volumes,
"curve_prices_m3": tariff.curve_prices_m3,
"curve_bills_rp": tariff.curve_bills_rp,
"curve_bills_rs": tariff.curve_bills_rs,
"impacts": [
{"volume": imp.volume, "old_price": imp.old_price,
"new_price_rp": imp.new_price_rp, "new_price_rs": imp.new_price_rs}
for imp in impacts
],
**baseline_data,
}
# ── Citizen: submit vote ──
@router.post("/communes/{slug}/votes", response_model=VoteOut)
async def submit_vote(
slug: str,
data: VoteCreate,
db: AsyncSession = Depends(get_db),
household: Household = Depends(get_current_citizen),
):
commune = await _get_commune_by_slug(slug, db)
if household.commune_id != commune.id:
raise HTTPException(status_code=403, detail="Accès interdit à cette commune")
# Deactivate previous votes
await db.execute(
update(Vote)
.where(Vote.household_id == household.id, Vote.is_active == True)
.values(is_active=False)
)
params, households = await _load_commune_context(commune.id, db)
computed_p0 = compute_p0(
households,
recettes=params.recettes, abop=params.abop, abos=params.abos,
vinf=data.vinf, vmax=params.vmax, pmax=params.pmax,
a=data.a, b=data.b, c=data.c, d=data.d, e=data.e,
) if params else None
vote = Vote(
commune_id=commune.id,
household_id=household.id,
vinf=data.vinf, a=data.a, b=data.b, c=data.c, d=data.d, e=data.e,
computed_p0=computed_p0,
)
db.add(vote)
household.has_voted = True
await db.commit()
await db.refresh(vote)
return vote
# ── Admin: list votes ──
@router.get("/communes/{slug}/votes", response_model=list[VoteOut])
async def list_votes(
slug: str,
db: AsyncSession = Depends(get_db),
admin: AdminUser = Depends(get_current_admin),
):
commune = await _get_commune_by_slug(slug, db)
result = await db.execute(
select(Vote).where(Vote.commune_id == commune.id, Vote.is_active == True)
)
return result.scalars().all()
# ── Admin: median ──
@router.get("/communes/{slug}/votes/median", response_model=MedianOut)
async def vote_median(
slug: str,
db: AsyncSession = Depends(get_db),
admin: AdminUser = Depends(get_current_admin),
):
commune = await _get_commune_by_slug(slug, db)
result = await db.execute(
select(Vote).where(Vote.commune_id == commune.id, Vote.is_active == True)
)
votes = result.scalars().all()
if not votes:
raise HTTPException(status_code=404, detail="Aucun vote actif")
vote_params = [
VoteParams(vinf=v.vinf, a=v.a, b=v.b, c=v.c, d=v.d, e=v.e)
for v in votes
]
median = compute_median(vote_params)
params, households = await _load_commune_context(commune.id, db)
computed_p0 = compute_p0(
households,
recettes=params.recettes, abop=params.abop, abos=params.abos,
vinf=median.vinf, vmax=params.vmax, pmax=params.pmax,
a=median.a, b=median.b, c=median.c, d=median.d, e=median.e,
) if params else 0
return MedianOut(
vinf=median.vinf, a=median.a, b=median.b, c=median.c, d=median.d, e=median.e,
computed_p0=computed_p0, vote_count=len(votes),
)
# ── Admin: overlay ──
@router.get("/communes/{slug}/votes/overlay")
async def vote_overlay(
slug: str,
db: AsyncSession = Depends(get_db),
admin: AdminUser = Depends(get_current_admin),
):
commune = await _get_commune_by_slug(slug, db)
result = await db.execute(
select(Vote).where(Vote.commune_id == commune.id, Vote.is_active == True)
)
votes = result.scalars().all()
return [
{"id": v.id, "vinf": v.vinf, "a": v.a, "b": v.b,
"c": v.c, "d": v.d, "e": v.e, "computed_p0": v.computed_p0}
for v in votes
]