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

0
backend/app/__init__.py Normal file
View File

19
backend/app/config.py Normal file
View File

@@ -0,0 +1,19 @@
from pydantic_settings import BaseSettings
from pathlib import Path
class Settings(BaseSettings):
APP_NAME: str = "SejeteralO"
DEBUG: bool = True
DATABASE_URL: str = "sqlite+aiosqlite:///./sejeteralo.db"
SECRET_KEY: str = "change-me-in-production-with-a-real-secret-key"
ALGORITHM: str = "HS256"
ADMIN_TOKEN_EXPIRE_HOURS: int = 24
CITIZEN_TOKEN_EXPIRE_HOURS: int = 4
BASE_DIR: Path = Path(__file__).resolve().parent.parent
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:3001", "http://localhost:3009"]
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()

21
backend/app/database.py Normal file
View File

@@ -0,0 +1,21 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
yield session
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View File

@@ -0,0 +1,23 @@
from app.engine.integrals import compute_integrals
from app.engine.pricing import (
HouseholdData,
TariffResult,
compute_p0,
compute_tariff,
compute_impacts,
)
from app.engine.current_model import compute_linear_tariff, LinearTariffResult
from app.engine.median import VoteParams, compute_median
__all__ = [
"compute_integrals",
"HouseholdData",
"TariffResult",
"compute_p0",
"compute_tariff",
"compute_impacts",
"compute_linear_tariff",
"LinearTariffResult",
"VoteParams",
"compute_median",
]

View File

@@ -0,0 +1,66 @@
"""
Current (linear) pricing model.
Ported from eau.py:256-354 (CurrentModel).
Pure Python + numpy, no matplotlib.
"""
from dataclasses import dataclass
from app.engine.pricing import HouseholdData
@dataclass
class LinearTariffResult:
"""Result of the linear tariff computation."""
p0: float # flat price per m³
curve_volumes: list[float]
curve_bills_rp: list[float]
curve_bills_rs: list[float]
curve_price_m3_rp: list[float]
curve_price_m3_rs: list[float]
def compute_linear_tariff(
households: list[HouseholdData],
recettes: float,
abop: float,
abos: float,
vmax: float = 2100,
nbpts: int = 200,
) -> LinearTariffResult:
"""
Compute the linear (current) pricing model.
p0 = (recettes - Σ abo) / Σ volume
"""
total_abo = 0.0
total_volume = 0.0
for h in households:
abo = abos if h.status == "RS" else abop
total_abo += abo
total_volume += max(h.volume_m3, 1e-5)
if total_abo >= recettes or total_volume == 0:
p0 = 0.0
else:
p0 = (recettes - total_abo) / total_volume
# Generate curves
import numpy as np
vv = np.linspace(1e-5, vmax, nbpts)
bills_rp = abop + p0 * vv
bills_rs = abos + p0 * vv
price_m3_rp = abop / vv + p0
price_m3_rs = abos / vv + p0
return LinearTariffResult(
p0=p0,
curve_volumes=vv.tolist(),
curve_bills_rp=bills_rp.tolist(),
curve_bills_rs=bills_rs.tolist(),
curve_price_m3_rp=price_m3_rp.tolist(),
curve_price_m3_rs=price_m3_rs.tolist(),
)

View File

