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

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Backend
DATABASE_URL=sqlite+aiosqlite:///./sejeteralo.db
SECRET_KEY=change-me-in-production-with-a-real-secret-key
DEBUG=true
CORS_ORIGINS=["http://localhost:3000"]
# Frontend
NUXT_PUBLIC_API_BASE=http://localhost:8000/api/v1

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg-info/
dist/
build/
.eggs/
venv/
.venv/
*.egg
# Jupyter
.ipynb_checkpoints/
# Environment
.env
.env.local
.env.production
# Database
*.db
*.sqlite3
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Node
node_modules/
.nuxt/
.output/
.nitro/
.cache/
# Uploads
backend/uploads/
# Coverage
htmlcov/
.coverage
coverage.xml

29
Makefile Normal file
View File

@@ -0,0 +1,29 @@
.PHONY: install dev dev-backend dev-frontend test seed docker-up docker-down
# ── Development ──
install:
cd backend && python3 -m venv venv && . venv/bin/activate && pip install -r requirements.txt
cd frontend && npm install
dev: dev-backend dev-frontend
dev-backend:
cd backend && . venv/bin/activate && uvicorn app.main:app --reload --port 8000
dev-frontend:
cd frontend && npm run dev
test:
cd backend && . venv/bin/activate && python -m pytest tests/ -v
seed:
cd backend && . venv/bin/activate && python seed.py
# ── Docker ──
docker-up:
docker compose up --build -d
docker-down:
docker compose down

4
backend/.env.example Normal file
View File

@@ -0,0 +1,4 @@
DATABASE_URL=sqlite+aiosqlite:///./sejeteralo.db
SECRET_KEY=change-me-in-production-with-a-real-secret-key
DEBUG=true
CORS_ORIGINS=["http://localhost:3000"]

12
backend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

117
backend/alembic.ini Normal file
View File

@@ -0,0 +1,117 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
# version_path_separator = newline
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

