Compare commits

..

2 Commits

Author SHA1 Message Date
Yvv f87cbc0f2f Typo Plus Jakarta Sans + renommage libreDecision + mode démo prod + seed mandats
- Fonte : Nunito → Plus Jakarta Sans (moderne, ronde sans être toy)
- Logo : ğ(Decision) → libreDecision (libre italic/muted + Decision bold)
- Footer et watermark DocumentPreview mis à jour
- Mode démo : DEMO_MODE flag dans config.py + auth.py (profils rapides en prod)
- docker-compose : ENVIRONMENT=production explicite + DEMO_MODE=true par défaut
- Seed : +décision Licence G1 v0.4.0, +3 mandats (ComTech, Admin Forgerons, Modération)
  runner 7→10 étapes, import Mandate/MandateStep ajouté

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:22:36 +01:00
Yvv 6fe0b41e7f Menu : Boîte à outils en premier + sidebar collapsible icônes
- Boîte à outils (/tools) en 1ère position dans la nav (desktop + mobile)
- Sidebar desktop repliable (toggle pictos-only, 14rem → 3.75rem)
  - labels masqués par transition CSS fluide (max-width + opacity)
  - état persisté en localStorage (libred-sidebar-collapsed)
- Page document : toggle Vue structurée / Aperçu document
  - sous-toggle En vigueur / Selon les votes
  - composant DocumentPreview (rendu PDF-like)
    - filigrane discret, items ordonnés par sort_order
    - mode projection : proposed_text substitu + encadrement orange
    - footer horodaté, print-friendly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 04:21:21 +01:00