@@ -0,0 +1,118 @@
"""
Integral computation for Bézier tariff curves.
Ported from eau.py:169-211 (NewModel.computeIntegrals).
Pure Python + numpy, no matplotlib.
"""
import numpy as np
def compute_integrals(
volume: float,
vinf: float,
vmax: float,
pmax: float,
a: float,
b: float,
c: float,
d: float,
e: float,
) -> tuple[float, float, float]:
"""
Compute (alpha1, alpha2, beta2) for a given consumption volume.
The total bill for a household consuming `volume` m³ is:
bill = abo + alpha1 * p0 + alpha2 * p0 + beta2
where p0 is the inflection price (computed separately to balance revenue).
Args:
volume: consumption in m³ for this household
vinf: inflection volume separating the two tiers
vmax: maximum volume (price = pmax at this volume)
pmax: maximum price per m³
a, b: shape parameters for tier 1 Bézier curve
c, d, e: shape parameters for tier 2 Bézier curve
Returns:
(alpha1, alpha2, beta2) tuple
"""
if volume <= vinf:
# Tier 1 only
T = _solve_tier1_t(volume, vinf, b)
alpha1 = _compute_alpha1(T, vinf, a, b)
return alpha1, 0.0, 0.0
else:
# Full tier 1 (T=1) + partial tier 2
alpha1 = _compute_alpha1(1.0, vinf, a, b)
# Tier 2
wmax = vmax - vinf
T = _solve_tier2_t(volume - vinf, wmax, c, d)
uu = _compute_uu(T, c, d, e)
alpha2 = (volume - vinf) - 3 * uu * wmax
beta2 = 3 * pmax * wmax * uu
return alpha1, alpha2, beta2
def _solve_tier1_t(volume: float, vinf: float, b: float) -> float:
"""Find T such that v(T) = volume for tier 1."""
if volume == 0:
return 0.0
if volume >= vinf:
return 1.0
# Solve: vinf * [(1 - 3b) * T³ + 3b * T²] = volume
# => (1-3b) * T³ + 3b * T² - volume/vinf = 0
p = [1 - 3 * b, 3 * b, 0, -volume / vinf]
roots = np.roots(p)
roots = np.unique(roots)
real_roots = np.real(roots[np.isreal(roots)])
mask = (real_roots <= 1.0) & (real_roots >= 0.0)
return float(real_roots[mask][0])
def _solve_tier2_t(w: float, wmax: float, c: float, d: float) -> float:
"""Find T such that w(T) = w for tier 2, where w = volume - vinf."""
if w == 0:
return 0.0
if w >= wmax:
return 1.0
# Solve: wmax * [(3(c+d-cd)-2)*T³ + 3(1-2c-d+cd)*T² + 3c*T] = w
p = [
3 * (c + d - c * d) - 2,
3 * (1 - 2 * c - d + c * d),
3 * c,
-w / wmax,
]
roots = np.roots(p)
roots = np.unique(roots)
real_roots = np.real(roots[np.isreal(roots)])
mask = (real_roots <= 1.0 + 1e-10) & (real_roots >= -1e-10)
if not mask.any():
# Fallback: closest root to [0,1]
return float(np.clip(np.real(roots[0]), 0.0, 1.0))
return float(np.clip(real_roots[mask][0], 0.0, 1.0))
def _compute_alpha1(T: float, vinf: float, a: float, b: float) -> float:
"""Compute alpha1 coefficient for tier 1."""
return 3 * vinf * (
T**6 / 6 * (-9 * a * b + 3 * a + 6 * b - 2)
+ T**5 / 5 * (24 * a * b - 6 * a - 13 * b + 3)
+ 3 * T**4 / 4 * (-7 * a * b + a + 2 * b)
+ T**3 / 3 * 6 * a * b
)
def _compute_uu(T: float, c: float, d: float, e: float) -> float:
"""Compute the uu intermediate value for tier 2."""
return (
(-3 * c * d + 9 * e * c * d + 3 * c - 9 * e * c + 3 * d - 9 * e * d + 6 * e - 2) * T**6 / 6
+ (2 * c * d - 15 * e * c * d - 4 * c + 21 * e * c - 2 * d + 15 * e * d - 12 * e + 2) * T**5 / 5
+ (6 * e * c * d + c - 15 * e * c - 6 * e * d + 6 * e) * T**4 / 4
+ (3 * e * c) * T**3 / 3
)

View File