54
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,54 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from app.database import Base
from app.models import models # noqa: F401 - import to register models
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
# Use sqlite for dev
config.set_main_option("sqlalchemy.url", "sqlite:///./sejeteralo.db")
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,130 @@
"""initial schema
Revision ID: 25f534648ea7
Revises:
Create Date: 2026-02-21 05:29:28.228738
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '25f534648ea7'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('admin_users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=200), nullable=False),
sa.Column('hashed_password', sa.String(length=200), nullable=False),
sa.Column('full_name', sa.String(length=200), nullable=True),
sa.Column('role', sa.String(length=20), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_admin_users_email'), 'admin_users', ['email'], unique=True)
op.create_index(op.f('ix_admin_users_id'), 'admin_users', ['id'], unique=False)
op.create_table('communes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('slug', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_communes_id'), 'communes', ['id'], unique=False)
op.create_index(op.f('ix_communes_slug'), 'communes', ['slug'], unique=True)
op.create_table('admin_commune',
sa.Column('admin_id', sa.Integer(), nullable=False),
sa.Column('commune_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['admin_id'], ['admin_users.id'], ),
sa.ForeignKeyConstraint(['commune_id'], ['communes.id'], ),
sa.PrimaryKeyConstraint('admin_id', 'commune_id')
)
op.create_table('commune_contents',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('commune_id', sa.Integer(), nullable=False),
sa.Column('slug', sa.String(length=200), nullable=False),
sa.Column('title', sa.String(length=200), nullable=True),
sa.Column('body_markdown', sa.Text(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['commune_id'], ['communes.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('commune_id', 'slug', name='uq_content_commune_slug')
)
op.create_index(op.f('ix_commune_contents_id'), 'commune_contents', ['id'], unique=False)
op.create_table('households',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('commune_id', sa.Integer(), nullable=False),
sa.Column('identifier', sa.String(length=200), nullable=False),
sa.Column('status', sa.String(length=10), nullable=False),
sa.Column('volume_m3', sa.Float(), nullable=False),
sa.Column('price_paid_eur', sa.Float(), nullable=True),
sa.Column('auth_code', sa.String(length=8), nullable=False),
sa.Column('has_voted', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['commune_id'], ['communes.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('commune_id', 'identifier', name='uq_household_commune_identifier')
)
op.create_index(op.f('ix_households_auth_code'), 'households', ['auth_code'], unique=True)
op.create_index(op.f('ix_households_id'), 'households', ['id'], unique=False)
op.create_table('tariff_params',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('commune_id', sa.Integer(), nullable=False),
sa.Column('abop', sa.Float(), nullable=True),
sa.Column('abos', sa.Float(), nullable=True),
sa.Column('recettes', sa.Float(), nullable=True),
sa.Column('pmax', sa.Float(), nullable=True),
sa.Column('vmax', sa.Float(), nullable=True),
sa.ForeignKeyConstraint(['commune_id'], ['communes.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('commune_id')
)
op.create_index(op.f('ix_tariff_params_id'), 'tariff_params', ['id'], unique=False)
op.create_table('votes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('commune_id', sa.Integer(), nullable=False),
sa.Column('household_id', sa.Integer(), nullable=False),
sa.Column('vinf', sa.Float(), nullable=False),
sa.Column('a', sa.Float(), nullable=False),
sa.Column('b', sa.Float(), nullable=False),
sa.Column('c', sa.Float(), nullable=False),
sa.Column('d', sa.Float(), nullable=False),
sa.Column('e', sa.Float(), nullable=False),
sa.Column('computed_p0', sa.Float(), nullable=True),
sa.Column('submitted_at', sa.DateTime(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['commune_id'], ['communes.id'], ),
sa.ForeignKeyConstraint(['household_id'], ['households.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_votes_id'), 'votes', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_votes_id'), table_name='votes')
op.drop_table('votes')
op.drop_index(op.f('ix_tariff_params_id'), table_name='tariff_params')
op.drop_table('tariff_params')
op.drop_index(op.f('ix_households_id'), table_name='households')
op.drop_index(op.f('ix_households_auth_code'), table_name='households')
op.drop_table('households')
op.drop_index(op.f('ix_commune_contents_id'), table_name='commune_contents')
op.drop_table('commune_contents')
op.drop_table('admin_commune')
op.drop_index(op.f('ix_communes_slug'), table_name='communes')
op.drop_index(op.f('ix_communes_id'), table_name='communes')
op.drop_table('communes')
op.drop_index(op.f('ix_admin_users_id'), table_name='admin_users')
op.drop_index(op.f('ix_admin_users_email'), table_name='admin_users')
op.drop_table('admin_users')
# ### end Alembic commands ###

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

18
backend/requirements.txt Normal file
View File

@@ -0,0 +1,18 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
alembic==1.14.0
pydantic==2.10.3
pydantic-settings==2.7.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.1.3
python-multipart==0.0.18
numpy==1.26.4
openpyxl==3.1.5
xlrd==2.0.1
pandas==2.2.3
aiosqlite==0.20.0
pytest==8.3.4
pytest-asyncio==0.24.0
httpx==0.28.1

103
backend/seed.py Normal file
View File

@@ -0,0 +1,103 @@
"""Seed the database with Saoû data from Eau2018.xls."""
import asyncio
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
import xlrd
from sqlalchemy import select
from app.database import engine, async_session, init_db
from app.models import Commune, TariffParams, Household, AdminUser
from app.services.auth_service import hash_password
from app.services.import_service import generate_auth_code
XLS_PATH = os.path.join(os.path.dirname(__file__), "..", "Eau2018.xls")
async def seed():
await init_db()
async with async_session() as db:
# Check if already seeded
result = await db.execute(select(Commune).where(Commune.slug == "saou"))
if result.scalar_one_or_none():
print("Saoû already seeded.")
return
# Create commune
commune = Commune(
name="Saoû",
slug="saou",
description="Commune de Saoû - Tarification progressive de l'eau",
)
db.add(commune)
await db.flush()
# Create tariff params
params = TariffParams(
commune_id=commune.id,
abop=100,
abos=100,
recettes=75000,
pmax=20,
vmax=2100,
)
db.add(params)
# Create super admin (manages all communes)
super_admin = AdminUser(
email="superadmin@sejeteralo.fr",
hashed_password=hash_password("superadmin"),
full_name="Super Admin",
role="super_admin",
)
db.add(super_admin)
# Create commune admin for Saoû (manages only this commune)
commune_admin = AdminUser(
email="saou@sejeteralo.fr",
hashed_password=hash_password("saou2024"),
full_name="Admin Saoû",
role="commune_admin",
)
commune_admin.communes.append(commune)
db.add(commune_admin)
# Import households from Eau2018.xls
book = xlrd.open_workbook(XLS_PATH)
sheet = book.sheet_by_name("CALCULS")
nb_hab = 363
existing_codes = set()
for r in range(1, nb_hab + 1):
name = sheet.cell_value(r, 0)
status = sheet.cell_value(r, 3)
volume = sheet.cell_value(r, 4)
price = sheet.cell_value(r, 33)
code = generate_auth_code()
while code in existing_codes:
code = generate_auth_code()
existing_codes.add(code)
household = Household(
commune_id=commune.id,
identifier=str(name).strip(),
status=str(status).strip().upper(),
volume_m3=float(volume),
price_paid_eur=float(price) if price else 0.0,
auth_code=code,
)
db.add(household)
await db.commit()
print(f"Seeded: commune 'saou', {nb_hab} households")
print(f" Super admin: superadmin@sejeteralo.fr / superadmin")
print(f" Commune admin Saoû: saou@sejeteralo.fr / saou2024")
if __name__ == "__main__":
asyncio.run(seed())

View File

View File

@@ -0,0 +1,280 @@
"""
Tests for the extracted math engine.
Validates that the engine produces identical results to the original eau.py
using the Saoû data (Eau2018.xls).
"""
import sys
import os
import numpy as np
import pytest
import xlrd
# Add backend to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.engine.integrals import compute_integrals
from app.engine.pricing import HouseholdData, compute_p0, compute_tariff
from app.engine.current_model import compute_linear_tariff
from app.engine.median import VoteParams, compute_median
# Path to the Excel file
DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "data")
XLS_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "Eau2018.xls")
def load_saou_households() -> list[HouseholdData]:
"""Load household data from Eau2018.xls exactly as eau.py does."""
book = xlrd.open_workbook(XLS_PATH)
sheet = book.sheet_by_name("CALCULS")
nb_hab = 363
households = []
for r in range(1, nb_hab + 1):
vol = sheet.cell_value(r, 4)
status = sheet.cell_value(r, 3)
prix = sheet.cell_value(r, 33)
households.append(HouseholdData(
volume_m3=vol,
status=status,
price_paid_eur=prix,
))
return households
# Reference original eau.py computeIntegrals for comparison
def original_compute_integrals(vv, vinf, vmax, pmax, a, b, c, d, e):
"""Direct port of eau.py computeIntegrals for validation."""
if vv <= vinf:
if vv == 0:
T = 0.0
elif vv == vinf:
T = 1.0
else:
p = [1 - 3 * b, 3 * b, 0, -vv / vinf]
roots = np.roots(p)
roots = np.unique(roots)
roots2 = np.real(roots[np.isreal(roots)])
mask = (roots2 <= 1.0) & (roots2 >= 0.0)
T = float(roots2[mask])
alpha1 = 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
)
return alpha1, 0, 0
else:
alpha1 = 3 * vinf * (
1 / 6 * (-9 * a * b + 3 * a + 6 * b - 2)
+ 1 / 5 * (24 * a * b - 6 * a - 13 * b + 3)
+ 3 / 4 * (-7 * a * b + a + 2 * b)
+ 1 / 3 * 6 * a * b
)
wmax = vmax - vinf
if vv == vinf:
T = 0.0
elif vv == vmax:
T = 1.0
else:
p = [3 * (c + d - c * d) - 2, 3 * (1 - 2 * c - d + c * d), 3 * c, -(vv - vinf) / wmax]
roots = np.roots(p)
roots = np.unique(roots)
roots2 = np.real(roots[np.isreal(roots)])
mask = (roots2 <= 1.0) & (roots2 >= 0.0)
T = float(np.real(roots2[mask]))
uu = (
(-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
)
alpha2 = vv - vinf - 3 * uu * wmax
beta2 = 3 * pmax * wmax * uu
return alpha1, alpha2, beta2
class TestIntegrals:
"""Test the integral computation against the original."""
def test_tier1_zero_volume(self):
a1, a2, b2 = compute_integrals(0, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
assert a1 == 0.0
assert a2 == 0.0
assert b2 == 0.0
def test_tier1_at_vinf(self):
a1, a2, b2 = compute_integrals(1050, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
oa1, oa2, ob2 = original_compute_integrals(1050, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
assert abs(a1 - oa1) < 1e-10
assert a2 == 0.0
assert b2 == 0.0
def test_tier2_at_vmax(self):
a1, a2, b2 = compute_integrals(2100, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
oa1, oa2, ob2 = original_compute_integrals(2100, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
assert abs(a1 - oa1) < 1e-10
assert abs(a2 - oa2) < 1e-6
assert abs(b2 - ob2) < 1e-6
def test_various_volumes_match_original(self):
"""Test multiple volumes with various parameter sets."""
params_sets = [
(0.5, 0.5, 0.5, 0.5, 0.5),
(0.25, 0.75, 0.3, 0.6, 0.8),
(0.1, 0.1, 0.9, 0.9, 0.1),
(0.9, 0.9, 0.1, 0.1, 0.9),
]
volumes = [0, 10, 50, 100, 300, 500, 1000, 1050, 1051, 1500, 2000, 2100]
for a, b, c, d, e in params_sets:
for vol in volumes:
vinf, vmax, pmax = 1050, 2100, 20
result = compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e)
expected = original_compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e)
for i in range(3):
assert abs(result[i] - expected[i]) < 1e-6, (
f"Mismatch at vol={vol}, params=({a},{b},{c},{d},{e}), "
f"component={i}: got {result[i]}, expected {expected[i]}"
)
class TestPricing:
"""Test the pricing computation with Saoû data."""
@pytest.fixture
def saou_households(self):
return load_saou_households()
def test_saou_data_loaded(self, saou_households):
assert len(saou_households) == 363
def test_p0_default_params(self, saou_households):
"""Test p0 with default slider values from eau.py mainFunction."""
# Default values from eau.py lines 54-62
p0 = compute_p0(
saou_households,
recettes=75000, # recettesArray[25]
abop=100, # abopArray[100]
abos=100, # abosArray[100]
vinf=1050, # vinfArray[vmax/2]
vmax=2100,
pmax=20,
a=0.5, # aArray[25]
b=0.5, # bArray[25]
c=0.5, # cArray[25]
d=0.5, # dArray[25]
e=0.5, # eArray[25]
)
# Compute the same p0 using original algorithm
volumes = np.array([max(h.volume_m3, 1e-5) for h in saou_households])
statuses = np.array([h.status for h in saou_households])
abo = 100 * np.ones(363)
abo[statuses == "RS"] = 100
alpha1_arr = np.zeros(363)
alpha2_arr = np.zeros(363)
beta2_arr = np.zeros(363)
for ih in range(363):
alpha1_arr[ih], alpha2_arr[ih], beta2_arr[ih] = original_compute_integrals(
volumes[ih], 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5
)
expected_p0 = (75000 - np.sum(beta2_arr + abo)) / np.sum(alpha1_arr + alpha2_arr)
assert abs(p0 - expected_p0) < 1e-6, f"p0={p0}, expected={expected_p0}"
assert p0 > 0, "p0 should be positive"
def test_p0_various_params(self, saou_households):
"""Test p0 with various parameter sets."""
param_sets = [
(75000, 100, 100, 1050, 0.5, 0.5, 0.5, 0.5, 0.5),
(60000, 80, 80, 800, 0.3, 0.7, 0.4, 0.6, 0.2),
(90000, 120, 90, 1200, 0.8, 0.2, 0.6, 0.3, 0.7),
]
for recettes, abop, abos, vinf, a, b, c, d, e in param_sets:
p0 = compute_p0(
saou_households, recettes, abop, abos, vinf, 2100, 20, a, b, c, d, e
)
# Verify: total bills should equal recettes
total = 0
for h in saou_households:
vol = max(h.volume_m3, 1e-5)
abo_val = abos if h.status == "RS" else abop
a1, a2, b2 = compute_integrals(vol, vinf, 2100, 20, a, b, c, d, e)
total += abo_val + (a1 + a2) * p0 + b2
assert abs(total - recettes) < 0.01, (
f"Revenue mismatch: got {total:.2f}, expected {recettes}. "
f"Params: recettes={recettes}, abop={abop}, abos={abos}, vinf={vinf}, "
f"a={a}, b={b}, c={c}, d={d}, e={e}"
)
def test_full_tariff_computation(self, saou_households):
result = compute_tariff(
saou_households,
recettes=75000,
abop=100,
abos=100,
vinf=1050,
vmax=2100,
pmax=20,
a=0.5,
b=0.5,
c=0.5,
d=0.5,
e=0.5,
)
assert result.p0 > 0
assert len(result.curve_volumes) == 400 # 200 * 2 tiers
assert len(result.household_bills) == 363
class TestLinearModel:
"""Test the linear (current) pricing model."""
@pytest.fixture
def saou_households(self):
return load_saou_households()
def test_linear_p0(self, saou_households):
result = compute_linear_tariff(saou_households, recettes=75000, abop=100, abos=100)
# p0 = (recettes - sum_abo) / sum_volume
volumes = [max(h.volume_m3, 1e-5) for h in saou_households]
total_vol = sum(volumes)
expected_p0 = (75000 - 363 * 100) / total_vol # all RS have same abo in this case
assert abs(result.p0 - expected_p0) < 1e-6
class TestMedian:
"""Test the median computation."""
def test_single_vote(self):
votes = [VoteParams(vinf=1050, a=0.5, b=0.5, c=0.5, d=0.5, e=0.5)]
m = compute_median(votes)
assert m.vinf == 1050
assert m.a == 0.5
def test_odd_votes(self):
votes = [
VoteParams(vinf=800, a=0.3, b=0.2, c=0.4, d=0.5, e=0.6),
VoteParams(vinf=1000, a=0.5, b=0.5, c=0.5, d=0.5, e=0.5),
VoteParams(vinf=1200, a=0.7, b=0.8, c=0.6, d=0.5, e=0.4),
]
m = compute_median(votes)
assert m.vinf == 1000
assert m.a == 0.5
def test_even_votes(self):
votes = [
VoteParams(vinf=800, a=0.3, b=0.2, c=0.4, d=0.5, e=0.6),
VoteParams(vinf=1200, a=0.7, b=0.8, c=0.6, d=0.5, e=0.4),
]
m = compute_median(votes)
assert m.vinf == 1000 # average of 800, 1200
assert abs(m.a - 0.5) < 1e-10
def test_empty_votes(self):
assert compute_median([]) is None

24
docker-compose.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
backend:
build: ./backend
ports:
- "8000:8000"
environment:
- DATABASE_URL=sqlite+aiosqlite:///./sejeteralo.db
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- DEBUG=false
- CORS_ORIGINS=["http://localhost:3000"]
volumes:
- backend-data:/app
frontend:
build: ./frontend
ports:
- "3000:3000"
environment:
- NUXT_PUBLIC_API_BASE=http://localhost:8000/api/v1
depends_on:
- backend
volumes:
backend-data:

355
eau.py Normal file
View File

@@ -0,0 +1,355 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Created on Sat Jun 18 2022
Backend for water price computation
@author: alex delga
"""
import xlrd
import xlwt
import numpy as np
import matplotlib.pyplot as plt # main ploting module
from matplotlib.widgets import Slider
import pandas as pd
import seaborn as sns
sns.set_theme()
class NewModel():
def __init__(self,pmax=20,vmax=2100): # Set the static parameters
self.pmax=pmax #maximum price per m3 acceptable
self.vmax=vmax #consumed volume in m3 for which such price is reached. All inhabitants must be under.
self.nbpts=1023
self.tt=np.linspace(0,1-1e-6,self.nbpts) # the -1e-6 is to avoid pb at vv=vmax later
self.vv=np.zeros((2*self.nbpts,))#must be reset parametrically using tt everytime a,b,c,d,e,vinf are changed.
self.abopArray = np.linspace(0,200,201) # potential values for abop
self.abosArray = np.linspace(0,200,201) # potential values for abos
self.recettesArray = np.linspace (50000,100000,51) # potential values for recettes
self.vinfArray = np.linspace(0,vmax,vmax+1) # potential values for volume at inflexion point
self.aArray = np.linspace(0,1,51) # potential values for a
self.bArray = np.linspace(0,1,51) # potential values for b
self.cArray = np.linspace(0,1,51) # potential values for c
self.dArray = np.linspace(0,1,51) # potential values for d
self.eArray = np.linspace(0,1,51) # potential values for e
#loading the inhabitant data
book = xlrd.open_workbook('Eau2018.xls')
sheet = book.sheet_by_name('CALCULS')
self.nbHab=363
self.volume = np.array([sheet.cell_value(r,4) for r in range(1,self.nbHab+1)])#get the volume used in 2018 in m3
self.vTot=np.sum(self.volume)
self.volume[self.volume==0]=1e-5 #little trick to avoid divide by 0
self.status = np.array([sheet.cell_value(r,3) for r in range(1,self.nbHab+1)])#get the RS, RP, PRO status
self.prix2018 = np.array([sheet.cell_value(r,33) for r in range(1,self.nbHab+1)])#get the price paid in 2018 in EUR
self.prix2018_m3 = self.prix2018 / self.volume
def mainFunction(self):
'''
main function, the one called in the notebook, and that update computations depending on the slider values.
'''
# initial values
self.abop= self.abopArray[100]
self.abos= self.abosArray[100]
self.recettes= self.recettesArray[25]
self.vinf=self.vinfArray[int(self.vmax/2)]
self.a=self.aArray[25]
self.b=self.bArray[25]
self.c=self.cArray[25]
self.d=self.dArray[25]
self.e=self.eArray[25]
# initial plot
self.updateComputation()
self.fig = plt.figure(figsize=(9,6))
self.ax1 = plt.subplot(211)
self.ax2 = plt.subplot(212,sharex=self.ax1)
#self.ax1.set_xlabel(r'volume ($m^3$) ')
self.ax2.set_xlabel(r'volume ($m^3$) ')
self.ax1.set_ylabel('Facture totale (€)')
self.ax2.set_ylabel('Prix au m3 (€)')
#default values of ax limits
self.axx=(min(self.vv),max(self.vv))
self.ax1y=(0,max(self.prixp))
self.ax2y=(0,self.pmax)
self.updatePlot()
# SLIDERS
axcolor = 'lightgoldenrodyellow'
abopAxis = plt.axes([0.7, 0.70, 0.2, 0.03], facecolor=axcolor)
self.abopSlider = Slider(abopAxis, r'Abo. P/PRO (€)', np.min(self.abopArray), np.max(self.abopArray), self.abop)
self.abopSlider.on_changed(self.updateParam)
abosAxis = plt.axes([0.7, 0.65, 0.2, 0.03], facecolor=axcolor)
self.abosSlider = Slider(abosAxis, r'Abo. S (€)', np.min(self.abosArray), np.max(self.abosArray), self.abos)
self.abosSlider.on_changed(self.updateParam)
recettesAxis = plt.axes([0.7, 0.60, 0.2, 0.03], facecolor=axcolor)
self.recettesSlider = Slider(recettesAxis, r'Recettes (€)', np.min(self.recettesArray), np.max(self.recettesArray), self.recettes)
self.recettesSlider.on_changed(self.updateParam)
vinfAxis = plt.axes([0.7, 0.55, 0.2, 0.03], facecolor=axcolor)
self.vinfSlider = Slider(vinfAxis, r'$v_{0}$', np.min(self.vinfArray), np.max(self.vinfArray), self.vinf)
self.vinfSlider.on_changed(self.updateParam)
aAxis = plt.axes([0.7, 0.5, 0.2, 0.03], facecolor=axcolor)
self.aSlider = Slider(aAxis, r'a', np.min(self.aArray), np.max(self.aArray), self.a)
self.aSlider.on_changed(self.updateParam)
bAxis = plt.axes([0.7, 0.45, 0.2, 0.03], facecolor=axcolor)
self.bSlider = Slider(bAxis, r'b', np.min(self.bArray), np.max(self.bArray), self.b)
self.bSlider.on_changed(self.updateParam)
cAxis = plt.axes([0.7, 0.4, 0.2, 0.03], facecolor=axcolor)
self.cSlider = Slider(cAxis, r'c', np.min(self.cArray), np.max(self.cArray), self.c)
self.cSlider.on_changed(self.updateParam)
dAxis = plt.axes([0.7, 0.35, 0.2, 0.03], facecolor=axcolor)
self.dSlider = Slider(dAxis, r'd', np.min(self.dArray), np.max(self.dArray), self.dArray[25])
self.dSlider.on_changed(self.updateParam)
eAxis = plt.axes([0.7, 0.3, 0.2, 0.03], facecolor=axcolor)
self.eSlider = Slider(eAxis, r'e', np.min(self.eArray), np.max(self.eArray), self.eArray[25])
self.eSlider.on_changed(self.updateParam)
def updateComputation(self): # The computation, parameter dependant
#first deal with abonnements
mask= self.status=='RS'
self.abo= self.abop*np.ones((self.nbHab,))
self.abo[self.status=='RS']=self.abos
#second deal with consumption and total prices
self.prixConso=np.zeros(self.vv.shape)
self.prixp=np.zeros(self.vv.shape)
self.prixs=np.zeros(self.vv.shape)
if np.sum(self.abo)>=self.recettes:
self.p0=0
self.prixp = self.abop
self.prixs = self.abos
else:
#find p0 to balance income.
alpha1=np.zeros((self.nbHab,))# préfacteur de p_0 dans la tranche 1
alpha2=np.zeros((self.nbHab,))# préfacteur de p_0 dans la tranche 2
beta2=np.zeros((self.nbHab,))# constante dans la tranche 2
#compute consumption integral on all inhabitants
for ih in range(self.nbHab):
alpha1[ih],alpha2[ih],beta2[ih]=self.computeIntegrals(self.volume[ih])
self.p0=(self.recettes-np.sum(beta2+self.abo))/np.sum(alpha1+alpha2) # prix d'inflexion
self.prixProj = (alpha1+alpha2)*self.p0+beta2 #Calcul du prix payé par les habitantes dans le nouveau scénario
#compute consumption price per m3
#tranche 1
self.vv[0:self.nbpts]=self.vinf*((1-3*self.b)*(self.tt**3)+3*self.b*(self.tt**2))
self.prixConso[0:self.nbpts]=self.p0*((3*self.a-2)*(self.tt**3)+(-6*self.a+3)*(self.tt**2)+3*self.a*self.tt)
#tranche 2
self.vv[self.nbpts:]=self.vinf+(self.vmax-self.vinf)*((3*(self.c+self.d-self.c*self.d)-2)*(self.tt**3)+\
3*(1-2*self.c-self.d+self.c*self.d)*(self.tt**2)+3*self.c*self.tt)
self.prixConso[self.nbpts:]=self.p0+(self.pmax-self.p0)*((1-3*self.e)*(self.tt**3)+3*self.e*(self.tt**2))
#compute full consumption price (integral of price per m3)
alpha1=np.zeros(self.vv.shape)# préfacteur de p_0 dans la tranche 1
alpha2=np.zeros(self.vv.shape)# préfacteur de p_0 dans la tranche 2
beta2=np.zeros(self.vv.shape)# constante dans la tranche 2
for iv,vv in enumerate(self.vv):
alpha1[iv],alpha2[iv],beta2[iv]=self.computeIntegrals(vv)
self.prixp = self.abop+(alpha1+alpha2)*self.p0+beta2
self.prixs = self.abos+(alpha1+alpha2)*self.p0+beta2
#now compute prices
self.prixp_m3 = self.prixp/self.vv
self.prixs_m3 = self.prixs/self.vv
def computeIntegrals(self,vv):#return alpha1, alpha2 and beta for an input volume
a=self.a
b=self.b
c=self.c
d=self.d
e=self.e
if vv<=self.vinf:# on ne travaille que dans la tranche 1.
#Find the value of T by solving the equation V=v(T)
if vv==0: #remove extrema points where root finding can be problematic
T=0.0
elif vv==self.vinf:
T=1.0
else:
p = [1-3*b, 3*b, 0, -vv/self.vinf]# polynomial representation of the equation
roots = np.roots(p)
roots = np.unique(roots)#remove duplicates
roots2 = np.real(roots[np.isreal(roots)]) # take only real roots
mask = (roots2<=1.) & (roots2>=0.)#check that T is real and between 0 and 1
T=float(roots2[mask])
alpha1=3*self.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)
return alpha1,0,0
else:
alpha1=3*self.vinf*(1/6*(-9*a*b+3*a+6*b-2)+1/5*(24*a*b-6*a-13*b+3)+3/4*(-7*a*b+a+2*b)+1/3*6*a*b)# tranche 1 avec T=1
#tranche 2
wmax=self.vmax-self.vinf
#Find the value of T by solving the equation V-vinf=w(T)
if vv==self.vinf: #remove extrema points where root finding can be problematic
T=0.0
elif vv==self.vmax:
T=1.0
else:
p = [3*(c+d-c*d)-2, 3*(1-2*c-d+c*d), 3*c, -(vv-self.vinf)/wmax]
roots = np.roots(p)
roots = np.unique(roots)#remove duplicates
roots2 = np.real(roots[np.isreal(roots)]) # take only real roots
mask = (roots2<=1.) & (roots2>=0.)#check that T is real and between 0 and 1#
if not mask.any(): # for a weird reason, if vv=vmax, we have roots2=1.0 but it does not pass roots2<=1.0 ????
print(vv,roots,roots2,mask)
T=float(np.real(roots2[mask]))
uu=(-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
alpha2=vv-self.vinf-3*uu*wmax
beta2=3*self.pmax*wmax*uu
return alpha1,alpha2,beta2
def updatePlot(self): # The plot update
self.ax1.plot(self.vv,self.prixp,label='RP/PRO')
self.ax1.plot(self.vv,self.prixs,label='RS')
self.ax1.scatter(self.volume,self.prix2018,s=3,color='g',label='2018')
self.ax2.plot(self.vv,self.prixp_m3,label='RP/PRO')
self.ax2.plot(self.vv,self.prixs_m3,label='RS')
self.ax2.plot(self.vv,self.prixConso,'k-',label='Conso')
self.ax2.plot([self.vinf,self.vinf],[0,self.p0],color = '0.75')
self.ax2.plot([0,self.vinf],[self.p0,self.p0],color = '0.75')
self.ax2.scatter(self.volume,self.prix2018_m3,s=3,color='g',label='2018')
self.ax1.set_xlim(self.axx)
self.ax1.set_ylim(self.ax1y)
self.ax2.set_ylim(self.ax2y)
#self.ax2.text(0.9, 0.2, '$p_0=${:.2f} €'.format(self.p0),ha='right', va='bottom', transform=self.ax2.transAxes)
self.ax2.legend()
self.ax2.set_xlabel(r'volume ($m^3$) ')
self.ax1.set_ylabel('Facture totale (€)')
self.ax2.set_ylabel('Prix au m3 (€)')
plt.tight_layout()
plt.subplots_adjust(top = 0.9,right = 0.5)
def updateParam(self,val): # Update the parameter using the slider values
#get current values of ax limits
self.axx=self.ax1.get_xlim()
self.ax1y=self.ax1.get_ylim()
self.ax2y=self.ax2.get_ylim()
self.ax1.clear()
self.ax2.clear()
self.abop= self.abopSlider.val
self.abos= self.abosSlider.val
self.recettes= self.recettesSlider.val
self.vinf=self.vinfSlider.val
self.a=self.aSlider.val
self.b=self.bSlider.val
self.c=self.cSlider.val
self.d=self.dSlider.val
self.e=self.eSlider.val
self.updateComputation()
self.updatePlot() #
class CurrentModel():
def __init__(self): # Set the static parameters
self.vv = np.linspace(0,2100,2101) # volume values
self.vv[0]=1e-5 #little trick to avoid divide by 0
self.abopArray = np.linspace(0,200,201) # potential values for abop
self.abosArray = np.linspace(0,200,201) # potential values for abos
self.recettesArray = np.linspace (50000,100000,51) # potential values for recettes
#loading the inhabitant data
book = xlrd.open_workbook('Eau2018.xls')
sheet = book.sheet_by_name('CALCULS')
self.nbHab=363
self.volume = np.array([sheet.cell_value(r,4) for r in range(1,self.nbHab+1)])#get the volume used in 2018 in m3
self.vTot=np.sum(self.volume)
self.volume[self.volume==0]=1e-5 #little trick to avoid divide by 0
self.status = np.array([sheet.cell_value(r,3) for r in range(1,self.nbHab+1)])#get the RS, RP, PRO status
self.prix2018 = np.array([sheet.cell_value(r,33) for r in range(1,self.nbHab+1)])#get the price paid in 2018 in EUR
self.prix2018_m3 = self.prix2018 / self.volume
def mainFunction(self):
'''
main function, the one called in the notebook, and that update computations depending on the slider values.
'''
self.fig = plt.figure(figsize=(8,5))
self.ax1 = plt.subplot(121)
self.ax2 = plt.subplot(122,sharex=self.ax1)
self.ax1.set_xlabel(r'volume ($m^3$) ')
self.ax2.set_xlabel(r'volume ($m^3$) ')
self.ax1.set_ylabel('Facture totale (€)')
self.ax2.set_ylabel('Prix au m3 (€)')
# initial values
self.abop= self.abopArray[100]
self.abos= self.abosArray[100]
self.recettes= self.recettesArray[25]
# initial plot
self.updateComputation()
self.updatePlot()
# SLIDERS
axcolor = 'lightgoldenrodyellow'
abopAxis = plt.axes([0.2, 0.15, 0.65, 0.05], facecolor=axcolor)
self.abopSlider = Slider(abopAxis, r'Abo. P/PRO (EUR)', np.min(self.abopArray), np.max(self.abopArray), self.abopArray[100])
self.abopSlider.on_changed(self.updateParam)
abosAxis = plt.axes([0.2, 0.1, 0.65, 0.05], facecolor=axcolor)
self.abosSlider = Slider(abosAxis, r'Abo. S (EUR)', np.min(self.abosArray), np.max(self.abosArray), self.abosArray[100])
self.abosSlider.on_changed(self.updateParam)
recettesAxis = plt.axes([0.2, 0.05, 0.65, 0.05], facecolor=axcolor)
self.recettesSlider = Slider(recettesAxis, r'Recettes (EUR)', np.min(self.recettesArray), np.max(self.recettesArray), self.recettesArray[25])
self.recettesSlider.on_changed(self.updateParam)
def updateComputation(self): # The computation, parameter dependant
#first deal with abonnements
mask= self.status=='RS'
self.abo= self.abop*np.ones((self.nbHab,))
self.abo[self.status=='RS']=self.abos
#second deal with consumption
if np.sum(self.abo)>=self.recettes:
self.p0=0
else:
self.p0= (self.recettes-np.sum(self.abo))/self.vTot
#now deal with prices
self.prixp = self.abop+self.p0*self.vv
self.prixs = self.abos+self.p0*self.vv
self.prixp_m3 = self.abop/self.vv+self.p0
self.prixs_m3 = self.abos/self.vv+self.p0
def updatePlot(self): # The plot update
self.ax1.plot(self.vv,self.prixp,label='RP/PRO')
self.ax1.plot(self.vv,self.prixs,label='RS')
self.ax1.scatter(self.volume,self.prix2018,s=3,color='g',label='2018')
self.ax2.plot(self.vv,self.prixp_m3,label='RP/PRO')
self.ax2.plot(self.vv,self.prixs_m3,label='RS')
self.ax2.scatter(self.volume,self.prix2018_m3,s=3,color='g',label='2018')
self.ax2.set_ylim(0,20)
self.ax2.text(0.9, 0.2, '$p_0=${:.2f}'.format(self.p0),ha='right', va='bottom', transform=self.ax2.transAxes)
self.ax2.legend()
self.ax1.set_xlabel(r'volume ($m^3$) ')
self.ax2.set_xlabel(r'volume ($m^3$) ')
self.ax1.set_ylabel('Facture totale (€)')
self.ax2.set_ylabel('Prix au m3 (€)')
plt.tight_layout()
plt.subplots_adjust(top = 0.9,bottom = 0.35)
def updateParam(self,val): # Update the parameter using the slider values
self.ax1.clear()
self.ax2.clear()
self.abop= self.abopSlider.val
self.abos= self.abosSlider.val
self.recettes= self.recettesSlider.val
self.updateComputation()
self.updatePlot() #

13
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

13
frontend/app/app.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
const authStore = useAuthStore()
onMounted(() => {
authStore.restore()
})
</script>

View File

@@ -0,0 +1,230 @@
/* SejeteralO - Global Styles */
:root {
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;
--color-secondary: #059669;
--color-accent: #d97706;
--color-danger: #dc2626;
--color-bg: #f8fafc;
--color-surface: #ffffff;
--color-text: #1e293b;
--color-text-muted: #64748b;
--color-border: #e2e8f0;
--radius: 8px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
}
a {
color: var(--color-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Layout */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 700;
}
/* Cards */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover {
background: var(--color-primary-dark);
}
.btn-secondary {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover {
background: var(--color-bg);
}
.btn-danger {
background: var(--color-danger);
color: white;
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
}
.form-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 0.875rem;
transition: border-color 0.15s;
}
.form-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Tables */
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.table th {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-text-muted);
}
/* Grid */
.grid {
display: grid;
gap: 1.5rem;
}
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 768px) {
.grid-2, .grid-3, .grid-4 {
grid-template-columns: 1fr;
}
}
/* Alerts */
.alert {
padding: 0.75rem 1rem;
border-radius: var(--radius);
font-size: 0.875rem;
margin-bottom: 1rem;
}
.alert-info {
background: #eff6ff;
color: #1e40af;
border: 1px solid #bfdbfe;
}
.alert-success {
background: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}
.alert-error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
/* Badge */
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-green {
background: #dcfce7;
color: #166534;
}
.badge-blue {
background: #dbeafe;
color: #1e40af;
}
.badge-amber {
background: #fef3c7;
color: #92400e;
}
/* Loading spinner */
.spinner {
width: 1.5rem;
height: 1.5rem;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,518 @@
<template>
<div class="bezier-editor">
<div class="editor-layout">
<!-- SVG Canvas -->
<div class="editor-canvas card">
<svg
ref="svgRef"
:viewBox="`0 0 ${svgW} ${svgH}`"
preserveAspectRatio="xMidYMid meet"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
@touchmove.prevent="onTouchMove"
@touchend="onMouseUp"
>
<!-- Grid -->
<g class="grid-lines">
<line
v-for="v in gridVolumes"
:key="'gv' + v"
:x1="toSvgX(v)" :y1="toSvgY(0)" :x2="toSvgX(v)" :y2="toSvgY(pmax)"
stroke="#e2e8f0" stroke-width="0.5"
/>
<line
v-for="p in gridPrices"
:key="'gp' + p"
:x1="toSvgX(0)" :y1="toSvgY(p)" :x2="toSvgX(vmax)" :y2="toSvgY(p)"
stroke="#e2e8f0" stroke-width="0.5"
/>
<!-- Axis labels -->
<text
v-for="v in gridVolumes"
:key="'lv' + v"
:x="toSvgX(v)" :y="svgH - 2"
text-anchor="middle" font-size="10" fill="#94a3b8"
>{{ v }}</text>
<text
v-for="p in gridPrices"
:key="'lp' + p"
:x="4" :y="toSvgY(p) + 3"
font-size="10" fill="#94a3b8"
>{{ p }}</text>
</g>
<!-- Control point tangent lines -->
<g class="tangent-lines">
<line :x1="toSvgX(cp.p1.x)" :y1="toSvgY(cp.p1.y)" :x2="toSvgX(cp.p2.x)" :y2="toSvgY(cp.p2.y)"
stroke="#93c5fd" stroke-width="1" stroke-dasharray="4" />
<line :x1="toSvgX(cp.p3.x)" :y1="toSvgY(cp.p3.y)" :x2="toSvgX(cp.p4.x)" :y2="toSvgY(cp.p4.y)"
stroke="#93c5fd" stroke-width="1" stroke-dasharray="4" />
<line :x1="toSvgX(cp.p4.x)" :y1="toSvgY(cp.p4.y)" :x2="toSvgX(cp.p5.x)" :y2="toSvgY(cp.p5.y)"
stroke="#fca5a5" stroke-width="1" stroke-dasharray="4" />
<line :x1="toSvgX(cp.p6.x)" :y1="toSvgY(cp.p6.y)" :x2="toSvgX(cp.p7.x)" :y2="toSvgY(cp.p7.y)"
stroke="#fca5a5" stroke-width="1" stroke-dasharray="4" />
</g>
<!-- Bézier curves -->
<path :d="tier1Path" fill="none" stroke="#2563eb" stroke-width="2.5" />
<path :d="tier2Path" fill="none" stroke="#dc2626" stroke-width="2.5" />
<!-- Inflection point lines -->
<line :x1="toSvgX(params.vinf)" :y1="toSvgY(0)" :x2="toSvgX(params.vinf)" :y2="toSvgY(localP0)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
<line :x1="toSvgX(0)" :y1="toSvgY(localP0)" :x2="toSvgX(params.vinf)" :y2="toSvgY(localP0)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
<!-- p0 label -->
<text :x="toSvgX(0) + 25" :y="toSvgY(localP0) - 6" font-size="12" fill="#1e293b" font-weight="600">
p₀ = {{ localP0.toFixed(2) }} /
</text>
<!-- Draggable control points -->
<circle
v-for="(point, key) in draggablePoints"
:key="key"
:cx="toSvgX(point.x)"
:cy="toSvgY(point.y)"
:r="dragging === key ? 8 : 6"
:fill="pointColors[key]"
stroke="white"
stroke-width="2"
style="cursor: grab;"
@mousedown.prevent="startDrag(key, $event)"
@touchstart.prevent="startDragTouch(key, $event)"
/>
<!-- Point labels -->
<text
v-for="(point, key) in draggablePoints"
:key="'label-' + key"
:x="toSvgX(point.x) + 10"
:y="toSvgY(point.y) - 10"
font-size="11"
:fill="pointColors[key]"
font-weight="500"
>{{ pointLabels[key] }}</text>
</svg>
</div>
<!-- Right panel -->
<div class="editor-panel">
<!-- Parameters display -->
<div class="card" style="margin-bottom: 1rem;">
<h3 style="margin-bottom: 0.75rem;">Paramètres</h3>
<div class="param-grid">
<div class="param-item">
<span class="param-label">vinf</span>
<span class="param-value">{{ params.vinf.toFixed(0) }} </span>
</div>
<div class="param-item">
<span class="param-label">a</span>
<span class="param-value">{{ params.a.toFixed(3) }}</span>
</div>
<div class="param-item">
<span class="param-label">b</span>
<span class="param-value">{{ params.b.toFixed(3) }}</span>
</div>
<div class="param-item">
<span class="param-label">c</span>
<span class="param-value">{{ params.c.toFixed(3) }}</span>
</div>
<div class="param-item">
<span class="param-label">d</span>
<span class="param-value">{{ params.d.toFixed(3) }}</span>
</div>
<div class="param-item">
<span class="param-label">e</span>
<span class="param-value">{{ params.e.toFixed(3) }}</span>
</div>
<div class="param-item" style="grid-column: span 2;">
<span class="param-label">p₀ (prix inflexion)</span>
<span class="param-value" style="font-size: 1.25rem;">{{ localP0.toFixed(2) }} /</span>
</div>
</div>
</div>
<!-- Impact table -->
<div class="card" style="margin-bottom: 1rem;">
<h3 style="margin-bottom: 0.75rem;">Impact par volume</h3>
<table class="table">
<thead>
<tr>
<th>Volume</th>
<th>Ancien prix</th>
<th>Nouveau (RP)</th>
<th>Nouveau (RS)</th>
</tr>
</thead>
<tbody>
<tr v-for="imp in impacts" :key="imp.volume">
<td>{{ imp.volume }} </td>
<td>{{ imp.oldPrice.toFixed(2) }} </td>
<td :class="imp.newPriceRP > imp.oldPrice ? 'text-up' : 'text-down'">
{{ imp.newPriceRP.toFixed(2) }}
</td>
<td :class="imp.newPriceRS > imp.oldPrice ? 'text-up' : 'text-down'">
{{ imp.newPriceRS.toFixed(2) }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- Submit vote -->
<button class="btn btn-primary" style="width: 100%;" @click="submitVote" :disabled="submitting">
<span v-if="submitting" class="spinner" style="width: 1rem; height: 1rem;"></span>
Soumettre mon vote
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
computeP0, generateCurve, computeImpacts,
paramsToControlPoints, controlPointsToParams,
type HouseholdData, type ImpactRow, type ControlPoints,
} from '~/utils/bezier-math'
const props = defineProps<{ communeSlug: string }>()
const emit = defineEmits<{ 'vote-submitted': [] }>()
const api = useApi()
// SVG dimensions
const svgW = 600
const svgH = 400
const margin = { top: 20, right: 20, bottom: 30, left: 35 }
const plotW = svgW - margin.left - margin.right
const plotH = svgH - margin.top - margin.bottom
// Commune tariff params (fixed by admin)
const vmax = ref(2100)
const pmax = ref(20)
const recettes = ref(75000)
const abop = ref(100)
const abos = ref(100)
const households = ref<HouseholdData[]>([])
// Citizen-adjustable params
const params = reactive({
vinf: 1050,
a: 0.5,
b: 0.5,
c: 0.5,
d: 0.5,
e: 0.5,
})
// Computed
const localP0 = ref(0)
const impacts = ref<ImpactRow[]>([])
const submitting = ref(false)
const cp = computed<ControlPoints>(() =>
paramsToControlPoints(params.vinf, vmax.value, pmax.value, localP0.value, params.a, params.b, params.c, params.d, params.e)
)
const draggablePoints = computed(() => ({
p2: cp.value.p2,
p3: cp.value.p3,
p4: cp.value.p4,
p5: cp.value.p5,
p6: cp.value.p6,
}))
const pointColors: Record<string, string> = {
p2: '#3b82f6',
p3: '#3b82f6',
p4: '#8b5cf6',
p5: '#ef4444',
p6: '#ef4444',
}
const pointLabels: Record<string, string> = {
p2: 'P₂ (a)',
p3: 'P₃ (b)',
p4: 'P₄ (vinf)',
p5: 'P₅ (c)',
p6: 'P₆ (d,e)',
}
// Grid
const gridVolumes = computed(() => {
const step = Math.ceil(vmax.value / 7 / 100) * 100
const arr = []
for (let v = step; v < vmax.value; v += step) arr.push(v)
return arr
})
const gridPrices = computed(() => {
const step = Math.ceil(pmax.value / 5)
const arr = []
for (let p = step; p < pmax.value; p += step) arr.push(p)
return arr
})
// Coordinate transforms
function toSvgX(v: number): number {
return margin.left + (v / vmax.value) * plotW
}
function toSvgY(p: number): number {
return margin.top + plotH - (p / pmax.value) * plotH
}
function fromSvgX(sx: number): number {
return ((sx - margin.left) / plotW) * vmax.value
}
function fromSvgY(sy: number): number {
return ((margin.top + plotH - sy) / plotH) * pmax.value
}
// Bézier path generation
const tier1Path = computed(() => {
const c = cp.value
return `M ${toSvgX(c.p1.x)} ${toSvgY(c.p1.y)} C ${toSvgX(c.p2.x)} ${toSvgY(c.p2.y)}, ${toSvgX(c.p3.x)} ${toSvgY(c.p3.y)}, ${toSvgX(c.p4.x)} ${toSvgY(c.p4.y)}`
})
const tier2Path = computed(() => {
const c = cp.value
return `M ${toSvgX(c.p4.x)} ${toSvgY(c.p4.y)} C ${toSvgX(c.p5.x)} ${toSvgY(c.p5.y)}, ${toSvgX(c.p6.x)} ${toSvgY(c.p6.y)}, ${toSvgX(c.p7.x)} ${toSvgY(c.p7.y)}`
})
// Drag handling
const svgRef = ref<SVGSVGElement | null>(null)
const dragging = ref<string | null>(null)
function getSvgPoint(event: MouseEvent | Touch): { x: number; y: number } {
if (!svgRef.value) return { x: 0, y: 0 }
const rect = svgRef.value.getBoundingClientRect()
const scaleX = svgW / rect.width
const scaleY = svgH / rect.height
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY,
}
}
function startDrag(key: string, event: MouseEvent) {
dragging.value = key
}
function startDragTouch(key: string, event: TouchEvent) {
dragging.value = key
}
function onMouseMove(event: MouseEvent) {
if (!dragging.value) return
handleDrag(getSvgPoint(event))
}
function onTouchMove(event: TouchEvent) {
if (!dragging.value || !event.touches[0]) return
handleDrag(getSvgPoint(event.touches[0]))
}
function handleDrag(svgPoint: { x: number; y: number }) {
const v = Math.max(0, Math.min(vmax.value, fromSvgX(svgPoint.x)))
const p = Math.max(0, Math.min(pmax.value, fromSvgY(svgPoint.y)))
switch (dragging.value) {
case 'p2': // vertical only → a
params.a = localP0.value > 0 ? Math.max(0, Math.min(1, p / localP0.value)) : 0.5
break
case 'p3': // horizontal only → b
params.b = params.vinf > 0 ? Math.max(0, Math.min(1, v / params.vinf)) : 0.5
break
case 'p4': // horizontal → vinf
params.vinf = Math.max(1, Math.min(vmax.value - 1, v))
break
case 'p5': { // horizontal only → c
const wmax = vmax.value - params.vinf
params.c = wmax > 0 ? Math.max(0, Math.min(1, (v - params.vinf) / wmax)) : 0.5
break
}
case 'p6': { // 2D → d, e
const wmax = vmax.value - params.vinf
const qmax = pmax.value - localP0.value
// e from y
params.e = qmax > 0 ? Math.max(0, Math.min(1, (p - localP0.value) / qmax)) : 0.5
// d from x: x6 = vinf + wmax*(1-d+cd) => d = (1 - (x6-vinf)/wmax)/(1-c)
if (wmax > 0 && Math.abs(1 - params.c) > 1e-10) {
const ratio = (v - params.vinf) / wmax
params.d = Math.max(0, Math.min(1, (1 - ratio) / (1 - params.c)))
}
break
}
}
recalculate()
}
function onMouseUp() {
if (dragging.value) {
dragging.value = null
debouncedServerCompute()
}
}
// Recalculate locally
function recalculate() {
if (households.value.length === 0) return
localP0.value = computeP0(
households.value, recettes.value, abop.value, abos.value,
params.vinf, vmax.value, pmax.value,
params.a, params.b, params.c, params.d, params.e,
)
const result = computeImpacts(
households.value, recettes.value, abop.value, abos.value,
params.vinf, vmax.value, pmax.value,
params.a, params.b, params.c, params.d, params.e,
)
impacts.value = result.impacts
}
// Debounced server compute
let serverTimeout: ReturnType<typeof setTimeout> | null = null
function debouncedServerCompute() {
if (serverTimeout) clearTimeout(serverTimeout)
serverTimeout = setTimeout(async () => {
try {
const result = await api.post<any>('/tariff/compute', {
commune_slug: props.communeSlug,
vinf: params.vinf,
a: params.a,
b: params.b,
c: params.c,
d: params.d,
e: params.e,
})
// Use authoritative server p0
localP0.value = result.p0
impacts.value = result.impacts.map((imp: any) => ({
volume: imp.volume,
oldPrice: imp.old_price,
newPriceRP: imp.new_price_rp,
newPriceRS: imp.new_price_rs,
}))
} catch (e) {
// Silently fall back to client-side calculation
}
}, 300)
}
// Submit vote
async function submitVote() {
submitting.value = true
try {
await api.post(`/communes/${props.communeSlug}/votes`, {
vinf: params.vinf,
a: params.a,
b: params.b,
c: params.c,
d: params.d,
e: params.e,
})
emit('vote-submitted')
} catch (e: any) {
alert(e.message || 'Erreur lors de la soumission du vote')
} finally {
submitting.value = false
}
}
// Load data on mount
onMounted(async () => {
try {
// Load commune params
const communeParams = await api.get<any>(`/communes/${props.communeSlug}/params`)
vmax.value = communeParams.vmax
pmax.value = communeParams.pmax
recettes.value = communeParams.recettes
abop.value = communeParams.abop
abos.value = communeParams.abos
params.vinf = communeParams.vmax / 2
// Load household stats (we need volumes for p0 calculation)
// For client-side compute, we fetch stats and create a simplified model
const stats = await api.get<any>(`/communes/${props.communeSlug}/households/stats`)
// Create representative household distribution for client-side compute
// (simplified: use average volumes by status)
const rsCount = stats.rs_count || 0
const rpCount = stats.rp_count || 0
const proCount = stats.pro_count || 0
const avgVol = stats.avg_volume || 90
const hh: HouseholdData[] = []
for (let i = 0; i < rsCount; i++) hh.push({ volume_m3: avgVol, status: 'RS' })
for (let i = 0; i < rpCount; i++) hh.push({ volume_m3: avgVol, status: 'RP' })
for (let i = 0; i < proCount; i++) hh.push({ volume_m3: avgVol, status: 'PRO' })
households.value = hh
// Initial server compute for accurate p0
recalculate()
debouncedServerCompute()
} catch (e) {
console.error('Error loading commune data:', e)
}
})
</script>
<style scoped>
.editor-layout {
display: grid;
grid-template-columns: 1fr 350px;
gap: 1.5rem;
}
@media (max-width: 900px) {
.editor-layout {
grid-template-columns: 1fr;
}
}
.editor-canvas {
padding: 0.5rem;
}
.editor-canvas svg {
width: 100%;
height: auto;
user-select: none;
}
.param-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.param-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0;
}
.param-label {
font-size: 0.75rem;
color: var(--color-text-muted);
font-weight: 500;
}
.param-value {
font-family: monospace;
font-weight: 600;
}
.text-up { color: #dc2626; }
.text-down { color: #059669; }
</style>

View File

@@ -0,0 +1,112 @@
<template>
<div class="overlay-chart">
<svg :viewBox="`0 0 ${svgW} ${svgH}`" preserveAspectRatio="xMidYMid meet">
<!-- Grid -->
<g>
<line
v-for="v in gridVolumes"
:key="'gv' + v"
:x1="toSvgX(v)" :y1="toSvgY(0)" :x2="toSvgX(v)" :y2="toSvgY(pmax)"
stroke="#e2e8f0" stroke-width="0.5"
/>
<line
v-for="p in gridPrices"
:key="'gp' + p"
:x1="toSvgX(0)" :y1="toSvgY(p)" :x2="toSvgX(vmax)" :y2="toSvgY(p)"
stroke="#e2e8f0" stroke-width="0.5"
/>
</g>
<!-- Vote curves (semi-transparent) -->
<g v-for="(vote, i) in votes" :key="i">
<path :d="getVotePath(vote, 1)" fill="none" stroke="#3b82f6" stroke-width="1" opacity="0.3" />
<path :d="getVotePath(vote, 2)" fill="none" stroke="#ef4444" stroke-width="1" opacity="0.3" />
</g>
<!-- Median curve (if available) -->
<g v-if="medianVote">
<path :d="getVotePath(medianVote, 1)" fill="none" stroke="#1e40af" stroke-width="3" />
<path :d="getVotePath(medianVote, 2)" fill="none" stroke="#991b1b" stroke-width="3" />
</g>
<!-- Axis labels -->
<text :x="svgW / 2" :y="svgH - 2" text-anchor="middle" font-size="11" fill="#64748b">
Volume ()
</text>
<text :x="6" :y="12" font-size="11" fill="#64748b">/</text>
</svg>
</div>
</template>
<script setup lang="ts">
import { paramsToControlPoints } from '~/utils/bezier-math'
const props = defineProps<{
votes: Array<{ vinf: number; a: number; b: number; c: number; d: number; e: number; computed_p0?: number }>
slug: string
}>()
const api = useApi()
const svgW = 600
const svgH = 300
const margin = { top: 15, right: 15, bottom: 25, left: 30 }
const plotW = svgW - margin.left - margin.right
const plotH = svgH - margin.top - margin.bottom
const vmax = ref(2100)
const pmax = ref(20)
const medianVote = ref<any>(null)
function toSvgX(v: number): number {
return margin.left + (v / vmax.value) * plotW
}
function toSvgY(p: number): number {
return margin.top + plotH - (p / pmax.value) * plotH
}
const gridVolumes = computed(() => {
const step = Math.ceil(vmax.value / 7 / 100) * 100
const arr = []
for (let v = step; v < vmax.value; v += step) arr.push(v)
return arr
})
const gridPrices = computed(() => {
const step = Math.ceil(pmax.value / 5)
const arr = []
for (let p = step; p < pmax.value; p += step) arr.push(p)
return arr
})
function getVotePath(vote: any, tier: number): string {
const p0 = vote.computed_p0 || 5
const cp = paramsToControlPoints(vote.vinf, vmax.value, pmax.value, p0, vote.a, vote.b, vote.c, vote.d, vote.e)
if (tier === 1) {
return `M ${toSvgX(cp.p1.x)} ${toSvgY(cp.p1.y)} C ${toSvgX(cp.p2.x)} ${toSvgY(cp.p2.y)}, ${toSvgX(cp.p3.x)} ${toSvgY(cp.p3.y)}, ${toSvgX(cp.p4.x)} ${toSvgY(cp.p4.y)}`
} else {
return `M ${toSvgX(cp.p4.x)} ${toSvgY(cp.p4.y)} C ${toSvgX(cp.p5.x)} ${toSvgY(cp.p5.y)}, ${toSvgX(cp.p6.x)} ${toSvgY(cp.p6.y)}, ${toSvgX(cp.p7.x)} ${toSvgY(cp.p7.y)}`
}
}
onMounted(async () => {
try {
const params = await api.get<any>(`/communes/${props.slug}/params`)
vmax.value = params.vmax
pmax.value = params.pmax
} catch {}
try {
medianVote.value = await api.get(`/communes/${props.slug}/votes/median`)
} catch {}
})
</script>
<style scoped>
.overlay-chart svg {
width: 100%;
height: auto;
}
</style>

View File

@@ -0,0 +1,67 @@
/**
* Composable for API calls to the FastAPI backend.
*/
export function useApi() {
const config = useRuntimeConfig()
const baseURL = config.public.apiBase as string
function getToken(): string | null {
if (import.meta.client) {
return localStorage.getItem('sejeteralo_token')
}
return null
}
async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {}),
}
const token = getToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
if (options.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json'
}
let response: Response
try {
response = await fetch(`${baseURL}${path}`, {
...options,
headers,
})
} catch (err) {
throw new Error(`Impossible de contacter le serveur (${baseURL}). Vérifiez que le backend est lancé.`)
}
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }))
throw new Error(error.detail || `Erreur API ${response.status}`)
}
const text = await response.text()
if (!text) return {} as T
return JSON.parse(text)
}
return {
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body?: unknown) =>
apiFetch<T>(path, {
method: 'POST',
body: body instanceof FormData ? body : JSON.stringify(body),
}),
put: <T>(path: string, body?: unknown) =>
apiFetch<T>(path, {
method: 'PUT',
body: JSON.stringify(body),
}),
delete: <T>(path: string) =>
apiFetch<T>(path, { method: 'DELETE' }),
}
}