65 changed files with 834 additions and 9848 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
# PostgreSQL # PostgreSQL
POSTGRES_DB=libredecision POSTGRES_DB=glibredecision
POSTGRES_USER=libredecision POSTGRES_USER=glibredecision
POSTGRES_PASSWORD=change-me-in-production POSTGRES_PASSWORD=change-me-in-production
DATABASE_URL=postgresql+asyncpg://libredecision:change-me-in-production@localhost:5432/libredecision DATABASE_URL=postgresql+asyncpg://glibredecision:change-me-in-production@localhost:5432/glibredecision
# Backend # Backend
SECRET_KEY=change-me-in-production-with-a-real-secret-key SECRET_KEY=change-me-in-production-with-a-real-secret-key
@@ -46,4 +46,4 @@ IPFS_TIMEOUT_SECONDS=30
NUXT_PUBLIC_API_BASE=http://localhost:8002/api/v1 NUXT_PUBLIC_API_BASE=http://localhost:8002/api/v1
# Docker / Production # Docker / Production
DOMAIN=decision.librodrome.org DOMAIN=glibredecision.org
+53 -216
View File
@@ -1,238 +1,75 @@
when: when:
- branch: main branch: main
event: push event: push
steps: steps:
test-backend:
- name: security-check
image: alpine:3.20
commands:
- |
if [ -f .env ]; then
echo "ERREUR: .env ne doit pas etre commite dans le depot"
exit 1
fi
- 'grep -q "^\.env$" .gitignore || (echo "ERREUR: .env manquant dans .gitignore" && exit 1)'
- echo "Security check OK"
- name: test-backend
image: python:3.11-slim image: python:3.11-slim
commands: commands:
- cd backend - cd backend
- pip install --no-cache-dir -r requirements.txt - pip install --no-cache-dir -r requirements.txt
- pytest app/tests/ -v --tb=short - pytest app/tests/ -v --tb=short
- name: test-frontend test-frontend:
image: node:20-slim image: node:20-slim
commands: commands:
- cd frontend - cd frontend
- npm ci - npm ci
- npm run build - npm run build
# NOTE: volumes + pas de from_secret : compatible docker-backend:
- name: build-backend image: woodpeckerci/plugin-docker-buildx
image: docker:27-cli
depends_on: depends_on:
- test-backend - test-backend
volumes: settings:
- /var/run/docker.sock:/var/run/docker.sock repo: ${CI_FORGE_URL}/${CI_REPO}
commands: dockerfile: docker/backend.Dockerfile
- docker build -t libredecision-backend:latest -f docker/backend.Dockerfile --target production . context: .
- echo "Image backend construite" tag:
- latest
- ${CI_COMMIT_SHA:0:8}
target: production
registry:
from_secret: docker_registry
username:
from_secret: docker_username
password:
from_secret: docker_password
# NOTE: volumes + pas de from_secret : compatible docker-frontend:
- name: build-frontend image: woodpeckerci/plugin-docker-buildx
image: docker:27-cli
depends_on: depends_on:
- test-frontend - test-frontend
volumes: settings:
- /var/run/docker.sock:/var/run/docker.sock repo: ${CI_FORGE_URL}/${CI_REPO}
commands: dockerfile: docker/frontend.Dockerfile
- docker build -t libredecision-frontend:latest -f docker/frontend.Dockerfile --target production . context: .
- echo "Image frontend construite" tag:
- latest
- ${CI_COMMIT_SHA:0:8}
target: production
registry:
from_secret: docker_registry
username:
from_secret: docker_username
password:
from_secret: docker_password
# NOTE: volumes + pas de from_secret : compatible deploy:
- name: sbom-generate image: appleboy/drone-ssh
image: alpine:3.20
depends_on: depends_on:
- build-backend - docker-backend
- build-frontend - docker-frontend
volumes: settings:
- /var/run/docker.sock:/var/run/docker.sock host:
commands: from_secret: deploy_host
- apk add --no-cache curl username:
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest from_secret: deploy_username
- mkdir -p .reports key:
- syft libredecision-backend:latest -o cyclonedx-json --file .reports/sbom-backend.cyclonedx.json from_secret: deploy_key
- syft libredecision-frontend:latest -o cyclonedx-json --file .reports/sbom-frontend.cyclonedx.json port: 22
- echo "SBOM generes" script:
- cd /opt/libredecision
# NOTE: volumes + pas de from_secret : compatible - docker compose -f docker/docker-compose.yml pull
- name: sbom-scan - docker compose -f docker/docker-compose.yml up -d --remove-orphans
image: aquasec/trivy:latest - docker image prune -f
depends_on:
- sbom-generate
volumes:
- /home/syoul/trivy-cache:/root/.cache/trivy
commands:
- trivy sbom --format json --output .reports/trivy-backend.json .reports/sbom-backend.cyclonedx.json
- trivy sbom --format json --output .reports/trivy-frontend.json .reports/sbom-frontend.cyclonedx.json
- echo "Scan CVE termine"
# NOTE: from_secret + pas de volumes : compatible
- name: sbom-publish
image: alpine/curl:latest
depends_on:
- sbom-scan
environment:
DTRACK_TOKEN:
from_secret: dependency_track_token
DTRACK_DOMAIN:
from_secret: dtrack_domain
commands:
- |
VERSION=$(date +%Y-%m-%d)-$(echo "$CI_COMMIT_SHA" | cut -c1-8)
for COMPONENT in backend frontend; do
HTTP=$(curl -s -o /tmp/dtrack-resp.txt -w "%{http_code}" -X POST "https://$DTRACK_DOMAIN/api/v1/bom" \
-H "X-Api-Key: $DTRACK_TOKEN" \
-F "autoCreate=true" \
-F "projectName=libredecision-$COMPONENT" \
-F "projectVersion=$VERSION" \
-F "bom=@.reports/sbom-$COMPONENT.cyclonedx.json")
echo "HTTP $HTTP ($COMPONENT) : $(cat /tmp/dtrack-resp.txt)"
[ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ] || exit 1
done
# NOTE: from_secret + pas de volumes : compatible
- name: write-env
image: alpine:3.20
depends_on:
- sbom-publish
environment:
APP_DOMAIN:
from_secret: app_domain
POSTGRES_PASSWORD:
from_secret: postgres_password
SECRET_KEY:
from_secret: secret_key
commands:
- echo "APP_DOMAIN=$APP_DOMAIN" > .env.deploy
- echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> .env.deploy
- echo "SECRET_KEY=$SECRET_KEY" >> .env.deploy
- OWNER=$(echo "$CI_REPO_OWNER" | tr 'A-Z' 'a-z') && REPO=$(echo "$CI_REPO_NAME" | tr 'A-Z' 'a-z') && BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-') && echo "COMPOSE_PROJECT_NAME=$OWNER-$REPO-$BRANCH" >> .env.deploy
- echo ".env.deploy cree ($(wc -c < .env.deploy) octets)"
- name: test-env
image: alpine:3.20
depends_on:
- write-env
commands:
- |
[ -f .env.deploy ] || { echo "FAIL: .env.deploy introuvable"; exit 1; }
echo "PASS: .env.deploy present"
- |
VAL=$(grep '^COMPOSE_PROJECT_NAME=' .env.deploy | cut -d= -f2)
[ -z "$VAL" ] && echo "FAIL: COMPOSE_PROJECT_NAME vide" && exit 1
echo "PASS: COMPOSE_PROJECT_NAME = $VAL"
- |
VAL=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
[ -z "$VAL" ] && echo "FAIL: APP_DOMAIN vide" && exit 1
echo "PASS: APP_DOMAIN = $VAL"
# NOTE: volumes + pas de from_secret : compatible
# Fabio/Traefik routing géré via labels du docker-compose.yml
- name: deploy
image: docker:27-cli
depends_on:
- test-env
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision:/opt/libredecision
commands:
- cp .env.deploy /opt/libredecision/.env
- chmod 600 /opt/libredecision/.env
- cp docker/docker-compose.yml /opt/libredecision/docker-compose.yml
# Arreter avant le challenge ACME : libere le webroot pour sonic-acme-1
- cd /opt/libredecision && docker compose stop
- |
DOMAIN=$(grep '^APP_DOMAIN=' /opt/libredecision/.env | cut -d= -f2)
ACME_EXIT=0
docker exec sonic-acme-1 /app/acme.sh \
--home /etc/acme.sh \
--issue -d "$DOMAIN" \
--webroot /usr/share/nginx/html \
--server letsencrypt \
--accountemail support+acme@asycn.io || ACME_EXIT=$?
if [ "$ACME_EXIT" -ne 0 ] && [ "$ACME_EXIT" -ne 2 ]; then
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT)"
exit 1
fi
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/fullchain.cer /host/certs/$DOMAIN-cert.pem
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/$DOMAIN.key /host/certs/$DOMAIN-key.pem
echo "TLS OK (acme exit $ACME_EXIT)"
# Images construites localement dans la pipeline : pas de docker compose pull
- cd /opt/libredecision && docker compose up -d --remove-orphans
- cd /opt/libredecision && docker compose ps
# Vérification que les containers sont running
- name: test-deploy
image: docker:27-cli
depends_on:
- deploy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision:/opt/libredecision
commands:
- |
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' /opt/libredecision/.env | cut -d= -f2)
for SVC in backend frontend; do
STATUS=$(docker inspect --format '{{.State.Status}}' "$PROJECT-$SVC" 2>/dev/null || echo "absent")
echo "$PROJECT-$SVC : $STATUS"
[ "$STATUS" = "running" ] || { echo "FAIL: $PROJECT-$SVC non running"; exit 1; }
done
echo "PASS: tous les containers running"
- name: seed
image: docker:27-cli
depends_on:
- test-deploy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision:/opt/libredecision
commands:
- |
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' /opt/libredecision/.env | cut -d= -f2)
docker exec "$PROJECT-backend" python seed.py
echo "Seed terminée"
- name: healthcheck
image: alpine:3.20
depends_on:
- seed
commands:
- apk add --no-cache --quiet curl
- |
SITE=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
TARGET="https://$SITE"
echo "Healthcheck $TARGET..."
MAX=60
i=0
until [ $i -ge $MAX ]; do
CODE=$(curl -sSo /dev/null -w "%{http_code}" "$TARGET" 2>/dev/null)
echo "Tentative $((i+1))/$MAX - HTTP $CODE"
if [ "$CODE" = "200" ] || [ "$CODE" = "301" ] || [ "$CODE" = "302" ]; then
echo "PASS: app repond sur $TARGET"
exit 0
fi
i=$((i+1))
sleep 10
done
echo "FAIL: app ne repond pas apres 10 minutes"
exit 1
- name: notify-failure
image: alpine:3.20
commands:
- 'echo "ECHEC pipeline #$CI_BUILD_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
when:
- status: failure
+1 -1
View File
@@ -84,5 +84,5 @@ docker compose -f docker/docker-compose.yml up
- **Mood system** : `useMood.ts` synchronise `colorMode.preference` avec la palette — **jamais** de `:global()` dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil) - **Mood system** : `useMood.ts` synchronise `colorMode.preference` avec la palette — **jamais** de `:global()` dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil)
- **Sceau** `井` (#48 Tsing) : `.app-seal` dans `app.vue`, right-aligned ; SVGs dans `public/` - **Sceau** `井` (#48 Tsing) : `.app-seal` dans `app.vue`, right-aligned ; SVGs dans `public/`
- **CSS drop-shadow()** safe pour effets emboss ; `<filter>` SVG inline cause des artefacts de rendu - **CSS drop-shadow()** safe pour effets emboss ; `<filter>` SVG inline cause des artefacts de rendu
- **Domaine** : decision.librodrome.org (Woodpecker CI ; ancien dossier : Glibredecision) - **Domaine** : libredecision.org (Woodpecker CI ; ancien dossier : Glibredecision)
- **Ed25519 verification** : stub en dev (substrate-interface), autoritaire en prod — ne pas bypasser sans test - **Ed25519 verification** : stub en dev (substrate-interface), autoritaire en prod — ne pas bypasser sans test
-3
View File
@@ -26,8 +26,6 @@ from app.database import Base
from app.models import ( # noqa: F401 from app.models import ( # noqa: F401
DuniterIdentity, DuniterIdentity,
Session, Session,
Organization,
OrgMember,
Document, Document,
DocumentItem, DocumentItem,
ItemVersion, ItemVersion,
@@ -39,7 +37,6 @@ from app.models import ( # noqa: F401
MandateStep, MandateStep,
VotingProtocol, VotingProtocol,
FormulaConfig, FormulaConfig,
QualificationProtocol,
SanctuaryEntry, SanctuaryEntry,
BlockchainCache, BlockchainCache,
) )
@@ -1,301 +0,0 @@
"""initial schema
Revision ID: 0b9c1d2e3f4a
Revises:
Create Date: 2026-04-23 00:00:00.000000+00:00
Idempotent: uses CREATE TABLE IF NOT EXISTS so it is safe to run
against a DB that was already bootstrapped via SQLAlchemy create_all().
"""
from typing import Sequence, Union
from alembic import op
revision: str = '0b9c1d2e3f4a'
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:
op.execute("""
CREATE TABLE IF NOT EXISTS duniter_identities (
id UUID PRIMARY KEY,
address VARCHAR(64) NOT NULL,
display_name VARCHAR(128),
wot_status VARCHAR(32) NOT NULL DEFAULT 'unknown',
is_smith BOOLEAN NOT NULL DEFAULT FALSE,
is_techcomm BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_duniter_identities_address ON duniter_identities (address)")
op.execute("""
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY,
token_hash VARCHAR(128) NOT NULL,
identity_id UUID NOT NULL REFERENCES duniter_identities(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_sessions_token_hash ON sessions (token_hash)")
op.execute("""
CREATE TABLE IF NOT EXISTS organizations (
id UUID PRIMARY KEY,
name VARCHAR(128) NOT NULL,
slug VARCHAR(128) NOT NULL,
org_type VARCHAR(64) NOT NULL DEFAULT 'community',
is_transparent BOOLEAN NOT NULL DEFAULT FALSE,
color VARCHAR(32),
icon VARCHAR(64),
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_organizations_slug ON organizations (slug)")
op.execute("""
CREATE TABLE IF NOT EXISTS org_members (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organizations(id),
identity_id UUID NOT NULL REFERENCES duniter_identities(id),
role VARCHAR(32) NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS formula_configs (
id UUID PRIMARY KEY,
name VARCHAR(128) NOT NULL,
description TEXT,
duration_days INTEGER NOT NULL DEFAULT 30,
majority_pct INTEGER NOT NULL DEFAULT 50,
base_exponent FLOAT NOT NULL DEFAULT 0.1,
gradient_exponent FLOAT NOT NULL DEFAULT 0.2,
constant_base FLOAT NOT NULL DEFAULT 0.0,
smith_exponent FLOAT,
techcomm_exponent FLOAT,
nuanced_min_participants INTEGER,
nuanced_threshold_pct INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS voting_protocols (
id UUID PRIMARY KEY,
name VARCHAR(128) NOT NULL,
description TEXT,
vote_type VARCHAR(32) NOT NULL,
formula_config_id UUID NOT NULL REFERENCES formula_configs(id),
mode_params VARCHAR(64),
is_meta_governed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS sanctuary_entries (
id UUID PRIMARY KEY,
entry_type VARCHAR(64) NOT NULL,
reference_id UUID NOT NULL,
title VARCHAR(256),
content_hash VARCHAR(128) NOT NULL,
ipfs_cid VARCHAR(128),
chain_tx_hash VARCHAR(128),
chain_block INTEGER,
metadata_json TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS blockchain_cache (
id UUID PRIMARY KEY,
cache_key VARCHAR(256) NOT NULL,
cache_value JSON NOT NULL,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_blockchain_cache_cache_key ON blockchain_cache (cache_key)")
op.execute("""
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY,
slug VARCHAR(128) NOT NULL,
title VARCHAR(256) NOT NULL,
doc_type VARCHAR(64) NOT NULL,
version VARCHAR(32) NOT NULL DEFAULT '0.1.0',
status VARCHAR(32) NOT NULL DEFAULT 'draft',
description TEXT,
ipfs_cid VARCHAR(128),
chain_anchor VARCHAR(128),
genesis_json TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_documents_slug ON documents (slug)")
op.execute("""
CREATE TABLE IF NOT EXISTS decisions (
id UUID PRIMARY KEY,
title VARCHAR(256) NOT NULL,
description TEXT,
context TEXT,
decision_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'draft',
voting_protocol_id UUID REFERENCES voting_protocols(id),
created_by_id UUID REFERENCES duniter_identities(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS item_versions (
id UUID PRIMARY KEY,
item_id UUID NOT NULL,
proposed_text TEXT NOT NULL,
diff_text TEXT,
rationale TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'proposed',
decision_id UUID REFERENCES decisions(id),
proposed_by_id UUID REFERENCES duniter_identities(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS vote_sessions (
id UUID PRIMARY KEY,
decision_id UUID REFERENCES decisions(id),
item_version_id UUID REFERENCES item_versions(id),
voting_protocol_id UUID NOT NULL REFERENCES voting_protocols(id),
wot_size INTEGER NOT NULL DEFAULT 0,
smith_size INTEGER NOT NULL DEFAULT 0,
techcomm_size INTEGER NOT NULL DEFAULT 0,
starts_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ends_at TIMESTAMPTZ NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'open',
votes_for INTEGER NOT NULL DEFAULT 0,
votes_against INTEGER NOT NULL DEFAULT 0,
votes_total INTEGER NOT NULL DEFAULT 0,
smith_votes_for INTEGER NOT NULL DEFAULT 0,
techcomm_votes_for INTEGER NOT NULL DEFAULT 0,
threshold_required FLOAT NOT NULL DEFAULT 0.0,
result VARCHAR(32),
chain_recorded BOOLEAN NOT NULL DEFAULT FALSE,
chain_tx_hash VARCHAR(128),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS decision_steps (
id UUID PRIMARY KEY,
decision_id UUID NOT NULL REFERENCES decisions(id),
step_order INTEGER NOT NULL,
step_type VARCHAR(32) NOT NULL,
title VARCHAR(256),
description TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
vote_session_id UUID REFERENCES vote_sessions(id),
outcome TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS document_items (
id UUID PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id),
position VARCHAR(16) NOT NULL,
item_type VARCHAR(32) NOT NULL DEFAULT 'clause',
title VARCHAR(256),
current_text TEXT NOT NULL,
voting_protocol_id UUID REFERENCES voting_protocols(id),
sort_order INTEGER NOT NULL DEFAULT 0,
section_tag VARCHAR(64),
inertia_preset VARCHAR(16) NOT NULL DEFAULT 'standard',
is_permanent_vote BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'item_versions_item_id_fkey'
AND table_name = 'item_versions'
) THEN
ALTER TABLE item_versions
ADD CONSTRAINT item_versions_item_id_fkey
FOREIGN KEY (item_id) REFERENCES document_items(id);
END IF;
END $$
""")
op.execute("""
CREATE TABLE IF NOT EXISTS votes (
id UUID PRIMARY KEY,
session_id UUID NOT NULL REFERENCES vote_sessions(id),
voter_id UUID NOT NULL REFERENCES duniter_identities(id),
vote_value VARCHAR(32) NOT NULL,
nuanced_level INTEGER,
comment TEXT,
signature TEXT NOT NULL,
signed_payload TEXT NOT NULL,
voter_wot_status VARCHAR(32) NOT NULL DEFAULT 'member',
voter_is_smith BOOLEAN NOT NULL DEFAULT FALSE,
voter_is_techcomm BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS mandates (
id UUID PRIMARY KEY,
title VARCHAR(256) NOT NULL,
description TEXT,
mandate_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'draft',
mandatee_id UUID REFERENCES duniter_identities(id),
decision_id UUID REFERENCES decisions(id),
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS mandate_steps (
id UUID PRIMARY KEY,
mandate_id UUID NOT NULL REFERENCES mandates(id),
step_order INTEGER NOT NULL,
step_type VARCHAR(32) NOT NULL,
title VARCHAR(256),
description TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
vote_session_id UUID REFERENCES vote_sessions(id),
outcome TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
def downgrade() -> None:
# Intentionally left empty — dropping the initial schema would destroy all data.
pass
@@ -1,40 +0,0 @@
"""add organizations
Revision ID: 70914b334cfb
Revises:
Create Date: 2026-04-23 12:27:56.220214+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '70914b334cfb'
down_revision: Union[str, None] = '0b9c1d2e3f4a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ADD COLUMN IF NOT EXISTS — idempotent (safe on DBs bootstrapped via create_all)
op.execute("ALTER TABLE decisions ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_decisions_organization_id ON decisions (organization_id)")
op.execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_documents_organization_id ON documents (organization_id)")
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_mandates_organization_id ON mandates (organization_id)")
op.execute("ALTER TABLE voting_protocols ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_voting_protocols_organization_id ON voting_protocols (organization_id)")
def downgrade() -> None:
op.drop_index(op.f('ix_voting_protocols_organization_id'), table_name='voting_protocols')
op.drop_column('voting_protocols', 'organization_id')
op.drop_index(op.f('ix_mandates_organization_id'), table_name='mandates')
op.drop_column('mandates', 'organization_id')
op.drop_index(op.f('ix_documents_organization_id'), table_name='documents')
op.drop_column('documents', 'organization_id')
op.drop_index(op.f('ix_decisions_organization_id'), table_name='decisions')
op.drop_column('decisions', 'organization_id')
@@ -1,34 +0,0 @@
"""add_qualification_protocol
Revision ID: b78571ae9e00
Revises: 70914b334cfb
Create Date: 2026-04-23 17:08:07.161306+00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = 'b78571ae9e00'
down_revision: Union[str, None] = '70914b334cfb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS qualification_protocols (
id UUID PRIMARY KEY,
name VARCHAR(128) NOT NULL,
description TEXT,
small_group_max INTEGER NOT NULL DEFAULT 5,
collective_wot_min INTEGER NOT NULL DEFAULT 50,
default_modalities_json TEXT NOT NULL DEFAULT '["vote_wot","vote_smith","consultation_avis","election"]',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT now()
)
""")
def downgrade() -> None:
op.drop_table('qualification_protocols')
@@ -1,48 +0,0 @@
"""Add groups and group_members tables.
Revision ID: c4e812fb3a01
Revises: b78571ae9e00
Create Date: 2026-04-23 19:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "c4e812fb3a01"
down_revision = "b78571ae9e00"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY,
name VARCHAR(128) NOT NULL,
description TEXT,
organization_id UUID REFERENCES organizations(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_groups_organization_id ON groups (organization_id)")
op.execute("""
CREATE TABLE IF NOT EXISTS group_members (
id UUID PRIMARY KEY,
group_id UUID NOT NULL REFERENCES groups(id),
identity_id UUID REFERENCES duniter_identities(id),
display_name VARCHAR(128) NOT NULL,
added_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_group_members_group_id ON group_members (group_id)")
def downgrade() -> None:
op.drop_index("ix_group_members_group_id", table_name="group_members")
op.drop_table("group_members")
op.drop_index("ix_groups_organization_id", table_name="groups")
op.drop_table("groups")
@@ -1,24 +0,0 @@
"""Add origin column to mandates table.
Revision ID: d91a3c7f8b02
Revises: c4e812fb3a01
Create Date: 2026-04-24 10:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "d91a3c7f8b02"
down_revision = "c4e812fb3a01"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin TEXT")
def downgrade() -> None:
op.drop_column("mandates", "origin")
@@ -1,27 +0,0 @@
"""Mandate origin: replace free-text with FK to duniter_identities.
Revision ID: e3f4a5b6c7d8
Revises: d91a3c7f8b02
Create Date: 2026-04-25 10:00:00.000000
"""
from __future__ import annotations
from alembic import op
revision = "e3f4a5b6c7d8"
down_revision = "d91a3c7f8b02"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE mandates DROP COLUMN IF EXISTS origin")
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin_id UUID REFERENCES duniter_identities(id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_mandates_origin_id ON mandates (origin_id)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_mandates_origin_id")
op.execute("ALTER TABLE mandates DROP COLUMN IF EXISTS origin_id")
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin TEXT")
-3
View File
@@ -39,9 +39,6 @@ class Settings(BaseSettings):
RATE_LIMIT_AUTH: int = 10 RATE_LIMIT_AUTH: int = 10
RATE_LIMIT_VOTE: int = 30 RATE_LIMIT_VOTE: int = 30
# AI — Qwen3.6 (MacStudio) endpoint, branché plus tard
QWEN_API_URL: str = ""
# Blockchain cache # Blockchain cache
BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600 BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600
-26
View File
@@ -1,26 +0,0 @@
"""FastAPI dependency: resolve X-Organization header → org UUID."""
from __future__ import annotations
import uuid
from fastapi import Depends, Header
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.org_service import get_organization_by_slug
async def get_active_org_id(
x_organization: str | None = Header(default=None, alias="X-Organization"),
db: AsyncSession = Depends(get_db),
) -> uuid.UUID | None:
"""Return the UUID of the org named in the X-Organization header, or None.
None means no org filter — used for backward compat and internal tooling.
An unknown slug is silently treated as None (don't break the client).
"""
if not x_organization:
return None
org = await get_organization_by_slug(db, x_organization)
return org.id if org else None
-211
View File
@@ -1,211 +0,0 @@
"""Decision qualification engine.
Pure functions — no database, no I/O.
Takes a QualificationInput + QualificationConfig and returns a QualificationResult.
LLM integration (suggest_modalities_from_context) is stubbed pending local Qwen deployment.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class DecisionType(str, Enum):
INDIVIDUAL = "individual"
COLLECTIVE = "collective"
# ---------------------------------------------------------------------------
# Configuration (thresholds — stored as QualificationProtocol in DB)
# ---------------------------------------------------------------------------
@dataclass
class QualificationConfig:
"""Configurable thresholds for the qualification engine.
Seeded as a QualificationProtocol record so they can be adjusted
through the admin interface without code changes.
small_group_max: affected_count <= this → individual recommended, collective available
collective_wot_min: affected_count > this → WoT formula applicable (still recommended, not required)
affected_count must be >= 2 — decisions affecting only the author
have no place in this tool.
"""
small_group_max: int = 5
collective_wot_min: int = 50
default_modalities: list[str] = field(default_factory=lambda: [
"vote_wot",
"vote_smith",
"consultation_avis",
"election",
])
# ---------------------------------------------------------------------------
# Input / Output
# ---------------------------------------------------------------------------
@dataclass
class QualificationInput:
within_mandate: bool = False
affected_count: int | None = None # must be >= 2 when within_mandate=False
is_structural: bool = False
context_description: str | None = None # reserved for LLM suggestion
@dataclass
class QualificationResult:
decision_type: DecisionType
process: str
recommended_modalities: list[str]
recommend_onchain: bool
onchain_reason: str | None
confidence: str # "required" | "recommended" | "optional"
collective_available: bool
record_in_observatory: bool # True → decision must be logged in Observatoire
reasons: list[str]
# ---------------------------------------------------------------------------
# LLM stub
# ---------------------------------------------------------------------------
def suggest_modalities_from_context(
context: str,
config: QualificationConfig,
) -> list[str]:
"""Suggest voting modalities based on a natural-language context description.
Stub — returns empty list until local Qwen (qwen3.6, MacStudio) is integrated.
When implemented, will call the LLM API and return an ordered subset of
config.default_modalities ranked by contextual relevance.
"""
return []
# ---------------------------------------------------------------------------
# Core engine
# ---------------------------------------------------------------------------
def qualify(inp: QualificationInput, config: QualificationConfig) -> QualificationResult:
"""Qualify a decision and recommend a type, process, and modalities.
Rules (in priority order):
R1/R2 within_mandate → individual + consultation_avis, no vote modalities,
decision must be recorded in Observatoire des décisions
R4 2 ≤ affected_count ≤ small_group_max → individual recommended, collective available
R5 small_group_max < affected_count ≤ collective_wot_min → collective recommended
R6 affected_count > collective_wot_min → collective recommended (WoT formula applicable)
R7/R8 is_structural → recommend_onchain with reason
"""
reasons: list[str] = []
# ── R1/R2: mandate scope overrides everything ───────────────────────────
if inp.within_mandate:
reasons.append("Décision dans le périmètre d'un mandat existant.")
return QualificationResult(
decision_type=DecisionType.INDIVIDUAL,
process="consultation_avis",
recommended_modalities=[],
recommend_onchain=_onchain(inp, reasons),
onchain_reason=_onchain_reason(inp),
confidence="required",
collective_available=False,
record_in_observatory=True,
reasons=reasons,
)
count = inp.affected_count if inp.affected_count is not None else 2
# ── R4: small group → individual recommended, collective available ───────
if count <= config.small_group_max:
reasons.append(
f"{count} personnes concernées : décision individuelle recommandée, "
"vote collectif possible."
)
return QualificationResult(
decision_type=DecisionType.INDIVIDUAL,
process="personal",
recommended_modalities=[],
recommend_onchain=_onchain(inp, reasons),
onchain_reason=_onchain_reason(inp),
confidence="recommended",
collective_available=True,
record_in_observatory=False,
reasons=reasons,
)
# ── R5/R6: medium or large group → collective ────────────────────────────
modalities = _collect_modalities(inp, config)
if count <= config.collective_wot_min:
reasons.append(f"{count} personnes concernées : vote collectif recommandé.")
confidence = "recommended"
else:
reasons.append(
f"{count} personnes concernées : vote collectif recommandé "
"(formule WoT applicable à cette échelle)."
)
confidence = "recommended"
if "vote_wot" not in modalities:
modalities = ["vote_wot"] + modalities
return QualificationResult(
decision_type=DecisionType.COLLECTIVE,
process="vote_collective",
recommended_modalities=modalities,
recommend_onchain=_onchain(inp, reasons),
onchain_reason=_onchain_reason(inp),
confidence=confidence,
collective_available=True,
record_in_observatory=False,
reasons=reasons,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _onchain(inp: QualificationInput, reasons: list[str]) -> bool:
if inp.is_structural:
reasons.append(
"Décision structurante : gravure on-chain recommandée "
"(a force de loi ou déclenche une action machine)."
)
return inp.is_structural
def _onchain_reason(inp: QualificationInput) -> str | None:
if not inp.is_structural:
return None
return (
"Cette décision est structurante : elle a valeur de loi au sein de la "
"communauté ou déclenche une action machine (ex : runtime upgrade). "
"La gravure on-chain (IPFS + system.remark) garantit son immuabilité "
"et sa vérifiabilité publique."
)
def _collect_modalities(inp: QualificationInput, config: QualificationConfig) -> list[str]:
"""Combine default modalities with any LLM suggestions (stub for now)."""
llm_suggestions = []
if inp.context_description:
llm_suggestions = suggest_modalities_from_context(inp.context_description, config)
seen: set[str] = set()
result: list[str] = []
for m in llm_suggestions + config.default_modalities:
if m not in seen:
seen.add(m)
result.append(m)
return result
+11 -28
View File
@@ -13,9 +13,6 @@ from app.middleware.rate_limiter import RateLimiterMiddleware
from app.middleware.security_headers import SecurityHeadersMiddleware from app.middleware.security_headers import SecurityHeadersMiddleware
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
from app.routers import public from app.routers import public
from app.routers import organizations
from app.routers import qualify
from app.routers import groups
# ── Structured logging setup ─────────────────────────────────────────────── # ── Structured logging setup ───────────────────────────────────────────────
@@ -88,28 +85,8 @@ app = FastAPI(
# ── Middleware stack ────────────────────────────────────────────────────── # ── Middleware stack ──────────────────────────────────────────────────────
# add_middleware is LIFO: last added = outermost = first to execute on request, # Middleware is applied in reverse order: last added = first executed.
# last to execute on response (wraps everything inside it). # Order: SecurityHeaders -> RateLimiter -> CORS -> Application
#
# Required order so CORS headers appear on ALL responses including 429:
# CORS (outermost) → RateLimiter → SecurityHeaders → Application
#
# If RateLimiter were outside CORS, its 429 responses would have no CORS
# headers and the browser would silently discard them as network errors.
app.add_middleware(SecurityHeadersMiddleware)
# Prototype mode: use RATE_LIMIT_DEFAULT for auth so demos/testing don't hit
# the stricter RATE_LIMIT_AUTH (10/min). Set RATE_LIMIT_AUTH >= RATE_LIMIT_DEFAULT
# in .env only when going to real production.
_auth_rate_limit = settings.RATE_LIMIT_DEFAULT
app.add_middleware(
RateLimiterMiddleware,
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
rate_limit_auth=_auth_rate_limit,
rate_limit_vote=settings.RATE_LIMIT_VOTE,
)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -119,6 +96,15 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.add_middleware(
RateLimiterMiddleware,
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
rate_limit_auth=settings.RATE_LIMIT_AUTH,
rate_limit_vote=settings.RATE_LIMIT_VOTE,
)
app.add_middleware(SecurityHeadersMiddleware)
# ── Routers ────────────────────────────────────────────────────────────── # ── Routers ──────────────────────────────────────────────────────────────
@@ -131,9 +117,6 @@ app.include_router(protocols.router, prefix="/api/v1/protocols", tags=["protocol
app.include_router(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"]) app.include_router(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"])
app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"]) app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
app.include_router(public.router, prefix="/api/v1/public", tags=["public"]) app.include_router(public.router, prefix="/api/v1/public", tags=["public"])
app.include_router(organizations.router, prefix="/api/v1/organizations", tags=["organizations"])
app.include_router(qualify.router, prefix="/api/v1/qualify", tags=["qualify"])
app.include_router(groups.router, prefix="/api/v1/groups", tags=["groups"])
# ── Health check ───────────────────────────────────────────────────────── # ── Health check ─────────────────────────────────────────────────────────
+11 -18
View File
@@ -64,6 +64,14 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
self._last_cleanup: float = time.time() self._last_cleanup: float = time.time()
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
def _get_limit_for_path(self, path: str) -> int:
"""Return the rate limit applicable to the given request path."""
if "/auth" in path:
return self.rate_limit_auth
if "/vote" in path:
return self.rate_limit_vote
return self.rate_limit_default
def _get_client_ip(self, request: Request) -> str: def _get_client_ip(self, request: Request) -> str:
"""Extract the client IP from the request, respecting X-Forwarded-For.""" """Extract the client IP from the request, respecting X-Forwarded-For."""
forwarded = request.headers.get("x-forwarded-for") forwarded = request.headers.get("x-forwarded-for")
@@ -93,22 +101,6 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
if ips_to_delete: if ips_to_delete:
logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(ips_to_delete)) logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(ips_to_delete))
def _get_limit_for_request(self, request: Request) -> int:
"""Return the rate limit applicable to the given request.
CORS preflight (OPTIONS) requests are never rate-limited — blocking them
breaks authenticated cross-origin requests in browsers.
Strict auth limit applies only to POST (login flows), not to GET /auth/me.
"""
if request.method == "OPTIONS":
return 10_000 # effectively unlimited for preflights
path = request.url.path
if request.method == "POST" and "/auth" in path:
return self.rate_limit_auth
if "/vote" in path:
return self.rate_limit_vote
return self.rate_limit_default
async def dispatch(self, request: Request, call_next) -> Response: async def dispatch(self, request: Request, call_next) -> Response:
"""Check rate limit and either allow the request or return 429.""" """Check rate limit and either allow the request or return 429."""
# Skip rate limiting for WebSocket upgrades # Skip rate limiting for WebSocket upgrades
@@ -119,7 +111,8 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
await self._cleanup_old_entries() await self._cleanup_old_entries()
client_ip = self._get_client_ip(request) client_ip = self._get_client_ip(request)
limit = self._get_limit_for_request(request) path = request.url.path
limit = self._get_limit_for_path(path)
now = time.time() now = time.time()
window_start = now - 60 window_start = now - 60
@@ -140,7 +133,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
logger.warning( logger.warning(
"Rate limit depasse pour %s sur %s (%d/%d)", "Rate limit depasse pour %s sur %s (%d/%d)",
client_ip, request.url.path, request_count, limit, client_ip, path, request_count, limit,
) )
return JSONResponse( return JSONResponse(
-6
View File
@@ -1,25 +1,19 @@
from app.models.user import DuniterIdentity, Session from app.models.user import DuniterIdentity, Session
from app.models.organization import Organization, OrgMember
from app.models.document import Document, DocumentItem, ItemVersion from app.models.document import Document, DocumentItem, ItemVersion
from app.models.decision import Decision, DecisionStep from app.models.decision import Decision, DecisionStep
from app.models.vote import VoteSession, Vote from app.models.vote import VoteSession, Vote
from app.models.mandate import Mandate, MandateStep from app.models.mandate import Mandate, MandateStep
from app.models.protocol import VotingProtocol, FormulaConfig from app.models.protocol import VotingProtocol, FormulaConfig
from app.models.qualification import QualificationProtocol
from app.models.group import Group, GroupMember
from app.models.sanctuary import SanctuaryEntry from app.models.sanctuary import SanctuaryEntry
from app.models.cache import BlockchainCache from app.models.cache import BlockchainCache
__all__ = [ __all__ = [
"DuniterIdentity", "Session", "DuniterIdentity", "Session",
"Organization", "OrgMember",
"Document", "DocumentItem", "ItemVersion", "Document", "DocumentItem", "ItemVersion",
"Decision", "DecisionStep", "Decision", "DecisionStep",
"VoteSession", "Vote", "VoteSession", "Vote",
"Mandate", "MandateStep", "Mandate", "MandateStep",
"VotingProtocol", "FormulaConfig", "VotingProtocol", "FormulaConfig",
"QualificationProtocol",
"Group", "GroupMember",
"SanctuaryEntry", "SanctuaryEntry",
"BlockchainCache", "BlockchainCache",
] ]
-1
View File
@@ -16,7 +16,6 @@ class Decision(Base):
context: Mapped[str | None] = mapped_column(Text) context: Mapped[str | None] = mapped_column(Text)
decision_type: Mapped[str] = mapped_column(String(64), nullable=False) # runtime_upgrade, document_change, mandate_vote, custom decision_type: Mapped[str] = mapped_column(String(64), nullable=False) # runtime_upgrade, document_change, mandate_vote, custom
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, qualification, review, voting, executed, closed status: Mapped[str] = mapped_column(String(32), default="draft") # draft, qualification, review, voting, executed, closed
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id")) voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id"))
created_by_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id")) created_by_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
-1
View File
@@ -17,7 +17,6 @@ class Document(Base):
version: Mapped[str] = mapped_column(String(32), default="0.1.0") version: Mapped[str] = mapped_column(String(32), default="0.1.0")
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
ipfs_cid: Mapped[str | None] = mapped_column(String(128)) ipfs_cid: Mapped[str | None] = mapped_column(String(128))
chain_anchor: Mapped[str | None] = mapped_column(String(128)) chain_anchor: Mapped[str | None] = mapped_column(String(128))
genesis_json: Mapped[str | None] = mapped_column(Text) # JSON: source files, repos, forum URLs, formula trigger genesis_json: Mapped[str | None] = mapped_column(Text) # JSON: source files, repos, forum URLs, formula trigger
-41
View File
@@ -1,41 +0,0 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Group(Base):
__tablename__ = "groups"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
organization_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("organizations.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
members: Mapped[list["GroupMember"]] = relationship(
back_populates="group", cascade="all, delete-orphan"
)
class GroupMember(Base):
__tablename__ = "group_members"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
group_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groups.id"), nullable=False, index=True)
# FK to duniter_identities when the member is a known WoT member; nullable for free-text entries
identity_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("duniter_identities.id"), nullable=True
)
display_name: Mapped[str] = mapped_column(String(128), nullable=False)
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
group: Mapped["Group"] = relationship(back_populates="members")
+5 -31
View File
@@ -12,11 +12,9 @@ class Mandate(Base):
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(256), nullable=False) title: Mapped[str] = mapped_column(String(256), nullable=False)
origin_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"), nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom
status: Mapped[str] = mapped_column(String(32), default="draft") status: Mapped[str] = mapped_column(String(32), default="draft") # draft, candidacy, voting, active, reporting, completed, revoked
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id")) mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id")) decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -24,27 +22,7 @@ class Mandate(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
steps: Mapped[list["MandateStep"]] = relationship( steps: Mapped[list["MandateStep"]] = relationship(back_populates="mandate", cascade="all, delete-orphan", order_by="MandateStep.step_order")
back_populates="mandate", cascade="all, delete-orphan", order_by="MandateStep.step_order"
)
origin_identity: Mapped["DuniterIdentity | None"] = relationship( # type: ignore[name-defined]
"DuniterIdentity", foreign_keys=[origin_id]
)
mandatee: Mapped["DuniterIdentity | None"] = relationship( # type: ignore[name-defined]
"DuniterIdentity", foreign_keys=[mandatee_id]
)
@property
def origin_display_name(self) -> str | None:
if self.origin_identity is not None:
return self.origin_identity.display_name or self.origin_identity.address
return None
@property
def mandatee_display_name(self) -> str | None:
if self.mandatee is not None:
return self.mandatee.display_name or self.mandatee.address
return None
class MandateStep(Base): class MandateStep(Base):
@@ -53,16 +31,12 @@ class MandateStep(Base):
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False) mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False)
step_order: Mapped[int] = mapped_column(Integer, nullable=False) step_order: Mapped[int] = mapped_column(Integer, nullable=False)
step_type: Mapped[str] = mapped_column(String(32), nullable=False) step_type: Mapped[str] = mapped_column(String(32), nullable=False) # formulation, candidacy, vote, assignment, reporting, completion, revocation
title: Mapped[str | None] = mapped_column(String(256)) title: Mapped[str | None] = mapped_column(String(256))
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(32), default="pending") status: Mapped[str] = mapped_column(String(32), default="pending") # pending, active, completed, skipped
vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id")) vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id"))
outcome: Mapped[str | None] = mapped_column(Text) outcome: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
mandate: Mapped["Mandate"] = relationship(back_populates="steps") mandate: Mapped["Mandate"] = relationship(back_populates="steps")
# Avoid circular import — DuniterIdentity imported at runtime by SQLAlchemy relationship resolution
from app.models.user import DuniterIdentity # noqa: E402, F401
-42
View File
@@ -1,42 +0,0 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Organization(Base):
__tablename__ = "organizations"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
# commune, enterprise, association, collective, basin, intercommunality, community
org_type: Mapped[str] = mapped_column(String(64), default="community")
# True = all authenticated users see & interact with content (Duniter G1, Axiom Team)
# False = membership required
is_transparent: Mapped[bool] = mapped_column(Boolean, default=False)
color: Mapped[str | None] = mapped_column(String(32)) # CSS color or mood token
icon: Mapped[str | None] = mapped_column(String(64)) # lucide icon name
description: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
members: Mapped[list["OrgMember"]] = relationship(
back_populates="organization", cascade="all, delete-orphan"
)
class OrgMember(Base):
__tablename__ = "org_members"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
org_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("organizations.id"), nullable=False)
identity_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("duniter_identities.id"), nullable=False
)
role: Mapped[str] = mapped_column(String(32), default="member") # admin, member, observer
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
organization: Mapped["Organization"] = relationship(back_populates="members")
-1
View File
@@ -44,7 +44,6 @@ class VotingProtocol(Base):
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced
formula_config_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("formula_configs.id"), nullable=False) formula_config_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("formula_configs.id"), nullable=False)
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
mode_params: Mapped[str | None] = mapped_column(String(64)) # e.g. "D30M50B.1G.2T.1" mode_params: Mapped[str | None] = mapped_column(String(64)) # e.g. "D30M50B.1G.2T.1"
is_meta_governed: Mapped[bool] = mapped_column(Boolean, default=False) is_meta_governed: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
-38
View File
@@ -1,38 +0,0 @@
import json
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class QualificationProtocol(Base):
"""Active configuration for the decision qualification engine.
Thresholds stored here override the engine defaults and can be updated
through the admin interface (meta-governance).
Only one record should be active at a time (is_active=True).
"""
__tablename__ = "qualification_protocols"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
small_group_max: Mapped[int] = mapped_column(Integer, default=5)
collective_wot_min: Mapped[int] = mapped_column(Integer, default=50)
# JSON array of modality slugs, e.g. '["vote_wot","vote_smith","election"]'
default_modalities_json: Mapped[str] = mapped_column(
Text,
default='["vote_wot","vote_smith","consultation_avis","election"]',
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@property
def default_modalities(self) -> list[str]:
return json.loads(self.default_modalities_json)
+13 -64
View File
@@ -5,8 +5,7 @@ from __future__ import annotations
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -46,15 +45,15 @@ DEV_PROFILES = [
}, },
{ {
"address": "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmkP7j4bJa3zN7d8tY", "address": "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmkP7j4bJa3zN7d8tY",
"display_name": "Charlie (Référent structure)", "display_name": "Charlie (Comite Tech)",
"wot_status": "member", "wot_status": "member",
"is_smith": True, "is_smith": True,
"is_techcomm": True, "is_techcomm": True,
}, },
{ {
"address": "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy", "address": "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy",
"display_name": "Dave (Auteur)", "display_name": "Dave (Observateur)",
"wot_status": "member", "wot_status": "unknown",
"is_smith": False, "is_smith": False,
"is_techcomm": False, "is_techcomm": False,
}, },
@@ -133,44 +132,15 @@ async def verify_challenge(
detail="Challenge invalide", detail="Challenge invalide",
) )
# 4. Verify signature # 4. Verify Ed25519 signature
# TODO: trustWallet — déléguer la vérification au protocole trustWallet (librodrome) # TODO: Implement actual Ed25519 verification using substrate-interface
# Quand trustWallet sera disponible : remplacer le bloc ci-dessous par une vérification # For now we accept any signature to allow development/testing.
# du token signé fourni par trustWallet (JWT ou preuve Ed25519 via iframe postMessage). # In production this MUST verify: verify(address_pubkey, challenge_bytes, signature_bytes)
# Le bypass DEMO_MODE sera alors supprimé. #
_demo_addresses = {p["address"] for p in DEV_PROFILES} # from substrateinterface import Keypair
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses # keypair = Keypair(ss58_address=payload.address)
# if not keypair.verify(payload.challenge.encode(), bytes.fromhex(payload.signature)):
if not is_demo_bypass: # raise HTTPException(status_code=401, detail="Signature invalide")
# polkadot.js / Cesium2 signRaw(type='bytes') wraps: <Bytes>{challenge}</Bytes>
message = f"<Bytes>{payload.challenge}</Bytes>".encode("utf-8")
sig_hex = payload.signature.removeprefix("0x")
try:
sig_bytes = bytes.fromhex(sig_hex)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Format de signature invalide (hex attendu)",
)
from substrateinterface import Keypair, KeypairType
verified = False
# Try Sr25519 first (default Substrate/Cesium2), then Ed25519 (Duniter v1 migration)
for key_type in [KeypairType.SR25519, KeypairType.ED25519]:
try:
kp = Keypair(ss58_address=payload.address, crypto_type=key_type)
if kp.verify(message, sig_bytes):
verified = True
break
except Exception:
continue
if not verified:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Signature invalide",
)
# 5. Consume the challenge # 5. Consume the challenge
del _pending_challenges[payload.address] del _pending_challenges[payload.address]
@@ -233,24 +203,3 @@ async def logout(
for session in sessions: for session in sessions:
await db.delete(session) await db.delete(session)
await db.commit() await db.commit()
@router.get("/identities", response_model=list[IdentityOut])
async def search_identities(
q: str = Query(..., min_length=1, description="Recherche par adresse ou nom"),
limit: int = Query(default=10, ge=1, le=50),
db: AsyncSession = Depends(get_db),
) -> list[IdentityOut]:
"""Search Duniter identities by address prefix or display_name."""
result = await db.execute(
select(DuniterIdentity)
.where(
or_(
DuniterIdentity.address.ilike(f"{q}%"),
DuniterIdentity.display_name.ilike(f"%{q}%"),
)
)
.order_by(DuniterIdentity.display_name)
.limit(limit)
)
return [IdentityOut.model_validate(i) for i in result.scalars().all()]
-6
View File
@@ -21,7 +21,6 @@ from app.schemas.decision import (
DecisionUpdate, DecisionUpdate,
) )
from app.schemas.vote import VoteSessionOut from app.schemas.vote import VoteSessionOut
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity from app.services.auth_service import get_current_identity
from app.services.decision_service import advance_decision, create_vote_session_for_step from app.services.decision_service import advance_decision, create_vote_session_for_step
@@ -50,7 +49,6 @@ async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
@router.get("/", response_model=list[DecisionOut]) @router.get("/", response_model=list[DecisionOut])
async def list_decisions( async def list_decisions(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
decision_type: str | None = Query(default=None, description="Filtrer par type de decision"), decision_type: str | None = Query(default=None, description="Filtrer par type de decision"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"), status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0), skip: int = Query(default=0, ge=0),
@@ -59,8 +57,6 @@ async def list_decisions(
"""List all decisions with optional filters.""" """List all decisions with optional filters."""
stmt = select(Decision).options(selectinload(Decision.steps)) stmt = select(Decision).options(selectinload(Decision.steps))
if org_id is not None:
stmt = stmt.where(Decision.organization_id == org_id)
if decision_type is not None: if decision_type is not None:
stmt = stmt.where(Decision.decision_type == decision_type) stmt = stmt.where(Decision.decision_type == decision_type)
if status_filter is not None: if status_filter is not None:
@@ -78,13 +74,11 @@ async def create_decision(
payload: DecisionCreate, payload: DecisionCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut: ) -> DecisionOut:
"""Create a new decision process.""" """Create a new decision process."""
decision = Decision( decision = Decision(
**payload.model_dump(), **payload.model_dump(),
created_by_id=identity.id, created_by_id=identity.id,
organization_id=org_id,
) )
db.add(decision) db.add(decision)
await db.commit() await db.commit()
+1 -6
View File
@@ -25,7 +25,6 @@ from app.schemas.document import (
ItemVersionCreate, ItemVersionCreate,
ItemVersionOut, ItemVersionOut,
) )
from app.dependencies.org import get_active_org_id
from app.services import document_service from app.services import document_service
from app.services.auth_service import get_current_identity from app.services.auth_service import get_current_identity
@@ -66,7 +65,6 @@ async def _get_item(db: AsyncSession, document_id: uuid.UUID, item_id: uuid.UUID
@router.get("/", response_model=list[DocumentOut]) @router.get("/", response_model=list[DocumentOut])
async def list_documents( async def list_documents(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
doc_type: str | None = Query(default=None, description="Filtrer par type de document"), doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"), status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0), skip: int = Query(default=0, ge=0),
@@ -75,8 +73,6 @@ async def list_documents(
"""List all reference documents, with optional filters.""" """List all reference documents, with optional filters."""
stmt = select(Document) stmt = select(Document)
if org_id is not None:
stmt = stmt.where(Document.organization_id == org_id)
if doc_type is not None: if doc_type is not None:
stmt = stmt.where(Document.doc_type == doc_type) stmt = stmt.where(Document.doc_type == doc_type)
if status_filter is not None: if status_filter is not None:
@@ -105,7 +101,6 @@ async def create_document(
payload: DocumentCreate, payload: DocumentCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DocumentOut: ) -> DocumentOut:
"""Create a new reference document.""" """Create a new reference document."""
# Check slug uniqueness # Check slug uniqueness
@@ -116,7 +111,7 @@ async def create_document(
detail="Un document avec ce slug existe deja", detail="Un document avec ce slug existe deja",
) )
doc = Document(**payload.model_dump(), organization_id=org_id) doc = Document(**payload.model_dump())
db.add(doc) db.add(doc)
await db.commit() await db.commit()
await db.refresh(doc) await db.refresh(doc)
-126
View File
@@ -1,126 +0,0 @@
"""Groups router — predefined sets of Duniter identities used in decision circles."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
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
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),
) -> 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)
)
groups = result.scalars().all()
return [
GroupSummary(
id=g.id,
name=g.name,
description=g.description,
organization_id=g.organization_id,
member_count=len(g.members),
)
for g in groups
]
@router.post("/", response_model=GroupOut, status_code=201)
async def create_group(
payload: GroupCreate,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> GroupOut:
group = Group(name=payload.name, description=payload.description)
db.add(group)
await db.commit()
await db.refresh(group)
await db.execute(select(Group).where(Group.id == group.id).options(selectinload(Group.members)))
return GroupOut.model_validate(group)
@router.get("/{group_id}", response_model=GroupOut)
async def get_group(group_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> GroupOut:
result = await db.execute(
select(Group).where(Group.id == group_id).options(selectinload(Group.members))
)
group = result.scalar_one_or_none()
if group is None:
raise HTTPException(status_code=404, detail="Group not found")
return GroupOut.model_validate(group)
@router.delete("/{group_id}", status_code=204, response_class=Response, response_model=None)
async def delete_group(
group_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> None:
result = await db.execute(select(Group).where(Group.id == group_id))
group = result.scalar_one_or_none()
if group is None:
raise HTTPException(status_code=404, detail="Group not found")
await db.delete(group)
await db.commit()
@router.post("/{group_id}/members", response_model=GroupMemberOut, status_code=201)
async def add_member(
group_id: uuid.UUID,
payload: GroupMemberCreate,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> GroupMemberOut:
result = await db.execute(select(Group).where(Group.id == group_id))
if result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Group not found")
member = GroupMember(
group_id=group_id,
display_name=payload.display_name,
identity_id=payload.identity_id,
)
db.add(member)
await db.commit()
await db.refresh(member)
return GroupMemberOut.model_validate(member)
@router.delete("/{group_id}/members/{member_id}", status_code=204, response_class=Response, response_model=None)
async def remove_member(
group_id: uuid.UUID,
member_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> None:
result = await db.execute(
select(GroupMember).where(GroupMember.id == member_id, GroupMember.group_id == group_id)
)
member = result.scalar_one_or_none()
if member is None:
raise HTTPException(status_code=404, detail="Member not found")
await db.delete(member)
await db.commit()
+39 -45
View File
@@ -22,7 +22,6 @@ from app.schemas.mandate import (
MandateUpdate, MandateUpdate,
) )
from app.schemas.vote import VoteSessionOut from app.schemas.vote import VoteSessionOut
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity from app.services.auth_service import get_current_identity
from app.services.mandate_service import ( from app.services.mandate_service import (
advance_mandate, advance_mandate,
@@ -38,13 +37,10 @@ router = APIRouter()
async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate: async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
"""Fetch a mandate by ID with its steps eagerly loaded, or raise 404."""
result = await db.execute( result = await db.execute(
select(Mandate) select(Mandate)
.options( .options(selectinload(Mandate.steps))
selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
.where(Mandate.id == mandate_id) .where(Mandate.id == mandate_id)
) )
mandate = result.scalar_one_or_none() mandate = result.scalar_one_or_none()
@@ -53,33 +49,20 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
return mandate return mandate
def _mandate_out(mandate: Mandate) -> MandateOut:
out = MandateOut.model_validate(mandate)
out.origin_display_name = mandate.origin_display_name
out.mandatee_display_name = mandate.mandatee_display_name
return out
# ── Mandate routes ────────────────────────────────────────────────────────── # ── Mandate routes ──────────────────────────────────────────────────────────
@router.get("/", response_model=list[MandateOut]) @router.get("/", response_model=list[MandateOut])
async def list_mandates( async def list_mandates(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id), mandate_type: str | None = Query(default=None, description="Filtrer par type de mandat"),
mandate_type: str | None = Query(default=None), status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
status_filter: str | None = Query(default=None, alias="status"),
skip: int = Query(default=0, ge=0), skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200), limit: int = Query(default=50, ge=1, le=200),
) -> list[MandateOut]: ) -> list[MandateOut]:
stmt = select(Mandate).options( """List all mandates with optional filters."""
selectinload(Mandate.steps), stmt = select(Mandate).options(selectinload(Mandate.steps))
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
if org_id is not None:
stmt = stmt.where(Mandate.organization_id == org_id)
if mandate_type is not None: if mandate_type is not None:
stmt = stmt.where(Mandate.mandate_type == mandate_type) stmt = stmt.where(Mandate.mandate_type == mandate_type)
if status_filter is not None: if status_filter is not None:
@@ -89,7 +72,7 @@ async def list_mandates(
result = await db.execute(stmt) result = await db.execute(stmt)
mandates = result.scalars().unique().all() mandates = result.scalars().unique().all()
return [_mandate_out(m) for m in mandates] return [MandateOut.model_validate(m) for m in mandates]
@router.post("/", response_model=MandateOut, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=MandateOut, status_code=status.HTTP_201_CREATED)
@@ -97,22 +80,16 @@ async def create_mandate(
payload: MandateCreate, payload: MandateCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut: ) -> MandateOut:
data = payload.model_dump() """Create a new mandate."""
nomination_mode = data.pop("nomination_mode", "postpone") mandate = Mandate(**payload.model_dump())
mandate = Mandate(**data, organization_id=org_id)
if nomination_mode == "auto":
mandate.mandatee_id = identity.id
db.add(mandate) db.add(mandate)
await db.commit() await db.commit()
await db.refresh(mandate) await db.refresh(mandate)
# Reload with steps (empty at creation)
mandate = await _get_mandate(db, mandate.id) mandate = await _get_mandate(db, mandate.id)
return _mandate_out(mandate) return MandateOut.model_validate(mandate)
@router.get("/{id}", response_model=MandateOut) @router.get("/{id}", response_model=MandateOut)
@@ -120,8 +97,9 @@ async def get_mandate(
id: uuid.UUID, id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> MandateOut: ) -> MandateOut:
"""Get a single mandate with all its steps."""
mandate = await _get_mandate(db, id) mandate = await _get_mandate(db, id)
return _mandate_out(mandate) return MandateOut.model_validate(mandate)
@router.put("/{id}", response_model=MandateOut) @router.put("/{id}", response_model=MandateOut)
@@ -131,14 +109,19 @@ async def update_mandate(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
) -> MandateOut: ) -> MandateOut:
"""Update a mandate's metadata."""
mandate = await _get_mandate(db, id) mandate = await _get_mandate(db, id)
for field, value in payload.model_dump(exclude_unset=True).items(): update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(mandate, field, value) setattr(mandate, field, value)
await db.commit() await db.commit()
await db.refresh(mandate)
# Reload with steps
mandate = await _get_mandate(db, mandate.id) mandate = await _get_mandate(db, mandate.id)
return _mandate_out(mandate) return MandateOut.model_validate(mandate)
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None) @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
@@ -147,6 +130,7 @@ async def delete_mandate(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
) -> None: ) -> None:
"""Delete a mandate (only if in draft status)."""
mandate = await _get_mandate(db, id) mandate = await _get_mandate(db, id)
if mandate.status != "draft": if mandate.status != "draft":
@@ -169,9 +153,13 @@ async def add_step(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
) -> MandateStepOut: ) -> MandateStepOut:
"""Add a step to a mandate process."""
mandate = await _get_mandate(db, id) mandate = await _get_mandate(db, id)
step = MandateStep(mandate_id=mandate.id, **payload.model_dump()) step = MandateStep(
mandate_id=mandate.id,
**payload.model_dump(),
)
db.add(step) db.add(step)
await db.commit() await db.commit()
await db.refresh(step) await db.refresh(step)
@@ -184,6 +172,7 @@ async def list_steps(
id: uuid.UUID, id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> list[MandateStepOut]: ) -> list[MandateStepOut]:
"""List all steps for a mandate, ordered by step_order."""
mandate = await _get_mandate(db, id) mandate = await _get_mandate(db, id)
return [MandateStepOut.model_validate(s) for s in mandate.steps] return [MandateStepOut.model_validate(s) for s in mandate.steps]
@@ -197,17 +186,17 @@ async def advance_mandate_endpoint(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
) -> MandateAdvanceOut: ) -> MandateAdvanceOut:
"""Advance a mandate to its next step or status."""
try: try:
mandate = await advance_mandate(id, db) mandate = await advance_mandate(id, db)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps for complete output
mandate = await _get_mandate(db, mandate.id) mandate = await _get_mandate(db, mandate.id)
out = _mandate_out(mandate) data = MandateOut.model_validate(mandate).model_dump()
return MandateAdvanceOut( data["message"] = f"Mandat avance au statut : {mandate.status}"
**out.model_dump(), return MandateAdvanceOut(**data)
message=f"Mandat avance au statut : {mandate.status}",
)
@router.post("/{id}/assign", response_model=MandateOut) @router.post("/{id}/assign", response_model=MandateOut)
@@ -217,13 +206,15 @@ async def assign_mandatee_endpoint(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
) -> MandateOut: ) -> MandateOut:
"""Assign a mandatee to a mandate."""
try: try:
mandate = await assign_mandatee(id, payload.mandatee_id, db) mandate = await assign_mandatee(id, payload.mandatee_id, db)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps
mandate = await _get_mandate(db, mandate.id) mandate = await _get_mandate(db, mandate.id)
return _mandate_out(mandate) return MandateOut.model_validate(mandate)
@router.post("/{id}/revoke", response_model=MandateOut) @router.post("/{id}/revoke", response_model=MandateOut)
@@ -232,13 +223,15 @@ async def revoke_mandate_endpoint(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
) -> MandateOut: ) -> MandateOut:
"""Revoke an active mandate."""
try: try:
mandate = await revoke_mandate(id, db) mandate = await revoke_mandate(id, db)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps
mandate = await _get_mandate(db, mandate.id) mandate = await _get_mandate(db, mandate.id)
return _mandate_out(mandate) return MandateOut.model_validate(mandate)
@router.post( @router.post(
@@ -252,6 +245,7 @@ async def create_vote_session_for_step_endpoint(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
) -> VoteSessionOut: ) -> VoteSessionOut:
"""Create a vote session linked to a mandate step."""
try: try:
session = await create_vote_session_for_step(id, step_id, db) session = await create_vote_session_for_step(id, step_id, db)
except ValueError as exc: except ValueError as exc:
-96
View File
@@ -1,96 +0,0 @@
"""Organizations router: list, create, membership."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import DuniterIdentity
from app.schemas.organization import OrgMemberOut, OrganizationCreate, OrganizationOut
from app.services.auth_service import get_current_identity
from app.services.org_service import (
add_member,
create_organization,
get_organization,
get_organization_by_slug,
is_member,
list_members,
list_organizations,
)
router = APIRouter()
@router.get("/", response_model=list[OrganizationOut])
async def get_organizations(db: AsyncSession = Depends(get_db)) -> list[OrganizationOut]:
"""List all organizations (public — transparent ones need no auth)."""
orgs = await list_organizations(db)
return [OrganizationOut.model_validate(o) for o in orgs]
@router.post("/", response_model=OrganizationOut, status_code=status.HTTP_201_CREATED)
async def post_organization(
payload: OrganizationCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> OrganizationOut:
"""Create a new organization (authenticated users only)."""
existing = await get_organization_by_slug(db, payload.slug)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Slug '{payload.slug}' déjà utilisé",
)
org = await create_organization(db, **payload.model_dump())
# Creator becomes admin
await add_member(db, org.id, identity.id, role="admin")
return OrganizationOut.model_validate(org)
@router.get("/{org_id}", response_model=OrganizationOut)
async def get_organization_detail(
org_id: uuid.UUID, db: AsyncSession = Depends(get_db)
) -> OrganizationOut:
org = await get_organization(db, org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
return OrganizationOut.model_validate(org)
@router.get("/{org_id}/members", response_model=list[OrgMemberOut])
async def get_members(
org_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> list[OrgMemberOut]:
org = await get_organization(db, org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
if not org.is_transparent and not await is_member(db, org_id, identity.id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Accès refusé")
members = await list_members(db, org_id)
return [OrgMemberOut.model_validate(m) for m in members]
@router.post("/{org_id}/join", response_model=OrgMemberOut, status_code=status.HTTP_201_CREATED)
async def join_organization(
org_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> OrgMemberOut:
"""Join a transparent organization."""
org = await get_organization(db, org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
if not org.is_transparent:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Rejoindre cette organisation nécessite une invitation",
)
if await is_member(db, org_id, identity.id):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Déjà membre")
member = await add_member(db, org_id, identity.id)
return OrgMemberOut.model_validate(member)
+1 -6
View File
@@ -25,7 +25,6 @@ from app.schemas.protocol import (
VotingProtocolOut, VotingProtocolOut,
VotingProtocolUpdate, VotingProtocolUpdate,
) )
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity from app.services.auth_service import get_current_identity
router = APIRouter() router = APIRouter()
@@ -64,7 +63,6 @@ async def _get_formula(db: AsyncSession, formula_id: uuid.UUID) -> FormulaConfig
@router.get("/", response_model=list[VotingProtocolOut]) @router.get("/", response_model=list[VotingProtocolOut])
async def list_protocols( async def list_protocols(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"), vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
skip: int = Query(default=0, ge=0), skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200), limit: int = Query(default=50, ge=1, le=200),
@@ -72,8 +70,6 @@ async def list_protocols(
"""List all voting protocols with their formula configurations.""" """List all voting protocols with their formula configurations."""
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config)) stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
if vote_type is not None: if vote_type is not None:
stmt = stmt.where(VotingProtocol.vote_type == vote_type) stmt = stmt.where(VotingProtocol.vote_type == vote_type)
@@ -89,7 +85,6 @@ async def create_protocol(
payload: VotingProtocolCreate, payload: VotingProtocolCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut: ) -> VotingProtocolOut:
"""Create a new voting protocol. """Create a new voting protocol.
@@ -105,7 +100,7 @@ async def create_protocol(
detail="Configuration de formule introuvable", detail="Configuration de formule introuvable",
) )
protocol = VotingProtocol(**payload.model_dump(), organization_id=org_id) protocol = VotingProtocol(**payload.model_dump())
db.add(protocol) db.add(protocol)
await db.commit() await db.commit()
await db.refresh(protocol) await db.refresh(protocol)
-186
View File
@@ -1,186 +0,0 @@
"""Qualify router: decision qualification engine endpoint."""
from __future__ import annotations
from dataclasses import asdict
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.engine.qualifier import QualificationConfig, QualificationInput, qualify
from app.models.qualification import QualificationProtocol
from app.schemas.qualification import (
QualificationProtocolCreate,
QualificationProtocolOut,
QualifyRequest,
QualifyResponse,
)
from app.services.auth_service import get_current_identity
from app.services.qualify_ai_service import (
AIFrameRequest,
AIFrameResponse,
AIMessage,
AIQuestion,
AIQualifyResult,
ai_frame,
)
router = APIRouter()
# ── Pydantic wrappers for AI chat (FastAPI needs Pydantic, not dataclasses) ──
class AIMessagePayload(BaseModel):
role: str
content: str
class AIChatRequest(BaseModel):
within_mandate: bool = False
affected_count: int | None = None
is_structural: bool = False
context: str | None = None
messages: list[AIMessagePayload] = []
class AIQuestionOut(BaseModel):
id: str
text: str
options: list[str]
class AIQualifyResultOut(BaseModel):
decision_type: str
process: str
recommended_modalities: list[str]
recommend_onchain: bool
onchain_reason: str | None
confidence: str
collective_available: bool
record_in_observatory: bool
reasons: list[str]
class AIChatResponse(BaseModel):
done: bool
questions: list[AIQuestionOut] = []
result: AIQualifyResultOut | None = None
explanation: str | None = None
async def _load_config(db: AsyncSession) -> QualificationConfig:
"""Load the active QualificationProtocol from DB, or fall back to defaults."""
result = await db.execute(
select(QualificationProtocol)
.where(QualificationProtocol.is_active == True) # noqa: E712
.order_by(QualificationProtocol.created_at.desc())
.limit(1)
)
proto = result.scalar_one_or_none()
if proto is None:
return QualificationConfig()
return QualificationConfig(
small_group_max=proto.small_group_max,
collective_wot_min=proto.collective_wot_min,
default_modalities=proto.default_modalities,
)
@router.post("/", response_model=QualifyResponse)
async def qualify_decision(
payload: QualifyRequest,
db: AsyncSession = Depends(get_db),
) -> QualifyResponse:
"""Qualify a decision: determine type, process, and modalities.
No authentication required this is an advisory endpoint that helps
users understand which decision pathway fits their situation.
"""
config = await _load_config(db)
inp = QualificationInput(
within_mandate=payload.within_mandate,
affected_count=payload.affected_count,
is_structural=payload.is_structural,
context_description=payload.context_description,
)
result = qualify(inp, config)
return QualifyResponse(**asdict(result))
@router.post("/ai-chat", response_model=AIChatResponse)
async def ai_chat(payload: AIChatRequest) -> AIChatResponse:
"""Run one round of AI-assisted qualification framing.
Round 1 (messages=[]) returns 2 clarifying questions.
Round 2 (messages set) returns final qualification result.
No auth required advisory endpoint.
"""
req = AIFrameRequest(
within_mandate=payload.within_mandate,
affected_count=payload.affected_count,
is_structural=payload.is_structural,
context=payload.context,
messages=[AIMessage(role=m.role, content=m.content) for m in payload.messages],
)
resp = ai_frame(req)
return AIChatResponse(
done=resp.done,
questions=[AIQuestionOut(id=q.id, text=q.text, options=q.options) for q in resp.questions],
result=AIQualifyResultOut(**asdict(resp.result)) if resp.result else None,
explanation=resp.explanation,
)
@router.get("/protocol", response_model=QualificationProtocolOut | None)
async def get_active_protocol(
db: AsyncSession = Depends(get_db),
) -> QualificationProtocolOut | None:
"""Return the currently active qualification protocol (thresholds)."""
result = await db.execute(
select(QualificationProtocol)
.where(QualificationProtocol.is_active == True) # noqa: E712
.order_by(QualificationProtocol.created_at.desc())
.limit(1)
)
proto = result.scalar_one_or_none()
if proto is None:
return None
return QualificationProtocolOut.model_validate(proto)
@router.post("/protocol", response_model=QualificationProtocolOut, status_code=201)
async def create_protocol(
payload: QualificationProtocolCreate,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> QualificationProtocolOut:
"""Create a new qualification protocol (requires auth).
Deactivates the current active protocol before saving the new one.
"""
# Deactivate current
current = await db.execute(
select(QualificationProtocol).where(QualificationProtocol.is_active == True) # noqa: E712
)
for proto in current.scalars().all():
proto.is_active = False
import json
proto = QualificationProtocol(
name=payload.name,
description=payload.description,
small_group_max=payload.small_group_max,
collective_wot_min=payload.collective_wot_min,
default_modalities_json=json.dumps(payload.default_modalities),
is_active=True,
)
db.add(proto)
await db.commit()
await db.refresh(proto)
return QualificationProtocolOut.model_validate(proto)
-46
View File
@@ -1,46 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class GroupMemberCreate(BaseModel):
display_name: str
identity_id: uuid.UUID | None = None
class GroupMemberOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
display_name: str
identity_id: uuid.UUID | None
added_at: datetime
class GroupCreate(BaseModel):
name: str
description: str | None = None
class GroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
organization_id: uuid.UUID | None
created_at: datetime
members: list[GroupMemberOut] = []
class GroupSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
organization_id: uuid.UUID | None
member_count: int = 0
+36 -17
View File
@@ -10,13 +10,21 @@ from pydantic import BaseModel, ConfigDict, Field
class MandateStepCreate(BaseModel): class MandateStepCreate(BaseModel):
"""Payload for creating a step within a mandate process."""
step_order: int = Field(..., ge=0) step_order: int = Field(..., ge=0)
step_type: str = Field(..., max_length=32) step_type: str = Field(
...,
max_length=32,
description="formulation, candidacy, vote, assignment, reporting, completion, revocation",
)
title: str | None = Field(default=None, max_length=256) title: str | None = Field(default=None, max_length=256)
description: str | None = None description: str | None = None
class MandateStepOut(BaseModel): class MandateStepOut(BaseModel):
"""Full mandate step representation."""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: UUID id: UUID
@@ -35,45 +43,40 @@ class MandateStepOut(BaseModel):
class MandateCreate(BaseModel): class MandateCreate(BaseModel):
"""Payload for creating a new mandate."""
title: str = Field(..., min_length=1, max_length=256) title: str = Field(..., min_length=1, max_length=256)
origin_id: UUID | None = None
description: str | None = None description: str | None = None
mandate_type: str = Field(..., max_length=64) mandate_type: str = Field(..., max_length=64, description="techcomm, smith, custom")
nomination_mode: str = Field(
default="postpone",
description="auto (auto-assign author), collective, postpone",
)
decision_id: UUID | None = None decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
class MandateUpdate(BaseModel): class MandateUpdate(BaseModel):
"""Partial update for a mandate."""
title: str | None = Field(default=None, max_length=256) title: str | None = Field(default=None, max_length=256)
origin_id: UUID | None = None
description: str | None = None description: str | None = None
mandate_type: str | None = Field(default=None, max_length=64) mandate_type: str | None = Field(default=None, max_length=64)
decision_id: UUID | None = None decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
class MandateAssignRequest(BaseModel): class MandateAssignRequest(BaseModel):
mandatee_id: UUID = Field(..., description="UUID de l'identite Duniter du mandataire") """Request body for assigning a mandatee to a mandate."""
mandatee_id: UUID = Field(..., description="ID de l'identite Duniter du mandataire")
class MandateOut(BaseModel): class MandateOut(BaseModel):
"""Full mandate representation returned by the API."""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: UUID id: UUID
title: str title: str
origin_id: UUID | None = None
origin_display_name: str | None = None
description: str | None = None description: str | None = None
mandate_type: str mandate_type: str
status: str status: str
mandatee_id: UUID | None = None mandatee_id: UUID | None = None
mandatee_display_name: str | None = None
decision_id: UUID | None = None decision_id: UUID | None = None
starts_at: datetime | None = None starts_at: datetime | None = None
ends_at: datetime | None = None ends_at: datetime | None = None
@@ -82,5 +85,21 @@ class MandateOut(BaseModel):
steps: list[MandateStepOut] = Field(default_factory=list) steps: list[MandateStepOut] = Field(default_factory=list)
class MandateAdvanceOut(MandateOut): class MandateAdvanceOut(BaseModel):
"""Output after advancing a mandate through its workflow."""
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
description: str | None = None
mandate_type: str
status: str
mandatee_id: UUID | None = None
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
created_at: datetime
updated_at: datetime
steps: list[MandateStepOut] = Field(default_factory=list)
message: str = Field(..., description="Message decrivant l'avancement effectue") message: str = Field(..., description="Message decrivant l'avancement effectue")
-42
View File
@@ -1,42 +0,0 @@
"""Pydantic v2 schemas for organizations."""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class OrganizationOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
slug: str
org_type: str
is_transparent: bool
color: str | None
icon: str | None
description: str | None
created_at: datetime
class OrganizationCreate(BaseModel):
name: str
slug: str
org_type: str = "community"
is_transparent: bool = False
color: str | None = None
icon: str | None = None
description: str | None = None
class OrgMemberOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
org_id: uuid.UUID
identity_id: uuid.UUID
role: str
created_at: datetime
-48
View File
@@ -1,48 +0,0 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class QualifyRequest(BaseModel):
within_mandate: bool = False
affected_count: int | None = Field(default=None, ge=2, description="Nombre de personnes concernées (minimum 2)")
is_structural: bool = False
context_description: str | None = Field(default=None, max_length=2000)
class QualifyResponse(BaseModel):
decision_type: str
process: str
recommended_modalities: list[str]
recommend_onchain: bool
onchain_reason: str | None
confidence: str
collective_available: bool
record_in_observatory: bool
reasons: list[str]
class QualificationProtocolOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
description: str | None = None
small_group_max: int
collective_wot_min: int
default_modalities: list[str]
is_active: bool
created_at: datetime
class QualificationProtocolCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=128)
description: str | None = None
small_group_max: int = Field(default=5, ge=1)
collective_wot_min: int = Field(default=50, ge=1)
default_modalities: list[str] = Field(
default=["vote_wot", "vote_smith", "consultation_avis", "election"]
)
-60
View File
@@ -1,60 +0,0 @@
"""Organization service: CRUD + membership helpers."""
from __future__ import annotations
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.organization import OrgMember, Organization
async def list_organizations(db: AsyncSession) -> Sequence[Organization]:
result = await db.execute(select(Organization).order_by(Organization.name))
return result.scalars().all()
async def get_organization(db: AsyncSession, org_id: uuid.UUID) -> Organization | None:
return await db.get(Organization, org_id)
async def get_organization_by_slug(db: AsyncSession, slug: str) -> Organization | None:
result = await db.execute(select(Organization).where(Organization.slug == slug))
return result.scalar_one_or_none()
async def create_organization(db: AsyncSession, **kwargs) -> Organization:
org = Organization(**kwargs)
db.add(org)
await db.commit()
await db.refresh(org)
return org
async def is_member(db: AsyncSession, org_id: uuid.UUID, identity_id: uuid.UUID) -> bool:
result = await db.execute(
select(OrgMember).where(
OrgMember.org_id == org_id,
OrgMember.identity_id == identity_id,
)
)
return result.scalar_one_or_none() is not None
async def add_member(
db: AsyncSession, org_id: uuid.UUID, identity_id: uuid.UUID, role: str = "member"
) -> OrgMember:
member = OrgMember(org_id=org_id, identity_id=identity_id, role=role)
db.add(member)
await db.commit()
await db.refresh(member)
return member
async def list_members(db: AsyncSession, org_id: uuid.UUID) -> Sequence[OrgMember]:
result = await db.execute(
select(OrgMember).where(OrgMember.org_id == org_id).order_by(OrgMember.created_at)
)
return result.scalars().all()
-196
View File
@@ -1,196 +0,0 @@
"""AI framing service for decision qualification.
Orchestrates a 2-round conversation that clarifies reversibility and urgency
before producing a final QualificationResult.
Rule-based stub Qwen3.6 (MacStudio) calls will replace ai_frame() internals
once the local endpoint is available. The interface is stable.
"""
from __future__ import annotations
from dataclasses import dataclass
# ---------------------------------------------------------------------------
# Schemas (dataclasses — no Pydantic dependency in the engine layer)
# ---------------------------------------------------------------------------
@dataclass
class AIMessage:
role: str # "user" | "assistant"
content: str
@dataclass
class AIQuestion:
id: str
text: str
options: list[str]
@dataclass
class AIQualifyResult:
decision_type: str
process: str
recommended_modalities: list[str]
recommend_onchain: bool
onchain_reason: str | None
confidence: str
collective_available: bool
record_in_observatory: bool
reasons: list[str]
@dataclass
class AIFrameRequest:
within_mandate: bool = False
affected_count: int | None = None
is_structural: bool = False
context: str | None = None
messages: list[AIMessage] | None = None
def __post_init__(self):
if self.messages is None:
self.messages = []
@dataclass
class AIFrameResponse:
done: bool
questions: list[AIQuestion]
result: AIQualifyResult | None
explanation: str | None
# ---------------------------------------------------------------------------
# Standard clarifying questions
# ---------------------------------------------------------------------------
_CLARIFYING_QUESTIONS: list[AIQuestion] = [
AIQuestion(
id="reversibility",
text="Si cette décision s'avère inappropriée dans 6 mois, peut-on facilement revenir en arrière ?",
options=[
"Oui, facilement",
"Difficilement",
"Non, c'est irréversible",
],
),
AIQuestion(
id="urgency",
text="Y a-t-il une contrainte temporelle sur cette décision ?",
options=[
"Urgente (< 1 semaine)",
"Délai raisonnable (quelques semaines)",
"Pas d'urgence",
],
),
]
# ---------------------------------------------------------------------------
# Core function
# ---------------------------------------------------------------------------
def ai_frame(request: AIFrameRequest) -> AIFrameResponse:
"""Run one round of AI framing.
Round 1 (messages=[]) return 2 clarifying questions, done=False
Round 2 (messages set) parse answers, qualify, return result, done=True
"""
messages = request.messages or []
if not messages:
return AIFrameResponse(
done=False,
questions=list(_CLARIFYING_QUESTIONS),
result=None,
explanation=None,
)
answers = _parse_answers(messages)
result = _build_result(request, answers)
explanation = _build_explanation(answers)
return AIFrameResponse(
done=True,
questions=[],
result=result,
explanation=explanation,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_answers(messages: list[AIMessage]) -> dict[str, str]:
"""Extract question answers from the last user message.
Expected format: "reversibility:<answer>|urgency:<answer>"
"""
answers: dict[str, str] = {}
for msg in reversed(messages):
if msg.role == "user" and "|" in msg.content and ":" in msg.content:
for part in msg.content.split("|"):
if ":" in part:
key, _, val = part.partition(":")
answers[key.strip()] = val.strip()
break
return answers
def _build_result(request: AIFrameRequest, answers: dict[str, str]) -> AIQualifyResult:
"""Produce a qualification result enriched by the AI answers."""
from app.engine.qualifier import (
QualificationConfig,
QualificationInput,
qualify,
)
config = QualificationConfig()
inp = QualificationInput(
within_mandate=request.within_mandate,
affected_count=request.affected_count,
is_structural=request.is_structural,
context_description=request.context,
)
base = qualify(inp, config)
reasons = list(base.reasons)
reversibility = answers.get("reversibility", "")
if "irréversible" in reversibility.lower():
reasons.append("Décision irréversible : consensus élevé recommandé.")
urgency = answers.get("urgency", "")
if "urgente" in urgency.lower() or "< 1" in urgency:
reasons.append("Urgence signalée : privilégier un protocole à délai court.")
return AIQualifyResult(
decision_type=base.decision_type.value,
process=base.process,
recommended_modalities=base.recommended_modalities,
recommend_onchain=base.recommend_onchain,
onchain_reason=base.onchain_reason,
confidence=base.confidence,
collective_available=base.collective_available,
record_in_observatory=base.record_in_observatory,
reasons=reasons,
)
def _build_explanation(answers: dict[str, str]) -> str:
parts = []
rev = answers.get("reversibility", "")
urg = answers.get("urgency", "")
if rev:
parts.append(f"Réversibilité : {rev}.")
if urg:
parts.append(f"Urgence : {urg}.")
return " ".join(parts) if parts else "Qualification basée sur les éléments fournis."
-405
View File
@@ -1,405 +0,0 @@
"""Integration tests for mandate flows: nomination, lifecycle, assignment, revocation.
Uses a real in-memory SQLite database no mocks of the DB layer.
Tests the service functions directly to verify interconnected business logic.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import selectinload
from sqlalchemy import select
import app.models # noqa: F401 — registers all models with Base.metadata
from app.database import Base
from app.models.mandate import Mandate, MandateStep
from app.models.user import DuniterIdentity
from app.services.mandate_service import (
advance_mandate,
assign_mandatee,
revoke_mandate,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def engine():
eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield eng
await eng.dispose()
@pytest_asyncio.fixture
async def db(engine):
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
yield session
async def _mk_identity(db: AsyncSession, display_name: str = "Alice") -> DuniterIdentity:
ident = DuniterIdentity(
id=uuid.uuid4(),
address=f"5{uuid.uuid4().hex[:46]}",
display_name=display_name,
wot_status="member",
is_smith=False,
is_techcomm=False,
)
db.add(ident)
await db.commit()
await db.refresh(ident)
return ident
async def _mk_mandate(
db: AsyncSession,
*,
mandatee_id: uuid.UUID | None = None,
origin_id: uuid.UUID | None = None,
status: str = "draft",
steps: list[dict] | None = None,
) -> Mandate:
mandate = Mandate(
id=uuid.uuid4(),
title="Mandat test",
mandate_type="functional",
status=status,
mandatee_id=mandatee_id,
origin_id=origin_id,
)
db.add(mandate)
await db.flush()
for i, s in enumerate(steps or []):
step = MandateStep(
id=uuid.uuid4(),
mandate_id=mandate.id,
step_order=i,
step_type=s.get("step_type", "formulation"),
title=s.get("title"),
status=s.get("status", "pending"),
)
db.add(step)
await db.commit()
await db.refresh(mandate)
return mandate
async def _reload(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
result = await db.execute(
select(Mandate)
.options(
selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
.where(Mandate.id == mandate_id)
)
return result.scalar_one()
# ---------------------------------------------------------------------------
# TestMandateOrigin
# ---------------------------------------------------------------------------
class TestMandateOrigin:
"""origin_id must link to a real DuniterIdentity and expose display_name."""
@pytest.mark.asyncio
async def test_origin_id_linked(self, db: AsyncSession):
author = await _mk_identity(db, "Baptiste")
mandate = await _mk_mandate(db, origin_id=author.id)
loaded = await _reload(db, mandate.id)
assert loaded.origin_id == author.id
assert loaded.origin_display_name == "Baptiste"
@pytest.mark.asyncio
async def test_origin_id_optional(self, db: AsyncSession):
mandate = await _mk_mandate(db)
loaded = await _reload(db, mandate.id)
assert loaded.origin_id is None
assert loaded.origin_display_name is None
# ---------------------------------------------------------------------------
# TestAutoNomination
# ---------------------------------------------------------------------------
class TestAutoNomination:
"""Auto-désignation: mandatee = author, no candidacy steps needed."""
@pytest.mark.asyncio
async def test_auto_assign_author(self, db: AsyncSession):
author = await _mk_identity(db, "Constance")
mandate = await _mk_mandate(db, mandatee_id=author.id)
loaded = await _reload(db, mandate.id)
assert loaded.mandatee_id == author.id
assert loaded.mandatee_display_name == "Constance"
@pytest.mark.asyncio
async def test_auto_assign_then_advance_to_active(self, db: AsyncSession):
author = await _mk_identity(db, "David")
mandate = await _mk_mandate(
db,
mandatee_id=author.id,
steps=[
{"step_type": "formulation", "status": "pending"},
{"step_type": "assignment", "status": "pending"},
{"step_type": "reporting", "status": "pending"},
],
)
# First advance: draft → candidacy, activates step 0
result = await advance_mandate(mandate.id, db)
assert result.status == "candidacy"
loaded = await _reload(db, mandate.id)
assert loaded.steps[0].status == "active"
assert loaded.steps[1].status == "pending"
# Second advance: step 0 → completed, step 1 → active
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[0].status == "completed"
assert loaded.steps[1].status == "active"
# ---------------------------------------------------------------------------
# TestMandateAssign
# ---------------------------------------------------------------------------
class TestMandateAssign:
"""assign_mandatee service: proper UUID lookup, display_name populated."""
@pytest.mark.asyncio
async def test_assign_sets_mandatee_and_starts_at(self, db: AsyncSession):
identity = await _mk_identity(db, "Elodie")
mandate = await _mk_mandate(db, status="active")
await assign_mandatee(mandate.id, identity.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.mandatee_id == identity.id
assert loaded.starts_at is not None
assert loaded.mandatee_display_name == "Elodie"
@pytest.mark.asyncio
async def test_assign_unknown_identity_raises(self, db: AsyncSession):
mandate = await _mk_mandate(db, status="active")
with pytest.raises(ValueError, match="Identite Duniter introuvable"):
await assign_mandatee(mandate.id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_assign_completed_mandate_raises(self, db: AsyncSession):
identity = await _mk_identity(db, "Fabien")
mandate = await _mk_mandate(db, status="completed")
with pytest.raises(ValueError, match="statut terminal"):
await assign_mandatee(mandate.id, identity.id, db)
@pytest.mark.asyncio
async def test_reassign_replaces_mandatee(self, db: AsyncSession):
first = await _mk_identity(db, "Gilles")
second = await _mk_identity(db, "Hélène")
mandate = await _mk_mandate(db, mandatee_id=first.id, status="active")
await assign_mandatee(mandate.id, second.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.mandatee_id == second.id
# ---------------------------------------------------------------------------
# TestMandateLifecycle
# ---------------------------------------------------------------------------
class TestMandateLifecycle:
"""advance_mandate: full lifecycle with and without steps."""
@pytest.mark.asyncio
async def test_full_lifecycle_no_steps(self, db: AsyncSession):
mandate = await _mk_mandate(db)
statuses = [mandate.status]
for _ in range(10):
m = await advance_mandate(mandate.id, db)
statuses.append(m.status)
if m.status == "completed":
break
assert statuses == ["draft", "candidacy", "voting", "active", "reporting", "completed"]
@pytest.mark.asyncio
async def test_steps_activate_in_order(self, db: AsyncSession):
mandate = await _mk_mandate(
db,
steps=[
{"step_type": "formulation", "status": "pending"},
{"step_type": "candidacy", "status": "pending"},
{"step_type": "vote", "status": "pending"},
],
)
# Advance 1: activates step 0, moves to candidacy
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.status == "candidacy"
assert loaded.steps[0].status == "active"
assert loaded.steps[1].status == "pending"
# Advance 2: step 0 → completed, step 1 → active
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[0].status == "completed"
assert loaded.steps[1].status == "active"
# Advance 3: step 1 → completed, step 2 → active
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[1].status == "completed"
assert loaded.steps[2].status == "active"
# Advance 4: step 2 → completed, no more pending → advance mandate status
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[2].status == "completed"
assert loaded.status == "voting"
@pytest.mark.asyncio
async def test_advance_terminal_raises(self, db: AsyncSession):
for terminal in ("completed", "revoked"):
mandate = await _mk_mandate(db, status=terminal)
with pytest.raises(ValueError, match="statut terminal"):
await advance_mandate(mandate.id, db)
# ---------------------------------------------------------------------------
# TestMandateRevocation
# ---------------------------------------------------------------------------
class TestMandateRevocation:
"""revoke_mandate: active/pending steps cancelled, completed steps preserved."""
@pytest.mark.asyncio
async def test_revoke_cancels_active_and_pending(self, db: AsyncSession):
mandate = await _mk_mandate(
db,
status="active",
steps=[
{"step_type": "formulation", "status": "completed"},
{"step_type": "assignment", "status": "active"},
{"step_type": "reporting", "status": "pending"},
],
)
await revoke_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.status == "revoked"
assert loaded.ends_at is not None
assert loaded.steps[0].status == "completed"
assert loaded.steps[1].status == "cancelled"
assert loaded.steps[2].status == "cancelled"
@pytest.mark.asyncio
async def test_revoke_sets_ends_at(self, db: AsyncSession):
mandate = await _mk_mandate(db, status="draft")
await revoke_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.ends_at is not None
@pytest.mark.asyncio
async def test_revoke_already_revoked_raises(self, db: AsyncSession):
mandate = await _mk_mandate(db, status="revoked")
with pytest.raises(ValueError, match="statut terminal"):
await revoke_mandate(mandate.id, db)
# ---------------------------------------------------------------------------
# TestNominationInteractions
# ---------------------------------------------------------------------------
class TestNominationInteractions:
"""Cross-process: nomination + assignment + lifecycle are consistent."""
@pytest.mark.asyncio
async def test_assign_then_advance_full_cycle(self, db: AsyncSession):
"""Assigning a mandatee then running the full lifecycle completes cleanly."""
mandatee = await _mk_identity(db, "Isabelle")
mandate = await _mk_mandate(
db,
steps=[
{"step_type": "formulation", "status": "pending"},
{"step_type": "assignment", "status": "pending"},
{"step_type": "completion", "status": "pending"},
],
)
# Assign before starting
await assign_mandatee(mandate.id, mandatee.id, db)
# Run through all steps
for _ in range(5):
m = await advance_mandate(mandate.id, db)
if m.status in ("completed", "revoked"):
break
loaded = await _reload(db, mandate.id)
# All steps completed, mandate advanced beyond steps
assert all(s.status == "completed" for s in loaded.steps)
assert loaded.mandatee_id == mandatee.id
@pytest.mark.asyncio
async def test_revoke_after_assign_preserves_mandatee_id(self, db: AsyncSession):
"""Revoking a mandate keeps mandatee_id (for audit trail)."""
mandatee = await _mk_identity(db, "Jacques")
mandate = await _mk_mandate(db, mandatee_id=mandatee.id, status="active")
await revoke_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.status == "revoked"
assert loaded.mandatee_id == mandatee.id
@pytest.mark.asyncio
async def test_origin_and_mandatee_can_differ(self, db: AsyncSession):
"""The person who proposed the mandate (origin) is different from the mandatee."""
proposer = await _mk_identity(db, "Kim")
mandatee = await _mk_identity(db, "Laurent")
mandate = await _mk_mandate(db, origin_id=proposer.id)
await assign_mandatee(mandate.id, mandatee.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.origin_id == proposer.id
assert loaded.mandatee_id == mandatee.id
assert loaded.origin_display_name == "Kim"
assert loaded.mandatee_display_name == "Laurent"
-185
View File
@@ -1,185 +0,0 @@
"""Tests for middleware stack: CORS headers, rate limiting, dev auth flow.
Critical invariants:
- ALL responses (including 429) must carry CORS headers when origin is allowed
- Dev login flow must survive repeated logins without hitting rate limit
- OPTIONS preflight must never be rate-limited
Note: each test uses a unique X-Forwarded-For IP to isolate rate limit counters,
since the rate limiter is in-memory and shared across the app instance.
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
import app.models # noqa: F401 — registers all models with Base.metadata before create_all
from app.database import init_db
from app.main import app
@pytest.fixture(scope="module", autouse=True)
async def _create_tables():
"""Create DB tables once for this module.
ASGITransport does not trigger the FastAPI lifespan, so init_db() would
never run. Tests that hit endpoints backed by the DB need the tables to
exist beforehand.
"""
await init_db()
ORIGIN = "http://localhost:3002"
CHALLENGE_URL = "/api/v1/auth/challenge"
VERIFY_URL = "/api/v1/auth/verify"
ME_URL = "/api/v1/auth/me"
DEV_ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
# ---------------------------------------------------------------------------
# INVARIANT 1: 429 responses must include CORS headers
# Without this, the browser sees "Failed to fetch" instead of "Too Many Requests"
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_rate_limited_response_has_cors_headers():
"""A 429 from the rate limiter must still carry Access-Control-Allow-Origin.
Root cause of the "no response / Failed to fetch" bug: the rate limiter
sits outside CORS in the middleware stack, so its 429 responses have no
CORS headers and the browser discards them as network errors.
"""
# dev auth limit = 60/min (RATE_LIMIT_DEFAULT), prod = 10/min (RATE_LIMIT_AUTH)
# Send 65 requests to guarantee 429 regardless of environment.
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
last_response = None
for _ in range(65):
resp = await client.post(
CHALLENGE_URL,
json={"address": DEV_ADDRESS},
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.1.1"},
)
last_response = resp
if resp.status_code == 429:
break
assert last_response is not None
assert last_response.status_code == 429, (
"Expected 429 after exceeding auth rate limit"
)
assert "access-control-allow-origin" in last_response.headers, (
"429 response must include CORS headers so the browser can read the error"
)
assert last_response.headers["access-control-allow-origin"] == ORIGIN
# ---------------------------------------------------------------------------
# INVARIANT 2: OPTIONS preflight must never be rate-limited
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_options_preflight_never_rate_limited():
"""OPTIONS requests must pass through regardless of request count.
Browsers send a preflight before every cross-origin POST with custom headers.
A 429 on OPTIONS prevents the real request from ever being sent.
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
for i in range(20):
resp = await client.options(
CHALLENGE_URL,
headers={
"Origin": ORIGIN,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type",
"X-Forwarded-For": "10.0.2.1",
},
)
assert resp.status_code != 429, (
f"OPTIONS request #{i + 1} was rate-limited (429) — preflights must never be blocked"
)
# ---------------------------------------------------------------------------
# INVARIANT 3: Dev login flow must survive ≥ 10 consecutive logins
# (challenge + verify cycle, dev profile bypass)
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_dev_login_survives_repeated_cycles():
"""Complete login cycle (challenge → verify) must work ≥ 10 times in a row.
In dev mode, the developer disconnects and reconnects frequently.
With auth rate limit = 10/min, the 6th challenge request would be blocked.
Dev mode must use a higher limit ( 60/min) to prevent login lockout.
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
tokens = []
for i in range(10):
# Step 1: get challenge
ch_resp = await client.post(
CHALLENGE_URL,
json={"address": DEV_ADDRESS},
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.3.1"},
)
assert ch_resp.status_code == 200, (
f"Login cycle #{i + 1}: challenge returned {ch_resp.status_code}"
f"rate limit likely hit. Dev mode requires RATE_LIMIT_AUTH ≥ 60/min."
)
challenge = ch_resp.json()["challenge"]
# Step 2: verify (dev bypass — any signature accepted for dev addresses)
v_resp = await client.post(
VERIFY_URL,
json={
"address": DEV_ADDRESS,
"challenge": challenge,
"signature": "0x" + "ab" * 64,
},
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.3.1"},
)
assert v_resp.status_code == 200, (
f"Login cycle #{i + 1}: verify returned {v_resp.status_code}"
)
tokens.append(v_resp.json()["token"])
assert len(tokens) == 10, "Expected 10 successful login cycles"
assert len(set(tokens)) == 10, "Each login cycle must produce a unique token"
# ---------------------------------------------------------------------------
# INVARIANT 4: /auth/me OPTIONS preflight must return CORS headers
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_auth_me_options_preflight_returns_cors():
"""OPTIONS preflight for /auth/me must return 200 with CORS headers.
This was the root cause of the session-lost-on-reload bug:
repeated /auth/me calls would exhaust the auth rate limit,
the 429 OPTIONS response had no CORS headers,
and the browser threw 'Failed to fetch'.
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.options(
ME_URL,
headers={
"Origin": ORIGIN,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "authorization",
"X-Forwarded-For": "10.0.4.1",
},
)
assert resp.status_code in (200, 204), (
f"OPTIONS /auth/me returned {resp.status_code}"
)
assert "access-control-allow-origin" in resp.headers
-270
View File
@@ -1,270 +0,0 @@
"""TDD — Moteur de qualification des décisions.
Source de vérité exécutable des règles métier du tunnel "Décider".
Règles testées :
R1 within_mandate individual + consultation_avis
R2 within_mandate aucune modalité de vote + consignation Observatoire
R4 2 affected_count small_group_max individual recommandé, collectif disponible
R5 small_group_max < affected_count collective_wot_min collective recommandé
R6 affected_count > collective_wot_min collective recommandé (WoT applicable, non obligatoire)
R7 is_structural recommend_onchain + raison explicite
R8 is_structural=False recommend_onchain=False
GARDE-FOUS (invariants internes qui ne doivent jamais régresser) :
G1 decision_type est toujours dans l'enum autorisé
G2 individual n'expose jamais de modalités de vote
G3 collective expose au moins une modalité
G4 les seuils sont lus depuis QualificationConfig (configurables)
"""
from __future__ import annotations
import pytest
from app.engine.qualifier import (
DecisionType,
QualificationConfig,
QualificationInput,
QualificationResult,
qualify,
)
DEFAULT_CONFIG = QualificationConfig()
# ---------------------------------------------------------------------------
# R1 — within_mandate → individual + consultation_avis
# ---------------------------------------------------------------------------
def test_r1_within_mandate_gives_individual():
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.decision_type == DecisionType.INDIVIDUAL
def test_r1_within_mandate_gives_consultation_avis():
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.process == "consultation_avis"
def test_r1_within_mandate_overrides_large_affected_count():
"""Même si de nombreuses personnes sont concernées, un mandat impose individual."""
result = qualify(
QualificationInput(within_mandate=True, affected_count=500),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.INDIVIDUAL
assert result.process == "consultation_avis"
# ---------------------------------------------------------------------------
# R2 — within_mandate → aucune modalité de vote + consignation Observatoire
# ---------------------------------------------------------------------------
def test_r2_within_mandate_no_vote_modalities():
"""Le mandataire décide seul après consultation — pas de vote collectif."""
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.recommended_modalities == []
def test_r2_within_mandate_records_in_observatory():
"""Une décision dans un mandat doit être consignée dans l'Observatoire."""
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.record_in_observatory is True
def test_r2_out_of_mandate_does_not_force_observatory():
"""Hors mandat, la consignation dans l'Observatoire n'est pas imposée."""
result = qualify(
QualificationInput(within_mandate=False, affected_count=10),
DEFAULT_CONFIG,
)
assert result.record_in_observatory is False
# ---------------------------------------------------------------------------
# R4 — 2 ≤ affected_count ≤ small_group_max → individual recommandé
# ---------------------------------------------------------------------------
def test_r4_small_group_recommends_individual():
for count in range(2, DEFAULT_CONFIG.small_group_max + 1):
result = qualify(
QualificationInput(within_mandate=False, affected_count=count),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.INDIVIDUAL, (
f"affected_count={count} devrait recommander individual"
)
def test_r4_small_group_collective_is_available():
result = qualify(
QualificationInput(within_mandate=False, affected_count=3),
DEFAULT_CONFIG,
)
assert result.collective_available is True
def test_r4_small_group_confidence_is_recommended():
result = qualify(
QualificationInput(within_mandate=False, affected_count=3),
DEFAULT_CONFIG,
)
assert result.confidence == "recommended"
# ---------------------------------------------------------------------------
# R5 — small_group_max < affected_count ≤ collective_wot_min → collective recommandé
# ---------------------------------------------------------------------------
def test_r5_medium_group_recommends_collective():
mid = DEFAULT_CONFIG.small_group_max + 1
result = qualify(
QualificationInput(within_mandate=False, affected_count=mid),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.COLLECTIVE
def test_r5_medium_group_confidence_is_recommended():
mid = DEFAULT_CONFIG.small_group_max + 1
result = qualify(
QualificationInput(within_mandate=False, affected_count=mid),
DEFAULT_CONFIG,
)
assert result.confidence == "recommended"
# ---------------------------------------------------------------------------
# R6 — affected_count > collective_wot_min → collective recommandé (pas obligatoire)
# ---------------------------------------------------------------------------
def test_r6_large_group_recommends_collective():
result = qualify(
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.COLLECTIVE
def test_r6_large_group_confidence_is_recommended_not_required():
"""Au-delà du seuil WoT, le vote collectif est recommandé — pas imposé."""
result = qualify(
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
DEFAULT_CONFIG,
)
assert result.confidence == "recommended"
def test_r6_large_group_includes_vote_wot_modality():
result = qualify(
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
DEFAULT_CONFIG,
)
assert "vote_wot" in result.recommended_modalities
# ---------------------------------------------------------------------------
# R7 — is_structural=True → recommend_onchain + raison
# ---------------------------------------------------------------------------
def test_r7_structural_recommends_onchain():
result = qualify(
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
DEFAULT_CONFIG,
)
assert result.recommend_onchain is True
def test_r7_structural_provides_onchain_reason():
result = qualify(
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
DEFAULT_CONFIG,
)
assert result.onchain_reason is not None and len(result.onchain_reason) > 0
def test_r7_structural_within_mandate_can_also_recommend_onchain():
"""Même une décision dans un mandat peut être gravée si structurante."""
result = qualify(
QualificationInput(within_mandate=True, is_structural=True),
DEFAULT_CONFIG,
)
assert result.recommend_onchain is True
# ---------------------------------------------------------------------------
# R8 — is_structural=False → recommend_onchain=False
# ---------------------------------------------------------------------------
def test_r8_non_structural_never_recommends_onchain():
for count in [2, 3, 10, 100]:
result = qualify(
QualificationInput(within_mandate=False, affected_count=count, is_structural=False),
DEFAULT_CONFIG,
)
assert result.recommend_onchain is False, (
f"affected_count={count} non structurant : on-chain ne doit pas être proposé"
)
# ---------------------------------------------------------------------------
# GARDE-FOUS internes (régressions silencieuses)
# ---------------------------------------------------------------------------
def test_g1_decision_type_always_valid():
valid_types = set(DecisionType)
for inp in [
QualificationInput(within_mandate=True),
QualificationInput(within_mandate=False, affected_count=2),
QualificationInput(within_mandate=False, affected_count=10),
QualificationInput(within_mandate=False, affected_count=100),
]:
result = qualify(inp, DEFAULT_CONFIG)
assert result.decision_type in valid_types
def test_g2_individual_never_has_vote_modalities():
for inp in [
QualificationInput(within_mandate=True),
QualificationInput(within_mandate=False, affected_count=2),
QualificationInput(within_mandate=False, affected_count=3),
]:
result = qualify(inp, DEFAULT_CONFIG)
if result.decision_type == DecisionType.INDIVIDUAL:
assert result.recommended_modalities == [], (
f"Individual ne doit pas exposer de modalités : {inp}"
)
def test_g3_collective_has_at_least_one_modality():
result = qualify(
QualificationInput(within_mandate=False, affected_count=20),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.COLLECTIVE
assert len(result.recommended_modalities) >= 1
def test_g4_custom_config_overrides_thresholds():
"""Les seuils viennent de QualificationConfig — pas de constantes hardcodées."""
custom = QualificationConfig(small_group_max=2, collective_wot_min=10)
result = qualify(
QualificationInput(within_mandate=False, affected_count=3),
custom,
)
assert result.decision_type == DecisionType.COLLECTIVE
def test_g4_default_thresholds_are_stable():
cfg = QualificationConfig()
assert cfg.small_group_max == 5
assert cfg.collective_wot_min == 50
-172
View File
@@ -1,172 +0,0 @@
"""TDD — Service AI de cadrage des décisions (qualify/ai-chat).
Invariants testés :
A1 Premier appel (messages=[]) retourne toujours 2 questions, done=False
A2 Les 2 questions couvrent réversibilité et urgence (ids stables)
A3 Deuxième appel (messages=[q+réponse]) done=True, résultat qualifié
A4 Réponse "irréversible" recommend_onchain conservé si is_structural
A5 Réponse "urgente" raison "urgence" présente dans le résultat
A6 La qualification finale respecte les règles du moteur (R1/R2/R4/R5/R6)
A7 Sans contexte, les questions restent les mêmes (stub ne dépend pas du LLM)
"""
from __future__ import annotations
import pytest
from app.services.qualify_ai_service import (
AIFrameRequest,
AIMessage,
ai_frame,
)
DEFAULT_REQUEST = AIFrameRequest(
context="Révision du règlement intérieur de l'association",
within_mandate=False,
affected_count=20,
is_structural=False,
messages=[],
)
# ---------------------------------------------------------------------------
# A1 — Premier appel → 2 questions, done=False
# ---------------------------------------------------------------------------
def test_a1_first_call_returns_questions():
resp = ai_frame(DEFAULT_REQUEST)
assert resp.done is False
assert len(resp.questions) == 2
def test_a1_first_call_result_is_none():
resp = ai_frame(DEFAULT_REQUEST)
assert resp.result is None
# ---------------------------------------------------------------------------
# A2 — Questions couvrent réversibilité et urgence
# ---------------------------------------------------------------------------
def test_a2_questions_have_stable_ids():
resp = ai_frame(DEFAULT_REQUEST)
ids = {q.id for q in resp.questions}
assert "reversibility" in ids
assert "urgency" in ids
def test_a2_questions_have_options():
resp = ai_frame(DEFAULT_REQUEST)
for q in resp.questions:
assert len(q.options) >= 2, f"Question '{q.id}' doit avoir au moins 2 options"
# ---------------------------------------------------------------------------
# A3 — Deuxième appel (avec réponses) → done=True + résultat
# ---------------------------------------------------------------------------
def _make_second_request(reversibility_ans: str, urgency_ans: str, **kwargs) -> AIFrameRequest:
questions = ai_frame(DEFAULT_REQUEST).questions
messages = []
for q in questions:
messages.append(AIMessage(role="assistant", content=q.text))
# One user message bundling all answers
messages.append(AIMessage(
role="user",
content=f"reversibility:{reversibility_ans}|urgency:{urgency_ans}",
))
return AIFrameRequest(
**{**vars(DEFAULT_REQUEST), "messages": messages, **kwargs}
)
def test_a3_second_call_is_done():
req = _make_second_request("Difficilement", "Pas d'urgence")
resp = ai_frame(req)
assert resp.done is True
def test_a3_second_call_has_result():
req = _make_second_request("Difficilement", "Pas d'urgence")
resp = ai_frame(req)
assert resp.result is not None
assert resp.result.decision_type in ("individual", "collective")
# ---------------------------------------------------------------------------
# A4 — Irréversible + structurant → recommend_onchain
# ---------------------------------------------------------------------------
def test_a4_irreversible_structural_recommends_onchain():
req = _make_second_request(
"Non, c'est irréversible",
"Pas d'urgence",
is_structural=True,
)
resp = ai_frame(req)
assert resp.result is not None
assert resp.result.recommend_onchain is True
# ---------------------------------------------------------------------------
# A5 — Urgence → raison présente
# ---------------------------------------------------------------------------
def test_a5_urgent_adds_urgency_reason():
req = _make_second_request("Oui, facilement", "Urgente (< 1 semaine)")
resp = ai_frame(req)
assert resp.result is not None
reasons_text = " ".join(resp.result.reasons).lower()
assert "urgence" in reasons_text or "urgent" in reasons_text
# ---------------------------------------------------------------------------
# A6 — Résultat respecte les règles du moteur
# ---------------------------------------------------------------------------
def test_a6_within_mandate_gives_individual():
req = AIFrameRequest(
within_mandate=True,
affected_count=None,
messages=[
AIMessage(role="assistant", content="q"),
AIMessage(role="user", content="reversibility:Facilement|urgency:Pas d'urgence"),
],
)
resp = ai_frame(req)
assert resp.done is True
assert resp.result is not None
assert resp.result.decision_type == "individual"
assert resp.result.process == "consultation_avis"
def test_a6_large_group_gives_collective():
req = _make_second_request("Difficilement", "Pas d'urgence", affected_count=100)
resp = ai_frame(req)
assert resp.result is not None
assert resp.result.decision_type == "collective"
# ---------------------------------------------------------------------------
# A7 — Sans contexte, mêmes questions (stub ne dépend pas du LLM)
# ---------------------------------------------------------------------------
def test_a7_no_context_same_question_ids():
req_with = DEFAULT_REQUEST
req_without = AIFrameRequest(
context=None,
within_mandate=False,
affected_count=20,
messages=[],
)
ids_with = {q.id for q in ai_frame(req_with).questions}
ids_without = {q.id for q in ai_frame(req_without).questions}
assert ids_with == ids_without
+9 -95
View File
@@ -32,8 +32,6 @@ from app.models.protocol import FormulaConfig, VotingProtocol
from app.models.document import Document, DocumentItem from app.models.document import Document, DocumentItem
from app.models.decision import Decision, DecisionStep from app.models.decision import Decision, DecisionStep
from app.models.mandate import Mandate, MandateStep from app.models.mandate import Mandate, MandateStep
from app.models.organization import Organization
from app.models.qualification import QualificationProtocol
from app.models.user import DuniterIdentity from app.models.user import DuniterIdentity
from app.models.vote import VoteSession, Vote from app.models.vote import VoteSession, Vote
@@ -163,7 +161,6 @@ async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig
async def seed_voting_protocols( async def seed_voting_protocols(
session: AsyncSession, session: AsyncSession,
formulas: dict[str, FormulaConfig], formulas: dict[str, FormulaConfig],
org_id: uuid.UUID | None = None,
) -> dict[str, VotingProtocol]: ) -> dict[str, VotingProtocol]:
protocols: dict[str, dict] = { protocols: dict[str, dict] = {
"Vote WoT standard": { "Vote WoT standard": {
@@ -209,7 +206,6 @@ async def seed_voting_protocols(
instance, created = await get_or_create( instance, created = await get_or_create(
session, VotingProtocol, "name", name, **params, session, VotingProtocol, "name", name, **params,
) )
instance.organization_id = org_id
status = "created" if created else "exists" status = "created" if created else "exists"
print(f" VotingProtocol '{name}': {status}") print(f" VotingProtocol '{name}': {status}")
result[name] = instance result[name] = instance
@@ -833,7 +829,6 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
async def seed_document_engagement_certification( async def seed_document_engagement_certification(
session: AsyncSession, session: AsyncSession,
protocols: dict[str, VotingProtocol], protocols: dict[str, VotingProtocol],
org_id: uuid.UUID | None = None,
) -> Document: ) -> Document:
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2) genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
@@ -855,7 +850,6 @@ async def seed_document_engagement_certification(
), ),
genesis_json=genesis, genesis_json=genesis,
) )
doc.organization_id = org_id
print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}") print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
if created: if created:
@@ -1899,7 +1893,6 @@ ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
async def seed_document_engagement_forgeron( async def seed_document_engagement_forgeron(
session: AsyncSession, session: AsyncSession,
protocols: dict[str, VotingProtocol], protocols: dict[str, VotingProtocol],
org_id: uuid.UUID | None = None,
) -> Document: ) -> Document:
genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2) genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
@@ -1923,7 +1916,6 @@ async def seed_document_engagement_forgeron(
), ),
genesis_json=genesis, genesis_json=genesis,
) )
doc.organization_id = org_id
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}") print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
if created: if created:
@@ -1996,7 +1988,7 @@ RUNTIME_UPGRADE_STEPS: list[dict] = [
] ]
async def seed_decision_runtime_upgrade(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision: async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
decision, created = await get_or_create( decision, created = await get_or_create(
session, session,
Decision, Decision,
@@ -2017,7 +2009,6 @@ async def seed_decision_runtime_upgrade(session: AsyncSession, org_id: uuid.UUID
decision_type="runtime_upgrade", decision_type="runtime_upgrade",
status="draft", status="draft",
) )
decision.organization_id = org_id
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}") print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
if created: if created:
@@ -2157,7 +2148,7 @@ async def seed_votes_on_items(
# Seed: Additional decisions (demo content) # Seed: Additional decisions (demo content)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def seed_decision_licence_evolution(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision: async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
"""Seed a community decision: evolution of the G1 monetary license.""" """Seed a community decision: evolution of the G1 monetary license."""
decision, created = await get_or_create( decision, created = await get_or_create(
session, session,
@@ -2179,7 +2170,6 @@ async def seed_decision_licence_evolution(session: AsyncSession, org_id: uuid.UU
decision_type="community", decision_type="community",
status="draft", status="draft",
) )
decision.organization_id = org_id
print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}") print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}")
if created: if created:
@@ -2235,7 +2225,7 @@ async def seed_decision_licence_evolution(session: AsyncSession, org_id: uuid.UU
# Seed: Mandates (Comité Technique + Admin Forgerons) # Seed: Mandates (Comité Technique + Admin Forgerons)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity], org_id: uuid.UUID | None = None) -> None: async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) -> None:
"""Seed example mandates: TechComm and Smith Admin.""" """Seed example mandates: TechComm and Smith Admin."""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -2407,7 +2397,6 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity], or
m_data["title"], m_data["title"],
**{k: v for k, v in m_data.items() if k != "title"}, **{k: v for k, v in m_data.items() if k != "title"},
) )
mandate.organization_id = org_id
status_str = "created" if created else "exists" status_str = "created" if created else "exists"
print(f" Mandate '{mandate.title[:50]}': {status_str}") print(f" Mandate '{mandate.title[:50]}': {status_str}")
@@ -2419,78 +2408,10 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity], or
print(f" -> {len(steps_data)} steps created") print(f" -> {len(steps_data)} steps created")
# ---------------------------------------------------------------------------
# Seed: Organizations
# ---------------------------------------------------------------------------
async def seed_organizations(session: AsyncSession) -> dict[str, Organization]:
"""Create the two base transparent organizations (idempotent)."""
orgs_data = [
{
"slug": "duniter-g1",
"name": "Duniter G1",
"org_type": "community",
"is_transparent": True,
"color": "#22c55e",
"icon": "i-lucide-globe",
"description": "Communauté Duniter — monnaie libre G1. Accessible à tous les membres authentifiés.",
},
{
"slug": "axiom-team",
"name": "Axiom Team",
"org_type": "collective",
"is_transparent": True,
"color": "#3b82f6",
"icon": "i-lucide-users",
"description": "Équipe Axiom — développement et gouvernance des outils communs.",
},
]
orgs: dict[str, Organization] = {}
for data in orgs_data:
org, created = await get_or_create(session, Organization, "slug", data["slug"], **{k: v for k, v in data.items() if k != "slug"})
status_str = "created" if created else "exists"
print(f" Organisation '{org.name}': {status_str}")
orgs[org.slug] = org
return orgs
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main seed runner # Main seed runner
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def seed_qualification_protocol(session: AsyncSession) -> QualificationProtocol:
"""Seed the default qualification protocol (thresholds for the Décider tunnel)."""
stmt = select(QualificationProtocol).where(QualificationProtocol.is_active == True) # noqa: E712
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
if existing is not None:
print(" [skip] Protocole de qualification déjà présent")
return existing
proto = QualificationProtocol(
name="Protocole de qualification par défaut",
description=(
"Seuils utilisés par le tunnel Décider pour router vers "
"individual/collective et proposer les modalités de vote."
),
small_group_max=5,
collective_wot_min=50,
default_modalities_json=json.dumps([
"vote_wot",
"vote_smith",
"consultation_avis",
"election",
]),
is_active=True,
)
session.add(proto)
await session.flush()
print(" [ok] Protocole de qualification créé")
return proto
async def run_seed(): async def run_seed():
print("=" * 60) print("=" * 60)
print("libreDecision - Seed Database") print("libreDecision - Seed Database")
@@ -2502,30 +2423,23 @@ async def run_seed():
async with async_session() as session: async with async_session() as session:
async with session.begin(): async with session.begin():
print("\n[0/10] Organizations...")
orgs = await seed_organizations(session)
duniter_g1_id = orgs["duniter-g1"].id
print("\n[0b] Protocole de qualification...")
await seed_qualification_protocol(session)
print("\n[1/10] Formula Configs...") print("\n[1/10] Formula Configs...")
formulas = await seed_formula_configs(session) formulas = await seed_formula_configs(session)
print("\n[2/10] Voting Protocols...") print("\n[2/10] Voting Protocols...")
protocols = await seed_voting_protocols(session, formulas, org_id=duniter_g1_id) protocols = await seed_voting_protocols(session, formulas)
print("\n[3/10] Document: Acte d'engagement Certification...") print("\n[3/10] Document: Acte d'engagement Certification...")
await seed_document_engagement_certification(session, protocols, org_id=duniter_g1_id) await seed_document_engagement_certification(session, protocols)
print("\n[4/10] Document: Acte d'engagement forgeron v2.0.0...") print("\n[4/10] Document: Acte d'engagement forgeron v2.0.0...")
doc_forgeron = await seed_document_engagement_forgeron(session, protocols, org_id=duniter_g1_id) doc_forgeron = await seed_document_engagement_forgeron(session, protocols)
print("\n[5/10] Decision: Runtime Upgrade...") print("\n[5/10] Decision: Runtime Upgrade...")
await seed_decision_runtime_upgrade(session, org_id=duniter_g1_id) await seed_decision_runtime_upgrade(session)
print("\n[6/10] Decision: Évolution Licence G1 v0.4.0...") print("\n[6/10] Decision: Évolution Licence G1 v0.4.0...")
await seed_decision_licence_evolution(session, org_id=duniter_g1_id) await seed_decision_licence_evolution(session)
print("\n[7/10] Simulated voters...") print("\n[7/10] Simulated voters...")
voters = await seed_voters(session) voters = await seed_voters(session)
@@ -2539,7 +2453,7 @@ async def run_seed():
) )
print("\n[9/10] Mandates...") print("\n[9/10] Mandates...")
await seed_mandates(session, voters, org_id=duniter_g1_id) await seed_mandates(session, voters)
print("\n[10/10] Done.") print("\n[10/10] Done.")
+1 -1
View File
@@ -41,4 +41,4 @@ COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
WORKDIR /app WORKDIR /app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002", "--reload"] CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload"]
+32 -29
View File
@@ -1,14 +1,13 @@
name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main} version: "3.9"
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-postgres restart: unless-stopped
restart: always
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-libredecision} POSTGRES_DB: ${POSTGRES_DB:-libredecision}
POSTGRES_USER: ${POSTGRES_USER:-libredecision} POSTGRES_USER: ${POSTGRES_USER:-libredecision}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-in-production}
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -19,59 +18,63 @@ services:
start_period: 30s start_period: 30s
networks: networks:
- libredecision - libredecision
# Pas de label SERVICE_* : postgres non exposé publiquement
backend: backend:
image: libredecision-backend:latest build:
container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-backend context: ../
restart: always dockerfile: docker/backend.Dockerfile
target: production
restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-libredecision}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-libredecision} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-libredecision}:${POSTGRES_PASSWORD:-change-me-in-production}@postgres:5432/${POSTGRES_DB:-libredecision}
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY:-change-me-in-production-with-a-real-secret-key}
ENVIRONMENT: production ENVIRONMENT: production
DEBUG: "false" DEBUG: "false"
DEMO_MODE: ${DEMO_MODE:-true} DEMO_MODE: ${DEMO_MODE:-true}
CORS_ORIGINS: '["https://${APP_DOMAIN:-decision.librodrome.org}"]' CORS_ORIGINS: '["https://${DOMAIN:-libredecision.org}"]'
DUNITER_RPC_URL: ${DUNITER_RPC_URL:-wss://gdev.p2p.legal/ws} DUNITER_RPC_URL: ${DUNITER_RPC_URL:-wss://gdev.p2p.legal/ws}
IPFS_API_URL: http://ipfs:5001 IPFS_API_URL: http://ipfs:5001
IPFS_GATEWAY_URL: http://ipfs:8080 IPFS_GATEWAY_URL: http://ipfs:8080
labels: labels:
# Registrator enregistre dans Consul, Fabio route automatiquement - "traefik.enable=true"
- SERVICE_8002_NAME=${COMPOSE_PROJECT_NAME:-ehv-decision-main}-backend-8002 - "traefik.http.routers.libredecision-api.rule=Host(`${DOMAIN:-libredecision.org}`) && PathPrefix(`/api`)"
- SERVICE_8002_TAGS=urlprefix-${APP_DOMAIN:-decision.librodrome.org}/api/* - "traefik.http.routers.libredecision-api.entrypoints=websecure"
# TCP : HTTP check échoue si le service redirige (301/302) - "traefik.http.routers.libredecision-api.tls.certresolver=letsencrypt"
- SERVICE_8002_CHECK_TCP=true - "traefik.http.services.libredecision-api.loadbalancer.server.port=8002"
networks: networks:
- libredecision - libredecision
- sonic - traefik
frontend: frontend:
image: libredecision-frontend:latest build:
container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-frontend context: ../
restart: always dockerfile: docker/frontend.Dockerfile
target: production
restart: unless-stopped
depends_on: depends_on:
- backend - backend
environment: environment:
NUXT_PUBLIC_API_BASE: https://${APP_DOMAIN:-decision.librodrome.org}/api/v1 NUXT_PUBLIC_API_BASE: https://${DOMAIN:-libredecision.org}/api/v1
labels: labels:
- SERVICE_3000_NAME=${COMPOSE_PROJECT_NAME:-ehv-decision-main}-frontend-3000 - "traefik.enable=true"
- SERVICE_3000_TAGS=urlprefix-${APP_DOMAIN:-decision.librodrome.org}/* - "traefik.http.routers.libredecision-front.rule=Host(`${DOMAIN:-libredecision.org}`)"
- SERVICE_3000_CHECK_TCP=true - "traefik.http.routers.libredecision-front.entrypoints=websecure"
- "traefik.http.routers.libredecision-front.tls.certresolver=letsencrypt"
- "traefik.http.services.libredecision-front.loadbalancer.server.port=3000"
networks: networks:
- sonic - libredecision
- traefik
ipfs: ipfs:
image: ipfs/kubo:latest image: ipfs/kubo:latest
container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-ipfs restart: unless-stopped
restart: always
volumes: volumes:
- ipfs-data:/data/ipfs - ipfs-data:/data/ipfs
networks: networks:
- libredecision - libredecision
# Pas de label SERVICE_* : ipfs non exposé publiquement
volumes: volumes:
postgres-data: postgres-data:
@@ -80,5 +83,5 @@ volumes:
networks: networks:
libredecision: libredecision:
driver: bridge driver: bridge
sonic: traefik:
external: true external: true
+11 -11
View File
@@ -13,7 +13,7 @@ Ce guide couvre le deploiement complet de libreDecision en production avec Docke
| --------- | ---------------- | ----------- | | --------- | ---------------- | ----------- |
| Docker | 24+ | Moteur de conteneurs | | Docker | 24+ | Moteur de conteneurs |
| Docker Compose | 2.20+ | Orchestration multi-conteneurs | | Docker Compose | 2.20+ | Orchestration multi-conteneurs |
| Nom de domaine | -- | Domaine pointe vers le serveur (ex: `decision.librodrome.org`) | | Nom de domaine | -- | Domaine pointe vers le serveur (ex: `libredecision.org`) |
| Certificat TLS | -- | Genere automatiquement par Traefik via Let's Encrypt | | Certificat TLS | -- | Genere automatiquement par Traefik via Let's Encrypt |
| Traefik | 2.10+ | Reverse proxy avec terminaison TLS (reseau externe `traefik`) | | Traefik | 2.10+ | Reverse proxy avec terminaison TLS (reseau externe `traefik`) |
| Serveur | 2 vCPU, 4 Go RAM, 40 Go SSD | Ressources recommandees | | Serveur | 2 vCPU, 4 Go RAM, 40 Go SSD | Ressources recommandees |
@@ -46,12 +46,12 @@ cp .env.example .env
| `DATABASE_URL` | URL de connexion asyncpg | `postgresql+asyncpg://...@localhost:5432/...` | Construite automatiquement dans docker-compose | | `DATABASE_URL` | URL de connexion asyncpg | `postgresql+asyncpg://...@localhost:5432/...` | Construite automatiquement dans docker-compose |
| `SECRET_KEY` | Cle secrete pour les tokens de session | `change-me-in-production-with-a-real-secret-key` | **Generer une cle aleatoire** (`openssl rand -hex 64`) | | `SECRET_KEY` | Cle secrete pour les tokens de session | `change-me-in-production-with-a-real-secret-key` | **Generer une cle aleatoire** (`openssl rand -hex 64`) |
| `DEBUG` | Mode debug | `true` | **`false`** | | `DEBUG` | Mode debug | `true` | **`false`** |
| `CORS_ORIGINS` | Origines CORS autorisees | `["http://localhost:3002"]` | `["https://decision.librodrome.org"]` | | `CORS_ORIGINS` | Origines CORS autorisees | `["http://localhost:3002"]` | `["https://libredecision.org"]` |
| `DUNITER_RPC_URL` | URL du noeud Duniter V2 RPC | `wss://gdev.p2p.legal/ws` | URL du noeud de production | | `DUNITER_RPC_URL` | URL du noeud Duniter V2 RPC | `wss://gdev.p2p.legal/ws` | URL du noeud de production |
| `IPFS_API_URL` | URL de l'API IPFS (kubo) | `http://localhost:5001` | `http://ipfs:5001` (interne Docker) | | `IPFS_API_URL` | URL de l'API IPFS (kubo) | `http://localhost:5001` | `http://ipfs:5001` (interne Docker) |
| `IPFS_GATEWAY_URL` | URL de la passerelle IPFS | `http://localhost:8080` | `http://ipfs:8080` (interne Docker) | | `IPFS_GATEWAY_URL` | URL de la passerelle IPFS | `http://localhost:8080` | `http://ipfs:8080` (interne Docker) |
| `NUXT_PUBLIC_API_BASE` | URL publique de l'API pour le frontend | `http://localhost:8002/api/v1` | `https://decision.librodrome.org/api/v1` | | `NUXT_PUBLIC_API_BASE` | URL publique de l'API pour le frontend | `http://localhost:8002/api/v1` | `https://libredecision.org/api/v1` |
| `DOMAIN` | Nom de domaine | `decision.librodrome.org` | Votre domaine | | `DOMAIN` | Nom de domaine | `libredecision.org` | Votre domaine |
### Generer les secrets ### Generer les secrets
@@ -108,7 +108,7 @@ docker compose -f docker/docker-compose.yml ps
docker compose -f docker/docker-compose.yml logs -f backend docker compose -f docker/docker-compose.yml logs -f backend
# Health check de l'API # Health check de l'API
curl -s https://decision.librodrome.org/api/health | jq . curl -s https://libredecision.org/api/health | jq .
``` ```
## Migration de base de donnees (Alembic) ## Migration de base de donnees (Alembic)
@@ -182,7 +182,7 @@ services:
- "--entrypoints.web.http.redirections.entryPoint.to=websecure" - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https" - "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=admin@decision.librodrome.org" - "--certificatesresolvers.letsencrypt.acme.email=admin@libredecision.org"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports: ports:
- "80:80" - "80:80"
@@ -209,8 +209,8 @@ docker compose -f docker-compose.traefik.yml up -d
Le `docker-compose.yml` de libreDecision configure automatiquement les labels Traefik : Le `docker-compose.yml` de libreDecision configure automatiquement les labels Traefik :
- **Frontend** : `Host(decision.librodrome.org)` sur le port 3000 - **Frontend** : `Host(libredecision.org)` sur le port 3000
- **Backend** : `Host(decision.librodrome.org) && PathPrefix(/api)` sur le port 8002 - **Backend** : `Host(libredecision.org) && PathPrefix(/api)` sur le port 8002
- Les deux utilisent le certificat Let's Encrypt (`certresolver=letsencrypt`) - Les deux utilisent le certificat Let's Encrypt (`certresolver=letsencrypt`)
- Redirection HTTP vers HTTPS automatique - Redirection HTTP vers HTTPS automatique
@@ -230,7 +230,7 @@ Le service PostgreSQL dispose d'un health check integre (`pg_isready`). Le backe
```bash ```bash
# Health check de l'API # Health check de l'API
curl -s https://decision.librodrome.org/api/health curl -s https://libredecision.org/api/health
# Reponse attendue : {"status": "healthy"} # Reponse attendue : {"status": "healthy"}
``` ```
@@ -317,7 +317,7 @@ docker image prune -f
# 5. Verifier le deploiement # 5. Verifier le deploiement
docker compose -f docker/docker-compose.yml ps docker compose -f docker/docker-compose.yml ps
curl -s https://decision.librodrome.org/api/health curl -s https://libredecision.org/api/health
``` ```
### Pipeline CI/CD (Woodpecker) ### Pipeline CI/CD (Woodpecker)
@@ -377,7 +377,7 @@ docker compose -f docker/docker-compose.yml up -d # recree avec le nouveau m
**Symptome** : Le site est inaccessible en HTTPS ou affiche un certificat invalide. **Symptome** : Le site est inaccessible en HTTPS ou affiche un certificat invalide.
1. Verifier que le DNS A/AAAA pointe vers le serveur : `dig decision.librodrome.org` 1. Verifier que le DNS A/AAAA pointe vers le serveur : `dig libredecision.org`
2. Verifier que les ports 80 et 443 sont ouverts : `ss -tlnp | grep -E '80|443'` 2. Verifier que les ports 80 et 443 sont ouverts : `ss -tlnp | grep -E '80|443'`
3. Consulter les logs Traefik : `docker logs traefik 2>&1 | grep -i acme` 3. Consulter les logs Traefik : `docker logs traefik 2>&1 | grep -i acme`
-144
View File
@@ -1,144 +0,0 @@
# Méthode de travail TDD — libreDecision
## Principe fondamental
**Tu décris la règle métier. Claude traduit en test. Tu valides. Claude implémente.**
Jamais l'inverse. Le test est la source de vérité ; l'implémentation n'est que le moyen de le faire passer.
---
## Workflow par itération
```
1. Tu décris une règle en français naturel
→ "Si scope=personal, la décision est toujours individuelle"
2. Claude écrit le(s) test(s) — RED (le test échoue avant l'implémentation)
→ Tu valides que le test capture bien l'intention
3. Claude implémente le minimum pour que le test passe — GREEN
→ Rien de plus que ce que le test exige
4. Claude refactorise si nécessaire — REFACTOR
→ Sans casser les tests existants
5. Répétition avec la règle suivante
```
---
## Commandes de prompt
| Commande | Action |
|---|---|
| `+test` | Écrire le(s) test(s) sans implémenter |
| `+impl` | Implémenter pour faire passer les tests en attente |
| `+test+impl` | Test + implémentation d'un coup (règle simple) |
| `+règle` | Ajouter une règle au moteur existant |
| `+règle remplace` | Une nouvelle règle remplace une précédente (précise laquelle) |
| `+régression` | Vérifier qu'aucun test existant n'est cassé après un changement |
| `+résumé` | Afficher l'état des règles implémentées et en attente |
---
## Format d'une règle métier
Pour être efficace, une règle doit préciser :
```
ENTRÉES : les variables concernées et leurs valeurs
RÉSULTAT : ce que le système doit retourner ou faire
EXCEPTIONS : cas qui brisent la règle générale (si aucune, dire "aucune")
```
**Exemple :**
```
ENTRÉES : scope = "personal"
RÉSULTAT : decision_type = "individual", recommend_onchain = False
EXCEPTIONS : aucune — même si stakes = "critical", une décision personnelle reste individuelle
```
---
## Structure des tests dans ce projet
```
backend/app/tests/
test_qualifier.py ← moteur de qualification (tunnel Décider)
test_middleware.py ← rate limiter, CORS, headers
test_threshold.py ← formules WoT existantes
test_votes.py ← logique de vote
test_decisions.py ← service décisions
...
```
Chaque fichier de test correspond à un module ou un bloc fonctionnel.
Les tests d'intégration (qui touchent la DB) sont marqués `@pytest.mark.integration`.
---
## Les 4 blocs algorithmiques
```
┌─────────────────────────────────────────────────────────┐
│ TUNNEL "DÉCIDER" │
│ │
│ 1. QUALIFIER → nature / enjeu / réversibilité │
│ ↓ │
│ 2. ROUTEUR → individual / collective / delegated │
│ ↓ ↓ │
│ 3. PROTOCOLE → sélection formule WoT + paramètres │
│ ↓ │
│ 4. GRAVURE → recommandation on-chain (IPFS+remark) │
└─────────────────────────────────────────────────────────┘
```
Les blocs sont testés indépendamment puis en intégration.
Un changement dans le bloc 1 ne doit jamais casser silencieusement le bloc 4.
---
## Invariants fondamentaux (ne jamais casser)
Ces règles doivent avoir un test dédié et rester vertes en permanence :
1. Une décision `individual` ne génère jamais de session de vote
2. Une décision `on_chain` implique toujours `recommend_onchain = True`
3. `recommend_onchain = True` requiert `reversibility = "impossible"` **ou** `stakes = "critical"`
4. Le qualificateur ne retourne jamais un type inconnu (enum strict)
5. Un protocole WoT sélectionné doit exister en base (slug valide)
---
## Règles de régression
- Après chaque implémentation : `pytest backend/app/tests/ -v --tb=short`
- Avant tout commit : zéro test rouge
- Si un test existant casse après un nouveau changement → **stop, analyser, ne pas contourner**
- `RATE_LIMIT_AUTH` en dev = 60/min minimum (pas de blocage en développement)
---
## Où sont les règles métier documentées
| Source | Contenu |
|---|---|
| `docs/dev/tdd-methode.md` | Cette méthode |
| `docs/dev/qualifier-rules.md` | Règles du moteur de qualification (créé au fil des itérations) |
| `backend/app/engine/qualifier.py` | Implémentation du qualificateur |
| `backend/app/tests/test_qualifier.py` | Tests — source de vérité exécutable |
---
## À propos de la mémoire de contexte
Entre les sessions, Claude peut perdre le contexte des règles en cours.
Pour reprendre efficacement :
```
"Résume les règles du qualificateur implémentées jusqu'ici"
→ Claude lit test_qualifier.py et qualifier.py et synthétise
```
Les tests sont leur propre documentation. Ne pas dupliquer les règles en commentaires.
+11 -38
View File
@@ -1,24 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
const auth = useAuthStore() const auth = useAuthStore()
const orgsStore = useOrganizationsStore()
const documentsStore = useDocumentsStore()
const decisionsStore = useDecisionsStore()
const protocolsStore = useProtocolsStore()
const mandatesStore = useMandatesStore()
const route = useRoute() const route = useRoute()
const { initMood } = useMood() const { initMood } = useMood()
const navigationItems = [ const navigationItems = [
{ {
label: 'Décisions', label: 'Boîte à outils',
icon: 'i-lucide-scale', icon: 'i-lucide-wrench',
to: '/decisions', to: '/tools',
}, },
{ {
label: 'Documents', label: 'Documents',
icon: 'i-lucide-book-open', icon: 'i-lucide-book-open',
to: '/documents', to: '/documents',
}, },
{
label: 'Decisions',
icon: 'i-lucide-scale',
to: '/decisions',
},
{ {
label: 'Mandats', label: 'Mandats',
icon: 'i-lucide-user-check', icon: 'i-lucide-user-check',
@@ -29,11 +29,6 @@ const navigationItems = [
icon: 'i-lucide-settings', icon: 'i-lucide-settings',
to: '/protocols', to: '/protocols',
}, },
{
label: 'Outils',
icon: 'i-lucide-wrench',
to: '/tools',
},
{ {
label: 'Sanctuaire', label: 'Sanctuaire',
icon: 'i-lucide-archive', icon: 'i-lucide-archive',
@@ -52,16 +47,6 @@ watch(() => route.path, () => {
mobileMenuOpen.value = false mobileMenuOpen.value = false
}) })
/** Refetch all content stores when the active workspace changes. */
watch(() => orgsStore.activeSlug, (newSlug, oldSlug) => {
if (oldSlug !== null && newSlug !== null && newSlug !== oldSlug) {
documentsStore.fetchAll()
decisionsStore.fetchAll()
protocolsStore.fetchProtocols()
mandatesStore.fetchAll()
}
})
/** WebSocket connection and notifications. */ /** WebSocket connection and notifications. */
const ws = useWebSocket() const ws = useWebSocket()
const { setupWsNotifications } = useNotifications() const { setupWsNotifications } = useNotifications()
@@ -78,18 +63,12 @@ onMounted(async () => {
if (auth.token) { if (auth.token) {
try { try {
await auth.fetchMe() await auth.fetchMe()
} catch (err: any) { } catch {
// Déconnexion seulement sur session réellement invalide (401/403) auth.logout()
// Erreur réseau ou backend temporairement indisponible conserver la session
if (err?.status === 401 || err?.status === 403) {
auth.logout()
}
} }
} }
ws.connect() ws.connect()
setupWsNotifications(ws) setupWsNotifications(ws)
// Load organizations in parallel non-blocking, no auth required
orgsStore.fetchOrganizations()
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -118,7 +97,7 @@ function isActive(to: string) {
<!-- Left: Hamburger (mobile) + Logo --> <!-- Left: Hamburger (mobile) + Logo -->
<div class="app-header__left"> <div class="app-header__left">
<button <button
class="app-header__menu-btn" class="app-header__menu-btn md:hidden"
aria-label="Ouvrir le menu" aria-label="Ouvrir le menu"
@click="mobileMenuOpen = true" @click="mobileMenuOpen = true"
> >
@@ -348,12 +327,6 @@ function isActive(to: string) {
background: var(--mood-accent-soft); background: var(--mood-accent-soft);
} }
@media (min-width: 768px) {
.app-header__menu-btn {
display: none;
}
}
.app-header__logo { .app-header__logo {
text-decoration: none; text-decoration: none;
display: flex; display: flex;
+60 -40
View File
@@ -1,18 +1,52 @@
<script setup lang="ts"> <script setup lang="ts">
const orgsStore = useOrganizationsStore() /**
* WorkspaceSelector Sélecteur de collectif / espace de travail.
* Compartimentage multi-collectifs, multi-sites.
* UI-only pour l'instant, prêt pour le backend (collective_id sur toutes les entités).
*/
interface Workspace {
id: string
name: string
slug: string
icon: string
role?: string
color?: string
}
// Mock data sera remplacé par le store collectifs
const workspaces: Workspace[] = [
{
id: 'g1-main',
name: 'Duniter G1',
slug: 'duniter-g1',
icon: 'i-lucide-coins',
role: 'Membre',
color: 'accent',
},
{
id: 'axiom',
name: 'Axiom Team',
slug: 'axiom-team',
icon: 'i-lucide-layers',
role: 'Admin',
color: 'secondary',
},
]
const activeId = ref('g1-main')
const isOpen = ref(false) const isOpen = ref(false)
const containerRef = ref<HTMLElement | null>(null)
const active = computed(() => orgsStore.active) const active = computed(() => workspaces.find(w => w.id === activeId.value) ?? workspaces[0])
const organizations = computed(() => orgsStore.organizations)
function selectOrg(slug: string) { function selectWorkspace(id: string) {
orgsStore.setActive(slug) activeId.value = id
isOpen.value = false isOpen.value = false
// TODO: store.setActiveCollective(id) + refetch all data
} }
// Close on outside click // Close on outside click
const containerRef = ref<HTMLElement | null>(null)
onMounted(() => { onMounted(() => {
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) { if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
@@ -24,46 +58,35 @@ onMounted(() => {
<template> <template>
<div ref="containerRef" class="ws"> <div ref="containerRef" class="ws">
<button <button class="ws__trigger" :class="{ 'ws__trigger--open': isOpen }" @click="isOpen = !isOpen">
class="ws__trigger" <div class="ws__icon" :class="`ws__icon--${active.color}`">
:class="{ 'ws__trigger--open': isOpen }" <UIcon :name="active.icon" />
:disabled="orgsStore.loading || !active"
@click="isOpen = !isOpen"
>
<div v-if="orgsStore.loading" class="ws__icon ws__icon--muted">
<UIcon name="i-lucide-loader-2" class="animate-spin" />
</div> </div>
<div v-else-if="active" class="ws__icon" :style="{ background: active.color ? active.color + '22' : undefined, color: active.color || undefined }"> <span class="ws__name">{{ active.name }}</span>
<UIcon :name="active.icon || 'i-lucide-building'" />
</div>
<span class="ws__name">{{ active?.name ?? '…' }}</span>
<UIcon name="i-lucide-chevrons-up-down" class="ws__caret" /> <UIcon name="i-lucide-chevrons-up-down" class="ws__caret" />
</button> </button>
<Transition name="dropdown"> <Transition name="dropdown">
<div v-if="isOpen && organizations.length" class="ws__dropdown"> <div v-if="isOpen" class="ws__dropdown">
<div class="ws__dropdown-header"> <div class="ws__dropdown-header">
Espace de travail Espace de travail
</div> </div>
<div class="ws__items"> <div class="ws__items">
<button <button
v-for="org in organizations" v-for="ws in workspaces"
:key="org.id" :key="ws.id"
class="ws__item" class="ws__item"
:class="{ 'ws__item--active': org.slug === orgsStore.activeSlug }" :class="{ 'ws__item--active': ws.id === activeId }"
@click="selectOrg(org.slug)" @click="selectWorkspace(ws.id)"
> >
<div <div class="ws__item-icon" :class="`ws__icon--${ws.color}`">
class="ws__item-icon" <UIcon :name="ws.icon" />
:style="{ background: org.color ? org.color + '22' : undefined, color: org.color || undefined }"
>
<UIcon :name="org.icon || 'i-lucide-building'" />
</div> </div>
<div class="ws__item-info"> <div class="ws__item-info">
<span class="ws__item-name">{{ org.name }}</span> <span class="ws__item-name">{{ ws.name }}</span>
<span class="ws__item-role">{{ org.is_transparent ? 'Public' : 'Membres' }}</span> <span v-if="ws.role" class="ws__item-role">{{ ws.role }}</span>
</div> </div>
<UIcon v-if="org.slug === orgsStore.activeSlug" name="i-lucide-check" class="ws__item-check" /> <UIcon v-if="ws.id === activeId" name="i-lucide-check" class="ws__item-check" />
</button> </button>
</div> </div>
<div class="ws__dropdown-footer"> <div class="ws__dropdown-footer">
@@ -95,7 +118,7 @@ onMounted(() => {
max-width: 11rem; max-width: 11rem;
} }
.ws__trigger:hover:not(:disabled) { .ws__trigger:hover {
background: color-mix(in srgb, var(--mood-accent-soft) 80%, var(--mood-accent) 20%); background: color-mix(in srgb, var(--mood-accent-soft) 80%, var(--mood-accent) 20%);
} }
@@ -103,11 +126,6 @@ onMounted(() => {
background: color-mix(in srgb, var(--mood-accent-soft) 60%, var(--mood-accent) 40%); background: color-mix(in srgb, var(--mood-accent-soft) 60%, var(--mood-accent) 40%);
} }
.ws__trigger:disabled {
opacity: 0.5;
cursor: default;
}
.ws__icon { .ws__icon {
width: 1.375rem; width: 1.375rem;
height: 1.375rem; height: 1.375rem;
@@ -119,9 +137,10 @@ onMounted(() => {
font-size: 0.75rem; font-size: 0.75rem;
} }
.ws__icon--muted { .ws__icon--accent { background: var(--mood-accent); color: var(--mood-accent-text); }
background: var(--mood-accent-soft); .ws__icon--secondary {
color: var(--mood-text-muted); background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 20%, transparent);
color: var(--mood-secondary, var(--mood-accent));
} }
.ws__name { .ws__name {
@@ -183,6 +202,7 @@ onMounted(() => {
} }
.ws__item:hover { background: var(--mood-accent-soft); } .ws__item:hover { background: var(--mood-accent-soft); }
.ws__item--active { background: var(--mood-accent-soft); } .ws__item--active { background: var(--mood-accent-soft); }
.ws__item-icon { .ws__item-icon {
-4
View File
@@ -73,7 +73,6 @@ function isRetryable(status: number): boolean {
export function useApi() { export function useApi() {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const auth = useAuthStore() const auth = useAuthStore()
const orgsStore = useOrganizationsStore()
/** /**
* Perform a typed fetch against the backend API. * Perform a typed fetch against the backend API.
@@ -95,9 +94,6 @@ export function useApi() {
if (auth.token) { if (auth.token) {
headers.Authorization = `Bearer ${auth.token}` headers.Authorization = `Bearer ${auth.token}`
} }
if (orgsStore.activeSlug) {
headers['X-Organization'] = orgsStore.activeSlug
}
const maxAttempts = noRetry ? 1 : MAX_RETRIES const maxAttempts = noRetry ? 1 : MAX_RETRIES
let lastError: any = null let lastError: any = null
File diff suppressed because it is too large Load Diff
+70 -196
View File
@@ -2,7 +2,6 @@
const documents = useDocumentsStore() const documents = useDocumentsStore()
const decisions = useDecisionsStore() const decisions = useDecisionsStore()
const protocols = useProtocolsStore() const protocols = useProtocolsStore()
const mandates = useMandatesStore()
const auth = useAuthStore() const auth = useAuthStore()
const loading = ref(true) const loading = ref(true)
@@ -14,7 +13,6 @@ onMounted(async () => {
documents.fetchAll(), documents.fetchAll(),
decisions.fetchAll(), decisions.fetchAll(),
protocols.fetchProtocols(), protocols.fetchProtocols(),
mandates.fetchAll(),
]) ])
} }
finally { finally {
@@ -22,61 +20,52 @@ onMounted(async () => {
} }
}) })
const entryCards = computed(() => { const entryCards = computed(() => [
const decisionsTotal = decisions.list.length {
const decisionsActive = decisions.activeDecisions.length key: 'decisions',
title: 'Décisions structurantes',
const docsActive = documents.activeDocuments.length icon: 'i-lucide-scale',
const docsTotal = documents.list.length to: '/decisions',
count: decisions.activeDecisions.length,
const mandatesActive = mandates.list.filter(m => ['active', 'voting'].includes(m.status)).length countLabel: `${decisions.activeDecisions.length} en cours`,
const mandatesTotal = mandates.list.length totalLabel: `${decisions.list.length} au total`,
description: 'Processus de décision collectifs',
const protocolsCount = protocols.protocols.length color: 'var(--mood-secondary, var(--mood-accent))',
},
return [ {
{ key: 'documents',
key: 'decisions', title: 'Documents de référence',
title: 'Décisions et consultation d\'avis', icon: 'i-lucide-book-open',
icon: 'i-lucide-scale', to: '/documents',
to: '/decisions', count: documents.activeDocuments.length,
count: decisionsTotal, countLabel: `${documents.activeDocuments.length} actif${documents.activeDocuments.length > 1 ? 's' : ''}`,
countLabel: decisionsActive > 0 ? `${decisionsActive} en cours` : `${decisionsTotal} au total`, totalLabel: `${documents.list.length} au total`,
totalLabel: decisionsActive > 0 ? `${decisionsTotal} au total` : 'aucune en cours', description: 'Textes fondateurs sous vote permanent',
color: 'var(--mood-secondary, var(--mood-accent))', color: 'var(--mood-accent)',
}, },
{ {
key: 'documents', key: 'mandats',
title: 'Documents de référence', title: 'Mandats et nominations',
icon: 'i-lucide-book-open', icon: 'i-lucide-user-check',
to: '/documents', to: '/mandates',
count: docsActive || docsTotal, count: null,
countLabel: docsActive > 0 ? `${docsActive} actif${docsActive > 1 ? 's' : ''}` : `${docsTotal} au total`, countLabel: null,
totalLabel: docsTotal > 0 ? `${docsTotal} document${docsTotal > 1 ? 's' : ''}` : 'textes fondateurs', totalLabel: null,
color: 'var(--mood-accent)', description: 'Missions déléguées avec nomination en binôme',
}, color: 'var(--mood-success)',
{ },
key: 'mandats', {
title: 'Mandats et nominations', key: 'protocoles',
icon: 'i-lucide-user-check', title: 'Protocoles et fonctionnement',
to: '/mandates', icon: 'i-lucide-settings',
count: mandatesTotal || null, to: '/protocols',
countLabel: mandatesTotal > 0 ? `${mandatesActive} en cours` : null, count: 2,
totalLabel: mandatesTotal > 0 ? `${mandatesTotal} mandats` : 'missions déléguées', countLabel: '2 protocoles',
color: 'var(--mood-success)', totalLabel: `${protocols.protocols.length} modalités de vote`,
}, description: 'Modalités de vote, formules, workflows',
{ color: 'var(--mood-tertiary, var(--mood-accent))',
key: 'protocoles', },
title: 'Protocoles et fonctionnement', ])
icon: 'i-lucide-settings',
to: '/protocols',
count: protocolsCount || null,
countLabel: protocolsCount > 0 ? `${protocolsCount} protocoles` : null,
totalLabel: 'modalités de vote',
color: 'var(--mood-tertiary, var(--mood-accent))',
},
]
})
const recentDecisions = computed(() => { const recentDecisions = computed(() => {
return [...decisions.list] return [...decisions.list]
@@ -109,29 +98,13 @@ function formatDate(dateStr: string): string {
<!-- Welcome --> <!-- Welcome -->
<div class="dash__welcome"> <div class="dash__welcome">
<h1 class="dash__title"> <h1 class="dash__title">
<span class="dash__title-libre">libre</span><span class="dash__title-decision">Decision</span> <span class="dash__title-g">ğ</span><span class="dash__title-paren">(</span>Decision<span class="dash__title-paren">)</span>
</h1> </h1>
<p class="dash__subtitle"> <p class="dash__subtitle">
Décisions collectives pour la communauté Duniter / G1 Décisions collectives pour la communauté Duniter / G1
</p> </p>
</div> </div>
<!-- Décider CTA -->
<NuxtLink to="/decisions/new" class="dash__decide">
<div class="dash__decide-left">
<div class="dash__decide-icon">
<UIcon name="i-lucide-scale" class="text-xl" />
</div>
<div class="dash__decide-text">
<span class="dash__decide-label">Prendre une décision</span>
<span class="dash__decide-sub">Individuelle · collective · déléguée le parcours s'adapte</span>
</div>
</div>
<div class="dash__decide-arrow">
<UIcon name="i-lucide-arrow-right" class="text-base" />
</div>
</NuxtLink>
<!-- Entry cards --> <!-- Entry cards -->
<div class="dash__entries"> <div class="dash__entries">
<template v-if="loading"> <template v-if="loading">
@@ -154,7 +127,7 @@ function formatDate(dateStr: string): string {
<span class="entry-card__total">{{ card.totalLabel }}</span> <span class="entry-card__total">{{ card.totalLabel }}</span>
</template> </template>
<template v-else> <template v-else>
<span class="entry-card__desc">{{ card.totalLabel }}</span> <span class="entry-card__desc">{{ card.description }}</span>
</template> </template>
<span class="entry-card__arrow"> <span class="entry-card__arrow">
<UIcon name="i-lucide-arrow-right" /> <UIcon name="i-lucide-arrow-right" />
@@ -178,7 +151,7 @@ function formatDate(dateStr: string): string {
</NuxtLink> </NuxtLink>
</div> </div>
<!-- Toolbox teaser --> <!-- Toolbox teaser (5th block, distinct look) -->
<NuxtLink to="/tools" class="dash__toolbox-card"> <NuxtLink to="/tools" class="dash__toolbox-card">
<div class="dash__toolbox-card-inner"> <div class="dash__toolbox-card-inner">
<div class="dash__toolbox-card-icon"> <div class="dash__toolbox-card-icon">
@@ -193,7 +166,7 @@ function formatDate(dateStr: string): string {
<span class="dash__toolbox-card-tag">Vote WoT</span> <span class="dash__toolbox-card-tag">Vote WoT</span>
<span class="dash__toolbox-card-tag">Inertie</span> <span class="dash__toolbox-card-tag">Inertie</span>
<span class="dash__toolbox-card-tag">Smith</span> <span class="dash__toolbox-card-tag">Smith</span>
<span class="dash__toolbox-card-tag">Élection</span> <span class="dash__toolbox-card-tag">Nuance</span>
</div> </div>
</div> </div>
<UIcon name="i-lucide-arrow-right" class="dash__toolbox-card-arrow" /> <UIcon name="i-lucide-arrow-right" class="dash__toolbox-card-arrow" />
@@ -263,19 +236,17 @@ function formatDate(dateStr: string): string {
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 2rem;
max-width: 56rem; max-width: 56rem;
margin: 0 auto;
} }
/* --- Welcome --- */ /* --- Welcome --- */
.dash__welcome { .dash__welcome {
padding: 0.5rem 0; padding: 0.5rem 0;
} }
.dash__title { .dash__title {
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 700; font-weight: 800;
letter-spacing: -0.02em; color: var(--mood-accent);
line-height: 1.1; letter-spacing: -0.03em;
} }
@media (min-width: 640px) { @media (min-width: 640px) {
@@ -283,18 +254,14 @@ function formatDate(dateStr: string): string {
font-size: 2.25rem; font-size: 2.25rem;
} }
} }
.dash__title-g {
.dash__title-libre {
font-style: italic; font-style: italic;
font-weight: 400; }
.dash__title-paren {
font-weight: 300;
color: var(--mood-text-muted); color: var(--mood-text-muted);
opacity: 0.4;
} }
.dash__title-decision {
font-weight: 700;
color: var(--mood-accent);
}
.dash__subtitle { .dash__subtitle {
margin-top: 0.375rem; margin-top: 0.375rem;
font-size: 0.9375rem; font-size: 0.9375rem;
@@ -308,87 +275,6 @@ function formatDate(dateStr: string): string {
} }
} }
/* --- Décider CTA --- */
.dash__decide {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem;
background: var(--mood-accent);
border-radius: 16px;
text-decoration: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.dash__decide:hover {
transform: translateY(-2px);
box-shadow: 0 8px 28px var(--mood-shadow);
}
.dash__decide:active { transform: translateY(0); }
.dash__decide-left {
display: flex;
align-items: center;
gap: 0.875rem;
min-width: 0;
}
.dash__decide-icon {
width: 2.75rem;
height: 2.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: rgba(255,255,255,0.18);
color: var(--mood-accent-text);
}
.dash__decide-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.dash__decide-label {
font-size: 1rem;
font-weight: 800;
color: var(--mood-accent-text);
letter-spacing: -0.01em;
}
@media (min-width: 640px) {
.dash__decide-label { font-size: 1.125rem; }
}
.dash__decide-sub {
font-size: 0.75rem;
color: rgba(255,255,255,0.75);
font-weight: 500;
}
@media (min-width: 640px) {
.dash__decide-sub { font-size: 0.8125rem; }
}
.dash__decide-arrow {
flex-shrink: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255,255,255,0.18);
color: var(--mood-accent-text);
transition: transform 0.12s;
}
.dash__decide:hover .dash__decide-arrow {
transform: translateX(3px);
}
/* --- Entry cards --- */ /* --- Entry cards --- */
.dash__entries { .dash__entries {
display: grid; display: grid;
@@ -398,6 +284,7 @@ function formatDate(dateStr: string): string {
@media (min-width: 640px) { @media (min-width: 640px) {
.dash__entries { .dash__entries {
grid-template-columns: 1fr 1fr;
gap: 1rem; gap: 1rem;
} }
} }
@@ -444,30 +331,17 @@ function formatDate(dateStr: string): string {
} }
.entry-card__title { .entry-card__title {
font-size: 0.875rem; font-size: 1.25rem;
font-weight: 700; font-weight: 800;
color: var(--mood-text); color: var(--mood-text);
margin: 0; margin: 0;
line-height: 1.25;
}
@media (min-width: 640px) {
.entry-card__title {
font-size: 0.9375rem;
}
} }
.entry-card__count { .entry-card__count {
font-size: 0.9375rem; font-size: 1.5rem;
font-weight: 700; font-weight: 800;
color: var(--card-color, var(--mood-accent)); color: var(--card-color, var(--mood-accent));
line-height: 1.2; line-height: 1;
}
@media (min-width: 640px) {
.entry-card__count {
font-size: 1.125rem;
}
} }
.entry-card__total { .entry-card__total {
@@ -476,7 +350,7 @@ function formatDate(dateStr: string): string {
} }
.entry-card__desc { .entry-card__desc {
font-size: 0.8125rem; font-size: 0.875rem;
color: var(--mood-text-muted); color: var(--mood-text-muted);
line-height: 1.4; line-height: 1.4;
} }
@@ -578,7 +452,7 @@ function formatDate(dateStr: string): string {
transform: translateY(0); transform: translateY(0);
} }
/* --- Toolbox card --- */ /* --- Toolbox card (5th block, distinct) --- */
.dash__toolbox-card { .dash__toolbox-card {
display: block; display: block;
text-decoration: none; text-decoration: none;
@@ -621,8 +495,8 @@ function formatDate(dateStr: string): string {
} }
.dash__toolbox-card-title { .dash__toolbox-card-title {
font-size: 1.0625rem; font-size: 1.125rem;
font-weight: 700; font-weight: 800;
color: var(--mood-text); color: var(--mood-text);
margin: 0; margin: 0;
} }
@@ -683,8 +557,8 @@ function formatDate(dateStr: string): string {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
color: var(--mood-text); color: var(--mood-text);
font-weight: 700; font-weight: 800;
font-size: 1rem; font-size: 1.0625rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.dash__activity-head h3 { margin: 0; } .dash__activity-head h3 { margin: 0; }
+46 -62
View File
@@ -49,8 +49,7 @@ async function loginAsProfile(p: DevProfile) {
try { try {
step.value = 'signing' step.value = 'signing'
// Dev mode: bypass extension backend accepte toute signature pour les profils dev await auth.login(p.address)
await auth.login(p.address, () => Promise.resolve('0x' + 'a'.repeat(128)))
step.value = 'success' step.value = 'success'
setTimeout(() => router.push('/'), 800) setTimeout(() => router.push('/'), 800)
} catch (err: any) { } catch (err: any) {
@@ -100,8 +99,6 @@ const activeStepIndex = computed(() => {
} }
}) })
const isProtoMode = computed(() => devProfiles.value.length > 0)
onMounted(() => { onMounted(() => {
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
router.push('/') router.push('/')
@@ -168,50 +165,45 @@ onMounted(() => {
<span>Connecte. Redirection...</span> <span>Connecte. Redirection...</span>
</div> </div>
<!-- Mode prototype : profils démo --> <!-- Button -->
<template v-if="isProtoMode"> <button
<div class="proto-panel"> class="login-card__btn"
<div class="proto-panel__header"> :disabled="!address.trim() || step === 'success' || auth.loading"
<UIcon name="i-lucide-flask-conical" /> @click="handleLogin"
<span>Mode prototype sélectionnez un profil</span> >
</div> <UIcon v-if="auth.loading" name="i-lucide-loader-2" class="animate-spin" />
<div class="proto-panel__profiles"> <UIcon v-else name="i-lucide-log-in" />
<button <span>{{ auth.loading ? 'Verification...' : 'Se connecter' }}</span>
v-for="p in devProfiles" </button>
:key="p.address"
class="dev-profile"
:disabled="devLoading || step === 'success'"
@click="loginAsProfile(p)"
>
<div class="dev-profile__dot" :style="{ background: statusColor(p) }" />
<div class="dev-profile__info">
<span class="dev-profile__name">{{ p.display_name }}</span>
<span class="dev-profile__status">{{ statusLabel(p) }}</span>
</div>
<span class="dev-profile__addr">{{ p.address.slice(0, 8) }}...</span>
</button>
</div>
<p class="proto-panel__note">
Authentification trustWallet à venir intégration librodrome
</p>
</div>
</template>
<!-- Mode production : formulaire + extension --> <!-- Dev Mode Panel -->
<template v-else> <div v-if="devProfiles.length" class="dev-panel">
<button <div class="dev-panel__header">
class="login-card__btn" <UIcon name="i-lucide-bug" />
:disabled="!address.trim() || step === 'success' || auth.loading" <span>Mode Dev Connexion rapide</span>
@click="handleLogin" </div>
> <div class="dev-panel__profiles">
<UIcon v-if="auth.loading" name="i-lucide-loader-2" class="animate-spin" /> <button
<UIcon v-else name="i-lucide-log-in" /> v-for="p in devProfiles"
<span>{{ auth.loading ? 'Verification...' : 'Se connecter' }}</span> :key="p.address"
</button> class="dev-profile"
<p class="login-card__note"> :disabled="devLoading || step === 'success'"
Aucun mot de passe. Authentification par signature cryptographique. @click="loginAsProfile(p)"
</p> >
</template> <div class="dev-profile__dot" :style="{ background: statusColor(p) }" />
<div class="dev-profile__info">
<span class="dev-profile__name">{{ p.display_name }}</span>
<span class="dev-profile__status">{{ statusLabel(p) }}</span>
</div>
<span class="dev-profile__addr">{{ p.address.slice(0, 8) }}...</span>
</button>
</div>
</div>
<!-- Note -->
<p class="login-card__note">
Aucun mot de passe. Authentification par signature cryptographique.
</p>
</div> </div>
</div> </div>
</template> </template>
@@ -460,40 +452,32 @@ onMounted(() => {
cursor: not-allowed; cursor: not-allowed;
} }
/* Proto panel */ /* Dev panel */
.proto-panel { .dev-panel {
border: 2px dashed var(--mood-warning, #f59e0b);
border-radius: 16px; border-radius: 16px;
padding: 1rem; padding: 1rem;
background: var(--mood-accent-soft); background: rgba(245, 158, 11, 0.04);
box-shadow: 0 2px 12px var(--mood-shadow, rgba(0,0,0,0.06));
} }
.proto-panel__header { .dev-panel__header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 700; font-weight: 700;
color: var(--mood-accent); color: var(--mood-warning, #f59e0b);
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.proto-panel__profiles { .dev-panel__profiles {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.proto-panel__note {
margin-top: 0.75rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
opacity: 0.7;
text-align: center;
}
.dev-profile { .dev-profile {
display: flex; display: flex;
align-items: center; align-items: center;
+251 -216
View File
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
const route = useRoute() const route = useRoute()
const mandates = useMandatesStore() const mandates = useMandatesStore()
const { $api } = useApi()
const mandateId = computed(() => route.params.id as string) const mandateId = computed(() => route.params.id as string)
@@ -14,95 +13,77 @@ onUnmounted(() => {
}) })
watch(mandateId, async (newId) => { watch(mandateId, async (newId) => {
if (newId) await mandates.fetchById(newId) if (newId) {
await mandates.fetchById(newId)
}
}) })
// --- Helpers --- // --- Status helpers ---
const typeLabel = (t: string) => ({ statutory: 'Statutaire', functional: 'Fonctionnel' }[t] ?? t) const typeLabel = (mandateType: string) => {
switch (mandateType) {
function formatDate(d: string | null): string { case 'techcomm': return 'Comite technique'
if (!d) return '-' case 'smith': return 'Forgeron'
return new Date(d).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) case 'custom': return 'Personnalise'
default: return mandateType
}
} }
const terminalStatuses = ['completed', 'revoked'] function formatDate(dateStr: string | null): string {
const isTerminal = computed(() => !mandates.current || terminalStatuses.includes(mandates.current.status)) if (!dateStr) return '-'
const canRevoke = computed(() => mandates.current?.status === 'active') return new Date(dateStr).toLocaleDateString('fr-FR', {
const isDraft = computed(() => mandates.current?.status === 'draft') day: 'numeric',
month: 'long',
year: 'numeric',
})
}
// --- Advance --- // --- Terminal state check ---
const terminalStatuses = ['completed', 'revoked']
const isTerminal = computed(() => {
if (!mandates.current) return true
return terminalStatuses.includes(mandates.current.status)
})
const canRevoke = computed(() => {
if (!mandates.current) return false
return mandates.current.status === 'active'
})
// --- Advance action ---
const advancing = ref(false) const advancing = ref(false)
async function handleAdvance() { async function handleAdvance() {
advancing.value = true advancing.value = true
try { await mandates.advance(mandateId.value) } catch { /* store holds error */ } finally { advancing.value = false } try {
} await mandates.advance(mandateId.value)
} catch {
// --- Identity search (shared for assign + edit) --- // Error handled by store
} finally {
interface IdentityResult { id: string; address: string; display_name: string | null } advancing.value = false
function useIdentitySearch() {
const query = ref('')
const results = ref<IdentityResult[]>([])
const searching = ref(false)
const selectedId = ref<string | null>(null)
const selectedLabel = ref('')
let timer: ReturnType<typeof setTimeout> | null = null
async function search(q: string) {
if (q.length < 2) { results.value = []; return }
searching.value = true
try {
results.value = await $api<IdentityResult[]>('/auth/identities', { query: { q } })
} catch { results.value = [] } finally { searching.value = false }
} }
function onInput(q: string) {
query.value = q
selectedId.value = null
if (timer) clearTimeout(timer)
timer = setTimeout(() => search(q), 300)
}
function select(i: IdentityResult) {
selectedId.value = i.id
selectedLabel.value = i.display_name || i.address
query.value = i.display_name || i.address
results.value = []
}
function reset() {
query.value = ''
results.value = []
selectedId.value = null
selectedLabel.value = ''
}
return { query, results, searching, selectedId, selectedLabel, onInput, select, reset }
} }
// --- Assign mandatee --- // --- Assign mandatee ---
const showAssignModal = ref(false) const showAssignModal = ref(false)
const mandateeAddress = ref('')
const assigning = ref(false) const assigning = ref(false)
const assignSearch = useIdentitySearch()
async function handleAssign() { async function handleAssign() {
if (!assignSearch.selectedId.value) return if (!mandateeAddress.value.trim()) return
assigning.value = true assigning.value = true
try { try {
await mandates.assignMandatee(mandateId.value, assignSearch.selectedId.value) await mandates.assignMandatee(mandateId.value, mandateeAddress.value.trim())
showAssignModal.value = false showAssignModal.value = false
assignSearch.reset() mandateeAddress.value = ''
} catch { /* store holds error */ } finally { assigning.value = false } } catch {
} // Error handled by store
} finally {
function openAssign() { assigning.value = false
assignSearch.reset() }
showAssignModal.value = true
} }
// --- Revoke --- // --- Revoke ---
@@ -115,28 +96,27 @@ async function handleRevoke() {
try { try {
await mandates.revoke(mandateId.value) await mandates.revoke(mandateId.value)
showRevokeConfirm.value = false showRevokeConfirm.value = false
} catch { /* store holds error */ } finally { revoking.value = false } } catch {
// Error handled by store
} finally {
revoking.value = false
}
} }
// --- Edit --- // --- Edit modal ---
const showEditModal = ref(false) const showEditModal = ref(false)
const editData = ref({ title: '', origin_id: null as string | null, description: '' }) const editData = ref({
const editOriginSearch = useIdentitySearch() title: '',
description: '' as string | null,
})
const saving = ref(false) const saving = ref(false)
function openEdit() { function openEdit() {
if (!mandates.current) return if (!mandates.current) return
editData.value = { editData.value = {
title: mandates.current.title, title: mandates.current.title,
origin_id: mandates.current.origin_id, description: mandates.current.description,
description: mandates.current.description ?? '',
}
if (mandates.current.origin_display_name) {
editOriginSearch.query.value = mandates.current.origin_display_name
editOriginSearch.selectedId.value = mandates.current.origin_id
} else {
editOriginSearch.reset()
} }
showEditModal.value = true showEditModal.value = true
} }
@@ -144,35 +124,50 @@ function openEdit() {
async function saveEdit() { async function saveEdit() {
saving.value = true saving.value = true
try { try {
await mandates.update(mandateId.value, { await mandates.update(mandateId.value, editData.value)
title: editData.value.title,
origin_id: editOriginSearch.selectedId.value ?? editData.value.origin_id,
description: editData.value.description || null,
})
showEditModal.value = false showEditModal.value = false
} catch { /* store holds error */ } finally { saving.value = false } } catch {
// Error handled by store
} finally {
saving.value = false
}
} }
// --- Delete --- // --- Delete ---
const showDeleteConfirm = ref(false) const showDeleteConfirm = ref(false)
const deleting = ref(false) const deleting = ref(false)
const isDraft = computed(() => mandates.current?.status === 'draft')
async function handleDelete() { async function handleDelete() {
deleting.value = true deleting.value = true
try { try {
await mandates.delete(mandateId.value) await mandates.delete(mandateId.value)
navigateTo('/mandates') navigateTo('/mandates')
} catch { /* store holds error */ } finally { deleting.value = false; showDeleteConfirm.value = false } } catch {
// Error handled by store
} finally {
deleting.value = false
showDeleteConfirm.value = false
}
} }
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<!-- Back link -->
<div> <div>
<UButton to="/mandates" variant="ghost" color="neutral" icon="i-lucide-arrow-left" label="Retour aux mandats" size="sm" /> <UButton
to="/mandates"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour aux mandats"
size="sm"
/>
</div> </div>
<!-- Loading state -->
<template v-if="mandates.loading"> <template v-if="mandates.loading">
<div class="space-y-4"> <div class="space-y-4">
<USkeleton class="h-8 w-96" /> <USkeleton class="h-8 w-96" />
@@ -183,6 +178,7 @@ async function handleDelete() {
</div> </div>
</template> </template>
<!-- Error state -->
<template v-else-if="mandates.error"> <template v-else-if="mandates.error">
<UCard> <UCard>
<div class="flex items-center gap-3 text-red-500"> <div class="flex items-center gap-3 text-red-500">
@@ -192,35 +188,79 @@ async function handleDelete() {
</UCard> </UCard>
</template> </template>
<!-- Mandate detail -->
<template v-else-if="mandates.current"> <template v-else-if="mandates.current">
<!-- Header --> <!-- Header with actions -->
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ mandates.current.title }}</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ mandates.current.title }}
</h1>
<div class="flex items-center gap-3 mt-2"> <div class="flex items-center gap-3 mt-2">
<UBadge variant="subtle" color="primary">{{ typeLabel(mandates.current.mandate_type) }}</UBadge> <UBadge variant="subtle" color="primary">
{{ typeLabel(mandates.current.mandate_type) }}
</UBadge>
<StatusBadge :status="mandates.current.status" type="mandate" /> <StatusBadge :status="mandates.current.status" type="mandate" />
</div> </div>
</div> </div>
<!-- Action buttons -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<UButton v-if="!isTerminal" icon="i-lucide-fast-forward" label="Avancer" color="primary" variant="soft" size="sm" :loading="advancing" @click="handleAdvance" /> <UButton
<UButton v-if="!isTerminal && !mandates.current.mandatee_id" icon="i-lucide-user-plus" label="Assigner un mandataire" variant="soft" color="primary" size="sm" @click="openAssign" /> v-if="!isTerminal"
<UButton icon="i-lucide-pen-line" label="Modifier" variant="soft" color="neutral" size="sm" @click="openEdit" /> icon="i-lucide-fast-forward"
<UButton v-if="canRevoke" icon="i-lucide-shield-off" label="Revoquer" variant="soft" color="error" size="sm" @click="showRevokeConfirm = true" /> label="Avancer"
<UButton v-if="isDraft" icon="i-lucide-trash-2" label="Supprimer" variant="soft" color="error" size="sm" @click="showDeleteConfirm = true" /> color="primary"
variant="soft"
size="sm"
:loading="advancing"
@click="handleAdvance"
/>
<UButton
v-if="!isTerminal && !mandates.current.mandatee_id"
icon="i-lucide-user-plus"
label="Assigner un mandataire"
variant="soft"
color="primary"
size="sm"
@click="showAssignModal = true"
/>
<UButton
icon="i-lucide-pen-line"
label="Modifier"
variant="soft"
color="neutral"
size="sm"
@click="openEdit"
/>
<UButton
v-if="canRevoke"
icon="i-lucide-shield-off"
label="Revoquer"
variant="soft"
color="error"
size="sm"
@click="showRevokeConfirm = true"
/>
<UButton
v-if="isDraft"
icon="i-lucide-trash-2"
label="Supprimer"
variant="soft"
color="error"
size="sm"
@click="showDeleteConfirm = true"
/>
</div> </div>
</div> </div>
<!-- Error feedback --> <!-- Description -->
<div v-if="mandates.error" class="text-sm text-red-500 bg-red-50 dark:bg-red-950 px-4 py-2 rounded-lg">
{{ mandates.error }}
</div>
<UCard v-if="mandates.current.description"> <UCard v-if="mandates.current.description">
<div> <div>
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Description</h3> <h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Description</h3>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{{ mandates.current.description }}</p> <p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{{ mandates.current.description }}
</p>
</div> </div>
</UCard> </UCard>
@@ -230,204 +270,199 @@ async function handleDelete() {
<div> <div>
<p class="text-gray-500">Mandataire</p> <p class="text-gray-500">Mandataire</p>
<p class="font-medium text-gray-900 dark:text-white"> <p class="font-medium text-gray-900 dark:text-white">
<template v-if="mandates.current.mandatee_display_name">{{ mandates.current.mandatee_display_name }}</template> <template v-if="mandates.current.mandatee_id">
<template v-else-if="mandates.current.mandatee_id"><span class="font-mono text-xs">{{ mandates.current.mandatee_id.slice(0, 12) }}</span></template> <span class="font-mono text-xs">{{ mandates.current.mandatee_id.slice(0, 12) }}...</span>
<template v-else><span class="text-gray-400 italic">Non assigne</span></template> </template>
</p> <template v-else>
</div> <span class="text-gray-400 italic">Non assigne</span>
<div> </template>
<p class="text-gray-500">Origine</p>
<p class="font-medium text-gray-900 dark:text-white">
<template v-if="mandates.current.origin_display_name">{{ mandates.current.origin_display_name }}</template>
<template v-else><span class="text-gray-400 italic">Non renseigné</span></template>
</p> </p>
</div> </div>
<div> <div>
<p class="text-gray-500">Debut</p> <p class="text-gray-500">Debut</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.starts_at) }}</p> <p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(mandates.current.starts_at) }}
</p>
</div> </div>
<div> <div>
<p class="text-gray-500">Fin</p> <p class="text-gray-500">Fin</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.ends_at) }}</p> <p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(mandates.current.ends_at) }}
</p>
</div>
<div>
<p class="text-gray-500">Nombre d'etapes</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ mandates.current.steps.length }}
</p>
</div> </div>
</div> </div>
</UCard> </UCard>
<!-- Dates metadata -->
<UCard> <UCard>
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<p class="text-gray-500">Cree le</p> <p class="text-gray-500">Cree le</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.created_at) }}</p> <p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(mandates.current.created_at) }}
</p>
</div> </div>
<div> <div>
<p class="text-gray-500">Mis a jour le</p> <p class="text-gray-500">Mis a jour le</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.updated_at) }}</p> <p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(mandates.current.updated_at) }}
</p>
</div> </div>
</div> </div>
</UCard> </UCard>
<!-- Steps --> <!-- Steps timeline -->
<div> <div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Etapes du mandat</h2> <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
<MandateTimeline :steps="mandates.current.steps" :current-status="mandates.current.status" /> Etapes du mandat
</h2>
<MandateTimeline
:steps="mandates.current.steps"
:current-status="mandates.current.status"
/>
</div> </div>
</template> </template>
<!-- Modal : Assigner un mandataire --> <!-- Assign mandatee modal -->
<UModal v-model:open="showAssignModal"> <UModal v-model:open="showAssignModal">
<template #content> <template #content>
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Assigner un mandataire</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Assigner un mandataire
</h3>
<div class="space-y-1"> <div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Rechercher un membre <span class="text-red-500">*</span> Adresse du mandataire <span class="text-red-500">*</span>
</label> </label>
<div class="relative"> <UInput
<input v-model="mandateeAddress"
:value="assignSearch.query.value" placeholder="Adresse Duniter (ex: 5Grw...)
type="text" "
class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
placeholder="Nom ou adresse Duniter…" <p class="text-xs text-gray-500">
@input="assignSearch.onInput(($event.target as HTMLInputElement).value)" Adresse SS58 du membre de la toile de confiance
/>
<div
v-if="assignSearch.results.value.length"
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
>
<button
v-for="r in assignSearch.results.value"
:key="r.id"
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
@click="assignSearch.select(r)"
>
<UIcon name="i-lucide-user" class="text-gray-400 shrink-0" />
<div>
<p class="font-medium text-gray-900 dark:text-white">{{ r.display_name || r.address }}</p>
<p class="text-xs text-gray-500 font-mono">{{ r.address.slice(0, 20) }}</p>
</div>
</button>
</div>
</div>
<p v-if="assignSearch.selectedId.value" class="text-xs text-green-600 flex items-center gap-1">
<UIcon name="i-lucide-check-circle" /> {{ assignSearch.selectedLabel.value }} sélectionné
</p> </p>
</div> </div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700"> <div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showAssignModal = false">Annuler</button> <UButton
<button label="Annuler"
class="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" variant="ghost"
:disabled="!assignSearch.selectedId.value || assigning" color="neutral"
@click="showAssignModal = false"
/>
<UButton
label="Assigner"
icon="i-lucide-user-plus"
color="primary"
:loading="assigning"
:disabled="!mandateeAddress.trim()"
@click="handleAssign" @click="handleAssign"
> />
<UIcon v-if="assigning" name="i-lucide-loader-2" class="animate-spin text-sm" />
Assigner
</button>
</div> </div>
</div> </div>
</template> </template>
</UModal> </UModal>
<!-- Modal : Révoquer --> <!-- Revoke confirmation modal -->
<UModal v-model:open="showRevokeConfirm"> <UModal v-model:open="showRevokeConfirm">
<template #content> <template #content>
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600">Confirmer la revocation</h3> <h3 class="text-lg font-semibold text-red-600">
Confirmer la revocation
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400"> <p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir revoquer ce mandat ? Le mandataire perdra ses droits et responsabilites. Etes-vous sur de vouloir revoquer ce mandat ? Le mandataire perdra ses droits et responsabilites.
</p> </p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700"> <div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showRevokeConfirm = false">Annuler</button> <UButton
<button label="Annuler"
class="px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-xl hover:bg-red-700 flex items-center gap-2" variant="ghost"
:disabled="revoking" color="neutral"
@click="showRevokeConfirm = false"
/>
<UButton
label="Revoquer"
icon="i-lucide-shield-off"
color="error"
:loading="revoking"
@click="handleRevoke" @click="handleRevoke"
> />
<UIcon v-if="revoking" name="i-lucide-loader-2" class="animate-spin text-sm" />
Revoquer
</button>
</div> </div>
</div> </div>
</template> </template>
</UModal> </UModal>
<!-- Modal : Modifier --> <!-- Edit modal -->
<UModal v-model:open="showEditModal"> <UModal v-model:open="showEditModal">
<template #content> <template #content>
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Modifier le mandat</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Modifier le mandat
</h3>
<div class="space-y-1"> <div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
<input v-model="editData.title" type="text" class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" /> <UInput v-model="editData.title" />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Origine</label>
<div class="relative">
<input
:value="editOriginSearch.query.value"
type="text"
class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Rechercher un membre…"
@input="editOriginSearch.onInput(($event.target as HTMLInputElement).value)"
/>
<div
v-if="editOriginSearch.results.value.length"
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
>
<button
v-for="r in editOriginSearch.results.value"
:key="r.id"
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
@click="editOriginSearch.select(r)"
>
<UIcon name="i-lucide-user" class="text-gray-400 shrink-0" />
<span>{{ r.display_name || r.address }}</span>
</button>
</div>
</div>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea v-model="editData.description" rows="4" class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none" /> <UTextarea v-model="editData.description" :rows="4" />
</div> </div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700"> <div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showEditModal = false">Annuler</button> <UButton
<button label="Annuler"
class="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" variant="ghost"
:disabled="!editData.title?.trim() || saving" color="neutral"
@click="showEditModal = false"
/>
<UButton
label="Enregistrer"
icon="i-lucide-save"
color="primary"
:loading="saving"
:disabled="!editData.title?.trim()"
@click="saveEdit" @click="saveEdit"
> />
<UIcon v-if="saving" name="i-lucide-loader-2" class="animate-spin text-sm" />
Enregistrer
</button>
</div> </div>
</div> </div>
</template> </template>
</UModal> </UModal>
<!-- Modal : Supprimer --> <!-- Delete confirmation modal -->
<UModal v-model:open="showDeleteConfirm"> <UModal v-model:open="showDeleteConfirm">
<template #content> <template #content>
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600">Confirmer la suppression</h3> <h3 class="text-lg font-semibold text-red-600">
Confirmer la suppression
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400"> <p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir supprimer ce mandat ? Cette action est irreversible. Etes-vous sur de vouloir supprimer ce mandat ? Cette action est irreversible.
</p> </p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700"> <div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showDeleteConfirm = false">Annuler</button> <UButton
<button label="Annuler"
class="px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-xl hover:bg-red-700 flex items-center gap-2" variant="ghost"
:disabled="deleting" color="neutral"
@click="showDeleteConfirm = false"
/>
<UButton
label="Supprimer"
icon="i-lucide-trash-2"
color="error"
:loading="deleting"
@click="handleDelete" @click="handleDelete"
> />
<UIcon v-if="deleting" name="i-lucide-loader-2" class="animate-spin text-sm" />
Supprimer
</button>
</div> </div>
</div> </div>
</template> </template>
+6 -8
View File
@@ -66,7 +66,7 @@ const filteredMandates = computed(() => {
// Filter by status group // Filter by status group
if (activeStatus.value && statusGroupMap[activeStatus.value]) { if (activeStatus.value && statusGroupMap[activeStatus.value]) {
const allowedStatuses = statusGroupMap[activeStatus.value]! const allowedStatuses = statusGroupMap[activeStatus.value]
list = list.filter(m => allowedStatuses.includes(m.status)) list = list.filter(m => allowedStatuses.includes(m.status))
} }
@@ -95,8 +95,6 @@ const filteredMandates = computed(() => {
const typeLabel = (mandateType: string) => { const typeLabel = (mandateType: string) => {
switch (mandateType) { switch (mandateType) {
case 'statutory': return 'Statutaire'
case 'functional': return 'Fonctionnel'
case 'techcomm': return 'Comité technique' case 'techcomm': return 'Comité technique'
case 'smith': return 'Forgeron' case 'smith': return 'Forgeron'
case 'custom': return 'Personnalisé' case 'custom': return 'Personnalisé'
@@ -156,14 +154,14 @@ async function handleCreate() {
{{ opt.label }} {{ opt.label }}
</option> </option>
</select> </select>
<NuxtLink <button
v-if="auth.isAuthenticated" v-if="auth.isAuthenticated"
to="/mandates/new"
class="action-btn" class="action-btn"
@click="showCreateModal = true"
> >
<UIcon name="i-lucide-plus" class="text-xs" /> <UIcon name="i-lucide-plus" class="text-xs" />
<span>Nouveau</span> <span>Nouveau</span>
</NuxtLink> </button>
</template> </template>
<!-- Main content: mandates list --> <!-- Main content: mandates list -->
@@ -201,11 +199,11 @@ async function handleCreate() {
<div class="mandate-onboarding__actions"> <div class="mandate-onboarding__actions">
<UButton <UButton
v-if="auth.isAuthenticated" v-if="auth.isAuthenticated"
to="/mandates/new"
label="Créer un premier mandat" label="Créer un premier mandat"
icon="i-lucide-plus" icon="i-lucide-plus"
color="primary" color="primary"
size="sm" size="sm"
@click="showCreateModal = true"
/> />
<UButton <UButton
to="/protocols" to="/protocols"
@@ -292,7 +290,7 @@ async function handleCreate() {
:actions="[ :actions="[
{ label: 'Nouveau mandat', icon: 'i-lucide-plus', emit: 'create', primary: true }, { label: 'Nouveau mandat', icon: 'i-lucide-plus', emit: 'create', primary: true },
]" ]"
@action="e => e === 'create' && navigateTo('/mandates/new')" @action="e => e === 'create' && (showCreateModal = true)"
/> />
<!-- Révocation --> <!-- Révocation -->
File diff suppressed because it is too large Load Diff
+18 -357
View File
@@ -24,7 +24,6 @@ onMounted(async () => {
await Promise.all([ await Promise.all([
protocols.fetchProtocols(), protocols.fetchProtocols(),
protocols.fetchFormulas(), protocols.fetchFormulas(),
groupsStore.fetchAll(),
]) ])
}) })
@@ -137,6 +136,24 @@ interface OperationalProtocol {
} }
const operationalProtocols: OperationalProtocol[] = [ const operationalProtocols: OperationalProtocol[] = [
{
slug: 'election-sociocratique',
name: 'Élection sociocratique',
description: 'Processus d\'élection d\'un rôle par consentement : clarification du rôle, nominations silencieuses, argumentaire, levée d\'objections. Garantit légitimité et clarté.',
category: 'gouvernance',
icon: 'i-lucide-users',
instancesLabel: 'Tout renouvellement de rôle',
linkedRefs: [
{ label: 'Mandats', icon: 'i-lucide-user-check', to: '/mandates', kind: 'decision' },
],
steps: [
{ label: 'Clarifier le rôle', actor: 'Cercle', icon: 'i-lucide-clipboard-list', type: 'checklist' },
{ label: 'Nominations silencieuses', actor: 'Tous les membres', icon: 'i-lucide-pencil', type: 'checklist' },
{ label: 'Recueil & argumentaire', actor: 'Facilitateur', icon: 'i-lucide-list-checks', type: 'checklist' },
{ label: 'Objections & consentement', actor: 'Cercle', icon: 'i-lucide-shield-check', type: 'certification' },
{ label: 'Proclamation', actor: 'Facilitateur', icon: 'i-lucide-star', type: 'on_chain' },
],
},
{ {
slug: 'embarquement-forgeron', slug: 'embarquement-forgeron',
name: 'Embarquement Forgeron', name: 'Embarquement Forgeron',
@@ -175,73 +192,6 @@ const operationalProtocols: OperationalProtocol[] = [
}, },
] ]
// Groups
const groupsStore = useGroupsStore()
const showGroupModal = ref(false)
const newGroupName = ref('')
const newGroupDesc = ref('')
const creatingGroup = ref(false)
const expandedGroupId = ref<string | null>(null)
const expandedGroupDetail = ref<import('~/stores/groups').Group | null>(null)
const loadingGroupDetail = ref(false)
const newMemberName = ref('')
const addingMember = ref(false)
async function openGroupModal() {
newGroupName.value = ''
newGroupDesc.value = ''
showGroupModal.value = true
}
async function createGroup() {
if (!newGroupName.value.trim()) return
creatingGroup.value = true
try {
await groupsStore.create({ name: newGroupName.value.trim(), description: newGroupDesc.value.trim() || null })
showGroupModal.value = false
} finally {
creatingGroup.value = false
}
}
async function toggleGroupDetail(groupId: string) {
if (expandedGroupId.value === groupId) {
expandedGroupId.value = null
expandedGroupDetail.value = null
return
}
expandedGroupId.value = groupId
loadingGroupDetail.value = true
expandedGroupDetail.value = await groupsStore.getGroup(groupId)
loadingGroupDetail.value = false
}
async function addMember(groupId: string) {
if (!newMemberName.value.trim()) return
addingMember.value = true
const member = await groupsStore.addMember(groupId, { display_name: newMemberName.value.trim() })
if (member && expandedGroupDetail.value) {
expandedGroupDetail.value.members.push(member)
newMemberName.value = ''
}
addingMember.value = false
}
async function removeMember(groupId: string, memberId: string) {
const ok = await groupsStore.removeMember(groupId, memberId)
if (ok && expandedGroupDetail.value) {
expandedGroupDetail.value.members = expandedGroupDetail.value.members.filter(m => m.id !== memberId)
}
}
async function deleteGroup(groupId: string) {
await groupsStore.remove(groupId)
if (expandedGroupId.value === groupId) {
expandedGroupId.value = null
expandedGroupDetail.value = null
}
}
/** n8n workflow demo items. */ /** n8n workflow demo items. */
const n8nWorkflows = [ const n8nWorkflows = [
{ {
@@ -398,79 +348,6 @@ const n8nWorkflows = [
</div> </div>
</template> </template>
<!-- Groups -->
<div class="proto-groups">
<h3 class="proto-groups__title">
<UIcon name="i-lucide-users-round" class="text-sm" />
Groupes d'identités
<span class="proto-groups__count">{{ groupsStore.list.length }}</span>
<button v-if="auth.isAuthenticated" class="proto-groups__add-btn" @click="openGroupModal">
<UIcon name="i-lucide-plus" class="text-sm" />
Nouveau groupe
</button>
</h3>
<div v-if="groupsStore.list.length === 0" class="proto-groups__empty">
<UIcon name="i-lucide-users" class="text-lg" />
<span>Aucun groupe défini. Les groupes permettent de pré-sélectionner des cercles dans les décisions.</span>
</div>
<div v-else class="proto-groups__list">
<div v-for="g in groupsStore.list" :key="g.id" class="proto-groups__item">
<div class="proto-groups__item-head" @click="toggleGroupDetail(g.id)">
<div class="proto-groups__item-info">
<UIcon name="i-lucide-users" class="text-sm" />
<span class="proto-groups__item-name">{{ g.name }}</span>
<span class="proto-groups__item-count">{{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }}</span>
</div>
<div class="proto-groups__item-actions">
<button v-if="auth.isAuthenticated" class="proto-groups__delete-btn" @click.stop="deleteGroup(g.id)">
<UIcon name="i-lucide-trash-2" class="text-xs" />
</button>
<UIcon :name="expandedGroupId === g.id ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="text-sm" />
</div>
</div>
<Transition name="slide-down">
<div v-if="expandedGroupId === g.id" class="proto-groups__detail">
<p v-if="g.description" class="proto-groups__detail-desc">{{ g.description }}</p>
<div v-if="loadingGroupDetail" class="proto-groups__members-loading">
<UIcon name="i-lucide-loader-2" class="animate-spin text-sm" />
</div>
<ul v-else-if="expandedGroupDetail" class="proto-groups__members">
<li v-for="m in expandedGroupDetail.members" :key="m.id" class="proto-groups__member">
<UIcon name="i-lucide-user" class="text-xs" />
<span>{{ m.display_name }}</span>
<button v-if="auth.isAuthenticated" class="proto-groups__member-remove" @click="removeMember(g.id, m.id)">
<UIcon name="i-lucide-x" class="text-xs" />
</button>
</li>
<li v-if="expandedGroupDetail.members.length === 0" class="proto-groups__member proto-groups__member--empty">
Aucun membre
</li>
</ul>
<div v-if="auth.isAuthenticated" class="proto-groups__add-member">
<input
v-model="newMemberName"
type="text"
lang="fr"
spellcheck="true"
class="proto-groups__member-input"
placeholder="Nom ou adresse Duniter"
@keydown.enter="addMember(g.id)"
/>
<button class="proto-groups__member-btn" :disabled="addingMember || !newMemberName.trim()" @click="addMember(g.id)">
<UIcon v-if="addingMember" name="i-lucide-loader-2" class="animate-spin text-xs" />
<UIcon v-else name="i-lucide-user-plus" class="text-xs" />
Ajouter
</button>
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- Operational protocols (always visible, frontend-only data) --> <!-- Operational protocols (always visible, frontend-only data) -->
<div class="proto-ops"> <div class="proto-ops">
<h3 class="proto-ops__title"> <h3 class="proto-ops__title">
@@ -647,51 +524,6 @@ const n8nWorkflows = [
</div> </div>
</template> </template>
</UModal> </UModal>
<!-- Create group modal -->
<UModal v-model:open="showGroupModal">
<template #content>
<div class="proto-modal">
<h3 class="proto-modal__title">Nouveau groupe d'identités</h3>
<div class="proto-modal__fields">
<div class="proto-modal__field">
<label class="proto-modal__label">Nom du groupe</label>
<input
v-model="newGroupName"
type="text"
lang="fr"
spellcheck="true"
class="proto-modal__input"
placeholder="Ex: Comité technique, Forgerons actifs…"
/>
</div>
<div class="proto-modal__field">
<label class="proto-modal__label">Description <span class="proto-modal__optional">(optionnel)</span></label>
<textarea
v-model="newGroupDesc"
class="proto-modal__textarea"
lang="fr"
spellcheck="true"
placeholder="Rôle ou périmètre de ce groupe…"
rows="2"
/>
</div>
</div>
<div class="proto-modal__actions">
<button class="proto-modal__cancel" @click="showGroupModal = false">Annuler</button>
<button
class="proto-modal__submit"
:disabled="!newGroupName.trim() || creatingGroup"
@click="createGroup"
>
<UIcon v-if="creatingGroup" name="i-lucide-loader-2" class="animate-spin" />
<UIcon v-else name="i-lucide-users-round" />
Créer le groupe
</button>
</div>
</div>
</template>
</UModal>
</template> </template>
<style scoped> <style scoped>
@@ -1371,175 +1203,4 @@ const n8nWorkflows = [
box-shadow: 0 4px 12px var(--mood-shadow); box-shadow: 0 4px 12px var(--mood-shadow);
} }
.proto-modal__submit:disabled { opacity: 0.4; cursor: not-allowed; } .proto-modal__submit:disabled { opacity: 0.4; cursor: not-allowed; }
.proto-modal__optional { font-size: 0.8125rem; opacity: 0.55; font-weight: 400; }
/* --- Groups --- */
.proto-groups {
margin-top: 2rem;
}
.proto-groups__title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
margin-bottom: 1rem;
}
.proto-groups__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.375rem;
height: 1.375rem;
padding: 0 0.375rem;
font-size: 0.75rem;
font-weight: 700;
background: var(--mood-accent-soft);
color: var(--mood-accent);
border-radius: 20px;
}
.proto-groups__add-btn {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
background: var(--mood-accent-soft);
border-radius: 16px;
padding: 0.25rem 0.75rem;
cursor: pointer;
transition: transform 0.1s ease;
}
.proto-groups__add-btn:hover { transform: translateY(-1px); }
.proto-groups__empty {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 1rem 1.25rem;
color: var(--mood-muted);
background: var(--mood-surface);
border-radius: 14px;
font-size: 0.875rem;
}
.proto-groups__list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.proto-groups__item {
background: var(--mood-surface);
border-radius: 14px;
overflow: hidden;
}
.proto-groups__item-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background 0.12s ease;
}
.proto-groups__item-head:hover { background: var(--mood-hover); }
.proto-groups__item-info {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--mood-text);
}
.proto-groups__item-name { font-weight: 600; font-size: 0.9375rem; }
.proto-groups__item-count {
font-size: 0.75rem;
color: var(--mood-muted);
background: var(--mood-accent-soft);
border-radius: 12px;
padding: 0.125rem 0.5rem;
}
.proto-groups__item-actions {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--mood-muted);
}
.proto-groups__delete-btn {
color: var(--mood-danger, #e53e3e);
opacity: 0.5;
cursor: pointer;
transition: opacity 0.1s;
}
.proto-groups__delete-btn:hover { opacity: 1; }
.proto-groups__detail {
padding: 0.75rem 1rem 1rem;
border-top: 1px solid var(--mood-border, rgba(0,0,0,0.06));
}
.proto-groups__detail-desc {
font-size: 0.875rem;
color: var(--mood-muted);
margin-bottom: 0.75rem;
}
.proto-groups__members-loading {
display: flex;
justify-content: center;
padding: 0.5rem;
color: var(--mood-muted);
}
.proto-groups__members {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.proto-groups__member {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--mood-text);
padding: 0.375rem 0.5rem;
background: var(--mood-bg);
border-radius: 8px;
}
.proto-groups__member--empty { color: var(--mood-muted); font-style: italic; }
.proto-groups__member-remove {
margin-left: auto;
color: var(--mood-danger, #e53e3e);
opacity: 0.4;
cursor: pointer;
transition: opacity 0.1s;
}
.proto-groups__member-remove:hover { opacity: 1; }
.proto-groups__add-member {
display: flex;
gap: 0.5rem;
align-items: center;
}
.proto-groups__member-input {
flex: 1;
padding: 0.4375rem 0.75rem;
font-size: 0.875rem;
color: var(--mood-text);
background: var(--mood-bg);
border-radius: 10px;
outline: none;
}
.proto-groups__member-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4375rem 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 14px;
cursor: pointer;
transition: transform 0.1s ease;
white-space: nowrap;
}
.proto-groups__member-btn:hover:not(:disabled) { transform: translateY(-1px); }
.proto-groups__member-btn:disabled { opacity: 0.4; cursor: not-allowed; }
</style> </style>
+4 -124
View File
@@ -20,12 +20,10 @@ interface ToolSection {
tools: Tool[] tools: Tool[]
} }
const expandSocio = ref(false)
const sections: ToolSection[] = [ const sections: ToolSection[] = [
{ {
key: 'documents', key: 'documents',
title: 'Documents de référence', title: 'Documents',
icon: 'i-lucide-book-open', icon: 'i-lucide-book-open',
color: 'var(--mood-accent)', color: 'var(--mood-accent)',
tools: [ tools: [
@@ -38,7 +36,7 @@ const sections: ToolSection[] = [
}, },
{ {
key: 'decisions', key: 'decisions',
title: 'Décisions et consultation d\'avis', title: 'Décisions',
icon: 'i-lucide-scale', icon: 'i-lucide-scale',
color: 'var(--mood-secondary, var(--mood-accent))', color: 'var(--mood-secondary, var(--mood-accent))',
tools: [ tools: [
@@ -51,7 +49,7 @@ const sections: ToolSection[] = [
}, },
{ {
key: 'mandats', key: 'mandats',
title: 'Mandats et nominations', title: 'Mandats',
icon: 'i-lucide-user-check', icon: 'i-lucide-user-check',
color: 'var(--mood-success)', color: 'var(--mood-success)',
tools: [ tools: [
@@ -63,7 +61,7 @@ const sections: ToolSection[] = [
}, },
{ {
key: 'protocoles', key: 'protocoles',
title: 'Protocoles et fonctionnement', title: 'Protocoles',
icon: 'i-lucide-settings', icon: 'i-lucide-settings',
color: 'var(--mood-tertiary, var(--mood-accent))', color: 'var(--mood-tertiary, var(--mood-accent))',
tools: [ tools: [
@@ -151,29 +149,6 @@ const sections: ToolSection[] = [
</div> </div>
</div> </div>
</div> </div>
<!-- Election sociocratique modalité d'élection, accessible depuis mandats -->
<div v-if="section.key === 'mandats'" class="socio-expand">
<button class="socio-expand__trigger" @click="expandSocio = !expandSocio">
<div class="socio-expand__icon">
<UIcon name="i-lucide-users" class="text-sm" />
</div>
<div class="socio-expand__info">
<span class="socio-expand__title">Élection sociocratique</span>
<span class="socio-expand__meta">6 étapes · clarification · consentement collectif</span>
</div>
<span class="socio-expand__tag">Modalité d'élection</span>
<UIcon
:name="expandSocio ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="socio-expand__toggle"
/>
</button>
<Transition name="socio-expand">
<div v-if="expandSocio" class="socio-expand__content">
<SocioElection />
</div>
</Transition>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -354,99 +329,4 @@ const sections: ToolSection[] = [
opacity: 1; opacity: 1;
color: var(--section-color); color: var(--section-color);
} }
/* --- Élection sociocratique expandable --- */
.socio-expand {
margin-top: 0.5rem;
border-radius: 16px;
overflow: hidden;
background: var(--mood-surface);
}
.socio-expand__trigger {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 1rem 1.125rem;
cursor: pointer;
background: none;
text-align: left;
transition: background 0.12s;
}
.socio-expand__trigger:hover { background: var(--mood-accent-soft); }
.socio-expand__icon {
width: 2rem;
height: 2rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(in srgb, var(--mood-success) 12%, transparent);
color: var(--mood-success);
}
.socio-expand__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.socio-expand__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
}
.socio-expand__meta {
font-size: 0.75rem;
color: var(--mood-text-muted);
}
.socio-expand__tag {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 20px;
background: color-mix(in srgb, var(--mood-success) 12%, transparent);
color: var(--mood-success);
white-space: nowrap;
flex-shrink: 0;
}
@media (max-width: 480px) {
.socio-expand__tag { display: none; }
}
.socio-expand__toggle {
flex-shrink: 0;
color: var(--mood-text-muted);
font-size: 0.875rem;
}
.socio-expand__content {
padding: 0 1rem 1rem;
}
.socio-expand-enter-active,
.socio-expand-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.socio-expand-enter-from,
.socio-expand-leave-to {
max-height: 0;
opacity: 0;
}
.socio-expand-enter-to,
.socio-expand-leave-from {
max-height: 2000px;
opacity: 1;
}
</style> </style>
+9 -48
View File
@@ -5,43 +5,6 @@
* The identity object mirrors the backend IdentityOut schema. * The identity object mirrors the backend IdentityOut schema.
*/ */
/**
* Sign a challenge using the injected Duniter/Substrate wallet extension
* (Cesium2, polkadot.js extension, Talisman, etc.).
*
* The extension signs <Bytes>{challenge}</Bytes> to match the backend verifier.
*/
// TODO: trustWallet — remplacer par postMessage vers l'iframe trustWallet (librodrome)
// Protocole prévu : window.postMessage({ type: 'LD_SIGN_REQUEST', address, challenge })
// → trustWallet répond { type: 'LD_SIGN_RESPONSE', signature }
async function _signWithExtension(address: string, challenge: string): Promise<string> {
const { web3Enable, web3FromAddress } = await import('@polkadot/extension-dapp')
const { stringToHex } = await import('@polkadot/util')
const extensions = await web3Enable('libreDecision')
if (!extensions.length) {
throw new Error('Aucune extension Duniter détectée. Installez Cesium² ou Polkadot.js.')
}
let injector
try {
injector = await web3FromAddress(address)
} catch {
throw new Error(`Adresse ${address.slice(0, 10)}… introuvable dans l'extension.`)
}
if (!injector.signer?.signRaw) {
throw new Error("L'extension ne supporte pas la signature de messages bruts.")
}
const { signature } = await injector.signer.signRaw({
address,
data: stringToHex(challenge),
type: 'bytes',
})
return signature
}
export interface DuniterIdentity { export interface DuniterIdentity {
id: string id: string
address: string address: string
@@ -102,12 +65,15 @@ export const useAuthStore = defineStore('auth', {
}, },
) )
// Step 2: Sign the challenge via polkadot.js / Cesium2 extension // Step 2: Sign the challenge
// In production, signFn would use the Duniter keypair to produce an Ed25519 signature.
// For development, we use a placeholder signature.
let signature: string let signature: string
if (signFn) { if (signFn) {
signature = await signFn(challengeRes.challenge) signature = await signFn(challengeRes.challenge)
} else { } else {
signature = await _signWithExtension(address, challengeRes.challenge) // Development placeholder -- backend currently accepts any signature
signature = 'dev_signature_placeholder'
} }
// Step 3: Verify and get token // Step 3: Verify and get token
@@ -152,15 +118,10 @@ export const useAuthStore = defineStore('auth', {
const identity = await $api<DuniterIdentity>('/auth/me') const identity = await $api<DuniterIdentity>('/auth/me')
this.identity = identity this.identity = identity
} catch (err: any) { } catch (err: any) {
const status = (err as any)?.status ?? 0 this.error = err?.data?.detail || err?.message || 'Session invalide'
this.error = err?.message || 'Session invalide' this.token = null
// N'effacer le token que sur 401/403 (session réellement invalide) this.identity = null
// Les erreurs réseau ou 5xx sont transitoires — conserver la session this._clearToken()
if (status === 401 || status === 403) {
this.token = null
this.identity = null
this._clearToken()
}
throw err throw err
} finally { } finally {
this.loading = false this.loading = false
-109
View File
@@ -1,109 +0,0 @@
export interface GroupMember {
id: string
display_name: string
identity_id: string | null
added_at: string
}
export interface Group {
id: string
name: string
description: string | null
organization_id: string | null
created_at: string
members: GroupMember[]
}
export interface GroupSummary {
id: string
name: string
description: string | null
organization_id: string | null
member_count: number
}
export interface GroupCreate {
name: string
description?: string | null
}
export interface GroupMemberCreate {
display_name: string
identity_id?: string | null
}
export const useGroupsStore = defineStore('groups', () => {
const { $api } = useApi()
const list = ref<GroupSummary[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchAll() {
loading.value = true
error.value = null
try {
list.value = await $api<GroupSummary[]>('/groups/')
} catch (e: any) {
error.value = e?.message ?? 'Erreur chargement groupes'
} finally {
loading.value = false
}
}
async function getGroup(id: string): Promise<Group | null> {
try {
return await $api<Group>(`/groups/${id}`)
} catch {
return null
}
}
async function create(payload: GroupCreate): Promise<Group | null> {
try {
const group = await $api<Group>('/groups/', { method: 'POST', body: payload })
await fetchAll()
return group
} catch (e: any) {
error.value = e?.message ?? 'Erreur création groupe'
return null
}
}
async function remove(id: string): Promise<boolean> {
try {
await $api(`/groups/${id}`, { method: 'DELETE' })
list.value = list.value.filter(g => g.id !== id)
return true
} catch {
return false
}
}
async function addMember(groupId: string, payload: GroupMemberCreate): Promise<GroupMember | null> {
try {
const member = await $api<GroupMember>(`/groups/${groupId}/members`, {
method: 'POST',
body: payload,
})
const g = list.value.find(g => g.id === groupId)
if (g) g.member_count++
return member
} catch {
return null
}
}
async function removeMember(groupId: string, memberId: string): Promise<boolean> {
try {
await $api(`/groups/${groupId}/members/${memberId}`, { method: 'DELETE' })
const g = list.value.find(g => g.id === groupId)
if (g) g.member_count--
return true
} catch {
return false
}
}
return { list, loading, error, fetchAll, getGroup, create, remove, addMember, removeMember }
})
+76 -16
View File
@@ -1,3 +1,9 @@
/**
* Mandates store: governance mandates and their lifecycle steps.
*
* Maps to the backend /api/v1/mandates endpoints.
*/
export interface MandateStep { export interface MandateStep {
id: string id: string
mandate_id: string mandate_id: string
@@ -14,13 +20,10 @@ export interface MandateStep {
export interface Mandate { export interface Mandate {
id: string id: string
title: string title: string
origin_id: string | null
origin_display_name: string | null
description: string | null description: string | null
mandate_type: string mandate_type: string
status: string status: string
mandatee_id: string | null mandatee_id: string | null
mandatee_display_name: string | null
decision_id: string | null decision_id: string | null
starts_at: string | null starts_at: string | null
ends_at: string | null ends_at: string | null
@@ -31,10 +34,8 @@ export interface Mandate {
export interface MandateCreate { export interface MandateCreate {
title: string title: string
origin_id?: string | null
description?: string | null description?: string | null
mandate_type: string mandate_type: string
nomination_mode?: string
decision_id?: string | null decision_id?: string | null
starts_at?: string | null starts_at?: string | null
ends_at?: string | null ends_at?: string | null
@@ -42,7 +43,6 @@ export interface MandateCreate {
export interface MandateUpdate { export interface MandateUpdate {
title?: string title?: string
origin_id?: string | null
description?: string | null description?: string | null
mandate_type?: string mandate_type?: string
starts_at?: string | null starts_at?: string | null
@@ -50,7 +50,6 @@ export interface MandateUpdate {
} }
export interface MandateStepCreate { export interface MandateStepCreate {
step_order: number
step_type: string step_type: string
title?: string | null title?: string | null
description?: string | null description?: string | null
@@ -72,20 +71,31 @@ export const useMandatesStore = defineStore('mandates', {
}), }),
getters: { getters: {
byStatus: (state) => (status: string) => state.list.filter(m => m.status === status), byStatus: (state) => {
activeMandates: (state): Mandate[] => state.list.filter(m => m.status === 'active'), return (status: string) => state.list.filter(m => m.status === status)
completedMandates: (state): Mandate[] => state.list.filter(m => m.status === 'completed'), },
activeMandates: (state): Mandate[] => {
return state.list.filter(m => m.status === 'active')
},
completedMandates: (state): Mandate[] => {
return state.list.filter(m => m.status === 'completed')
},
}, },
actions: { actions: {
/**
* Fetch all mandates with optional filters.
*/
async fetchAll(params?: { mandate_type?: string; status?: string }) { async fetchAll(params?: { mandate_type?: string; status?: string }) {
this.loading = true this.loading = true
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
const query: Record<string, string> = {} const query: Record<string, string> = {}
if (params?.mandate_type) query.mandate_type = params.mandate_type if (params?.mandate_type) query.mandate_type = params.mandate_type
if (params?.status) query.status = params.status if (params?.status) query.status = params.status
this.list = await $api<Mandate[]>('/mandates/', { query }) this.list = await $api<Mandate[]>('/mandates/', { query })
} catch (err: any) { } catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des mandats' this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des mandats'
@@ -94,9 +104,13 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Fetch a single mandate by ID with all its steps.
*/
async fetchById(id: string) { async fetchById(id: string) {
this.loading = true this.loading = true
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
this.current = await $api<Mandate>(`/mandates/${id}`) this.current = await $api<Mandate>(`/mandates/${id}`)
@@ -107,12 +121,19 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Create a new mandate.
*/
async create(payload: MandateCreate) { async create(payload: MandateCreate) {
this.loading = true this.loading = true
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
const mandate = await $api<Mandate>('/mandates/', { method: 'POST', body: payload }) const mandate = await $api<Mandate>('/mandates/', {
method: 'POST',
body: payload,
})
this.list.unshift(mandate) this.list.unshift(mandate)
return mandate return mandate
} catch (err: any) { } catch (err: any) {
@@ -123,11 +144,18 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Update an existing mandate.
*/
async update(id: string, data: MandateUpdate) { async update(id: string, data: MandateUpdate) {
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}`, { method: 'PUT', body: data }) const updated = await $api<Mandate>(`/mandates/${id}`, {
method: 'PUT',
body: data,
})
if (this.current?.id === id) this.current = updated if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(m => m.id === id) const idx = this.list.findIndex(m => m.id === id)
if (idx >= 0) this.list[idx] = updated if (idx >= 0) this.list[idx] = updated
@@ -138,8 +166,12 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Delete a mandate.
*/
async delete(id: string) { async delete(id: string) {
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
await $api(`/mandates/${id}`, { method: 'DELETE' }) await $api(`/mandates/${id}`, { method: 'DELETE' })
@@ -151,11 +183,17 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Advance the mandate to the next step in its workflow.
*/
async advance(id: string) { async advance(id: string) {
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}/advance`, { method: 'POST' }) const updated = await $api<Mandate>(`/mandates/${id}/advance`, {
method: 'POST',
})
if (this.current?.id === id) this.current = updated if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(m => m.id === id) const idx = this.list.findIndex(m => m.id === id)
if (idx >= 0) this.list[idx] = updated if (idx >= 0) this.list[idx] = updated
@@ -166,12 +204,21 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Add a step to a mandate.
*/
async addStep(id: string, step: MandateStepCreate) { async addStep(id: string, step: MandateStepCreate) {
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
const newStep = await $api<MandateStep>(`/mandates/${id}/steps`, { method: 'POST', body: step }) const newStep = await $api<MandateStep>(`/mandates/${id}/steps`, {
if (this.current?.id === id) this.current.steps.push(newStep) method: 'POST',
body: step,
})
if (this.current?.id === id) {
this.current.steps.push(newStep)
}
return newStep return newStep
} catch (err: any) { } catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'ajout de l\'etape' this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'ajout de l\'etape'
@@ -179,8 +226,12 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Assign a mandatee to the mandate.
*/
async assignMandatee(id: string, mandateeId: string) { async assignMandatee(id: string, mandateeId: string) {
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}/assign`, { const updated = await $api<Mandate>(`/mandates/${id}/assign`, {
@@ -197,11 +248,17 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Revoke the mandate.
*/
async revoke(id: string) { async revoke(id: string) {
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}/revoke`, { method: 'POST' }) const updated = await $api<Mandate>(`/mandates/${id}/revoke`, {
method: 'POST',
})
if (this.current?.id === id) this.current = updated if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(m => m.id === id) const idx = this.list.findIndex(m => m.id === id)
if (idx >= 0) this.list[idx] = updated if (idx >= 0) this.list[idx] = updated
@@ -212,6 +269,9 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Clear the current mandate.
*/
clearCurrent() { clearCurrent() {
this.current = null this.current = null
}, },
-71
View File
@@ -1,71 +0,0 @@
export interface Organization {
id: string
name: string
slug: string
org_type: string
is_transparent: boolean
color: string | null
icon: string | null
description: string | null
created_at: string
}
interface OrgState {
organizations: Organization[]
activeSlug: string | null
loading: boolean
error: string | null
}
export const useOrganizationsStore = defineStore('organizations', {
state: (): OrgState => ({
organizations: [],
activeSlug: null,
loading: false,
error: null,
}),
getters: {
active: (state): Organization | null =>
state.organizations.find(o => o.slug === state.activeSlug) ?? state.organizations[0] ?? null,
hasOrganizations: (state): boolean => state.organizations.length > 0,
},
actions: {
async fetchOrganizations() {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const orgs = await $api<Organization[]>('/organizations/')
// Duniter G1 first, then alphabetical
this.organizations = orgs.sort((a, b) => {
if (a.slug === 'duniter-g1') return -1
if (b.slug === 'duniter-g1') return 1
return a.name.localeCompare(b.name)
})
// Restore persisted active slug, or default to first org
const stored = import.meta.client ? localStorage.getItem('libredecision_org') : null
if (stored && this.organizations.some(o => o.slug === stored)) {
this.activeSlug = stored
} else if (this.organizations.length > 0) {
this.activeSlug = this.organizations[0].slug
}
} catch (err: any) {
this.error = err?.message || 'Erreur lors du chargement des organisations'
} finally {
this.loading = false
}
},
setActive(slug: string) {
if (this.organizations.some(o => o.slug === slug)) {
this.activeSlug = slug
if (import.meta.client) {
localStorage.setItem('libredecision_org', slug)
}
}
},
},
})
+1 -10
View File
@@ -1,7 +1,7 @@
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
ssr: false, ssr: false,
devtools: { enabled: false }, devtools: { enabled: true },
devServer: { port: 3002, host: '0.0.0.0' }, devServer: { port: 3002, host: '0.0.0.0' },
components: [{ path: '~/components', pathPrefix: false }], components: [{ path: '~/components', pathPrefix: false }],
css: ['~/assets/css/moods.css'], css: ['~/assets/css/moods.css'],
@@ -45,13 +45,4 @@ export default defineNuxtConfig({
nitro: { nitro: {
compressPublicAssets: true, compressPublicAssets: true,
}, },
vite: {
define: {
// Polkadot packages expect a Node-like global
global: 'globalThis',
},
optimizeDeps: {
include: ['@polkadot/extension-dapp', '@polkadot/util'],
},
},
}) })
+14 -2754
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -14,8 +14,6 @@
"@nuxt/content": "^3.11.2", "@nuxt/content": "^3.11.2",
"@nuxt/ui": "^3.1.0", "@nuxt/ui": "^3.1.0",
"@pinia/nuxt": "^0.11.0", "@pinia/nuxt": "^0.11.0",
"@polkadot/extension-dapp": "^0.46.9",
"@polkadot/util": "^13.5.9",
"@unocss/nuxt": "^66.6.0", "@unocss/nuxt": "^66.6.0",
"@vueuse/nuxt": "^14.2.1", "@vueuse/nuxt": "^14.2.1",
"nuxt": "^4.3.1", "nuxt": "^4.3.1",