@@ -0,0 +1,48 @@
"""
Median computation for vote parameters.
Computes the element-wise median of (vinf, a, b, c, d, e) across all active votes.
This parametric median is chosen over geometric median because:
- It's transparent and politically explainable
- The result is itself a valid set of Bézier parameters
"""
import numpy as np
from dataclasses import dataclass
@dataclass
class VoteParams:
"""The 6 citizen-adjustable parameters."""
vinf: float
a: float
b: float
c: float
d: float
e: float
def compute_median(votes: list[VoteParams]) -> VoteParams | None:
"""
Compute element-wise median of vote parameters.
Returns None if no votes provided.
"""
if not votes:
return None
vinfs = [v.vinf for v in votes]
a_s = [v.a for v in votes]
b_s = [v.b for v in votes]
c_s = [v.c for v in votes]
d_s = [v.d for v in votes]
e_s = [v.e for v in votes]
return VoteParams(
vinf=float(np.median(vinfs)),
a=float(np.median(a_s)),
b=float(np.median(b_s)),
c=float(np.median(c_s)),
d=float(np.median(d_s)),
e=float(np.median(e_s)),
)

View File

@@ -0,0 +1,196 @@
"""
Pricing computation for Bézier tariff model.
Ported from eau.py:120-167 (NewModel.updateComputation).
Pure Python + numpy, no matplotlib.
"""
import numpy as np
from dataclasses import dataclass
from app.engine.integrals import compute_integrals
@dataclass
class HouseholdData:
"""Minimal household data needed for computation."""
volume_m3: float
status: str # "RS", "RP", or "PRO"
price_paid_eur: float = 0.0
@dataclass
class TariffResult:
"""Result of a full tariff computation."""
p0: float
curve_volumes: list[float]
curve_prices_m3: list[float]
curve_bills_rp: list[float]
curve_bills_rs: list[float]
household_bills: list[float] # projected bill for each household
@dataclass
class ImpactRow:
"""Price impact for a specific volume level."""
volume: float
old_price: float
new_price_rp: float
new_price_rs: float
def compute_p0(
households: list[HouseholdData],
recettes: float,
abop: float,
abos: float,
vinf: float,
vmax: float,
pmax: float,
a: float,
b: float,
c: float,
d: float,
e: float,
) -> float:
"""
Compute p0 (inflection price) that balances total revenue.
p0 = (R - Σ(abo + β₂)) / Σ(α₁ + α₂)
"""
total_abo = 0.0
total_alpha = 0.0
total_beta = 0.0
for h in households:
abo = abos if h.status == "RS" else abop
total_abo += abo
vol = max(h.volume_m3, 1e-5) # avoid div by 0
alpha1, alpha2, beta2 = compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e)
total_alpha += alpha1 + alpha2
total_beta += beta2
if total_abo >= recettes:
return 0.0
if total_alpha == 0:
return 0.0
return (recettes - total_abo - total_beta) / total_alpha
def compute_tariff(
households: list[HouseholdData],
recettes: float,
abop: float,
abos: float,
vinf: float,
vmax: float,
pmax: float,
a: float,
b: float,
c: float,
d: float,
e: float,
nbpts: int = 200,
) -> TariffResult:
"""
Full tariff computation: p0, price curves, and per-household bills.
"""
p0 = compute_p0(households, recettes, abop, abos, vinf, vmax, pmax, a, b, c, d, e)
# Generate curve points
tt = np.linspace(0, 1 - 1e-6, nbpts)
# Tier 1 volumes and prices
vv1 = vinf * ((1 - 3 * b) * tt**3 + 3 * b * tt**2)
prix_m3_1 = p0 * ((3 * a - 2) * tt**3 + (-6 * a + 3) * tt**2 + 3 * a * tt)
# Tier 2 volumes and prices
vv2 = vinf + (vmax - vinf) * (
(3 * (c + d - c * d) - 2) * tt**3
+ 3 * (1 - 2 * c - d + c * d) * tt**2
+ 3 * c * tt
)
prix_m3_2 = p0 + (pmax - p0) * ((1 - 3 * e) * tt**3 + 3 * e * tt**2)
vv = np.concatenate([vv1, vv2])
prix_m3 = np.concatenate([prix_m3_1, prix_m3_2])
# Compute full bills (integral) for each curve point
alpha1_arr = np.zeros(len(vv))
alpha2_arr = np.zeros(len(vv))
beta2_arr = np.zeros(len(vv))
for iv, v in enumerate(vv):
alpha1_arr[iv], alpha2_arr[iv], beta2_arr[iv] = compute_integrals(
v, vinf, vmax, pmax, a, b, c, d, e
)
bills_rp = abop + (alpha1_arr + alpha2_arr) * p0 + beta2_arr
bills_rs = abos + (alpha1_arr + alpha2_arr) * p0 + beta2_arr
# Per-household projected bills
household_bills = []
for h in households:
vol = max(h.volume_m3, 1e-5)
abo = abos if h.status == "RS" else abop
a1, a2, b2 = compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e)
household_bills.append(abo + (a1 + a2) * p0 + b2)
return TariffResult(
p0=p0,
curve_volumes=vv.tolist(),
curve_prices_m3=prix_m3.tolist(),
curve_bills_rp=bills_rp.tolist(),
curve_bills_rs=bills_rs.tolist(),
household_bills=household_bills,
)
def compute_impacts(
households: list[HouseholdData],
recettes: float,
abop: float,
abos: float,
vinf: float,
vmax: float,
pmax: float,
a: float,
b: float,
c: float,
d: float,
e: float,
reference_volumes: list[float] | None = None,
) -> tuple[float, list[ImpactRow]]:
"""
Compute p0 and price impacts for reference volume levels.
Returns (p0, list of ImpactRow).
"""
if reference_volumes is None:
reference_volumes = [30, 60, 90, 150, 300]
p0 = compute_p0(households, recettes, abop, abos, vinf, vmax, pmax, a, b, c, d, e)
# Compute average 2018 price per m³ for a rough "old price" baseline
total_vol = sum(max(h.volume_m3, 1e-5) for h in households)
total_abo_old = sum(abos if h.status == "RS" else abop for h in households)
old_p_m3 = (recettes - total_abo_old) / total_vol if total_vol > 0 else 0
impacts = []
for vol in reference_volumes:
# Old price (linear model)
old_price_rp = abop + old_p_m3 * vol
# New price
a1, a2, b2 = compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e)
new_price_rp = abop + (a1 + a2) * p0 + b2
new_price_rs = abos + (a1 + a2) * p0 + b2
impacts.append(ImpactRow(
volume=vol,
old_price=old_price_rp,
new_price_rp=new_price_rp,
new_price_rs=new_price_rs,
))
return p0, impacts

