forked from yvv/decision
Compare commits
10 Commits
224e5b0f5e
...
3b339b643c
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b339b643c | |||
| 3ba9c43ce3 | |||
| 1f92f153c5 | |||
| 9b6322c546 | |||
| 9a8f10efdf | |||
| e2ae8b196e | |||
| 5c51cffc93 | |||
| 428299c9c8 | |||
| fc84600f97 | |||
| 79e468b40f |
@@ -26,6 +26,8 @@ from app.database import Base
|
||||
from app.models import ( # noqa: F401
|
||||
DuniterIdentity,
|
||||
Session,
|
||||
Organization,
|
||||
OrgMember,
|
||||
Document,
|
||||
DocumentItem,
|
||||
ItemVersion,
|
||||
@@ -37,6 +39,7 @@ from app.models import ( # noqa: F401
|
||||
MandateStep,
|
||||
VotingProtocol,
|
||||
FormulaConfig,
|
||||
QualificationProtocol,
|
||||
SanctuaryEntry,
|
||||
BlockchainCache,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""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] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# SQLite does not support ADD CONSTRAINT via ALTER TABLE — FK constraints
|
||||
# are declared in models only; integrity is enforced at app layer.
|
||||
op.add_column('decisions', sa.Column('organization_id', sa.Uuid(), nullable=True))
|
||||
op.create_index(op.f('ix_decisions_organization_id'), 'decisions', ['organization_id'], unique=False)
|
||||
op.add_column('documents', sa.Column('organization_id', sa.Uuid(), nullable=True))
|
||||
op.create_index(op.f('ix_documents_organization_id'), 'documents', ['organization_id'], unique=False)
|
||||
op.add_column('mandates', sa.Column('organization_id', sa.Uuid(), nullable=True))
|
||||
op.create_index(op.f('ix_mandates_organization_id'), 'mandates', ['organization_id'], unique=False)
|
||||
op.add_column('voting_protocols', sa.Column('organization_id', sa.Uuid(), nullable=True))
|
||||
op.create_index(op.f('ix_voting_protocols_organization_id'), 'voting_protocols', ['organization_id'], unique=False)
|
||||
|
||||
|
||||
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')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""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.create_table(
|
||||
'qualification_protocols',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(128), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('small_group_max', sa.Integer(), nullable=False, server_default='5'),
|
||||
sa.Column('collective_wot_min', sa.Integer(), nullable=False, server_default='50'),
|
||||
sa.Column(
|
||||
'default_modalities_json',
|
||||
sa.Text(),
|
||||
nullable=False,
|
||||
server_default='["vote_wot","vote_smith","consultation_avis","election"]',
|
||||
),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('qualification_protocols')
|
||||
@@ -0,0 +1,51 @@
|
||||
"""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.create_table(
|
||||
"groups",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("name", sa.String(128), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("organization_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_groups_organization_id", "groups", ["organization_id"])
|
||||
|
||||
op.create_table(
|
||||
"group_members",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("group_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("identity_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("display_name", sa.String(128), nullable=False),
|
||||
sa.Column("added_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["group_id"], ["groups.id"]),
|
||||
sa.ForeignKeyConstraint(["identity_id"], ["duniter_identities.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_group_members_group_id", "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")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""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.add_column("mandates", sa.Column("origin", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("mandates", "origin")
|
||||
@@ -39,6 +39,9 @@ class Settings(BaseSettings):
|
||||
RATE_LIMIT_AUTH: int = 10
|
||||
RATE_LIMIT_VOTE: int = 30
|
||||
|
||||
# AI — Qwen3.6 (MacStudio) endpoint, branché plus tard
|
||||
QWEN_API_URL: str = ""
|
||||
|
||||
# Blockchain cache
|
||||
BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""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
|
||||
@@ -0,0 +1,211 @@
|
||||
"""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
|
||||
+28
-11
@@ -13,6 +13,9 @@ from app.middleware.rate_limiter import RateLimiterMiddleware
|
||||
from app.middleware.security_headers import SecurityHeadersMiddleware
|
||||
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
|
||||
from app.routers import public
|
||||
from app.routers import organizations
|
||||
from app.routers import qualify
|
||||
from app.routers import groups
|
||||
|
||||
|
||||
# ── Structured logging setup ───────────────────────────────────────────────
|
||||
@@ -85,8 +88,28 @@ app = FastAPI(
|
||||
|
||||
|
||||
# ── Middleware stack ──────────────────────────────────────────────────────
|
||||
# Middleware is applied in reverse order: last added = first executed.
|
||||
# Order: SecurityHeaders -> RateLimiter -> CORS -> Application
|
||||
# add_middleware is LIFO: last added = outermost = first to execute on request,
|
||||
# last to execute on response (wraps everything inside it).
|
||||
#
|
||||
# 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(
|
||||
CORSMiddleware,
|
||||
@@ -96,15 +119,6 @@ app.add_middleware(
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -117,6 +131,9 @@ 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(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -64,14 +64,6 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
||||
self._last_cleanup: float = time.time()
|
||||
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:
|
||||
"""Extract the client IP from the request, respecting X-Forwarded-For."""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
@@ -101,6 +93,22 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
||||
if 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:
|
||||
"""Check rate limit and either allow the request or return 429."""
|
||||
# Skip rate limiting for WebSocket upgrades
|
||||
@@ -111,8 +119,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
||||
await self._cleanup_old_entries()
|
||||
|
||||
client_ip = self._get_client_ip(request)
|
||||
path = request.url.path
|
||||
limit = self._get_limit_for_path(path)
|
||||
limit = self._get_limit_for_request(request)
|
||||
now = time.time()
|
||||
window_start = now - 60
|
||||
|
||||
@@ -133,7 +140,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
logger.warning(
|
||||
"Rate limit depasse pour %s sur %s (%d/%d)",
|
||||
client_ip, path, request_count, limit,
|
||||
client_ip, request.url.path, request_count, limit,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
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.decision import Decision, DecisionStep
|
||||
from app.models.vote import VoteSession, Vote
|
||||
from app.models.mandate import Mandate, MandateStep
|
||||
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.cache import BlockchainCache
|
||||
|
||||
__all__ = [
|
||||
"DuniterIdentity", "Session",
|
||||
"Organization", "OrgMember",
|
||||
"Document", "DocumentItem", "ItemVersion",
|
||||
"Decision", "DecisionStep",
|
||||
"VoteSession", "Vote",
|
||||
"Mandate", "MandateStep",
|
||||
"VotingProtocol", "FormulaConfig",
|
||||
"QualificationProtocol",
|
||||
"Group", "GroupMember",
|
||||
"SanctuaryEntry",
|
||||
"BlockchainCache",
|
||||
]
|
||||
|
||||
@@ -16,6 +16,7 @@ class Decision(Base):
|
||||
context: Mapped[str | None] = mapped_column(Text)
|
||||
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
|
||||
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"))
|
||||
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())
|
||||
|
||||
@@ -17,6 +17,7 @@ class Document(Base):
|
||||
version: Mapped[str] = mapped_column(String(32), default="0.1.0")
|
||||
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
|
||||
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))
|
||||
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
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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,9 +12,11 @@ class Mandate(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
origin: Mapped[str | None] = mapped_column(Text) # contexte / déclencheur du mandat
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom
|
||||
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"))
|
||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
|
||||
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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,6 +44,7 @@ class VotingProtocol(Base):
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
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)
|
||||
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"
|
||||
is_meta_governed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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)
|
||||
@@ -132,9 +132,9 @@ async def verify_challenge(
|
||||
detail="Challenge invalide",
|
||||
)
|
||||
|
||||
# 4. Verify signature (bypass for demo profiles in DEMO_MODE)
|
||||
# 4. Verify signature (bypass for demo profiles in dev/demo mode)
|
||||
_demo_addresses = {p["address"] for p in DEV_PROFILES}
|
||||
is_demo_bypass = settings.DEMO_MODE and payload.address in _demo_addresses
|
||||
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses
|
||||
|
||||
if not is_demo_bypass:
|
||||
# polkadot.js / Cesium2 signRaw(type='bytes') wraps: <Bytes>{challenge}</Bytes>
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.schemas.decision import (
|
||||
DecisionUpdate,
|
||||
)
|
||||
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.decision_service import advance_decision, create_vote_session_for_step
|
||||
|
||||
@@ -49,6 +50,7 @@ async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
|
||||
@router.get("/", response_model=list[DecisionOut])
|
||||
async def list_decisions(
|
||||
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"),
|
||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
@@ -57,6 +59,8 @@ async def list_decisions(
|
||||
"""List all decisions with optional filters."""
|
||||
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:
|
||||
stmt = stmt.where(Decision.decision_type == decision_type)
|
||||
if status_filter is not None:
|
||||
@@ -74,11 +78,13 @@ async def create_decision(
|
||||
payload: DecisionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> DecisionOut:
|
||||
"""Create a new decision process."""
|
||||
decision = Decision(
|
||||
**payload.model_dump(),
|
||||
created_by_id=identity.id,
|
||||
organization_id=org_id,
|
||||
)
|
||||
db.add(decision)
|
||||
await db.commit()
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.schemas.document import (
|
||||
ItemVersionCreate,
|
||||
ItemVersionOut,
|
||||
)
|
||||
from app.dependencies.org import get_active_org_id
|
||||
from app.services import document_service
|
||||
from app.services.auth_service import get_current_identity
|
||||
|
||||
@@ -65,6 +66,7 @@ async def _get_item(db: AsyncSession, document_id: uuid.UUID, item_id: uuid.UUID
|
||||
@router.get("/", response_model=list[DocumentOut])
|
||||
async def list_documents(
|
||||
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"),
|
||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
@@ -73,6 +75,8 @@ async def list_documents(
|
||||
"""List all reference documents, with optional filters."""
|
||||
stmt = select(Document)
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Document.organization_id == org_id)
|
||||
if doc_type is not None:
|
||||
stmt = stmt.where(Document.doc_type == doc_type)
|
||||
if status_filter is not None:
|
||||
@@ -101,6 +105,7 @@ async def create_document(
|
||||
payload: DocumentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> DocumentOut:
|
||||
"""Create a new reference document."""
|
||||
# Check slug uniqueness
|
||||
@@ -111,7 +116,7 @@ async def create_document(
|
||||
detail="Un document avec ce slug existe deja",
|
||||
)
|
||||
|
||||
doc = Document(**payload.model_dump())
|
||||
doc = Document(**payload.model_dump(), organization_id=org_id)
|
||||
db.add(doc)
|
||||
await db.commit()
|
||||
await db.refresh(doc)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""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,6 +22,7 @@ from app.schemas.mandate import (
|
||||
MandateUpdate,
|
||||
)
|
||||
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.mandate_service import (
|
||||
advance_mandate,
|
||||
@@ -55,6 +56,7 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
|
||||
@router.get("/", response_model=list[MandateOut])
|
||||
async def list_mandates(
|
||||
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"),
|
||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
@@ -63,6 +65,8 @@ async def list_mandates(
|
||||
"""List all mandates with optional filters."""
|
||||
stmt = select(Mandate).options(selectinload(Mandate.steps))
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Mandate.organization_id == org_id)
|
||||
if mandate_type is not None:
|
||||
stmt = stmt.where(Mandate.mandate_type == mandate_type)
|
||||
if status_filter is not None:
|
||||
@@ -80,9 +84,10 @@ async def create_mandate(
|
||||
payload: MandateCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateOut:
|
||||
"""Create a new mandate."""
|
||||
mandate = Mandate(**payload.model_dump())
|
||||
mandate = Mandate(**payload.model_dump(), organization_id=org_id)
|
||||
db.add(mandate)
|
||||
await db.commit()
|
||||
await db.refresh(mandate)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""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,6 +25,7 @@ from app.schemas.protocol import (
|
||||
VotingProtocolOut,
|
||||
VotingProtocolUpdate,
|
||||
)
|
||||
from app.dependencies.org import get_active_org_id
|
||||
from app.services.auth_service import get_current_identity
|
||||
|
||||
router = APIRouter()
|
||||
@@ -63,6 +64,7 @@ async def _get_formula(db: AsyncSession, formula_id: uuid.UUID) -> FormulaConfig
|
||||
@router.get("/", response_model=list[VotingProtocolOut])
|
||||
async def list_protocols(
|
||||
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)"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
@@ -70,6 +72,8 @@ async def list_protocols(
|
||||
"""List all voting protocols with their formula configurations."""
|
||||
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:
|
||||
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
|
||||
|
||||
@@ -85,6 +89,7 @@ async def create_protocol(
|
||||
payload: VotingProtocolCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> VotingProtocolOut:
|
||||
"""Create a new voting protocol.
|
||||
|
||||
@@ -100,7 +105,7 @@ async def create_protocol(
|
||||
detail="Configuration de formule introuvable",
|
||||
)
|
||||
|
||||
protocol = VotingProtocol(**payload.model_dump())
|
||||
protocol = VotingProtocol(**payload.model_dump(), organization_id=org_id)
|
||||
db.add(protocol)
|
||||
await db.commit()
|
||||
await db.refresh(protocol)
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,46 @@
|
||||
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
|
||||
@@ -46,9 +46,12 @@ class MandateCreate(BaseModel):
|
||||
"""Payload for creating a new mandate."""
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=256)
|
||||
origin: str | None = None
|
||||
description: str | None = None
|
||||
mandate_type: str = Field(..., max_length=64, description="techcomm, smith, custom")
|
||||
decision_id: UUID | None = None
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
|
||||
|
||||
class MandateUpdate(BaseModel):
|
||||
@@ -73,6 +76,7 @@ class MandateOut(BaseModel):
|
||||
|
||||
id: UUID
|
||||
title: str
|
||||
origin: str | None = None
|
||||
description: str | None = None
|
||||
mandate_type: str
|
||||
status: str
|
||||
@@ -92,6 +96,7 @@ class MandateAdvanceOut(BaseModel):
|
||||
|
||||
id: UUID
|
||||
title: str
|
||||
origin: str | None = None
|
||||
description: str | None = None
|
||||
mandate_type: str
|
||||
status: str
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""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
|
||||
@@ -0,0 +1,48 @@
|
||||
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"]
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""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."
|
||||
@@ -0,0 +1,172 @@
|
||||
"""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
|
||||
|
||||
from app.main import app
|
||||
|
||||
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
|
||||
@@ -0,0 +1,270 @@
|
||||
"""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
|
||||
@@ -0,0 +1,172 @@
|
||||
"""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
|
||||
+95
-9
@@ -32,6 +32,8 @@ from app.models.protocol import FormulaConfig, VotingProtocol
|
||||
from app.models.document import Document, DocumentItem
|
||||
from app.models.decision import Decision, DecisionStep
|
||||
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.vote import VoteSession, Vote
|
||||
|
||||
@@ -161,6 +163,7 @@ async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig
|
||||
async def seed_voting_protocols(
|
||||
session: AsyncSession,
|
||||
formulas: dict[str, FormulaConfig],
|
||||
org_id: uuid.UUID | None = None,
|
||||
) -> dict[str, VotingProtocol]:
|
||||
protocols: dict[str, dict] = {
|
||||
"Vote WoT standard": {
|
||||
@@ -206,6 +209,7 @@ async def seed_voting_protocols(
|
||||
instance, created = await get_or_create(
|
||||
session, VotingProtocol, "name", name, **params,
|
||||
)
|
||||
instance.organization_id = org_id
|
||||
status = "created" if created else "exists"
|
||||
print(f" VotingProtocol '{name}': {status}")
|
||||
result[name] = instance
|
||||
@@ -829,6 +833,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
|
||||
async def seed_document_engagement_certification(
|
||||
session: AsyncSession,
|
||||
protocols: dict[str, VotingProtocol],
|
||||
org_id: uuid.UUID | None = None,
|
||||
) -> Document:
|
||||
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -850,6 +855,7 @@ async def seed_document_engagement_certification(
|
||||
),
|
||||
genesis_json=genesis,
|
||||
)
|
||||
doc.organization_id = org_id
|
||||
print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
|
||||
|
||||
if created:
|
||||
@@ -1893,6 +1899,7 @@ ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
|
||||
async def seed_document_engagement_forgeron(
|
||||
session: AsyncSession,
|
||||
protocols: dict[str, VotingProtocol],
|
||||
org_id: uuid.UUID | None = None,
|
||||
) -> Document:
|
||||
genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -1916,6 +1923,7 @@ async def seed_document_engagement_forgeron(
|
||||
),
|
||||
genesis_json=genesis,
|
||||
)
|
||||
doc.organization_id = org_id
|
||||
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
|
||||
|
||||
if created:
|
||||
@@ -1988,7 +1996,7 @@ RUNTIME_UPGRADE_STEPS: list[dict] = [
|
||||
]
|
||||
|
||||
|
||||
async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
|
||||
async def seed_decision_runtime_upgrade(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision:
|
||||
decision, created = await get_or_create(
|
||||
session,
|
||||
Decision,
|
||||
@@ -2009,6 +2017,7 @@ async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
|
||||
decision_type="runtime_upgrade",
|
||||
status="draft",
|
||||
)
|
||||
decision.organization_id = org_id
|
||||
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
|
||||
|
||||
if created:
|
||||
@@ -2148,7 +2157,7 @@ async def seed_votes_on_items(
|
||||
# Seed: Additional decisions (demo content)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
|
||||
async def seed_decision_licence_evolution(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision:
|
||||
"""Seed a community decision: evolution of the G1 monetary license."""
|
||||
decision, created = await get_or_create(
|
||||
session,
|
||||
@@ -2170,6 +2179,7 @@ async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
|
||||
decision_type="community",
|
||||
status="draft",
|
||||
)
|
||||
decision.organization_id = org_id
|
||||
print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}")
|
||||
|
||||
if created:
|
||||
@@ -2225,7 +2235,7 @@ async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
|
||||
# Seed: Mandates (Comité Technique + Admin Forgerons)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) -> None:
|
||||
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity], org_id: uuid.UUID | None = None) -> None:
|
||||
"""Seed example mandates: TechComm and Smith Admin."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -2397,6 +2407,7 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) ->
|
||||
m_data["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"
|
||||
print(f" Mandate '{mandate.title[:50]}': {status_str}")
|
||||
|
||||
@@ -2408,10 +2419,78 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) ->
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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():
|
||||
print("=" * 60)
|
||||
print("libreDecision - Seed Database")
|
||||
@@ -2423,23 +2502,30 @@ async def run_seed():
|
||||
|
||||
async with async_session() as session:
|
||||
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...")
|
||||
formulas = await seed_formula_configs(session)
|
||||
|
||||
print("\n[2/10] Voting Protocols...")
|
||||
protocols = await seed_voting_protocols(session, formulas)
|
||||
protocols = await seed_voting_protocols(session, formulas, org_id=duniter_g1_id)
|
||||
|
||||
print("\n[3/10] Document: Acte d'engagement Certification...")
|
||||
await seed_document_engagement_certification(session, protocols)
|
||||
await seed_document_engagement_certification(session, protocols, org_id=duniter_g1_id)
|
||||
|
||||
print("\n[4/10] Document: Acte d'engagement forgeron v2.0.0...")
|
||||
doc_forgeron = await seed_document_engagement_forgeron(session, protocols)
|
||||
doc_forgeron = await seed_document_engagement_forgeron(session, protocols, org_id=duniter_g1_id)
|
||||
|
||||
print("\n[5/10] Decision: Runtime Upgrade...")
|
||||
await seed_decision_runtime_upgrade(session)
|
||||
await seed_decision_runtime_upgrade(session, org_id=duniter_g1_id)
|
||||
|
||||
print("\n[6/10] Decision: Évolution Licence G1 v0.4.0...")
|
||||
await seed_decision_licence_evolution(session)
|
||||
await seed_decision_licence_evolution(session, org_id=duniter_g1_id)
|
||||
|
||||
print("\n[7/10] Simulated voters...")
|
||||
voters = await seed_voters(session)
|
||||
@@ -2453,7 +2539,7 @@ async def run_seed():
|
||||
)
|
||||
|
||||
print("\n[9/10] Mandates...")
|
||||
await seed_mandates(session, voters)
|
||||
await seed_mandates(session, voters, org_id=duniter_g1_id)
|
||||
|
||||
print("\n[10/10] Done.")
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# 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.
|
||||
+31
-10
@@ -1,24 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
const auth = useAuthStore()
|
||||
const orgsStore = useOrganizationsStore()
|
||||
const documentsStore = useDocumentsStore()
|
||||
const decisionsStore = useDecisionsStore()
|
||||
const protocolsStore = useProtocolsStore()
|
||||
const mandatesStore = useMandatesStore()
|
||||
const route = useRoute()
|
||||
const { initMood } = useMood()
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
label: 'Boîte à outils',
|
||||
icon: 'i-lucide-wrench',
|
||||
to: '/tools',
|
||||
label: 'Décisions',
|
||||
icon: 'i-lucide-scale',
|
||||
to: '/decisions',
|
||||
},
|
||||
{
|
||||
label: 'Documents',
|
||||
icon: 'i-lucide-book-open',
|
||||
to: '/documents',
|
||||
},
|
||||
{
|
||||
label: 'Decisions',
|
||||
icon: 'i-lucide-scale',
|
||||
to: '/decisions',
|
||||
},
|
||||
{
|
||||
label: 'Mandats',
|
||||
icon: 'i-lucide-user-check',
|
||||
@@ -29,6 +29,11 @@ const navigationItems = [
|
||||
icon: 'i-lucide-settings',
|
||||
to: '/protocols',
|
||||
},
|
||||
{
|
||||
label: 'Outils',
|
||||
icon: 'i-lucide-wrench',
|
||||
to: '/tools',
|
||||
},
|
||||
{
|
||||
label: 'Sanctuaire',
|
||||
icon: 'i-lucide-archive',
|
||||
@@ -47,6 +52,16 @@ watch(() => route.path, () => {
|
||||
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. */
|
||||
const ws = useWebSocket()
|
||||
const { setupWsNotifications } = useNotifications()
|
||||
@@ -63,12 +78,18 @@ onMounted(async () => {
|
||||
if (auth.token) {
|
||||
try {
|
||||
await auth.fetchMe()
|
||||
} catch {
|
||||
auth.logout()
|
||||
} catch (err: any) {
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
ws.connect()
|
||||
setupWsNotifications(ws)
|
||||
// Load organizations in parallel — non-blocking, no auth required
|
||||
orgsStore.fetchOrganizations()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -1,52 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
const orgsStore = useOrganizationsStore()
|
||||
|
||||
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 containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const active = computed(() => workspaces.find(w => w.id === activeId.value) ?? workspaces[0])
|
||||
const active = computed(() => orgsStore.active)
|
||||
const organizations = computed(() => orgsStore.organizations)
|
||||
|
||||
function selectWorkspace(id: string) {
|
||||
activeId.value = id
|
||||
function selectOrg(slug: string) {
|
||||
orgsStore.setActive(slug)
|
||||
isOpen.value = false
|
||||
// TODO: store.setActiveCollective(id) + refetch all data
|
||||
}
|
||||
|
||||
// Close on outside click
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', (e) => {
|
||||
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
||||
@@ -58,35 +24,46 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="ws">
|
||||
<button class="ws__trigger" :class="{ 'ws__trigger--open': isOpen }" @click="isOpen = !isOpen">
|
||||
<div class="ws__icon" :class="`ws__icon--${active.color}`">
|
||||
<UIcon :name="active.icon" />
|
||||
<button
|
||||
class="ws__trigger"
|
||||
:class="{ 'ws__trigger--open': isOpen }"
|
||||
: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>
|
||||
<span class="ws__name">{{ active.name }}</span>
|
||||
<div v-else-if="active" class="ws__icon" :style="{ background: active.color ? active.color + '22' : undefined, color: active.color || undefined }">
|
||||
<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" />
|
||||
</button>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div v-if="isOpen" class="ws__dropdown">
|
||||
<div v-if="isOpen && organizations.length" class="ws__dropdown">
|
||||
<div class="ws__dropdown-header">
|
||||
Espace de travail
|
||||
</div>
|
||||
<div class="ws__items">
|
||||
<button
|
||||
v-for="ws in workspaces"
|
||||
:key="ws.id"
|
||||
v-for="org in organizations"
|
||||
:key="org.id"
|
||||
class="ws__item"
|
||||
:class="{ 'ws__item--active': ws.id === activeId }"
|
||||
@click="selectWorkspace(ws.id)"
|
||||
:class="{ 'ws__item--active': org.slug === orgsStore.activeSlug }"
|
||||
@click="selectOrg(org.slug)"
|
||||
>
|
||||
<div class="ws__item-icon" :class="`ws__icon--${ws.color}`">
|
||||
<UIcon :name="ws.icon" />
|
||||
<div
|
||||
class="ws__item-icon"
|
||||
:style="{ background: org.color ? org.color + '22' : undefined, color: org.color || undefined }"
|
||||
>
|
||||
<UIcon :name="org.icon || 'i-lucide-building'" />
|
||||
</div>
|
||||
<div class="ws__item-info">
|
||||
<span class="ws__item-name">{{ ws.name }}</span>
|
||||
<span v-if="ws.role" class="ws__item-role">{{ ws.role }}</span>
|
||||
<span class="ws__item-name">{{ org.name }}</span>
|
||||
<span class="ws__item-role">{{ org.is_transparent ? 'Public' : 'Membres' }}</span>
|
||||
</div>
|
||||
<UIcon v-if="ws.id === activeId" name="i-lucide-check" class="ws__item-check" />
|
||||
<UIcon v-if="org.slug === orgsStore.activeSlug" name="i-lucide-check" class="ws__item-check" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="ws__dropdown-footer">
|
||||
@@ -118,7 +95,7 @@ onMounted(() => {
|
||||
max-width: 11rem;
|
||||
}
|
||||
|
||||
.ws__trigger:hover {
|
||||
.ws__trigger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--mood-accent-soft) 80%, var(--mood-accent) 20%);
|
||||
}
|
||||
|
||||
@@ -126,6 +103,11 @@ onMounted(() => {
|
||||
background: color-mix(in srgb, var(--mood-accent-soft) 60%, var(--mood-accent) 40%);
|
||||
}
|
||||
|
||||
.ws__trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ws__icon {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
@@ -137,10 +119,9 @@ onMounted(() => {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ws__icon--accent { background: var(--mood-accent); color: var(--mood-accent-text); }
|
||||
.ws__icon--secondary {
|
||||
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 20%, transparent);
|
||||
color: var(--mood-secondary, var(--mood-accent));
|
||||
.ws__icon--muted {
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.ws__name {
|
||||
@@ -202,7 +183,6 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.ws__item:hover { background: var(--mood-accent-soft); }
|
||||
|
||||
.ws__item--active { background: var(--mood-accent-soft); }
|
||||
|
||||
.ws__item-icon {
|
||||
|
||||
@@ -73,6 +73,7 @@ function isRetryable(status: number): boolean {
|
||||
export function useApi() {
|
||||
const config = useRuntimeConfig()
|
||||
const auth = useAuthStore()
|
||||
const orgsStore = useOrganizationsStore()
|
||||
|
||||
/**
|
||||
* Perform a typed fetch against the backend API.
|
||||
@@ -94,6 +95,9 @@ export function useApi() {
|
||||
if (auth.token) {
|
||||
headers.Authorization = `Bearer ${auth.token}`
|
||||
}
|
||||
if (orgsStore.activeSlug) {
|
||||
headers['X-Organization'] = orgsStore.activeSlug
|
||||
}
|
||||
|
||||
const maxAttempts = noRetry ? 1 : MAX_RETRIES
|
||||
let lastError: any = null
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -116,6 +116,22 @@ function formatDate(dateStr: string): string {
|
||||
</p>
|
||||
</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 -->
|
||||
<div class="dash__entries">
|
||||
<template v-if="loading">
|
||||
@@ -177,7 +193,7 @@ function formatDate(dateStr: string): string {
|
||||
<span class="dash__toolbox-card-tag">Vote WoT</span>
|
||||
<span class="dash__toolbox-card-tag">Inertie</span>
|
||||
<span class="dash__toolbox-card-tag">Smith</span>
|
||||
<span class="dash__toolbox-card-tag">Nuancé</span>
|
||||
<span class="dash__toolbox-card-tag">Élection</span>
|
||||
</div>
|
||||
</div>
|
||||
<UIcon name="i-lucide-arrow-right" class="dash__toolbox-card-arrow" />
|
||||
@@ -292,6 +308,87 @@ 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 --- */
|
||||
.dash__entries {
|
||||
display: grid;
|
||||
|
||||
@@ -49,7 +49,8 @@ async function loginAsProfile(p: DevProfile) {
|
||||
|
||||
try {
|
||||
step.value = 'signing'
|
||||
await auth.login(p.address)
|
||||
// Dev mode: bypass extension — backend accepte toute signature pour les profils dev
|
||||
await auth.login(p.address, () => Promise.resolve('0x' + 'a'.repeat(128)))
|
||||
step.value = 'success'
|
||||
setTimeout(() => router.push('/'), 800)
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -66,7 +66,7 @@ const filteredMandates = computed(() => {
|
||||
|
||||
// Filter by status group
|
||||
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
|
||||
const allowedStatuses = statusGroupMap[activeStatus.value]
|
||||
const allowedStatuses = statusGroupMap[activeStatus.value]!
|
||||
list = list.filter(m => allowedStatuses.includes(m.status))
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ const filteredMandates = computed(() => {
|
||||
|
||||
const typeLabel = (mandateType: string) => {
|
||||
switch (mandateType) {
|
||||
case 'statutory': return 'Statutaire'
|
||||
case 'functional': return 'Fonctionnel'
|
||||
case 'techcomm': return 'Comité technique'
|
||||
case 'smith': return 'Forgeron'
|
||||
case 'custom': return 'Personnalisé'
|
||||
@@ -154,14 +156,14 @@ async function handleCreate() {
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
<NuxtLink
|
||||
v-if="auth.isAuthenticated"
|
||||
to="/mandates/new"
|
||||
class="action-btn"
|
||||
@click="showCreateModal = true"
|
||||
>
|
||||
<UIcon name="i-lucide-plus" class="text-xs" />
|
||||
<span>Nouveau</span>
|
||||
</button>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<!-- Main content: mandates list -->
|
||||
@@ -199,11 +201,11 @@ async function handleCreate() {
|
||||
<div class="mandate-onboarding__actions">
|
||||
<UButton
|
||||
v-if="auth.isAuthenticated"
|
||||
to="/mandates/new"
|
||||
label="Créer un premier mandat"
|
||||
icon="i-lucide-plus"
|
||||
color="primary"
|
||||
size="sm"
|
||||
@click="showCreateModal = true"
|
||||
/>
|
||||
<UButton
|
||||
to="/protocols"
|
||||
@@ -290,7 +292,7 @@ async function handleCreate() {
|
||||
:actions="[
|
||||
{ label: 'Nouveau mandat', icon: 'i-lucide-plus', emit: 'create', primary: true },
|
||||
]"
|
||||
@action="e => e === 'create' && (showCreateModal = true)"
|
||||
@action="e => e === 'create' && navigateTo('/mandates/new')"
|
||||
/>
|
||||
|
||||
<!-- Révocation -->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ onMounted(async () => {
|
||||
await Promise.all([
|
||||
protocols.fetchProtocols(),
|
||||
protocols.fetchFormulas(),
|
||||
groupsStore.fetchAll(),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -136,24 +137,6 @@ interface 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',
|
||||
name: 'Embarquement Forgeron',
|
||||
@@ -192,6 +175,73 @@ 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. */
|
||||
const n8nWorkflows = [
|
||||
{
|
||||
@@ -348,6 +398,79 @@ const n8nWorkflows = [
|
||||
</div>
|
||||
</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) -->
|
||||
<div class="proto-ops">
|
||||
<h3 class="proto-ops__title">
|
||||
@@ -524,6 +647,51 @@ const n8nWorkflows = [
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
@@ -1203,4 +1371,175 @@ const n8nWorkflows = [
|
||||
box-shadow: 0 4px 12px var(--mood-shadow);
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -20,6 +20,8 @@ interface ToolSection {
|
||||
tools: Tool[]
|
||||
}
|
||||
|
||||
const expandSocio = ref(false)
|
||||
|
||||
const sections: ToolSection[] = [
|
||||
{
|
||||
key: 'documents',
|
||||
@@ -149,6 +151,29 @@ const sections: ToolSection[] = [
|
||||
</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>
|
||||
@@ -329,4 +354,99 @@ const sections: ToolSection[] = [
|
||||
opacity: 1;
|
||||
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>
|
||||
|
||||
@@ -149,10 +149,15 @@ export const useAuthStore = defineStore('auth', {
|
||||
const identity = await $api<DuniterIdentity>('/auth/me')
|
||||
this.identity = identity
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Session invalide'
|
||||
this.token = null
|
||||
this.identity = null
|
||||
this._clearToken()
|
||||
const status = (err as any)?.status ?? 0
|
||||
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.identity = null
|
||||
this._clearToken()
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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 }
|
||||
})
|
||||
@@ -20,6 +20,7 @@ export interface MandateStep {
|
||||
export interface Mandate {
|
||||
id: string
|
||||
title: string
|
||||
origin: string | null
|
||||
description: string | null
|
||||
mandate_type: string
|
||||
status: string
|
||||
@@ -34,6 +35,7 @@ export interface Mandate {
|
||||
|
||||
export interface MandateCreate {
|
||||
title: string
|
||||
origin?: string | null
|
||||
description?: string | null
|
||||
mandate_type: string
|
||||
decision_id?: string | null
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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,7 +1,7 @@
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
ssr: false,
|
||||
devtools: { enabled: true },
|
||||
devtools: { enabled: false },
|
||||
devServer: { port: 3002, host: '0.0.0.0' },
|
||||
components: [{ path: '~/components', pathPrefix: false }],
|
||||
css: ['~/assets/css/moods.css'],
|
||||
|
||||
Reference in New Issue
Block a user