View File

@@ -0,0 +1,96 @@
<template>
<div class="app-layout">
<header class="app-header">
<div class="container header-inner">
<NuxtLink to="/" class="logo">SejeteralO</NuxtLink>
<nav class="header-nav">
<NuxtLink to="/">Accueil</NuxtLink>
<template v-if="authStore.isAuthenticated">
<NuxtLink v-if="authStore.isSuperAdmin" to="/admin">Super Admin</NuxtLink>
<NuxtLink
v-else-if="authStore.isAdmin && authStore.communeSlug"
:to="`/admin/communes/${authStore.communeSlug}`"
>
Gestion commune
</NuxtLink>
<button class="btn btn-secondary btn-sm" @click="logout">Déconnexion</button>
</template>
</nav>
</div>
</header>
<main class="app-main container">
<slot />
</main>
<footer class="app-footer">
<div class="container">
SejeteralO Outil de démocratie participative pour la tarification de l'eau
</div>
</footer>
</div>
</template>
<script setup lang="ts">
const authStore = useAuthStore()
const router = useRouter()
function logout() {
authStore.logout()
router.push('/')
}
</script>
<style scoped>
.app-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-header {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 0.75rem 0;
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-primary);
}
.logo:hover {
text-decoration: none;
}
.header-nav {
display: flex;
align-items: center;
gap: 1.5rem;
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
}
.app-main {
flex: 1;
padding-top: 2rem;
padding-bottom: 2rem;
}
.app-footer {
background: var(--color-surface);
border-top: 1px solid var(--color-border);
padding: 1rem 0;
text-align: center;
font-size: 0.75rem;
color: var(--color-text-muted);
}
</style>

