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:
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
84
backend/app/routers/auth.py
Normal file
84
backend/app/routers/auth.py
Normal 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
|
||||
128
backend/app/routers/communes.py
Normal file
128
backend/app/routers/communes.py
Normal 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
|
||||
102
backend/app/routers/content.py
Normal file
102
backend/app/routers/content.py
Normal 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"}
|
||||
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()
|
||||
96
backend/app/routers/tariff.py
Normal file
96
backend/app/routers/tariff.py
Normal 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
|
||||
],
|
||||
)
|
||||
287
backend/app/routers/votes.py
Normal file
287
backend/app/routers/votes.py
Normal 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
|
||||
]
|
||||
Reference in New Issue
Block a user