Initial commit: SejeteralO water tarification platform

Full-stack app for participatory water pricing using Bezier curves.
- Backend: FastAPI + SQLAlchemy + SQLite with JWT auth
- Frontend: Nuxt 4 + TypeScript with interactive SVG editor
- Math engine: cubic Bezier tarification with Cardano solver
- Admin: commune management, household import, vote monitoring, CMS
- Citizen: interactive curve editor, vote submission
- Docker-compose deployment ready

Includes fixes for:
- Impact table snake_case/camelCase property mismatch
- CMS content backend API + frontend editor (was stub)
- Admin route protection middleware
- Public content display on commune page
- Vote confirmation page link fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-21 15:26:02 +01:00
commit b30e54a8f7
67 changed files with 16723 additions and 0 deletions

View File

@@ -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"}