Compare commits
2 Commits
main
..
f87cbc0f2f
| Author | SHA1 | Date | |
|---|---|---|---|
| f87cbc0f2f | |||
| 6fe0b41e7f |
+4
-4
@@ -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
|
||||||
|
|||||||
+52
-215
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -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 ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
@@ -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()]
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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"]
|
|
||||||
)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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."
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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.")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
+10
-37
@@ -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)
|
|
||||||
// Erreur réseau ou backend temporairement indisponible → conserver la session
|
|
||||||
if (err?.status === 401 || err?.status === 403) {
|
|
||||||
auth.logout()
|
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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
+43
-169
@@ -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,27 +20,16 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const entryCards = computed(() => {
|
const entryCards = computed(() => [
|
||||||
const decisionsTotal = decisions.list.length
|
|
||||||
const decisionsActive = decisions.activeDecisions.length
|
|
||||||
|
|
||||||
const docsActive = documents.activeDocuments.length
|
|
||||||
const docsTotal = documents.list.length
|
|
||||||
|
|
||||||
const mandatesActive = mandates.list.filter(m => ['active', 'voting'].includes(m.status)).length
|
|
||||||
const mandatesTotal = mandates.list.length
|
|
||||||
|
|
||||||
const protocolsCount = protocols.protocols.length
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
{
|
||||||
key: 'decisions',
|
key: 'decisions',
|
||||||
title: 'Décisions et consultation d\'avis',
|
title: 'Décisions structurantes',
|
||||||
icon: 'i-lucide-scale',
|
icon: 'i-lucide-scale',
|
||||||
to: '/decisions',
|
to: '/decisions',
|
||||||
count: decisionsTotal,
|
count: decisions.activeDecisions.length,
|
||||||
countLabel: decisionsActive > 0 ? `${decisionsActive} en cours` : `${decisionsTotal} au total`,
|
countLabel: `${decisions.activeDecisions.length} en cours`,
|
||||||
totalLabel: decisionsActive > 0 ? `${decisionsTotal} au total` : 'aucune en cours',
|
totalLabel: `${decisions.list.length} au total`,
|
||||||
|
description: 'Processus de décision collectifs',
|
||||||
color: 'var(--mood-secondary, var(--mood-accent))',
|
color: 'var(--mood-secondary, var(--mood-accent))',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,9 +37,10 @@ const entryCards = computed(() => {
|
|||||||
title: 'Documents de référence',
|
title: 'Documents de référence',
|
||||||
icon: 'i-lucide-book-open',
|
icon: 'i-lucide-book-open',
|
||||||
to: '/documents',
|
to: '/documents',
|
||||||
count: docsActive || docsTotal,
|
count: documents.activeDocuments.length,
|
||||||
countLabel: docsActive > 0 ? `${docsActive} actif${docsActive > 1 ? 's' : ''}` : `${docsTotal} au total`,
|
countLabel: `${documents.activeDocuments.length} actif${documents.activeDocuments.length > 1 ? 's' : ''}`,
|
||||||
totalLabel: docsTotal > 0 ? `${docsTotal} document${docsTotal > 1 ? 's' : ''}` : 'textes fondateurs',
|
totalLabel: `${documents.list.length} au total`,
|
||||||
|
description: 'Textes fondateurs sous vote permanent',
|
||||||
color: 'var(--mood-accent)',
|
color: 'var(--mood-accent)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -60,9 +48,10 @@ const entryCards = computed(() => {
|
|||||||
title: 'Mandats et nominations',
|
title: 'Mandats et nominations',
|
||||||
icon: 'i-lucide-user-check',
|
icon: 'i-lucide-user-check',
|
||||||
to: '/mandates',
|
to: '/mandates',
|
||||||
count: mandatesTotal || null,
|
count: null,
|
||||||
countLabel: mandatesTotal > 0 ? `${mandatesActive} en cours` : null,
|
countLabel: null,
|
||||||
totalLabel: mandatesTotal > 0 ? `${mandatesTotal} mandats` : 'missions déléguées',
|
totalLabel: null,
|
||||||
|
description: 'Missions déléguées avec nomination en binôme',
|
||||||
color: 'var(--mood-success)',
|
color: 'var(--mood-success)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -70,13 +59,13 @@ const entryCards = computed(() => {
|
|||||||
title: 'Protocoles et fonctionnement',
|
title: 'Protocoles et fonctionnement',
|
||||||
icon: 'i-lucide-settings',
|
icon: 'i-lucide-settings',
|
||||||
to: '/protocols',
|
to: '/protocols',
|
||||||
count: protocolsCount || null,
|
count: 2,
|
||||||
countLabel: protocolsCount > 0 ? `${protocolsCount} protocoles` : null,
|
countLabel: '2 protocoles',
|
||||||
totalLabel: 'modalités de vote',
|
totalLabel: `${protocols.protocols.length} modalités de vote`,
|
||||||
|
description: 'Modalités de vote, formules, workflows',
|
||||||
color: 'var(--mood-tertiary, var(--mood-accent))',
|
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; }
|
||||||
|
|||||||
@@ -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,14 +165,24 @@ 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>
|
>
|
||||||
|
<UIcon v-if="auth.loading" name="i-lucide-loader-2" class="animate-spin" />
|
||||||
|
<UIcon v-else name="i-lucide-log-in" />
|
||||||
|
<span>{{ auth.loading ? 'Verification...' : 'Se connecter' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Dev Mode Panel -->
|
||||||
|
<div v-if="devProfiles.length" class="dev-panel">
|
||||||
|
<div class="dev-panel__header">
|
||||||
|
<UIcon name="i-lucide-bug" />
|
||||||
|
<span>Mode Dev — Connexion rapide</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="proto-panel__profiles">
|
<div class="dev-panel__profiles">
|
||||||
<button
|
<button
|
||||||
v-for="p in devProfiles"
|
v-for="p in devProfiles"
|
||||||
:key="p.address"
|
:key="p.address"
|
||||||
@@ -191,27 +198,12 @@ onMounted(() => {
|
|||||||
<span class="dev-profile__addr">{{ p.address.slice(0, 8) }}...</span>
|
<span class="dev-profile__addr">{{ p.address.slice(0, 8) }}...</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="proto-panel__note">
|
|
||||||
Authentification trustWallet à venir — intégration librodrome
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Mode production : formulaire + extension -->
|
<!-- Note -->
|
||||||
<template v-else>
|
|
||||||
<button
|
|
||||||
class="login-card__btn"
|
|
||||||
:disabled="!address.trim() || step === 'success' || auth.loading"
|
|
||||||
@click="handleLogin"
|
|
||||||
>
|
|
||||||
<UIcon v-if="auth.loading" name="i-lucide-loader-2" class="animate-spin" />
|
|
||||||
<UIcon v-else name="i-lucide-log-in" />
|
|
||||||
<span>{{ auth.loading ? 'Verification...' : 'Se connecter' }}</span>
|
|
||||||
</button>
|
|
||||||
<p class="login-card__note">
|
<p class="login-card__note">
|
||||||
Aucun mot de passe. Authentification par signature cryptographique.
|
Aucun mot de passe. Authentification par signature cryptographique.
|
||||||
</p>
|
</p>
|
||||||
</template>
|
|
||||||
</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;
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Identity search (shared for assign + edit) ---
|
|
||||||
|
|
||||||
interface IdentityResult { id: string; address: string; display_name: string | null }
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
results.value = await $api<IdentityResult[]>('/auth/identities', { query: { q } })
|
await mandates.advance(mandateId.value)
|
||||||
} catch { results.value = [] } finally { searching.value = false }
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
} finally {
|
||||||
|
advancing.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 {
|
||||||
|
assigning.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAssign() {
|
|
||||||
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…"
|
|
||||||
@input="assignSearch.onInput(($event.target as HTMLInputElement).value)"
|
|
||||||
/>
|
/>
|
||||||
<div
|
<p class="text-xs text-gray-500">
|
||||||
v-if="assignSearch.results.value.length"
|
Adresse SS58 du membre de la toile de confiance
|
||||||
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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
|
||||||
// N'effacer le token que sur 401/403 (session réellement invalide)
|
|
||||||
// Les erreurs réseau ou 5xx sont transitoires — conserver la session
|
|
||||||
if (status === 401 || status === 403) {
|
|
||||||
this.token = null
|
this.token = null
|
||||||
this.identity = null
|
this.identity = null
|
||||||
this._clearToken()
|
this._clearToken()
|
||||||
}
|
|
||||||
throw err
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
})
|
|
||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+14
-2754
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user