Files
sejeteralo/backend/app/routers/votes.py
Yvv 1365f4c86c Fix critical bugs + add zoom/overlay for citizen chart
Bugs fixed:
- Auth middleware now works on page refresh (plugin restores
  auth from localStorage before middleware runs)
- Bezier drag no longer snaps back: removed client-side p0
  recalculation during drag, only server computes p0 on mouseUp
- Removed redundant /login.vue page (homepage already has links)

New features:
- Interactive zoom on Bezier chart (buttons + mouse wheel +
  tier 1/tier 2 presets)
- Toggle to display outlier vote curves (public overlay endpoint)
- Tier 1 curve visually emphasized (thicker stroke)
- Dev credentials file at data/DEV-CREDENTIALS.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:50:37 +01:00

305 lines
10 KiB
Python

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: overlay of all vote curves for citizens ──
@router.get("/communes/{slug}/votes/current/overlay")
async def current_overlay(slug: str, db: AsyncSession = Depends(get_db)):
"""Public: returns all active vote curves (params only, no auth required)."""
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 [
{"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
]
# ── 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
]