Compare commits

..
4 Commits
Author SHA1 Message Date
YvvandClaude Opus 4.8 39883ee444 build(deps): flip @yvv/nuxt-base vers la dép git distante (v0.1.0)
- extends: ['../../yvv-nuxt-base'] → ['@yvv/nuxt-base']
- package.json : dép git+ssh://gitea@git.open.us.org/yvv/yvv-nuxt-base.git#v0.1.0
- résout au commit taggé b1c6a2f (identique au layer local)
- build npm vert, prod :3099 → 200, smoke moods 8/8 (4 moods × light/dark × mobile/desktop)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 18:33:54 +02:00
YvvandClaude Opus 4.8 55020033ee chore(git): ignore les backups .db.bak (données runtime)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:45:02 +02:00
YvvandClaude Opus 4.8 a9e586cda1 refactor(moods): adopte le layer @yvv/nuxt-base pour le mécanisme d'ambiances
- nuxt.config: extends ['../../yvv-nuxt-base']
- useMood.ts: délègue à useMood(moods) du layer, exporté useLibreMood
  (évite la collision avec l'auto-import du layer)
- MoodSwitcher.vue + app.vue: appellent useLibreMood()
- moods.css inchangé (couleurs = identité projet)

Mécanisme partagé (application DOM, persistance, sync color-mode) mutualisé
sans toucher le contrat CSS. Vérifié headless (Playwright) : 4 moods × light/dark
× desktop/mobile OK, persistance au reload OK, zéro erreur JS, zéro régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 09:07:02 +02:00
YvvandClaude Sonnet 4.6 59fff64f9e Compartimentation : isolation stricte des données par espace de travail
- Ajout clause else IS NULL sur tous les endpoints list (protocols, decisions,
  mandates, documents, groups, votes) — sans X-Organization → données globales
  seulement, jamais le contenu d'un autre espace
- _get_protocol/_get_decision/_get_mandate : org_id propagé à tous les
  endpoints GET/PUT/DELETE/advance/assign/revoke/steps → 404 si UUID d'un
  autre espace
- votes.py : list_vote_sessions filtre via JOIN VotingProtocol.organization_id
- groups.py : suppression _org_id_from_header() mort, create_group assigne
  organization_id correctement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:52:16 +02:00
13 changed files with 112 additions and 95 deletions
+1
View File
@@ -25,6 +25,7 @@ Thumbs.db
# Database
*.db
*.db.bak*
*.sqlite
# Docker volumes
+25 -10
View File
@@ -31,13 +31,20 @@ router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
"""Fetch a decision by ID with its steps eagerly loaded, or raise 404."""
result = await db.execute(
async def _get_decision(
db: AsyncSession, decision_id: uuid.UUID, org_id: uuid.UUID | None = None
) -> Decision:
"""Fetch a decision by ID within the active org scope, or raise 404."""
stmt = (
select(Decision)
.options(selectinload(Decision.steps))
.where(Decision.id == decision_id)
)
if org_id is not None:
stmt = stmt.where(Decision.organization_id == org_id)
else:
stmt = stmt.where(Decision.organization_id.is_(None))
result = await db.execute(stmt)
decision = result.scalar_one_or_none()
if decision is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Decision introuvable")
@@ -61,6 +68,8 @@ async def list_decisions(
if org_id is not None:
stmt = stmt.where(Decision.organization_id == org_id)
else:
stmt = stmt.where(Decision.organization_id.is_(None))
if decision_type is not None:
stmt = stmt.where(Decision.decision_type == decision_type)
if status_filter is not None:
@@ -91,7 +100,7 @@ async def create_decision(
await db.refresh(decision)
# Reload with steps (empty at creation)
decision = await _get_decision(db, decision.id)
decision = await _get_decision(db, decision.id, org_id)
return DecisionOut.model_validate(decision)
@@ -99,9 +108,10 @@ async def create_decision(
async def get_decision(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut:
"""Get a single decision with all its steps."""
decision = await _get_decision(db, id)
decision = await _get_decision(db, id, org_id)
return DecisionOut.model_validate(decision)
@@ -111,9 +121,10 @@ async def update_decision(
payload: DecisionUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut:
"""Update a decision's metadata (title, description, status, protocol)."""
decision = await _get_decision(db, id)
decision = await _get_decision(db, id, org_id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
@@ -123,7 +134,7 @@ async def update_decision(
await db.refresh(decision)
# Reload with steps
decision = await _get_decision(db, decision.id)
decision = await _get_decision(db, decision.id, org_id)
return DecisionOut.model_validate(decision)
@@ -136,10 +147,11 @@ async def add_step(
payload: DecisionStepCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionStepOut:
"""Add a step to a decision process."""
# Verify decision exists
decision = await _get_decision(db, id)
decision = await _get_decision(db, id, org_id)
step = DecisionStep(
decision_id=decision.id,
@@ -160,6 +172,7 @@ async def advance_decision_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionAdvanceOut:
"""Advance a decision to its next step or status."""
try:
@@ -168,7 +181,7 @@ async def advance_decision_endpoint(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps for complete output
decision = await _get_decision(db, decision.id)
decision = await _get_decision(db, decision.id, org_id)
data = DecisionOut.model_validate(decision).model_dump()
data["message"] = f"Decision avancee au statut : {decision.status}"
return DecisionAdvanceOut(**data)
@@ -184,6 +197,7 @@ async def create_vote_session_for_step_endpoint(
step_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VoteSessionOut:
"""Create a vote session linked to a decision step."""
try:
@@ -199,9 +213,10 @@ async def delete_decision(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> None:
"""Delete a decision (only if in draft status)."""
decision = await _get_decision(db, id)
decision = await _get_decision(db, id, org_id)
if decision.status != "draft":
raise HTTPException(
+2
View File
@@ -77,6 +77,8 @@ async def list_documents(
if org_id is not None:
stmt = stmt.where(Document.organization_id == org_id)
else:
stmt = stmt.where(Document.organization_id.is_(None))
if doc_type is not None:
stmt = stmt.where(Document.doc_type == doc_type)
if status_filter is not None:
+11 -15
View File
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.dependencies.org import get_active_org_id
from app.models.group import Group, GroupMember
from app.schemas.group import GroupCreate, GroupMemberCreate, GroupMemberOut, GroupOut, GroupSummary
from app.services.auth_service import get_current_identity
@@ -18,24 +19,18 @@ from app.services.auth_service import get_current_identity
router = APIRouter()
def _org_id_from_header(request_headers) -> uuid.UUID | None:
raw = request_headers.get("x-organization")
if not raw:
return None
try:
return uuid.UUID(raw)
except ValueError:
return None
@router.get("/", response_model=list[GroupSummary])
async def list_groups(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> list[GroupSummary]:
"""List all groups. No auth required — groups are public within the workspace."""
result = await db.execute(
select(Group).options(selectinload(Group.members)).order_by(Group.name)
)
"""List groups within the active workspace."""
stmt = select(Group).options(selectinload(Group.members)).order_by(Group.name)
if org_id is not None:
stmt = stmt.where(Group.organization_id == org_id)
else:
stmt = stmt.where(Group.organization_id.is_(None))
result = await db.execute(stmt)
groups = result.scalars().all()
return [
GroupSummary(
@@ -54,8 +49,9 @@ async def create_group(
payload: GroupCreate,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> GroupOut:
group = Group(name=payload.name, description=payload.description)
group = Group(name=payload.name, description=payload.description, organization_id=org_id)
db.add(group)
await db.commit()
await db.refresh(group)
+30 -12
View File
@@ -37,8 +37,10 @@ router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
result = await db.execute(
async def _get_mandate(
db: AsyncSession, mandate_id: uuid.UUID, org_id: uuid.UUID | None = None
) -> Mandate:
stmt = (
select(Mandate)
.options(
selectinload(Mandate.steps),
@@ -47,6 +49,11 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
)
.where(Mandate.id == mandate_id)
)
if org_id is not None:
stmt = stmt.where(Mandate.organization_id == org_id)
else:
stmt = stmt.where(Mandate.organization_id.is_(None))
result = await db.execute(stmt)
mandate = result.scalar_one_or_none()
if mandate is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandat introuvable")
@@ -80,6 +87,8 @@ async def list_mandates(
if org_id is not None:
stmt = stmt.where(Mandate.organization_id == org_id)
else:
stmt = stmt.where(Mandate.organization_id.is_(None))
if mandate_type is not None:
stmt = stmt.where(Mandate.mandate_type == mandate_type)
if status_filter is not None:
@@ -111,7 +120,7 @@ async def create_mandate(
await db.commit()
await db.refresh(mandate)
mandate = await _get_mandate(db, mandate.id)
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
@@ -119,8 +128,9 @@ async def create_mandate(
async def get_mandate(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
mandate = await _get_mandate(db, id)
mandate = await _get_mandate(db, id, org_id)
return _mandate_out(mandate)
@@ -130,14 +140,15 @@ async def update_mandate(
payload: MandateUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
mandate = await _get_mandate(db, id)
mandate = await _get_mandate(db, id, org_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(mandate, field, value)
await db.commit()
mandate = await _get_mandate(db, mandate.id)
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
@@ -146,8 +157,9 @@ async def delete_mandate(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> None:
mandate = await _get_mandate(db, id)
mandate = await _get_mandate(db, id, org_id)
if mandate.status != "draft":
raise HTTPException(
@@ -168,8 +180,9 @@ async def add_step(
payload: MandateStepCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateStepOut:
mandate = await _get_mandate(db, id)
mandate = await _get_mandate(db, id, org_id)
step = MandateStep(mandate_id=mandate.id, **payload.model_dump())
db.add(step)
@@ -183,8 +196,9 @@ async def add_step(
async def list_steps(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> list[MandateStepOut]:
mandate = await _get_mandate(db, id)
mandate = await _get_mandate(db, id, org_id)
return [MandateStepOut.model_validate(s) for s in mandate.steps]
@@ -196,13 +210,14 @@ async def advance_mandate_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateAdvanceOut:
try:
mandate = await advance_mandate(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
mandate = await _get_mandate(db, mandate.id)
mandate = await _get_mandate(db, mandate.id, org_id)
out = _mandate_out(mandate)
return MandateAdvanceOut(
**out.model_dump(),
@@ -216,13 +231,14 @@ async def assign_mandatee_endpoint(
payload: MandateAssignRequest,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
try:
mandate = await assign_mandatee(id, payload.mandatee_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
mandate = await _get_mandate(db, mandate.id)
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
@@ -231,13 +247,14 @@ async def revoke_mandate_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
try:
mandate = await revoke_mandate(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
mandate = await _get_mandate(db, mandate.id)
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
@@ -251,6 +268,7 @@ async def create_vote_session_for_step_endpoint(
step_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VoteSessionOut:
try:
session = await create_vote_session_for_step(id, step_id, db)
+18 -7
View File
@@ -34,13 +34,20 @@ router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_protocol(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol:
"""Fetch a voting protocol by ID with its formula config, or raise 404."""
result = await db.execute(
async def _get_protocol(
db: AsyncSession, protocol_id: uuid.UUID, org_id: uuid.UUID | None = None
) -> VotingProtocol:
"""Fetch a voting protocol by ID within the active org scope, or raise 404."""
stmt = (
select(VotingProtocol)
.options(selectinload(VotingProtocol.formula_config))
.where(VotingProtocol.id == protocol_id)
)
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
result = await db.execute(stmt)
protocol = result.scalar_one_or_none()
if protocol is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole introuvable")
@@ -74,6 +81,8 @@ async def list_protocols(
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
if vote_type is not None:
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
@@ -111,7 +120,7 @@ async def create_protocol(
await db.refresh(protocol)
# Reload with formula config
protocol = await _get_protocol(db, protocol.id)
protocol = await _get_protocol(db, protocol.id, org_id)
return VotingProtocolOut.model_validate(protocol)
@@ -119,9 +128,10 @@ async def create_protocol(
async def get_protocol(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut:
"""Get a single voting protocol with its formula configuration."""
protocol = await _get_protocol(db, id)
protocol = await _get_protocol(db, id, org_id)
return VotingProtocolOut.model_validate(protocol)
@@ -131,12 +141,13 @@ async def update_protocol(
payload: VotingProtocolUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut:
"""Update a voting protocol (meta-governance).
Only provided fields will be updated. Requires authentication.
"""
protocol = await _get_protocol(db, id)
protocol = await _get_protocol(db, id, org_id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
@@ -145,7 +156,7 @@ async def update_protocol(
await db.commit()
# Reload with formula config
protocol = await _get_protocol(db, protocol.id)
protocol = await _get_protocol(db, protocol.id, org_id)
return VotingProtocolOut.model_validate(protocol)
+10 -1
View File
@@ -25,6 +25,7 @@ from app.schemas.vote import (
VoteSessionListOut,
VoteSessionOut,
)
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
from app.services.vote_service import (
close_session as svc_close_session,
@@ -170,14 +171,22 @@ def _compute_result(
@router.get("/sessions", response_model=list[VoteSessionListOut])
async def list_vote_sessions(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
session_status: str | None = Query(default=None, alias="status", description="Filtrer par statut (open, closed, tallied)"),
decision_id: uuid.UUID | None = Query(default=None, description="Filtrer par decision_id"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[VoteSessionListOut]:
"""List all vote sessions with optional filters by status and decision_id."""
stmt = select(VoteSession)
stmt = (
select(VoteSession)
.join(VotingProtocol, VoteSession.voting_protocol_id == VotingProtocol.id)
)
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
if session_status is not None:
stmt = stmt.where(VoteSession.status == session_status)
if decision_id is not None:
+1 -1
View File
@@ -6,7 +6,7 @@ const decisionsStore = useDecisionsStore()
const protocolsStore = useProtocolsStore()
const mandatesStore = useMandatesStore()
const route = useRoute()
const { initMood } = useMood()
const { initMood } = useLibreMood()
const navigationItems = [
{
@@ -1,5 +1,5 @@
<script setup lang="ts">
const { currentMood, moods, setMood } = useMood()
const { currentMood, moods, setMood } = useLibreMood()
</script>
<template>
+6 -48
View File
@@ -1,4 +1,7 @@
import type { Ref } from 'vue'
// Ambiances libreDecision — délègue le mécanisme au layer @yvv/nuxt-base.
// Les couleurs (identité du projet) restent dans assets/css/moods.css (classes .mood-*).
// Le layer fournit l'auto-import `useMood(moods, options)` ; on l'enveloppe ici
// sous le nom `useLibreMood` pour éviter la collision avec ce composable auto-importé.
export interface Mood {
id: string
@@ -9,8 +12,6 @@ export interface Mood {
isDark: boolean
}
const STORAGE_KEY = 'libredecision_mood'
const moods: Mood[] = [
{ id: 'peps', label: 'Peps', description: 'Chaud et tonique', icon: 'i-lucide-sun', color: '#d44a10', isDark: false },
{ id: 'zen', label: 'Zen', description: 'Nature vivante', icon: 'i-lucide-leaf', color: '#2e8b48', isDark: false },
@@ -18,49 +19,6 @@ const moods: Mood[] = [
{ id: 'grave', label: 'Grave', description: 'Ambre mineral', icon: 'i-lucide-shield', color: '#d8a030', isDark: true },
]
const currentMood: Ref<string> = ref('peps')
function applyMood(moodId: string) {
if (import.meta.server) return
const mood = moods.find(m => m.id === moodId)
if (!mood) return
const html = document.documentElement
// Remove all existing mood classes
moods.forEach(m => html.classList.remove(`mood-${m.id}`))
// Add the new mood class
html.classList.add(`mood-${moodId}`)
// Sync color-mode (light/dark) via Nuxt's useColorMode
const colorMode = useColorMode()
colorMode.preference = mood.isDark ? 'dark' : 'light'
// Persist choice
localStorage.setItem(STORAGE_KEY, moodId)
currentMood.value = moodId
}
function setMood(moodId: string) {
applyMood(moodId)
}
function initMood() {
if (import.meta.server) return
const saved = localStorage.getItem(STORAGE_KEY)
const moodId = saved && moods.some(m => m.id === saved) ? saved : 'peps'
applyMood(moodId)
}
export function useMood() {
return {
currentMood: readonly(currentMood),
moods,
setMood,
initMood,
}
export function useLibreMood() {
return useMood(moods, { storageKey: 'libredecision_mood', defaultId: 'peps' })
}
+1
View File
@@ -1,4 +1,5 @@
export default defineNuxtConfig({
extends: ['@yvv/nuxt-base'],
compatibilityDate: '2025-07-15',
ssr: false,
devtools: { enabled: false },
+5
View File
@@ -16,6 +16,7 @@
"@polkadot/util": "^13.5.9",
"@unocss/nuxt": "^66.6.0",
"@vueuse/nuxt": "^14.2.1",
"@yvv/nuxt-base": "git+ssh://gitea@git.open.us.org/yvv/yvv-nuxt-base.git#v0.1.0",
"nuxt": "^4.3.1",
"pinia": "^3.0.2",
"vue": "^3.5.28",
@@ -9035,6 +9036,10 @@
"license": "Apache-2.0",
"peer": true
},
"node_modules/@yvv/nuxt-base": {
"version": "0.1.0",
"resolved": "git+ssh://gitea@git.open.us.org/yvv/yvv-nuxt-base.git#b1c6a2f28052564d505dd05b363169cb97fb81b3"
},
"node_modules/abbrev": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
+1
View File
@@ -18,6 +18,7 @@
"@polkadot/util": "^13.5.9",
"@unocss/nuxt": "^66.6.0",
"@vueuse/nuxt": "^14.2.1",
"@yvv/nuxt-base": "git+ssh://gitea@git.open.us.org/yvv/yvv-nuxt-base.git#v0.1.0",
"nuxt": "^4.3.1",
"pinia": "^3.0.2",
"vue": "^3.5.28",