42
backend/app/main.py Normal file
View File

@@ -0,0 +1,42 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import init_db
from app.routers import auth, communes, content, tariff, votes, households
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(
title=settings.APP_NAME,
description="Outil de démocratie participative pour la tarification de l'eau",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(communes.router, prefix="/api/v1/communes", tags=["communes"])
app.include_router(tariff.router, prefix="/api/v1/tariff", tags=["tariff"])
app.include_router(votes.router, prefix="/api/v1", tags=["votes"])
app.include_router(households.router, prefix="/api/v1", tags=["households"])
app.include_router(content.router, prefix="/api/v1/communes", tags=["content"])
@app.get("/api/health")
async def health():
return {"status": "ok"}

View File

@@ -0,0 +1,9 @@
from app.models.models import (
Commune, TariffParams, Household, AdminUser, Vote, CommuneContent,
admin_commune_table,
)
__all__ = [
"Commune", "TariffParams", "Household", "AdminUser", "Vote",
"CommuneContent", "admin_commune_table",
]

View File

@@ -0,0 +1,120 @@
"""SQLAlchemy ORM models."""
from datetime import datetime
from sqlalchemy import (
Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, Table,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.database import Base
# Many-to-many: admin users <-> communes
admin_commune_table = Table(
"admin_commune",
Base.metadata,
Column("admin_id", Integer, ForeignKey("admin_users.id"), primary_key=True),
Column("commune_id", Integer, ForeignKey("communes.id"), primary_key=True),
)
class Commune(Base):
__tablename__ = "communes"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
slug = Column(String(200), unique=True, nullable=False, index=True)
description = Column(Text, default="")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
tariff_params = relationship("TariffParams", back_populates="commune", uselist=False)
households = relationship("Household", back_populates="commune")
votes = relationship("Vote", back_populates="commune")
contents = relationship("CommuneContent", back_populates="commune")
admins = relationship("AdminUser", secondary=admin_commune_table, back_populates="communes")
class TariffParams(Base):
__tablename__ = "tariff_params"
id = Column(Integer, primary_key=True, index=True)
commune_id = Column(Integer, ForeignKey("communes.id"), unique=True, nullable=False)
abop = Column(Float, default=100.0)
abos = Column(Float, default=100.0)
recettes = Column(Float, default=75000.0)
pmax = Column(Float, default=20.0)
vmax = Column(Float, default=2100.0)
commune = relationship("Commune", back_populates="tariff_params")
class Household(Base):
__tablename__ = "households"
id = Column(Integer, primary_key=True, index=True)
commune_id = Column(Integer, ForeignKey("communes.id"), nullable=False)
identifier = Column(String(200), nullable=False)
status = Column(String(10), nullable=False) # RS, RP, PRO
volume_m3 = Column(Float, nullable=False)
price_paid_eur = Column(Float, default=0.0)
auth_code = Column(String(8), unique=True, nullable=False, index=True)
has_voted = Column(Boolean, default=False)
commune = relationship("Commune", back_populates="households")
votes = relationship("Vote", back_populates="household")
__table_args__ = (
UniqueConstraint("commune_id", "identifier", name="uq_household_commune_identifier"),
)
class AdminUser(Base):
__tablename__ = "admin_users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(200), unique=True, nullable=False, index=True)
hashed_password = Column(String(200), nullable=False)
full_name = Column(String(200), default="")
role = Column(String(20), default="commune_admin") # super_admin / commune_admin
communes = relationship("Commune", secondary=admin_commune_table, back_populates="admins")
class Vote(Base):
__tablename__ = "votes"
id = Column(Integer, primary_key=True, index=True)
commune_id = Column(Integer, ForeignKey("communes.id"), nullable=False)
household_id = Column(Integer, ForeignKey("households.id"), nullable=False)
vinf = Column(Float, nullable=False)
a = Column(Float, nullable=False)
b = Column(Float, nullable=False)
c = Column(Float, nullable=False)
d = Column(Float, nullable=False)
e = Column(Float, nullable=False)
computed_p0 = Column(Float, nullable=True)
submitted_at = Column(DateTime, default=datetime.utcnow)
is_active = Column(Boolean, default=True)
commune = relationship("Commune", back_populates="votes")
household = relationship("Household", back_populates="votes")
class CommuneContent(Base):
__tablename__ = "commune_contents"
id = Column(Integer, primary_key=True, index=True)
commune_id = Column(Integer, ForeignKey("communes.id"), nullable=False)
slug = Column(String(200), nullable=False) # page identifier
title = Column(String(200), default="")
body_markdown = Column(Text, default="")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
commune = relationship("Commune", back_populates="contents")
__table_args__ = (
UniqueConstraint("commune_id", "slug", name="uq_content_commune_slug"),
)

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
]

View File

@@ -0,0 +1 @@
from app.schemas.schemas import * # noqa: F401, F403

View File

@@ -0,0 +1,205 @@
"""Pydantic schemas for API request/response validation."""
from datetime import datetime
from pydantic import BaseModel, Field
# ── Auth ──
class AdminLogin(BaseModel):
email: str
password: str
class CitizenVerify(BaseModel):
commune_slug: str
auth_code: str
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
role: str
commune_slug: str | None = None
# ── Commune ──
class CommuneCreate(BaseModel):
name: str
slug: str
description: str = ""
class CommuneUpdate(BaseModel):
name: str | None = None
description: str | None = None
is_active: bool | None = None
class CommuneOut(BaseModel):
id: int
name: str
slug: str
description: str
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
# ── TariffParams ──
class TariffParamsUpdate(BaseModel):
abop: float | None = None
abos: float | None = None
recettes: float | None = None
pmax: float | None = None
vmax: float | None = None
class TariffParamsOut(BaseModel):
abop: float
abos: float
recettes: float
pmax: float
vmax: float
model_config = {"from_attributes": True}
# ── Household ──
class HouseholdOut(BaseModel):
id: int
identifier: str
status: str
volume_m3: float
price_paid_eur: float
auth_code: str
has_voted: bool
model_config = {"from_attributes": True}
class HouseholdStats(BaseModel):
total: int
rs_count: int
rp_count: int
pro_count: int
total_volume: float
avg_volume: float
median_volume: float
voted_count: int
class ImportPreview(BaseModel):
valid_rows: int
errors: list[str]
sample: list[dict]
class ImportResult(BaseModel):
created: int
errors: list[str]
# ── Tariff Compute ──
class TariffComputeRequest(BaseModel):
commune_slug: str
vinf: float = Field(ge=0)
a: float = Field(ge=0, le=1)
b: float = Field(ge=0, le=1)
c: float = Field(ge=0, le=1)
d: float = Field(ge=0, le=1)
e: float = Field(ge=0, le=1)
class ImpactRowOut(BaseModel):
volume: float
old_price: float
new_price_rp: float
new_price_rs: float
class TariffComputeResponse(BaseModel):
p0: float
curve_volumes: list[float]
curve_prices_m3: list[float]
curve_bills_rp: list[float]
curve_bills_rs: list[float]
impacts: list[ImpactRowOut]
# ── Vote ──
class VoteCreate(BaseModel):
vinf: float = Field(ge=0)
a: float = Field(ge=0, le=1)
b: float = Field(ge=0, le=1)
c: float = Field(ge=0, le=1)
d: float = Field(ge=0, le=1)
e: float = Field(ge=0, le=1)
class VoteOut(BaseModel):
id: int
household_id: int
vinf: float
a: float
b: float
c: float
d: float
e: float
computed_p0: float | None
submitted_at: datetime
is_active: bool
model_config = {"from_attributes": True}
class MedianOut(BaseModel):
vinf: float
a: float
b: float
c: float
d: float
e: float
computed_p0: float
vote_count: int
# ── Admin User ──
class AdminUserCreate(BaseModel):
email: str
password: str
full_name: str = ""
role: str = "commune_admin"
commune_slugs: list[str] = []
class AdminUserOut(BaseModel):
id: int
email: str
full_name: str
role: str
model_config = {"from_attributes": True}
# ── Content ──
class ContentUpdate(BaseModel):
title: str
body_markdown: str
class ContentOut(BaseModel):
slug: str
title: str
body_markdown: str
updated_at: datetime
model_config = {"from_attributes": True}

View File

View File

@@ -0,0 +1,89 @@
"""Authentication service: JWT creation/validation, password hashing."""
from datetime import datetime, timedelta
from jose import jwt, JWTError
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.config import settings
from app.database import get_db
from app.models import AdminUser, Household
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_token(data: dict, expires_hours: int) -> str:
to_encode = data.copy()
to_encode["exp"] = datetime.utcnow() + timedelta(hours=expires_hours)
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_admin_token(admin: AdminUser) -> str:
return create_token(
{"sub": str(admin.id), "role": admin.role, "type": "admin"},
settings.ADMIN_TOKEN_EXPIRE_HOURS,
)
def create_citizen_token(household: Household, commune_slug: str) -> str:
return create_token(
{
"sub": str(household.id),
"commune_id": household.commune_id,
"commune_slug": commune_slug,
"type": "citizen",
},
settings.CITIZEN_TOKEN_EXPIRE_HOURS,
)
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
async def get_current_admin(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> AdminUser:
payload = decode_token(credentials.credentials)
if payload.get("type") != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
admin = await db.get(AdminUser, int(payload["sub"]))
if not admin:
raise HTTPException(status_code=401, detail="Admin not found")
return admin
async def get_current_citizen(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> Household:
payload = decode_token(credentials.credentials)
if payload.get("type") != "citizen":
raise HTTPException(status_code=403, detail="Citizen access required")
household = await db.get(Household, int(payload["sub"]))
if not household:
raise HTTPException(status_code=401, detail="Household not found")
return household
def require_super_admin(admin: AdminUser = Depends(get_current_admin)) -> AdminUser:
if admin.role != "super_admin":
raise HTTPException(status_code=403, detail="Super admin access required")
return admin

View File

@@ -0,0 +1,143 @@
"""Service for importing household data from CSV/XLSX files."""
import io
import secrets
import string
import pandas as pd
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models import Household
# Characters without ambiguous ones (O/0/I/1/l)
SAFE_CHARS = string.ascii_uppercase.replace("O", "").replace("I", "") + string.digits.replace("0", "").replace("1", "")
def generate_auth_code(length: int = 8) -> str:
return "".join(secrets.choice(SAFE_CHARS) for _ in range(length))
VALID_STATUSES = {"RS", "RP", "PRO"}
REQUIRED_COLUMNS = {"identifier", "status", "volume_m3", "price_eur"}
def parse_import_file(file_bytes: bytes, filename: str) -> tuple[pd.DataFrame | None, list[str]]:
"""
Parse a CSV or XLSX file and validate its contents.
Returns (dataframe, errors). If errors is non-empty, dataframe may be None.
"""
errors = []
try:
if filename.endswith(".csv"):
df = pd.read_csv(io.BytesIO(file_bytes))
elif filename.endswith((".xlsx", ".xls")):
df = pd.read_excel(io.BytesIO(file_bytes))
else:
return None, ["Format non supporté. Utilisez CSV ou XLSX."]
except Exception as e:
return None, [f"Erreur de lecture du fichier: {e}"]
# Normalize column names
df.columns = [c.strip().lower().replace(" ", "_") for c in df.columns]
missing = REQUIRED_COLUMNS - set(df.columns)
if missing:
return None, [f"Colonnes manquantes: {', '.join(missing)}"]
# Validate rows
for idx, row in df.iterrows():
line = idx + 2 # Excel line number (1-indexed + header)
status = str(row["status"]).strip().upper()
if status not in VALID_STATUSES:
errors.append(f"Ligne {line}: statut '{row['status']}' invalide (attendu: RS, RP, PRO)")
try:
vol = float(row["volume_m3"])
if vol < 0:
errors.append(f"Ligne {line}: volume négatif ({vol})")
except (ValueError, TypeError):
errors.append(f"Ligne {line}: volume invalide '{row['volume_m3']}'")
price = row.get("price_eur")
if pd.notna(price):
try:
p = float(price)
if p < 0:
errors.append(f"Ligne {line}: prix négatif ({p})")
except (ValueError, TypeError):
errors.append(f"Ligne {line}: prix invalide '{price}'")
# Normalize
df["status"] = df["status"].str.strip().str.upper()
df["identifier"] = df["identifier"].astype(str).str.strip()
return df, errors
async def import_households(
db: AsyncSession,
commune_id: int,
df: pd.DataFrame,
) -> tuple[int, list[str]]:
"""
Import validated households into the database.
Returns (created_count, errors).
"""
created = 0
errors = []
# Get existing auth codes to avoid collisions
existing_codes = set()
result = await db.execute(select(Household.auth_code))
for row in result.scalars():
existing_codes.add(row)
for idx, row in df.iterrows():
identifier = str(row["identifier"]).strip()
status = str(row["status"]).strip().upper()
volume = float(row["volume_m3"])
price = float(row["price_eur"]) if pd.notna(row.get("price_eur")) else 0.0
# Check for duplicate
existing = await db.execute(
select(Household).where(
Household.commune_id == commune_id,
Household.identifier == identifier,
)
)
if existing.scalar_one_or_none():
errors.append(f"Foyer '{identifier}' existe déjà, ignoré.")
continue
# Generate unique auth code
code = generate_auth_code()
while code in existing_codes:
code = generate_auth_code()
existing_codes.add(code)
household = Household(
commune_id=commune_id,
identifier=identifier,
status=status,
volume_m3=volume,
price_paid_eur=price,
auth_code=code,
)
db.add(household)
created += 1
await db.commit()
return created, errors
def generate_template_csv() -> bytes:
"""Generate a template CSV file for household import."""
content = "identifier,status,volume_m3,price_eur\n"
content += "DUPONT Jean,RS,85.5,189.50\n"
content += "MARTIN Pierre,RP,120.0,245.00\n"
content += "SARL Boulangerie,PRO,350.0,\n"
return content.encode("utf-8")