View File

@@ -0,0 +1,11 @@
/**
* Route middleware: redirects to login if user is not an admin.
* Apply via definePageMeta({ middleware: 'admin' }) on admin pages.
*/
export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated || !authStore.isAdmin) {
return navigateTo('/login')
}
})

View File

@@ -0,0 +1,317 @@
<template>
<div>
<div class="page-header">
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">&larr; {{ slug }}</NuxtLink>
<h1>Contenu CMS</h1>
</div>
<div v-if="error" class="alert alert-error">{{ error }}</div>
<div v-if="success" class="alert alert-success">{{ success }}</div>
<!-- Create new page -->
<div class="card" style="margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3>Pages de contenu</h3>
<button class="btn btn-primary" @click="showCreate = !showCreate">
{{ showCreate ? 'Annuler' : 'Nouvelle page' }}
</button>
</div>
<div v-if="showCreate" style="margin-bottom: 1rem; padding: 1rem; background: var(--color-bg); border-radius: var(--radius);">
<div class="grid grid-2">
<div class="form-group">
<label>Slug (identifiant URL)</label>
<input v-model="newSlug" class="form-input" placeholder="ex: presentation" pattern="[a-z0-9-]+" />
</div>
<div class="form-group">
<label>Titre</label>
<input v-model="newTitle" class="form-input" placeholder="ex: Presentation de la commune" />
</div>
</div>
<button class="btn btn-primary" @click="createPage" :disabled="!newSlug || !newTitle">
Creer la page
</button>
</div>
<!-- Pages list -->
<div v-if="loading" style="text-align: center; padding: 1rem;">
<div class="spinner" style="margin: 0 auto;"></div>
</div>
<div v-else-if="pages.length === 0" class="alert alert-info">
Aucune page de contenu. Cliquez sur "Nouvelle page" pour en creer une.
</div>
<div v-else>
<div
v-for="page in pages" :key="page.slug"
class="page-item"
:class="{ active: editing?.slug === page.slug }"
@click="startEdit(page)"
>
<div>
<strong>{{ page.title }}</strong>
<span style="color: var(--color-text-muted); font-size: 0.8rem; margin-left: 0.5rem;">
/{{ page.slug }}
</span>
</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">
{{ new Date(page.updated_at).toLocaleDateString('fr-FR') }}
</div>
</div>
</div>
</div>
<!-- Editor -->
<div v-if="editing" class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3>{{ editing.title }} <span style="color: var(--color-text-muted); font-size: 0.8rem;">/{{ editing.slug }}</span></h3>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" @click="previewMode = !previewMode">
{{ previewMode ? 'Editer' : 'Apercu' }}
</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete">Supprimer</button>
</div>
</div>
<div class="form-group">
<label>Titre</label>
<input v-model="editing.title" class="form-input" />
</div>
<div v-if="!previewMode" class="form-group">
<label>Contenu (Markdown)</label>
<textarea
v-model="editing.body_markdown"
class="form-input content-textarea"
rows="15"
placeholder="Redigez votre contenu en Markdown..."
></textarea>
</div>
<div v-else class="preview-box">
<div v-html="renderMarkdown(editing.body_markdown)"></div>
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
<button class="btn btn-primary" @click="savePage" :disabled="saving">
{{ saving ? 'Enregistrement...' : 'Enregistrer' }}
</button>
<button class="btn btn-secondary" @click="editing = null">Fermer</button>
</div>
</div>
<!-- Delete confirmation modal -->
<div v-if="deleting" class="modal-overlay" @click.self="deleting = false">
<div class="card modal-content">
<h3>Supprimer cette page ?</h3>
<p style="margin: 1rem 0; color: var(--color-text-muted);">
Supprimer la page <strong>{{ editing?.title }}</strong> est irreversible.
</p>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button class="btn btn-secondary" @click="deleting = false">Annuler</button>
<button class="btn btn-danger" @click="doDelete">Confirmer</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const route = useRoute()
const api = useApi()
const slug = route.params.slug as string
interface ContentPage {
slug: string
title: string
body_markdown: string
updated_at: string
}
const pages = ref<ContentPage[]>([])
const loading = ref(true)
const error = ref('')
const success = ref('')
const showCreate = ref(false)
const newSlug = ref('')
const newTitle = ref('')
const editing = ref<ContentPage | null>(null)
const previewMode = ref(false)
const saving = ref(false)
const deleting = ref(false)
onMounted(async () => {
await loadPages()
})
async function loadPages() {
loading.value = true
error.value = ''
try {
pages.value = await api.get<ContentPage[]>(`/communes/${slug}/content`)
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function createPage() {
error.value = ''; success.value = ''
try {
const page = await api.put<ContentPage>(
`/communes/${slug}/content/${newSlug.value}`,
{ title: newTitle.value, body_markdown: '' },
)
showCreate.value = false
newSlug.value = ''; newTitle.value = ''
await loadPages()
startEdit(page)
success.value = 'Page creee'
} catch (e: any) {
error.value = e.message
}
}
function startEdit(page: ContentPage) {
editing.value = { ...page }
previewMode.value = false
}
async function savePage() {
if (!editing.value) return
saving.value = true
error.value = ''; success.value = ''
try {
await api.put(
`/communes/${slug}/content/${editing.value.slug}`,
{ title: editing.value.title, body_markdown: editing.value.body_markdown },
)
success.value = 'Page enregistree'
await loadPages()
} catch (e: any) {
error.value = e.message
} finally {
saving.value = false
}
}
function confirmDelete() {
deleting.value = true
}
async function doDelete() {
if (!editing.value) return
error.value = ''; success.value = ''
try {
await api.delete(`/communes/${slug}/content/${editing.value.slug}`)
success.value = 'Page supprimee'
editing.value = null
deleting.value = false
await loadPages()
} catch (e: any) {
error.value = e.message
}
}
function renderMarkdown(md: string): string {
if (!md) return '<p style="color: var(--color-text-muted);">Aucun contenu.</p>'
// Simple markdown rendering (headings, bold, italic, links, paragraphs, lists)
return md
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n\n/g, '</p><p>')
.replace(/^(?!<[hulo])(.+)$/gm, '<p>$1</p>')
}
</script>
<style scoped>
.page-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.15s;
}
.page-item:hover {
background: var(--color-bg);
}
.page-item.active {
border-color: var(--color-primary);
background: #eff6ff;
}
.content-textarea {
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 0.85rem;
line-height: 1.6;
resize: vertical;
}
.preview-box {
padding: 1rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
min-height: 200px;
line-height: 1.7;
}
.preview-box h1 { font-size: 1.5rem; margin: 1rem 0 0.5rem; }
.preview-box h2 { font-size: 1.25rem; margin: 0.75rem 0 0.5rem; }
.preview-box h3 { font-size: 1.1rem; margin: 0.5rem 0 0.25rem; }
.preview-box p { margin: 0.5rem 0; }
.preview-box ul { margin: 0.5rem 0; padding-left: 1.5rem; }
.preview-box a { color: var(--color-primary); }
.btn-danger {
background: var(--color-danger);
color: white;
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
}
.alert-success {
background: #dcfce7;
color: #166534;
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-content {
max-width: 480px;
width: 90%;
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<div>
<div class="page-header">
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">&larr; {{ slug }}</NuxtLink>
<h1>Import des foyers</h1>
</div>
<div class="card" style="max-width: 700px;">
<p style="margin-bottom: 1rem;">
Importez un fichier CSV ou XLSX avec les colonnes :
<code>identifier, status, volume_m3, price_eur</code>
</p>
<a :href="`${apiBase}/communes/${slug}/households/template`" class="btn btn-secondary" style="margin-bottom: 1rem;">
Télécharger le template
</a>
<div class="form-group">
<label>Fichier (CSV ou XLSX)</label>
<input type="file" accept=".csv,.xlsx,.xls" @change="onFileChange" class="form-input" />
</div>
<!-- Preview -->
<div v-if="preview" style="margin: 1rem 0;">
<div v-if="preview.errors.length" class="alert alert-error">
<strong>Erreurs :</strong>
<ul style="margin: 0.5rem 0 0 1rem;">
<li v-for="err in preview.errors" :key="err">{{ err }}</li>
</ul>
</div>
<div v-else class="alert alert-success">
{{ preview.valid_rows }} foyers valides prêts à importer.
</div>
</div>
<!-- Result -->
<div v-if="result" class="alert alert-success">
{{ result.created }} foyers importés.
<span v-if="result.errors.length"> ({{ result.errors.length }} avertissements)</span>
</div>
<div style="display: flex; gap: 0.5rem;">
<button
class="btn btn-secondary"
:disabled="!file || previewLoading"
@click="doPreview"
>
Vérifier
</button>
<button
class="btn btn-primary"
:disabled="!file || importLoading || (preview && preview.errors.length > 0)"
@click="doImport"
>
Importer
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const route = useRoute()
const config = useRuntimeConfig()
const api = useApi()
const slug = route.params.slug as string
const apiBase = config.public.apiBase as string
const file = ref<File | null>(null)
const preview = ref<any>(null)
const result = ref<any>(null)
const previewLoading = ref(false)
const importLoading = ref(false)
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
file.value = input.files?.[0] || null
preview.value = null
result.value = null
}
async function doPreview() {
if (!file.value) return
previewLoading.value = true
const fd = new FormData()
fd.append('file', file.value)
try {
preview.value = await api.post(`/communes/${slug}/households/import/preview`, fd)
} catch (e: any) {
preview.value = { valid_rows: 0, errors: [e.message], sample: [] }
} finally {
previewLoading.value = false
}
}
async function doImport() {
if (!file.value) return
importLoading.value = true
const fd = new FormData()
fd.append('file', file.value)
try {
result.value = await api.post(`/communes/${slug}/households/import`, fd)
} catch (e: any) {
result.value = { created: 0, errors: [e.message] }
} finally {
importLoading.value = false
}
}
</script>

View File

@@ -0,0 +1,242 @@
<template>
<div v-if="commune">
<div class="page-header">
<div style="display: flex; align-items: center; gap: 1rem;">
<NuxtLink v-if="authStore.isSuperAdmin" to="/admin" style="color: var(--color-text-muted);">&larr; Admin</NuxtLink>
<h1>{{ commune.name }}</h1>
</div>
</div>
<div class="grid grid-2" style="margin-bottom: 2rem;">
<NuxtLink :to="`/admin/communes/${slug}/params`" class="card nav-card">
<h3>Parametres tarifs</h3>
<p class="nav-card-desc">Configurer les recettes, abonnements, prix max...</p>
</NuxtLink>
<NuxtLink :to="`/admin/communes/${slug}/import`" class="card nav-card">
<h3>Import foyers</h3>
<p class="nav-card-desc">Importer les donnees des foyers (CSV/XLSX)</p>
</NuxtLink>
<NuxtLink :to="`/admin/communes/${slug}/votes`" class="card nav-card">
<h3>Votes</h3>
<p class="nav-card-desc">Consulter les votes, la mediane et l'overlay</p>
</NuxtLink>
<NuxtLink :to="`/admin/communes/${slug}/content`" class="card nav-card">
<h3>Contenu CMS</h3>
<p class="nav-card-desc">Editer le contenu de la page commune</p>
</NuxtLink>
</div>
<!-- Stats -->
<div v-if="stats" class="card" style="margin-bottom: 2rem;">
<h3 style="margin-bottom: 1rem;">Statistiques foyers</h3>
<div class="grid grid-4">
<div>
<div style="font-size: 2rem; font-weight: 700;">{{ stats.total }}</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Foyers total</div>
</div>
<div>
<div style="font-size: 2rem; font-weight: 700;">{{ stats.voted_count }}</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Ont vote</div>
</div>
<div>
<div style="font-size: 2rem; font-weight: 700;">{{ stats.avg_volume?.toFixed(1) }}</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Volume moyen (m3)</div>
</div>
<div>
<div style="font-size: 2rem; font-weight: 700;">{{ stats.median_volume?.toFixed(1) }}</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Volume median (m3)</div>
</div>
</div>
</div>
<!-- Household codes management -->
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3>Codes foyers</h3>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<input
v-model="search"
type="text"
class="form-input"
placeholder="Rechercher un foyer..."
style="width: 220px; padding: 0.375rem 0.75rem; font-size: 0.875rem;"
/>
</div>
</div>
<div v-if="householdsLoading" style="text-align: center; padding: 1rem;">
<div class="spinner" style="margin: 0 auto;"></div>
</div>
<div v-else-if="households.length === 0" class="alert alert-info">
Aucun foyer importe. Utilisez la page "Import foyers" pour charger les donnees.
</div>
<div v-else>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">
{{ filteredHouseholds.length }} foyer(s) affiche(s) sur {{ households.length }}
</p>
<div class="table-scroll">
<table class="table">
<thead>
<tr>
<th>Identifiant</th>
<th>Statut</th>
<th>Volume (m3)</th>
<th>Code d'acces</th>
<th>A vote</th>
</tr>
</thead>
<tbody>
<tr v-for="h in paginatedHouseholds" :key="h.id">
<td>{{ h.identifier }}</td>
<td>
<span class="badge" :class="statusBadge(h.status)">{{ h.status }}</span>
</td>
<td>{{ h.volume_m3.toFixed(1) }}</td>
<td>
<code class="auth-code">{{ h.auth_code }}</code>
</td>
<td>
<span v-if="h.has_voted" style="color: #059669;">Oui</span>
<span v-else style="color: var(--color-text-muted);">Non</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" style="display: flex; justify-content: center; gap: 0.5rem; margin-top: 1rem;">
<button
class="btn btn-secondary btn-sm"
:disabled="page === 1"
@click="page--"
>&laquo; Prec.</button>
<span style="padding: 0.375rem 0.5rem; font-size: 0.875rem;">
{{ page }} / {{ totalPages }}
</span>
<button
class="btn btn-secondary btn-sm"
:disabled="page >= totalPages"
@click="page++"
>Suiv. &raquo;</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const route = useRoute()
const authStore = useAuthStore()
const api = useApi()
const slug = route.params.slug as string
const commune = ref<any>(null)
const stats = ref<any>(null)
const households = ref<any[]>([])
const householdsLoading = ref(false)
const search = ref('')
const page = ref(1)
const perPage = 20
const filteredHouseholds = computed(() => {
if (!search.value) return households.value
const q = search.value.toLowerCase()
return households.value.filter(h =>
h.identifier.toLowerCase().includes(q) ||
h.auth_code.toLowerCase().includes(q) ||
h.status.toLowerCase().includes(q)
)
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredHouseholds.value.length / perPage)))
const paginatedHouseholds = computed(() => {
const start = (page.value - 1) * perPage
return filteredHouseholds.value.slice(start, start + perPage)
})
watch(search, () => { page.value = 1 })
function statusBadge(status: string) {
if (status === 'RS') return 'badge-amber'
if (status === 'PRO') return 'badge-blue'
return 'badge-green'
}
onMounted(async () => {
try {
commune.value = await api.get<any>(`/communes/${slug}`)
} catch (e: any) {
return
}
// Load stats and households in parallel
householdsLoading.value = true
try {
const [s, hh] = await Promise.all([
api.get<any>(`/communes/${slug}/households/stats`),
api.get<any[]>(`/communes/${slug}/households`),
])
stats.value = s
households.value = hh
} catch (e: any) {
// stats or households may fail if not imported yet
} finally {
householdsLoading.value = false
}
})
</script>
<style scoped>
.nav-card {
cursor: pointer;
transition: box-shadow 0.15s;
}
.nav-card:hover {
box-shadow: var(--shadow-md);
text-decoration: none;
}
.nav-card h3 {
color: var(--color-primary);
margin-bottom: 0.25rem;
}
.nav-card-desc {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.auth-code {
background: var(--color-surface);
border: 1px solid var(--color-border);
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.9rem;
letter-spacing: 0.1em;
user-select: all;
}
.table-scroll {
overflow-x: auto;
}
.badge-blue {
background: #dbeafe;
color: #1e40af;
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div>
<div class="page-header">
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">&larr; {{ slug }}</NuxtLink>
<h1>Paramètres tarifs</h1>
</div>
<div v-if="saved" class="alert alert-success">Paramètres enregistrés.</div>
<div v-if="error" class="alert alert-error">{{ error }}</div>
<div class="card" style="max-width: 600px;">
<form @submit.prevent="save">
<div class="form-group">
<label>Recettes cibles ()</label>
<input v-model.number="form.recettes" type="number" class="form-input" step="1000" min="0" />
</div>
<div class="grid grid-2">
<div class="form-group">
<label>Abonnement RP/PRO ()</label>
<input v-model.number="form.abop" type="number" class="form-input" step="1" min="0" />
</div>
<div class="form-group">
<label>Abonnement RS ()</label>
<input v-model.number="form.abos" type="number" class="form-input" step="1" min="0" />
</div>
</div>
<div class="grid grid-2">
<div class="form-group">
<label>Prix max/ ()</label>
<input v-model.number="form.pmax" type="number" class="form-input" step="0.5" min="0" />
</div>
<div class="form-group">
<label>Volume max ()</label>
<input v-model.number="form.vmax" type="number" class="form-input" step="100" min="0" />
</div>
</div>
<button type="submit" class="btn btn-primary" :disabled="loading">
Enregistrer
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const route = useRoute()
const api = useApi()
const slug = route.params.slug as string
const form = reactive({ recettes: 75000, abop: 100, abos: 100, pmax: 20, vmax: 2100 })
const loading = ref(false)
const saved = ref(false)
const error = ref('')
onMounted(async () => {
try {
const params = await api.get<typeof form>(`/communes/${slug}/params`)
Object.assign(form, params)
} catch {}
})
async function save() {
loading.value = true
saved.value = false
error.value = ''
try {
await api.put(`/communes/${slug}/params`, form)
saved.value = true
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,89 @@
<template>
<div>
<div class="page-header">
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">&larr; {{ slug }}</NuxtLink>
<h1>Votes</h1>
</div>
<!-- Median -->
<div v-if="median" class="card" style="margin-bottom: 1.5rem;">
<h3>Médiane ({{ median.vote_count }} votes)</h3>
<div class="grid grid-4" style="margin-top: 1rem;">
<div><strong>vinf:</strong> {{ median.vinf.toFixed(0) }}</div>
<div><strong>a:</strong> {{ median.a.toFixed(3) }}</div>
<div><strong>b:</strong> {{ median.b.toFixed(3) }}</div>
<div><strong>c:</strong> {{ median.c.toFixed(3) }}</div>
<div><strong>d:</strong> {{ median.d.toFixed(3) }}</div>
<div><strong>e:</strong> {{ median.e.toFixed(3) }}</div>
<div><strong>p0:</strong> {{ median.computed_p0.toFixed(2) }} /</div>
</div>
</div>
<!-- Vote overlay chart placeholder -->
<div class="card" style="margin-bottom: 1.5rem;">
<h3>Overlay des courbes</h3>
<VoteOverlayChart v-if="overlayData.length" :votes="overlayData" :slug="slug" />
<p v-else style="color: var(--color-text-muted); padding: 2rem; text-align: center;">
Aucun vote pour le moment.
</p>
</div>
<!-- Vote list -->
<div class="card">
<h3 style="margin-bottom: 1rem;">Liste des votes actifs</h3>
<table class="table" v-if="votes.length">
<thead>
<tr>
<th>Foyer</th>
<th>vinf</th>
<th>a</th>
<th>b</th>
<th>c</th>
<th>d</th>
<th>e</th>
<th>p0</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr v-for="v in votes" :key="v.id">
<td>#{{ v.household_id }}</td>
<td>{{ v.vinf.toFixed(0) }}</td>
<td>{{ v.a.toFixed(2) }}</td>
<td>{{ v.b.toFixed(2) }}</td>
<td>{{ v.c.toFixed(2) }}</td>
<td>{{ v.d.toFixed(2) }}</td>
<td>{{ v.e.toFixed(2) }}</td>
<td>{{ v.computed_p0?.toFixed(2) }}</td>
<td>{{ new Date(v.submitted_at).toLocaleDateString() }}</td>
</tr>
</tbody>
</table>
<p v-else style="color: var(--color-text-muted);">Aucun vote actif.</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const route = useRoute()
const api = useApi()
const slug = route.params.slug as string
const votes = ref<any[]>([])
const median = ref<any>(null)
const overlayData = ref<any[]>([])
onMounted(async () => {
try {
[votes.value, overlayData.value] = await Promise.all([
api.get<any[]>(`/communes/${slug}/votes`),
api.get<any[]>(`/communes/${slug}/votes/overlay`),
])
} catch {}
try {
median.value = await api.get(`/communes/${slug}/votes/median`)
} catch {}
})
</script>

View File

@@ -0,0 +1,225 @@
<template>
<div>
<!-- Redirect commune admin to their commune page -->
<div v-if="!authStore.isSuperAdmin && authStore.communeSlug">
<div class="alert alert-info">
Redirection vers votre espace commune...
</div>
</div>
<!-- Super admin authenticated -->
<template v-else>
<div class="page-header"><h1>Super administration</h1></div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2>Communes</h2>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" @click="showAdminCreate = !showAdminCreate">
{{ showAdminCreate ? 'Masquer' : 'Nouvel admin commune' }}
</button>
<button class="btn btn-primary" @click="showCreate = true">
Nouvelle commune
</button>
</div>
</div>
<div v-if="error" class="alert alert-error">{{ error }}</div>
<div v-if="success" class="alert alert-success">{{ success }}</div>
<!-- Create commune admin form -->
<div v-if="showAdminCreate" class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin-bottom: 1rem;">Creer un admin commune</h3>
<form @submit.prevent="createAdmin">
<div class="grid grid-2">
<div class="form-group">
<label>Email</label>
<input v-model="newAdmin.email" type="email" class="form-input" required />
</div>
<div class="form-group">
<label>Mot de passe</label>
<input v-model="newAdmin.password" type="password" class="form-input" required />
</div>
</div>
<div class="grid grid-2">
<div class="form-group">
<label>Nom complet</label>
<input v-model="newAdmin.full_name" class="form-input" />
</div>
<div class="form-group">
<label>Commune</label>
<select v-model="newAdmin.commune_slug" class="form-input">
<option value="">-- Aucune --</option>
<option v-for="c in communes" :key="c.id" :value="c.slug">{{ c.name }}</option>
</select>
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn btn-primary">Creer l'admin</button>
<button type="button" class="btn btn-secondary" @click="showAdminCreate = false">Annuler</button>
</div>
</form>
</div>
<!-- Create commune form -->
<div v-if="showCreate" class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin-bottom: 1rem;">Creer une commune</h3>
<form @submit.prevent="createCommune">
<div class="grid grid-2">
<div class="form-group">
<label>Nom</label>
<input v-model="newCommune.name" class="form-input" required />
</div>
<div class="form-group">
<label>Slug (URL)</label>
<input v-model="newCommune.slug" class="form-input" required pattern="[a-z0-9-]+" />
</div>
</div>
<div class="form-group">
<label>Description</label>
<input v-model="newCommune.description" class="form-input" />
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn btn-primary">Creer</button>
<button type="button" class="btn btn-secondary" @click="showCreate = false">Annuler</button>
</div>
</form>
</div>
<div v-if="loading" style="text-align: center; padding: 2rem;">
<div class="spinner" style="margin: 0 auto;"></div>
</div>
<div v-else class="grid grid-3">
<div v-for="commune in communes" :key="commune.id" class="card commune-card">
<NuxtLink :to="`/admin/communes/${commune.slug}`" style="text-decoration: none; color: inherit;">
<h3>{{ commune.name }}</h3>
<p style="font-size: 0.875rem; color: var(--color-text-muted);">{{ commune.description }}</p>
<span class="badge" :class="commune.is_active ? 'badge-green' : 'badge-amber'" style="margin-top: 0.5rem;">
{{ commune.is_active ? 'Active' : 'Inactive' }}
</span>
</NuxtLink>
<button
class="btn btn-danger btn-sm"
style="margin-top: 0.75rem;"
@click.prevent="confirmDelete(commune)"
>Supprimer</button>
</div>
</div>
<!-- Delete confirmation modal -->
<div v-if="deletingCommune" class="modal-overlay" @click.self="deletingCommune = null">
<div class="card modal-content">
<h3>Supprimer la commune ?</h3>
<p style="margin: 1rem 0; color: var(--color-text-muted);">
Supprimer <strong>{{ deletingCommune.name }}</strong> effacera toutes les donnees associees
(foyers, votes, parametres). Cette action est irreversible.
</p>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button class="btn btn-secondary" @click="deletingCommune = null">Annuler</button>
<button class="btn btn-danger" @click="doDelete">Confirmer</button>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const authStore = useAuthStore()
const router = useRouter()
const api = useApi()
const communes = ref<any[]>([])
const showCreate = ref(false)
const showAdminCreate = ref(false)
const loading = ref(false)
const error = ref('')
const success = ref('')
const newCommune = reactive({ name: '', slug: '', description: '' })
const newAdmin = reactive({ email: '', password: '', full_name: '', commune_slug: '' })
const deletingCommune = ref<any>(null)
// Redirect commune admin away from super admin page
onMounted(async () => {
if (authStore.isAdmin && !authStore.isSuperAdmin && authStore.communeSlug) {
router.replace(`/admin/communes/${authStore.communeSlug}`)
return
}
if (authStore.isSuperAdmin) {
await loadCommunes()
}
})
async function loadCommunes() {
loading.value = true
try {
communes.value = await api.get<any[]>('/communes/')
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function createCommune() {
error.value = ''; success.value = ''
try {
await api.post('/communes/', newCommune)
showCreate.value = false
newCommune.name = ''; newCommune.slug = ''; newCommune.description = ''
await loadCommunes()
success.value = 'Commune creee'
} catch (e: any) { error.value = e.message }
}
async function createAdmin() {
error.value = ''; success.value = ''
try {
await api.post('/auth/admin/create', {
email: newAdmin.email, password: newAdmin.password,
full_name: newAdmin.full_name, role: 'commune_admin',
commune_slugs: newAdmin.commune_slug ? [newAdmin.commune_slug] : [],
})
showAdminCreate.value = false
newAdmin.email = ''; newAdmin.password = ''; newAdmin.full_name = ''; newAdmin.commune_slug = ''
success.value = 'Admin commune cree'
} catch (e: any) { error.value = e.message }
}
function confirmDelete(c: any) { deletingCommune.value = c }
async function doDelete() {
if (!deletingCommune.value) return
error.value = ''; success.value = ''
try {
await api.delete(`/communes/${deletingCommune.value.slug}`)
success.value = `Commune "${deletingCommune.value.name}" supprimee`
deletingCommune.value = null
await loadCommunes()
} catch (e: any) { error.value = e.message }
}
</script>
<style scoped>
.commune-card { transition: box-shadow 0.15s; }
.commune-card:hover { box-shadow: var(--shadow-md); }
.commune-card h3 { color: var(--color-primary); margin-bottom: 0.25rem; }
.btn-danger {
background: #dc2626; color: white; border: none;
padding: 0.375rem 0.75rem; border-radius: 0.375rem; cursor: pointer; font-size: 0.75rem;
}
.btn-danger:hover { background: #b91c1c; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.alert-success {
background: #dcfce7; color: #166534;
padding: 0.75rem 1rem; border-radius: 0.5rem; margin-bottom: 1rem;
}
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.modal-content { max-width: 480px; width: 90%; }
</style>

View File

@@ -0,0 +1,11 @@
<template>
<div></div>
</template>
<script setup lang="ts">
// Redirect to main commune page (editor is now integrated there)
const route = useRoute()
const router = useRouter()
const slug = route.params.slug as string
onMounted(() => router.replace(`/commune/${slug}`))
</script>

View File

@@ -0,0 +1,724 @@
<template>
<div v-if="commune">
<div class="page-header">
<NuxtLink to="/" style="color: var(--color-text-muted); font-size: 0.875rem;">&larr; Toutes les communes</NuxtLink>
<h1>{{ commune.name }}</h1>
<p style="color: var(--color-text-muted);">{{ commune.description }}</p>
</div>
<!-- Loading -->
<div v-if="loading" class="card" style="text-align: center; padding: 3rem;">
<div class="spinner" style="margin: 0 auto;"></div>
<p style="margin-top: 1rem; color: var(--color-text-muted);">Chargement...</p>
</div>
<template v-else-if="curveData">
<!-- CMS content (published by admin) -->
<div v-if="contentPages.length" style="margin-bottom: 1.5rem;">
<div v-for="page in contentPages" :key="page.slug" class="card" style="margin-bottom: 1rem;">
<h3 style="margin-bottom: 0.5rem;">{{ page.title }}</h3>
<div class="cms-body" v-html="renderMarkdown(page.body_markdown)"></div>
</div>
</div>
<!--
GRAPH 1: Interactive Bezier curve Prix au m3
(= dernier graph de eau.py NewModel bottom subplot)
-->
<div class="card" style="margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
<h3>Tarification progressive Prix au m<sup>3</sup></h3>
<span v-if="curveData.has_votes" class="badge badge-green">
Mediane de {{ curveData.vote_count }} vote(s)
</span>
<span v-else class="badge badge-amber">Courbe par defaut</span>
</div>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 1rem;">
Deplacez les poignees pour ajuster la forme de la courbe.
Le prix d'inflexion p<sub>0</sub> s'ajuste automatiquement pour equilibrer les recettes.
</p>
<div class="editor-layout">
<!-- SVG: Prix au m3 vs Volume (the Bezier curve) -->
<div class="chart-container">
<svg
ref="svgRef"
:viewBox="`0 0 ${W} ${H}`"
preserveAspectRatio="xMidYMid meet"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
@touchmove.prevent="onTouchMove"
@touchend="onMouseUp"
>
<!-- Grid -->
<g>
<line v-for="v in gridVols" :key="'gv'+v"
:x1="cx(v)" :y1="cy(0)" :x2="cx(v)" :y2="cy(pmax)"
stroke="#e2e8f0" stroke-width="0.5" />
<line v-for="p in gridPrices" :key="'gp'+p"
:x1="cx(0)" :y1="cy(p)" :x2="cx(vmax)" :y2="cy(p)"
stroke="#e2e8f0" stroke-width="0.5" />
<!-- Volume labels -->
<text v-for="v in gridVols" :key="'lv'+v"
:x="cx(v)" :y="H - 2" text-anchor="middle" font-size="10" fill="#94a3b8">
{{ v }}
</text>
<!-- Price labels -->
<text v-for="p in gridPrices" :key="'lp'+p"
:x="margin.left - 4" :y="cy(p) + 3" text-anchor="end" font-size="10" fill="#94a3b8">
{{ p }}
</text>
<!-- Axes labels -->
<text :x="W / 2" :y="H - 0" text-anchor="middle" font-size="10" fill="#64748b">
volume (m3)
</text>
<text :x="12" :y="margin.top - 4" font-size="10" fill="#64748b">
Prix/m3
</text>
</g>
<!-- Tangent lines (control arms) -->
<line :x1="cx(cp.p1.x)" :y1="cy(cp.p1.y)" :x2="cx(cp.p2.x)" :y2="cy(cp.p2.y)"
stroke="#93c5fd" stroke-width="1" stroke-dasharray="4" />
<line :x1="cx(cp.p3.x)" :y1="cy(cp.p3.y)" :x2="cx(cp.p4.x)" :y2="cy(cp.p4.y)"
stroke="#93c5fd" stroke-width="1" stroke-dasharray="4" />
<line :x1="cx(cp.p4.x)" :y1="cy(cp.p4.y)" :x2="cx(cp.p5.x)" :y2="cy(cp.p5.y)"
stroke="#fca5a5" stroke-width="1" stroke-dasharray="4" />
<line :x1="cx(cp.p6.x)" :y1="cy(cp.p6.y)" :x2="cx(cp.p7.x)" :y2="cy(cp.p7.y)"
stroke="#fca5a5" stroke-width="1" stroke-dasharray="4" />
<!-- Bezier curve: tier 1 (blue) -->
<path :d="tier1Path" fill="none" stroke="#2563eb" stroke-width="2.5" />
<!-- Bezier curve: tier 2 (red) -->
<path :d="tier2Path" fill="none" stroke="#dc2626" stroke-width="2.5" />
<!-- Inflection reference lines -->
<line :x1="cx(bp.vinf)" :y1="cy(0)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
<line :x1="cx(0)" :y1="cy(localP0)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
<!-- p0 label -->
<text :x="cx(0) + 30" :y="cy(localP0) - 6" font-size="12" fill="#1e293b" font-weight="600">
p0 = {{ localP0.toFixed(2) }} EUR/m3
</text>
<!-- Draggable control points -->
<circle v-for="(pt, key) in dragPoints" :key="key"
:cx="cx(pt.x)" :cy="cy(pt.y)"
:r="dragging === key ? 9 : 7"
:fill="ptColors[key]" stroke="white" stroke-width="2"
style="cursor: grab;"
@mousedown.prevent="startDrag(key)"
@touchstart.prevent="startDrag(key)"
/>
<!-- Point labels -->
<text v-for="(pt, key) in dragPoints" :key="'l-'+key"
:x="cx(pt.x) + 10" :y="cy(pt.y) - 10"
font-size="11" :fill="ptColors[key]" font-weight="500">
{{ ptLabels[key] }}
</text>
</svg>
</div>
<!-- Right panel: parameters + impacts -->
<div class="side-panel">
<div class="card" style="margin-bottom: 1rem;">
<h4 style="margin-bottom: 0.5rem;">Parametres de la courbe</h4>
<div class="param-grid">
<div class="param-row">
<span class="param-label">v<sub>inf</sub></span>
<span class="param-val">{{ bp.vinf.toFixed(0) }} m3</span>
</div>
<div class="param-row">
<span class="param-label">a</span>
<span class="param-val">{{ bp.a.toFixed(3) }}</span>
</div>
<div class="param-row">
<span class="param-label">b</span>
<span class="param-val">{{ bp.b.toFixed(3) }}</span>
</div>
<div class="param-row">
<span class="param-label">c</span>
<span class="param-val">{{ bp.c.toFixed(3) }}</span>
</div>
<div class="param-row">
<span class="param-label">d</span>
<span class="param-val">{{ bp.d.toFixed(3) }}</span>
</div>
<div class="param-row">
<span class="param-label">e</span>
<span class="param-val">{{ bp.e.toFixed(3) }}</span>
</div>
<div class="param-row" style="grid-column: span 2; border-top: 1px solid var(--color-border); padding-top: 0.5rem;">
<span class="param-label" style="font-weight: 600;">p<sub>0</sub></span>
<span class="param-val" style="font-size: 1.1rem;">{{ localP0.toFixed(2) }} EUR/m3</span>
</div>
</div>
</div>
<!-- Impact table -->
<div class="card" style="margin-bottom: 1rem;">
<h4 style="margin-bottom: 0.5rem;">Impact par volume</h4>
<table class="table table-sm">
<thead>
<tr><th>Vol.</th><th>Ancien</th><th>Nouveau RP</th><th>Nouveau RS</th></tr>
</thead>
<tbody>
<tr v-for="imp in impacts" :key="imp.volume">
<td>{{ imp.volume }} m3</td>
<td>{{ imp.old_price.toFixed(0) }} EUR</td>
<td :class="imp.new_price_rp > imp.old_price ? 'text-up' : 'text-down'">
{{ imp.new_price_rp.toFixed(0) }} EUR
</td>
<td :class="imp.new_price_rs > imp.old_price ? 'text-up' : 'text-down'">
{{ imp.new_price_rs.toFixed(0) }} EUR
</td>
</tr>
</tbody>
</table>
</div>
<!-- Vote action -->
<div class="card">
<div v-if="!isCitizenAuth">
<p style="font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">
Pour soumettre votre vote, entrez votre code foyer :
</p>
<div v-if="authError" class="alert alert-error" style="margin-bottom: 0.5rem;">{{ authError }}</div>
<form @submit.prevent="authenticate" style="display: flex; gap: 0.5rem;">
<input v-model="authCode" type="text" maxlength="8" placeholder="Code foyer"
class="form-input" style="flex: 1; text-transform: uppercase; font-family: monospace; letter-spacing: 0.15em;" />
<button type="submit" class="btn btn-primary" :disabled="authLoading">OK</button>
</form>
</div>
<div v-else>
<button class="btn btn-primary" style="width: 100%;" @click="submitVote" :disabled="submitting">
<span v-if="submitting" class="spinner" style="width: 1rem; height: 1rem;"></span>
Soumettre mon vote
</button>
<div v-if="voteSuccess" class="alert alert-success" style="margin-top: 0.5rem;">
Vote enregistre !
</div>
</div>
</div>
</div>
</div>
</div>
<!--
GRAPH 2: Static baseline Modele lineaire actuel
(= 1er graph de eau.py CurrentModel)
-->
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin-bottom: 0.5rem;">Tarification actuelle (modele lineaire)</h3>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 1rem;">
Situation tarifaire en vigueur : prix fixe de {{ curveData.p0_linear?.toFixed(2) }} EUR/m3 + abonnement.
</p>
<div class="baseline-charts">
<!-- Left: Facture totale -->
<div class="chart-container">
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem;">Facture totale (EUR)</h4>
<svg :viewBox="`0 0 ${W2} ${H2}`" preserveAspectRatio="xMidYMid meet">
<!-- Grid -->
<g>
<line v-for="v in gridVols2" :key="'bg1v'+v"
:x1="cx2(v)" :y1="cy2bill(0)" :x2="cx2(v)" :y2="cy2bill(maxBill)"
stroke="#e2e8f0" stroke-width="0.5" />
<line v-for="b in gridBills" :key="'bg1b'+b"
:x1="cx2(0)" :y1="cy2bill(b)" :x2="cx2(vmax)" :y2="cy2bill(b)"
stroke="#e2e8f0" stroke-width="0.5" />
<text v-for="v in gridVols2" :key="'bg1lv'+v"
:x="cx2(v)" :y="H2 - 2" text-anchor="middle" font-size="9" fill="#94a3b8">{{ v }}</text>
<text v-for="b in gridBills" :key="'bg1lb'+b"
:x="margin2.left - 4" :y="cy2bill(b) + 3" text-anchor="end" font-size="9" fill="#94a3b8">{{ b }}</text>
</g>
<!-- RP curve -->
<polyline :points="baselineBillRP" fill="none" stroke="#2563eb" stroke-width="1.5" />
<!-- RS curve -->
<polyline :points="baselineBillRS" fill="none" stroke="#dc2626" stroke-width="1.5" />
<!-- Legend -->
<g :transform="`translate(${W2 - 100}, 15)`">
<line x1="0" y1="0" x2="15" y2="0" stroke="#2563eb" stroke-width="1.5" />
<text x="18" y="3" font-size="9" fill="#1e293b">RP/PRO</text>
<line x1="0" y1="12" x2="15" y2="12" stroke="#dc2626" stroke-width="1.5" />
<text x="18" y="15" font-size="9" fill="#1e293b">RS</text>
</g>
<text :x="W2/2" :y="H2" text-anchor="middle" font-size="9" fill="#64748b">volume (m3)</text>
</svg>
</div>
<!-- Right: Prix au m3 -->
<div class="chart-container">
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem;">Prix au m<sup>3</sup> (EUR)</h4>
<svg :viewBox="`0 0 ${W2} ${H2}`" preserveAspectRatio="xMidYMid meet">
<!-- Grid -->
<g>
<line v-for="v in gridVols2" :key="'bg2v'+v"
:x1="cx2(v)" :y1="cy2price(0)" :x2="cx2(v)" :y2="cy2price(pmax)"
stroke="#e2e8f0" stroke-width="0.5" />
<line v-for="p in gridPrices2" :key="'bg2p'+p"
:x1="cx2(0)" :y1="cy2price(p)" :x2="cx2(vmax)" :y2="cy2price(p)"
stroke="#e2e8f0" stroke-width="0.5" />
<text v-for="v in gridVols2" :key="'bg2lv'+v"
:x="cx2(v)" :y="H2 - 2" text-anchor="middle" font-size="9" fill="#94a3b8">{{ v }}</text>
<text v-for="p in gridPrices2" :key="'bg2lp'+p"
:x="margin2.left - 4" :y="cy2price(p) + 3" text-anchor="end" font-size="9" fill="#94a3b8">{{ p }}</text>
</g>
<!-- RP price/m3 curve (hyperbolic) -->
<polyline :points="baselinePriceRP" fill="none" stroke="#2563eb" stroke-width="1.5" />
<!-- RS price/m3 curve -->
<polyline :points="baselinePriceRS" fill="none" stroke="#dc2626" stroke-width="1.5" />
<!-- p0 baseline line -->
<line :x1="cx2(0)" :y1="cy2price(curveData.p0_linear)" :x2="cx2(vmax)" :y2="cy2price(curveData.p0_linear)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
<text :x="cx2(vmax) - 5" :y="cy2price(curveData.p0_linear) - 5" text-anchor="end"
font-size="10" fill="#475569">
p0 = {{ curveData.p0_linear?.toFixed(2) }}
</text>
<text :x="W2/2" :y="H2" text-anchor="middle" font-size="9" fill="#64748b">volume (m3)</text>
</svg>
</div>
</div>
</div>
<!-- Tariff params info -->
<div class="card">
<h3 style="margin-bottom: 0.75rem;">Informations tarifaires</h3>
<div v-if="params" class="grid grid-5-info">
<div><strong>{{ params.recettes.toLocaleString() }} EUR</strong><br/><span class="info-label">Recettes cibles</span></div>
<div><strong>{{ params.abop }} EUR</strong><br/><span class="info-label">Abo RP/PRO</span></div>
<div><strong>{{ params.abos }} EUR</strong><br/><span class="info-label">Abo RS</span></div>
<div><strong>{{ params.pmax }} EUR/m3</strong><br/><span class="info-label">Prix max</span></div>
<div><strong>{{ params.vmax }} m3</strong><br/><span class="info-label">Volume max</span></div>
</div>
</div>
</template>
</div>
<div v-else-if="loadError" class="alert alert-error">{{ loadError }}</div>
<div v-else style="text-align: center; padding: 3rem;">
<div class="spinner" style="margin: 0 auto;"></div>
</div>
</template>
<script setup lang="ts">
import {
computeP0, computeImpacts, generateCurve,
paramsToControlPoints,
type HouseholdData, type ImpactRow, type ControlPoints,
} from '~/utils/bezier-math'
const route = useRoute()
const authStore = useAuthStore()
const api = useApi()
const slug = route.params.slug as string
const commune = ref<any>(null)
const params = ref<any>(null)
const curveData = ref<any>(null)
const loading = ref(true)
const loadError = ref('')
const contentPages = ref<any[]>([])
// Bezier params (citizen-adjustable)
const bp = reactive({ vinf: 1050, a: 0.5, b: 0.5, c: 0.5, d: 0.5, e: 0.5 })
const localP0 = ref(0)
const impacts = ref<any[]>([])
const households = ref<HouseholdData[]>([])
// Tariff fixed params
const vmax = ref(2100)
const pmax = ref(20)
const recettes = ref(75000)
const abop = ref(100)
const abos = ref(100)
// Auth
const authCode = ref('')
const authError = ref('')
const authLoading = ref(false)
const isCitizenAuth = computed(() => authStore.isCitizen && authStore.communeSlug === slug)
const submitting = ref(false)
const voteSuccess = ref(false)
// ── Chart 1: Interactive Bezier ──
const W = 620
const H = 380
const margin = { top: 20, right: 20, bottom: 28, left: 45 }
const plotW = W - margin.left - margin.right
const plotH = H - margin.top - margin.bottom
function cx(v: number) { return margin.left + (v / vmax.value) * plotW }
function cy(p: number) { return margin.top + plotH - (p / pmax.value) * plotH }
function fromX(sx: number) { return ((sx - margin.left) / plotW) * vmax.value }
function fromY(sy: number) { return ((margin.top + plotH - sy) / plotH) * pmax.value }
const gridVols = computed(() => {
const step = Math.ceil(vmax.value / 7 / 100) * 100
const arr: number[] = []
for (let v = step; v < vmax.value; v += step) arr.push(v)
return arr
})
const gridPrices = computed(() => {
const step = Math.ceil(pmax.value / 5)
const arr: number[] = []
for (let p = step; p <= pmax.value; p += step) arr.push(p)
return arr
})
// Control points
const cp = computed<ControlPoints>(() =>
paramsToControlPoints(bp.vinf, vmax.value, pmax.value, localP0.value, bp.a, bp.b, bp.c, bp.d, bp.e)
)
const dragPoints = computed(() => ({
p2: cp.value.p2,
p3: cp.value.p3,
p4: cp.value.p4,
p5: cp.value.p5,
p6: cp.value.p6,
}))
const ptColors: Record<string, string> = {
p2: '#3b82f6', p3: '#3b82f6', p4: '#8b5cf6', p5: '#ef4444', p6: '#ef4444',
}
const ptLabels: Record<string, string> = {
p2: 'a', p3: 'b', p4: 'vinf', p5: 'c', p6: 'd,e',
}
const tier1Path = computed(() => {
const c = cp.value
return `M ${cx(c.p1.x)} ${cy(c.p1.y)} C ${cx(c.p2.x)} ${cy(c.p2.y)}, ${cx(c.p3.x)} ${cy(c.p3.y)}, ${cx(c.p4.x)} ${cy(c.p4.y)}`
})
const tier2Path = computed(() => {
const c = cp.value
return `M ${cx(c.p4.x)} ${cy(c.p4.y)} C ${cx(c.p5.x)} ${cy(c.p5.y)}, ${cx(c.p6.x)} ${cy(c.p6.y)}, ${cx(c.p7.x)} ${cy(c.p7.y)}`
})
// ── Drag handling ──
const svgRef = ref<SVGSVGElement | null>(null)
const dragging = ref<string | null>(null)
function getSvgPt(event: MouseEvent | Touch) {
if (!svgRef.value) return { x: 0, y: 0 }
const rect = svgRef.value.getBoundingClientRect()
return {
x: (event.clientX - rect.left) * (W / rect.width),
y: (event.clientY - rect.top) * (H / rect.height),
}
}
function startDrag(key: string) { dragging.value = key }
function onMouseMove(e: MouseEvent) { if (dragging.value) handleDrag(getSvgPt(e)) }
function onTouchMove(e: TouchEvent) { if (dragging.value && e.touches[0]) handleDrag(getSvgPt(e.touches[0])) }
function onMouseUp() {
if (dragging.value) {
dragging.value = null
debouncedServerCompute()
}
}
function handleDrag(pt: { x: number; y: number }) {
const v = Math.max(0, Math.min(vmax.value, fromX(pt.x)))
const p = Math.max(0, Math.min(pmax.value, fromY(pt.y)))
switch (dragging.value) {
case 'p2':
bp.a = localP0.value > 0 ? Math.max(0, Math.min(1, p / localP0.value)) : 0.5
break
case 'p3':
bp.b = bp.vinf > 0 ? Math.max(0, Math.min(1, v / bp.vinf)) : 0.5
break
case 'p4':
bp.vinf = Math.max(1, Math.min(vmax.value - 1, v))
break
case 'p5': {
const wmax = vmax.value - bp.vinf
bp.c = wmax > 0 ? Math.max(0, Math.min(1, (v - bp.vinf) / wmax)) : 0.5
break
}
case 'p6': {
const wmax = vmax.value - bp.vinf
const qmax = pmax.value - localP0.value
bp.e = qmax > 0 ? Math.max(0, Math.min(1, (p - localP0.value) / qmax)) : 0.5
if (wmax > 0 && Math.abs(1 - bp.c) > 1e-10) {
const ratio = (v - bp.vinf) / wmax
bp.d = Math.max(0, Math.min(1, (1 - ratio) / (1 - bp.c)))
}
break
}
}
recalculate()
}
function recalculate() {
if (!households.value.length) return
localP0.value = computeP0(
households.value, recettes.value, abop.value, abos.value,
bp.vinf, vmax.value, pmax.value, bp.a, bp.b, bp.c, bp.d, bp.e,
)
const result = computeImpacts(
households.value, recettes.value, abop.value, abos.value,
bp.vinf, vmax.value, pmax.value, bp.a, bp.b, bp.c, bp.d, bp.e,
)
impacts.value = result.impacts.map(imp => ({
volume: imp.volume,
old_price: imp.oldPrice,
new_price_rp: imp.newPriceRP,
new_price_rs: imp.newPriceRS,
}))
}
let serverTimeout: ReturnType<typeof setTimeout> | null = null
function debouncedServerCompute() {
if (serverTimeout) clearTimeout(serverTimeout)
serverTimeout = setTimeout(async () => {
try {
const result = await api.post<any>('/tariff/compute', {
commune_slug: slug, vinf: bp.vinf,
a: bp.a, b: bp.b, c: bp.c, d: bp.d, e: bp.e,
})
localP0.value = result.p0
impacts.value = result.impacts.map((imp: any) => ({
volume: imp.volume,
old_price: imp.old_price,
new_price_rp: imp.new_price_rp,
new_price_rs: imp.new_price_rs,
}))
} catch {}
}, 300)
}
// ── Chart 2: Baseline linear model ──
const W2 = 300
const H2 = 220
const margin2 = { top: 10, right: 10, bottom: 24, left: 40 }
const plotW2 = W2 - margin2.left - margin2.right
const plotH2 = H2 - margin2.top - margin2.bottom
function cx2(v: number) { return margin2.left + (v / vmax.value) * plotW2 }
const maxBill = computed(() => {
if (!curveData.value?.baseline_bills_rp?.length) return 500
const mx = Math.max(...curveData.value.baseline_bills_rp)
return Math.ceil(mx * 1.1 / 100) * 100
})
function cy2bill(b: number) { return margin2.top + plotH2 - (b / maxBill.value) * plotH2 }
function cy2price(p: number) { return margin2.top + plotH2 - (p / pmax.value) * plotH2 }
const gridVols2 = computed(() => {
const step = Math.ceil(vmax.value / 5 / 100) * 100
const arr: number[] = []
for (let v = step; v < vmax.value; v += step) arr.push(v)
return arr
})
const gridBills = computed(() => {
const step = Math.ceil(maxBill.value / 4 / 100) * 100
const arr: number[] = []
for (let b = step; b < maxBill.value; b += step) arr.push(b)
return arr
})
const gridPrices2 = computed(() => {
const step = Math.ceil(pmax.value / 4)
const arr: number[] = []
for (let p = step; p <= pmax.value; p += step) arr.push(p)
return arr
})
function toPolyline(vols: number[], vals: number[], cyFn: (v: number) => number) {
if (!vols?.length) return ''
// Downsample for performance (every 4th point)
return vols
.filter((_: number, i: number) => i % 4 === 0 || i === vols.length - 1)
.map((_: number, i: number) => {
const idx = i * 4 >= vols.length ? vols.length - 1 : i * 4
return `${cx2(vols[idx])},${cyFn(vals[idx])}`
})
.join(' ')
}
const baselineBillRP = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_bills_rp, cy2bill))
const baselineBillRS = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_bills_rs, cy2bill))
const baselinePriceRP = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_price_m3_rp, cy2price))
const baselinePriceRS = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_price_m3_rs, cy2price))
// ── Auth & vote ──
async function authenticate() {
authError.value = ''
authLoading.value = true
try {
const data = await api.post<{ access_token: string; role: string; commune_slug: string }>(
'/auth/citizen/verify',
{ commune_slug: slug, auth_code: authCode.value.toUpperCase() },
)
authStore.setAuth(data.access_token, data.role, data.commune_slug)
} catch (e: any) {
authError.value = e.message || 'Code invalide'
} finally {
authLoading.value = false
}
}
async function submitVote() {
submitting.value = true
voteSuccess.value = false
try {
await api.post(`/communes/${slug}/votes`, {
vinf: bp.vinf, a: bp.a, b: bp.b, c: bp.c, d: bp.d, e: bp.e,
})
voteSuccess.value = true
} catch (e: any) {
alert(e.message || 'Erreur lors de la soumission')
} finally {
submitting.value = false
}
}
// ── Load data ──
onMounted(async () => {
try {
const [c, p, curve, pages] = await Promise.all([
api.get<any>(`/communes/${slug}`),
api.get<any>(`/communes/${slug}/params`),
api.get<any>(`/communes/${slug}/votes/current`),
api.get<any[]>(`/communes/${slug}/content`).catch(() => []),
])
contentPages.value = pages
commune.value = c
params.value = p
curveData.value = curve
// Set tariff params
vmax.value = p.vmax
pmax.value = p.pmax
recettes.value = p.recettes
abop.value = p.abop
abos.value = p.abos
// Set initial Bezier params from median (or default)
if (curve.median) {
bp.vinf = curve.median.vinf
bp.a = curve.median.a
bp.b = curve.median.b
bp.c = curve.median.c
bp.d = curve.median.d
bp.e = curve.median.e
}
localP0.value = curve.p0
// Set impacts from server
impacts.value = curve.impacts || []
// Build simplified household list for client-side compute
const stats = await api.get<any>(`/communes/${slug}/households/stats`)
const hh: HouseholdData[] = []
const avgVol = stats.avg_volume || 90
for (let i = 0; i < (stats.rs_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'RS' })
for (let i = 0; i < (stats.rp_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'RP' })
for (let i = 0; i < (stats.pro_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'PRO' })
households.value = hh
} catch (e: any) {
loadError.value = e.message
} finally {
loading.value = false
}
})
function renderMarkdown(md: string): string {
if (!md) return ''
return md
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/\n\n/g, '</p><p>')
.replace(/^(?!<[hulo])(.+)$/gm, '<p>$1</p>')
}
</script>
<style scoped>
.editor-layout {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1rem;
}
@media (max-width: 900px) {
.editor-layout { grid-template-columns: 1fr; }
}
.chart-container svg {
width: 100%;
height: auto;
user-select: none;
}
.side-panel .card {
background: var(--color-bg);
border: 1px solid var(--color-border);
}
.param-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25rem 1rem;
}
.param-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.15rem 0;
}
.param-label { font-size: 0.75rem; color: var(--color-text-muted); }
.param-val { font-family: monospace; font-weight: 600; font-size: 0.85rem; }
.table-sm { font-size: 0.8rem; }
.table-sm th, .table-sm td { padding: 0.25rem 0.5rem; }
.text-up { color: #dc2626; }
.text-down { color: #059669; }
.baseline-charts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 700px) {
.baseline-charts { grid-template-columns: 1fr; }
}
.grid-5-info {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1rem;
text-align: center;
}
@media (max-width: 700px) {
.grid-5-info { grid-template-columns: repeat(3, 1fr); }
}
.info-label { font-size: 0.75rem; color: var(--color-text-muted); }
.alert-success {
background: #dcfce7;
color: #166534;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.85rem;
}
.cms-body { line-height: 1.7; font-size: 0.9rem; }
.cms-body :deep(h2) { font-size: 1.2rem; margin: 0.75rem 0 0.5rem; }
.cms-body :deep(h3) { font-size: 1.05rem; margin: 0.5rem 0 0.25rem; }
.cms-body :deep(p) { margin: 0.5rem 0; }
.cms-body :deep(a) { color: var(--color-primary); }
.cms-body :deep(ul) { margin: 0.5rem 0; padding-left: 1.5rem; }
</style>

View File

@@ -0,0 +1,24 @@
<template>
<div style="max-width: 600px; margin: 2rem auto; text-align: center;">
<div class="card">
<h2 style="margin-bottom: 1rem;">Vote enregistré</h2>
<p style="margin-bottom: 1.5rem;">
Votre vote a été soumis avec succès. Vous pouvez revenir à l'éditeur pour modifier votre choix
à tout moment (seul votre dernier vote sera pris en compte).
</p>
<div style="display: flex; gap: 1rem; justify-content: center;">
<NuxtLink :to="`/commune/${slug}`" class="btn btn-primary">
Modifier mon vote
</NuxtLink>
<NuxtLink to="/" class="btn btn-secondary">
Retour à l'accueil
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const slug = route.params.slug as string
</script>

View File

@@ -0,0 +1,119 @@
<template>
<div>
<!-- Hero -->
<section class="hero">
<h1>Tarification participative de l'eau</h1>
<p>
Dessinez votre courbe de tarification idéale et participez aux choix de votre commune.
</p>
</section>
<!-- Communes publiques -->
<section>
<h2 style="margin-bottom: 1rem;">Communes participantes</h2>
<div v-if="loading" style="text-align: center; padding: 2rem;">
<div class="spinner" style="margin: 0 auto;"></div>
</div>
<div v-else-if="error" class="alert alert-error">
{{ error }}
</div>
<div v-else-if="communes.length === 0" class="alert alert-info">
Aucune commune active pour le moment.
</div>
<div v-else class="grid grid-3">
<NuxtLink
v-for="commune in communes"
:key="commune.id"
:to="`/commune/${commune.slug}`"
class="card commune-card"
>
<h3>{{ commune.name }}</h3>
<p>{{ commune.description }}</p>
</NuxtLink>
</div>
</section>
<!-- Accès administration -->
<section style="margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--color-border);">
<div class="grid grid-2">
<div class="card">
<h3>Espace commune</h3>
<p style="margin: 0.75rem 0; color: var(--color-text-muted); font-size: 0.875rem;">
Vous êtes responsable d'une commune ? Connectez-vous pour gérer vos données,
paramétrer la tarification et consulter les votes.
</p>
<NuxtLink to="/login/commune" class="btn btn-secondary">Connexion commune</NuxtLink>
</div>
<div class="card">
<h3>Super administration</h3>
<p style="margin: 0.75rem 0; color: var(--color-text-muted); font-size: 0.875rem;">
Gestion globale des communes et des administrateurs.
</p>
<NuxtLink to="/login/admin" class="btn btn-secondary">Connexion admin</NuxtLink>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
const api = useApi()
const communes = ref<any[]>([])
const loading = ref(true)
const error = ref('')
onMounted(async () => {
try {
communes.value = await api.get<any[]>('/communes/')
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
})
</script>
<style scoped>
.hero {
text-align: center;
padding: 2rem 0 2.5rem;
}
.hero h1 {
font-size: 2rem;
font-weight: 800;
margin-bottom: 0.5rem;
}
.hero p {
color: var(--color-text-muted);
font-size: 1.1rem;
max-width: 600px;
margin: 0 auto;
}
.commune-card {
cursor: pointer;
transition: box-shadow 0.15s;
}
.commune-card:hover {
box-shadow: var(--shadow-md);
text-decoration: none;
}
.commune-card h3 {
margin-bottom: 0.5rem;
color: var(--color-primary);
}
.commune-card p {
font-size: 0.875rem;
color: var(--color-text-muted);
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<div style="max-width: 500px; margin: 2rem auto;">
<div class="page-header" style="text-align: center;">
<h1>Connexion</h1>
<p style="color: var(--color-text-muted);">Choisissez votre espace.</p>
</div>
<div class="grid grid-2">
<NuxtLink to="/login/commune" class="card login-choice">
<h3>Commune</h3>
<p>Gérer les données et la tarification de votre commune.</p>
</NuxtLink>
<NuxtLink to="/login/admin" class="card login-choice">
<h3>Super Admin</h3>
<p>Gestion globale des communes et administrateurs.</p>
</NuxtLink>
</div>
<p style="text-align: center; margin-top: 1.5rem;">
<NuxtLink to="/">&larr; Retour à l'accueil</NuxtLink>
</p>
</div>
</template>
<style scoped>
.login-choice {
text-align: center;
cursor: pointer;
transition: box-shadow 0.15s;
}
.login-choice:hover {
box-shadow: var(--shadow-md);
text-decoration: none;
}
.login-choice h3 {
color: var(--color-primary);
margin-bottom: 0.5rem;
}
.login-choice p {
font-size: 0.8rem;
color: var(--color-text-muted);
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<div style="max-width: 420px; margin: 2rem auto;">
<div class="card">
<h2 style="margin-bottom: 0.5rem;">Super administration</h2>
<p style="color: var(--color-text-muted); margin-bottom: 1.5rem; font-size: 0.875rem;">
Gestion globale : création de communes, gestion des administrateurs.
</p>
<div v-if="error" class="alert alert-error">{{ error }}</div>
<form @submit.prevent="login">
<div class="form-group">
<label>Email</label>
<input v-model="email" type="email" class="form-input" required />
</div>
<div class="form-group">
<label>Mot de passe</label>
<input v-model="password" type="password" class="form-input" required />
</div>
<button type="submit" class="btn btn-primary" :disabled="loading" style="width: 100%;">
<span v-if="loading" class="spinner" style="width: 1rem; height: 1rem;"></span>
<span v-else>Se connecter</span>
</button>
</form>
</div>
<p style="text-align: center; margin-top: 1rem;">
<NuxtLink to="/">&larr; Retour à l'accueil</NuxtLink>
</p>
</div>
</template>
<script setup lang="ts">
const authStore = useAuthStore()
const router = useRouter()
const api = useApi()
const email = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function login() {
error.value = ''
loading.value = true
try {
const data = await api.post<{ access_token: string; role: string }>('/auth/admin/login', {
email: email.value,
password: password.value,
})
if (data.role !== 'super_admin') {
error.value = 'Ce compte n\'a pas les droits super admin. Utilisez la connexion commune.'
return
}
authStore.setAuth(data.access_token, data.role)
router.push('/admin')
} catch (e: any) {
error.value = e.message || 'Erreur de connexion'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,64 @@
<template>
<div style="max-width: 420px; margin: 2rem auto;">
<div class="card">
<h2 style="margin-bottom: 0.5rem;">Espace commune</h2>
<p style="color: var(--color-text-muted); margin-bottom: 1.5rem; font-size: 0.875rem;">
Connectez-vous pour gérer les données de votre commune.
</p>
<div v-if="error" class="alert alert-error">{{ error }}</div>
<form @submit.prevent="login">
<div class="form-group">
<label>Email</label>
<input v-model="email" type="email" class="form-input" placeholder="contact@mairie.fr" required />
</div>
<div class="form-group">
<label>Mot de passe</label>
<input v-model="password" type="password" class="form-input" required />
</div>
<button type="submit" class="btn btn-primary" :disabled="loading" style="width: 100%;">
<span v-if="loading" class="spinner" style="width: 1rem; height: 1rem;"></span>
<span v-else>Se connecter</span>
</button>
</form>
</div>
<p style="text-align: center; margin-top: 1rem;">
<NuxtLink to="/">&larr; Retour à l'accueil</NuxtLink>
</p>
</div>
</template>
<script setup lang="ts">
const authStore = useAuthStore()
const router = useRouter()
const api = useApi()
const email = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function login() {
error.value = ''
loading.value = true
try {
const data = await api.post<{ access_token: string; role: string; commune_slug: string | null }>(
'/auth/admin/login',
{ email: email.value, password: password.value },
)
authStore.setAuth(data.access_token, data.role, data.commune_slug || undefined)
if (data.commune_slug) {
router.push(`/admin/communes/${data.commune_slug}`)
} else {
router.push('/admin')
}
} catch (e: any) {
error.value = e.message || 'Erreur de connexion'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,56 @@
import { defineStore } from 'pinia'
interface AuthState {
token: string | null
role: string | null
communeSlug: string | null
}
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
token: null,
role: null,
communeSlug: null,
}),
getters: {
isAuthenticated: (state) => !!state.token,
isAdmin: (state) => state.role === 'super_admin' || state.role === 'commune_admin',
isSuperAdmin: (state) => state.role === 'super_admin',
isCitizen: (state) => state.role === 'citizen',
},
actions: {
setAuth(token: string, role: string, communeSlug?: string) {
this.token = token
this.role = role
this.communeSlug = communeSlug || null
if (import.meta.client) {
localStorage.setItem('sejeteralo_token', token)
localStorage.setItem('sejeteralo_role', role)
if (communeSlug) localStorage.setItem('sejeteralo_commune', communeSlug)
}
},
logout() {
this.token = null
this.role = null
this.communeSlug = null
if (import.meta.client) {
localStorage.removeItem('sejeteralo_token')
localStorage.removeItem('sejeteralo_role')
localStorage.removeItem('sejeteralo_commune')
}
},
restore() {
if (import.meta.client) {
this.token = localStorage.getItem('sejeteralo_token')
this.role = localStorage.getItem('sejeteralo_role')
this.communeSlug = localStorage.getItem('sejeteralo_commune')
}
},
},
})

View File

@@ -0,0 +1,48 @@
import { defineStore } from 'pinia'
interface Commune {
id: number
name: string
slug: string
description: string
is_active: boolean
}
interface TariffParams {
abop: number
abos: number
recettes: number
pmax: number
vmax: number
}
export const useCommuneStore = defineStore('commune', {
state: () => ({
communes: [] as Commune[],
current: null as Commune | null,
params: null as TariffParams | null,
loading: false,
}),
actions: {
async fetchCommunes() {
this.loading = true
try {
const api = useApi()
this.communes = await api.get<Commune[]>('/communes/')
} finally {
this.loading = false
}
},
async fetchCommune(slug: string) {
const api = useApi()
this.current = await api.get<Commune>(`/communes/${slug}`)
},
async fetchParams(slug: string) {
const api = useApi()
this.params = await api.get<TariffParams>(`/communes/${slug}/params`)
},
},
})

View File

@@ -0,0 +1,368 @@
/**
* TypeScript port of the Bézier tariff math engine.
*
* Mirrors backend/app/engine/integrals.py and pricing.py.
* Uses Cardano's formula + Newton-Raphson polish for cubic solving.
*/
// ── Cubic solver ──
/**
* Solve ax³ + bx² + cx + d = 0 for real roots in [0, 1].
* Uses Cardano's method with Newton-Raphson refinement.
*/
function solveCubicInUnit(a: number, b: number, c: number, d: number): number | null {
if (Math.abs(a) < 1e-12) {
// Degenerate: quadratic
if (Math.abs(b) < 1e-12) {
// Linear
if (Math.abs(c) < 1e-12) return null
const t = -d / c
return t >= -1e-10 && t <= 1 + 1e-10 ? clamp01(t) : null
}
const disc = c * c - 4 * b * d
if (disc < 0) return null
const sqrtDisc = Math.sqrt(disc)
const t1 = (-c + sqrtDisc) / (2 * b)
const t2 = (-c - sqrtDisc) / (2 * b)
if (t1 >= -1e-10 && t1 <= 1 + 1e-10) return clamp01(t1)
if (t2 >= -1e-10 && t2 <= 1 + 1e-10) return clamp01(t2)
return null
}
// Normalize: t³ + pt² + qt + r = 0
const p = b / a
const q = c / a
const r = d / a
// Depressed cubic: u³ + pu + q = 0 via substitution t = u - p/3
const p1 = q - p * p / 3
const q1 = r - p * q / 3 + 2 * p * p * p / 27
const discriminant = q1 * q1 / 4 + p1 * p1 * p1 / 27
const roots: number[] = []
if (discriminant > 1e-12) {
// One real root
const sqrtD = Math.sqrt(discriminant)
const u = cbrt(-q1 / 2 + sqrtD)
const v = cbrt(-q1 / 2 - sqrtD)
roots.push(u + v - p / 3)
} else if (discriminant < -1e-12) {
// Three real roots (casus irreducibilis)
const m = Math.sqrt(-p1 / 3)
const theta = Math.acos((-q1 / 2) / (m * m * m)) / 3
roots.push(
2 * m * Math.cos(theta) - p / 3,
2 * m * Math.cos(theta - 2 * Math.PI / 3) - p / 3,
2 * m * Math.cos(theta - 4 * Math.PI / 3) - p / 3,
)
} else {
// Double or triple root
const u = cbrt(-q1 / 2)
roots.push(2 * u - p / 3, -u - p / 3)
}
// Find root in [0,1] and refine with Newton-Raphson
for (const root of roots) {
if (root >= -0.1 && root <= 1.1) {
let t = clamp01(root)
// Newton-Raphson polish (3 iterations)
for (let i = 0; i < 3; i++) {
const f = ((a * t + b) * t + c) * t + d
const fp = (3 * a * t + 2 * b) * t + c
if (Math.abs(fp) < 1e-14) break
t = clamp01(t - f / fp)
}
return t
}
}
return null
}
function cbrt(x: number): number {
return x < 0 ? -Math.pow(-x, 1 / 3) : Math.pow(x, 1 / 3)
}
function clamp01(t: number): number {
return Math.max(0, Math.min(1, t))
}
// ── Integral computation ──
export interface IntegralResult {
alpha1: number
alpha2: number
beta2: number
}
export function computeIntegrals(
volume: number,
vinf: number,
vmax: number,
pmax: number,
a: number,
b: number,
c: number,
d: number,
e: number,
): IntegralResult {
if (volume <= vinf) {
const T = solveTier1T(volume, vinf, b)
const alpha1 = computeAlpha1(T, vinf, a, b)
return { alpha1, alpha2: 0, beta2: 0 }
} else {
const alpha1 = computeAlpha1(1.0, vinf, a, b)
const wmax = vmax - vinf
const T = solveTier2T(volume - vinf, wmax, c, d)
const uu = computeUU(T, c, d, e)
const alpha2 = (volume - vinf) - 3 * uu * wmax
const beta2 = 3 * pmax * wmax * uu
return { alpha1, alpha2, beta2 }
}
}
function solveTier1T(volume: number, vinf: number, b: number): number {
if (volume <= 0) return 0
if (volume >= vinf) return 1
const ratio = volume / vinf
const t = solveCubicInUnit(1 - 3 * b, 3 * b, 0, -ratio)
return t ?? 0
}
function solveTier2T(w: number, wmax: number, c: number, d: number): number {
if (w <= 0) return 0
if (w >= wmax) return 1
const ratio = w / wmax
const t = solveCubicInUnit(
3 * (c + d - c * d) - 2,
3 * (1 - 2 * c - d + c * d),
3 * c,
-ratio,
)
return t ?? 0
}
function computeAlpha1(T: number, vinf: number, a: number, b: number): number {
return 3 * vinf * (
Math.pow(T, 6) / 6 * (-9 * a * b + 3 * a + 6 * b - 2) +
Math.pow(T, 5) / 5 * (24 * a * b - 6 * a - 13 * b + 3) +
3 * Math.pow(T, 4) / 4 * (-7 * a * b + a + 2 * b) +
Math.pow(T, 3) / 3 * 6 * a * b
)
}
function computeUU(T: number, c: number, d: number, e: number): number {
return (
(-3 * c * d + 9 * e * c * d + 3 * c - 9 * e * c + 3 * d - 9 * e * d + 6 * e - 2) * Math.pow(T, 6) / 6 +
(2 * c * d - 15 * e * c * d - 4 * c + 21 * e * c - 2 * d + 15 * e * d - 12 * e + 2) * Math.pow(T, 5) / 5 +
(6 * e * c * d + c - 15 * e * c - 6 * e * d + 6 * e) * Math.pow(T, 4) / 4 +
(3 * e * c) * Math.pow(T, 3) / 3
)
}
// ── Pricing computation ──
export interface HouseholdData {
volume_m3: number
status: string
}
export interface PricingResult {
p0: number
curveVolumes: number[]
curvePricesM3: number[]
}
export interface ImpactRow {
volume: number
oldPrice: number
newPriceRP: number
newPriceRS: number
}
export function computeP0(
households: HouseholdData[],
recettes: number,
abop: number,
abos: number,
vinf: number,
vmax: number,
pmax: number,
a: number,
b: number,
c: number,
d: number,
e: number,
): number {
let totalAbo = 0
let totalAlpha = 0
let totalBeta = 0
for (const h of households) {
const abo = h.status === 'RS' ? abos : abop
totalAbo += abo
const vol = Math.max(h.volume_m3, 1e-5)
const { alpha1, alpha2, beta2 } = computeIntegrals(vol, vinf, vmax, pmax, a, b, c, d, e)
totalAlpha += alpha1 + alpha2
totalBeta += beta2
}
if (totalAbo >= recettes) return 0
if (totalAlpha === 0) return 0
return (recettes - totalAbo - totalBeta) / totalAlpha
}
/**
* Generate price curve points (price per m³ vs volume).
*/
export function generateCurve(
vinf: number,
vmax: number,
pmax: number,
p0: number,
a: number,
b: number,
c: number,
d: number,
e: number,
nbpts: number = 200,
): PricingResult {
const curveVolumes: number[] = []
const curvePricesM3: number[] = []
const dt = 1 / (nbpts - 1)
// Tier 1
for (let i = 0; i < nbpts; i++) {
const t = Math.min(i * dt, 1 - 1e-6)
const v = vinf * ((1 - 3 * b) * t * t * t + 3 * b * t * t)
const p = p0 * ((3 * a - 2) * t * t * t + (-6 * a + 3) * t * t + 3 * a * t)
curveVolumes.push(v)
curvePricesM3.push(p)
}
// Tier 2
for (let i = 0; i < nbpts; i++) {
const t = Math.min(i * dt, 1 - 1e-6)
const v = vinf + (vmax - vinf) * (
(3 * (c + d - c * d) - 2) * t * t * t +
3 * (1 - 2 * c - d + c * d) * t * t +
3 * c * t
)
const p = p0 + (pmax - p0) * ((1 - 3 * e) * t * t * t + 3 * e * t * t)
curveVolumes.push(v)
curvePricesM3.push(p)
}
return { p0, curveVolumes, curvePricesM3 }
}
/**
* Compute price impacts at reference volume levels.
*/
export function computeImpacts(
households: HouseholdData[],
recettes: number,
abop: number,
abos: number,
vinf: number,
vmax: number,
pmax: number,
a: number,
b: number,
c: number,
d: number,
e: number,
referenceVolumes: number[] = [30, 60, 90, 150, 300],
): { p0: number; impacts: ImpactRow[] } {
const p0 = computeP0(households, recettes, abop, abos, vinf, vmax, pmax, a, b, c, d, e)
// Linear baseline
const totalVol = households.reduce((s, h) => s + Math.max(h.volume_m3, 1e-5), 0)
const totalAbo = households.reduce((s, h) => s + (h.status === 'RS' ? abos : abop), 0)
const oldPM3 = totalVol > 0 ? (recettes - totalAbo) / totalVol : 0
const impacts: ImpactRow[] = referenceVolumes.map((vol) => {
const { alpha1, alpha2, beta2 } = computeIntegrals(vol, vinf, vmax, pmax, a, b, c, d, e)
return {
volume: vol,
oldPrice: abop + oldPM3 * vol,
newPriceRP: abop + (alpha1 + alpha2) * p0 + beta2,
newPriceRS: abos + (alpha1 + alpha2) * p0 + beta2,
}
})
return { p0, impacts }
}
// ── Control point mapping ──
export interface ControlPoints {
// Tier 1: P1(fixed), P2, P3, P4
p1: { x: number; y: number }
p2: { x: number; y: number }
p3: { x: number; y: number }
p4: { x: number; y: number }
// Tier 2: P4(shared), P5, P6, P7(fixed)
p5: { x: number; y: number }
p6: { x: number; y: number }
p7: { x: number; y: number }
}
export function paramsToControlPoints(
vinf: number,
vmax: number,
pmax: number,
p0: number,
a: number,
b: number,
c: number,
d: number,
e: number,
): ControlPoints {
return {
p1: { x: 0, y: 0 },
p2: { x: 0, y: a * p0 },
p3: { x: b * vinf, y: p0 },
p4: { x: vinf, y: p0 },
p5: { x: vinf + c * (vmax - vinf), y: p0 },
p6: {
x: vinf + (vmax - vinf) * (1 - d + c * d),
y: p0 + e * (pmax - p0),
},
p7: { x: vmax, y: pmax },
}
}
export function controlPointsToParams(
cp: ControlPoints,
vmax: number,
pmax: number,
p0: number,
): { vinf: number; a: number; b: number; c: number; d: number; e: number } {
const vinf = cp.p4.x
const a = p0 > 0 ? clamp01(cp.p2.y / p0) : 0.5
const b = vinf > 0 ? clamp01(cp.p3.x / vinf) : 0.5
const wmax = vmax - vinf
const c = wmax > 0 ? clamp01((cp.p5.x - vinf) / wmax) : 0.5
const qmax = pmax - p0
const e = qmax > 0 ? clamp01((cp.p6.y - p0) / qmax) : 0.5
// d from p6.x: x6 = vinf + wmax * (1 - d + c*d) => d = (1 - (x6-vinf)/wmax) / (1-c)
let d_val = 0.5
if (wmax > 0) {
const ratio = (cp.p6.x - vinf) / wmax
if (Math.abs(1 - c) > 1e-10) {
d_val = clamp01((1 - ratio) / (1 - c))
}
}
return { vinf, a, b, c, d: d_val, e }
}

30
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,30 @@
export default defineNuxtConfig({
compatibilityDate: '2025-01-01',
future: { compatibilityVersion: 4 },
modules: ['@pinia/nuxt'],
devtools: { enabled: true },
devServer: {
port: 3009,
},
runtimeConfig: {
public: {
apiBase: 'http://localhost:8000/api/v1',
},
},
css: ['~/assets/css/main.css'],
app: {
head: {
title: 'SejeteralO - Tarification participative de l\'eau',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
},
},
})

9900
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "sejeteralo-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview",
"generate": "nuxt generate"
},
"dependencies": {
"nuxt": "^4.3.1",
"vue": "^3.5.28",
"vue-router": "^5.0.3"
},
"devDependencies": {
"@pinia/nuxt": "^0.9.0",
"pinia": "^3.0.2",
"typescript": "^5.8.2"
}
}

3
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}