7
0
forked from yvv/decision

Compare commits

..

6 Commits

Author SHA1 Message Date
Yvv 59fff64f9e Compartimentation : isolation stricte des données par espace de travail
ci/woodpecker/push/woodpecker Pipeline was successful
- Ajout clause else IS NULL sur tous les endpoints list (protocols, decisions,
  mandates, documents, groups, votes) — sans X-Organization → données globales
  seulement, jamais le contenu d'un autre espace
- _get_protocol/_get_decision/_get_mandate : org_id propagé à tous les
  endpoints GET/PUT/DELETE/advance/assign/revoke/steps → 404 si UUID d'un
  autre espace
- votes.py : list_vote_sessions filtre via JOIN VotingProtocol.organization_id
- groups.py : suppression _org_id_from_header() mort, create_group assigne
  organization_id correctement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:52:16 +02:00
Yvv f56d84e76b Mandats : origin→FK identité + nomination auto + boutons + tests intégration
ci/woodpecker/push/woodpecker Pipeline was successful
- origin TEXT → origin_id UUID FK duniter_identities (migration e3f4a5b6c7d8)
- GET /auth/identities?q= : recherche d'identités par nom/adresse
- MandateCreate.nomination_mode : auto (auto-assign auteur), collective, postpone
- Wizard new.vue : champ origine = picker identité, checkbox "Démarrer maintenant"
- [id].vue : modal "Assigner" = search-picker (résout UUID vs adresse SS58), affiche
  origin_display_name + mandatee_display_name, inputs natifs (<input>/<textarea>)
- Erreurs API visibles dans l'UI (plus de catch silencieux)
- test_mandate_flows.py : 17 tests intégration SQLite réels (origin, nomination,
  assign, lifecycle, revocation, interactions croisées) — 241 tests total OK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:48:27 +02:00
Yvv 3423ac2e7e Migrations : fix ADD CONSTRAINT IF NOT EXISTS (syntaxe PostgreSQL invalide)
ci/woodpecker/push/woodpecker Pipeline was successful
Remplace par un bloc DO $$ IF NOT EXISTS ... $$ pour le FK circulaire
item_versions→document_items. Testé localement sur pg-test : 5 migrations
OK + idempotence OK.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 02:57:39 +02:00
Yvv 2d2ac79cd5 Alembic : migration initiale + chaîne idempotente IF NOT EXISTS
ci/woodpecker/push/woodpecker Pipeline failed
- 0b9c1d2e3f4a : migration initiale (CREATE TABLE IF NOT EXISTS) — safe
  sur une DB déjà bootstrappée via create_all()
- 70914b334cfb : ADD COLUMN IF NOT EXISTS (organization_id) — was down_revision=None
- b78571ae9e00 : CREATE TABLE IF NOT EXISTS qualification_protocols
- c4e812fb3a01 : CREATE TABLE IF NOT EXISTS groups + group_members
- d91a3c7f8b02 : ADD COLUMN IF NOT EXISTS origin (mandates)
- Dockerfile prod : restaure alembic upgrade head au démarrage

Sur le serveur prod, exécuter une fois :
  docker exec <projet>-backend alembic upgrade head

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 02:47:20 +02:00
Yvv 21bca67e6a Docker : supprime alembic upgrade head du startup
ci/woodpecker/push/woodpecker Pipeline failed
Le lifespan FastAPI appelle déjà init_db() (create_all) au démarrage.
Lancer alembic en plus cassait sur une DB vide (migration initiale manquante).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 02:16:50 +02:00
Yvv 56d72eeec2 Auth : mode prototype factice en prod + fix test DB manquante
ci/woodpecker/push/woodpecker Pipeline failed
- Login : panneau proto-mode en avant quand DEMO_MODE actif (profils API)
  masque le formulaire extension-required ; note trustWallet à venir
- auth.ts : TODO trustWallet avec protocole postMessage prévu
- routers/auth.py : TODO trustWallet au point de vérification signature
- test_middleware : fixture _create_tables (autouse) — ASGITransport ne
  déclenche pas le lifespan, init_db() ne tournait pas → duniter_identities
  introuvable au verify ; 224/224 passent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 00:34:49 +02:00
23 changed files with 1402 additions and 568 deletions
@@ -0,0 +1,301 @@
"""initial schema
Revision ID: 0b9c1d2e3f4a
Revises:
Create Date: 2026-04-23 00:00:00.000000+00:00
Idempotent: uses CREATE TABLE IF NOT EXISTS so it is safe to run
against a DB that was already bootstrapped via SQLAlchemy create_all().
"""
from typing import Sequence, Union
from alembic import op
revision: str = '0b9c1d2e3f4a'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS duniter_identities (
id UUID PRIMARY KEY,
address VARCHAR(64) NOT NULL,
display_name VARCHAR(128),
wot_status VARCHAR(32) NOT NULL DEFAULT 'unknown',
is_smith BOOLEAN NOT NULL DEFAULT FALSE,
is_techcomm BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_duniter_identities_address ON duniter_identities (address)")
op.execute("""
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY,
token_hash VARCHAR(128) NOT NULL,
identity_id UUID NOT NULL REFERENCES duniter_identities(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_sessions_token_hash ON sessions (token_hash)")
op.execute("""
CREATE TABLE IF NOT EXISTS organizations (
id UUID PRIMARY KEY,
name VARCHAR(128) NOT NULL,
slug VARCHAR(128) NOT NULL,
org_type VARCHAR(64) NOT NULL DEFAULT 'community',
is_transparent BOOLEAN NOT NULL DEFAULT FALSE,
color VARCHAR(32),
icon VARCHAR(64),
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_organizations_slug ON organizations (slug)")
op.execute("""
CREATE TABLE IF NOT EXISTS org_members (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organizations(id),
identity_id UUID NOT NULL REFERENCES duniter_identities(id),
role VARCHAR(32) NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS formula_configs (
id UUID PRIMARY KEY,
name VARCHAR(128) NOT NULL,
description TEXT,
duration_days INTEGER NOT NULL DEFAULT 30,
majority_pct INTEGER NOT NULL DEFAULT 50,
base_exponent FLOAT NOT NULL DEFAULT 0.1,
gradient_exponent FLOAT NOT NULL DEFAULT 0.2,
constant_base FLOAT NOT NULL DEFAULT 0.0,
smith_exponent FLOAT,
techcomm_exponent FLOAT,
nuanced_min_participants INTEGER,
nuanced_threshold_pct INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS voting_protocols (
id UUID PRIMARY KEY,
name VARCHAR(128) NOT NULL,
description TEXT,
vote_type VARCHAR(32) NOT NULL,
formula_config_id UUID NOT NULL REFERENCES formula_configs(id),
mode_params VARCHAR(64),
is_meta_governed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS sanctuary_entries (
id UUID PRIMARY KEY,
entry_type VARCHAR(64) NOT NULL,
reference_id UUID NOT NULL,
title VARCHAR(256),
content_hash VARCHAR(128) NOT NULL,
ipfs_cid VARCHAR(128),
chain_tx_hash VARCHAR(128),
chain_block INTEGER,
metadata_json TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS blockchain_cache (
id UUID PRIMARY KEY,
cache_key VARCHAR(256) NOT NULL,
cache_value JSON NOT NULL,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_blockchain_cache_cache_key ON blockchain_cache (cache_key)")
op.execute("""
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY,
slug VARCHAR(128) NOT NULL,
title VARCHAR(256) NOT NULL,
doc_type VARCHAR(64) NOT NULL,
version VARCHAR(32) NOT NULL DEFAULT '0.1.0',
status VARCHAR(32) NOT NULL DEFAULT 'draft',
description TEXT,
ipfs_cid VARCHAR(128),
chain_anchor VARCHAR(128),
genesis_json TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_documents_slug ON documents (slug)")
op.execute("""
CREATE TABLE IF NOT EXISTS decisions (
id UUID PRIMARY KEY,
title VARCHAR(256) NOT NULL,
description TEXT,
context TEXT,
decision_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'draft',
voting_protocol_id UUID REFERENCES voting_protocols(id),
created_by_id UUID REFERENCES duniter_identities(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS item_versions (
id UUID PRIMARY KEY,
item_id UUID NOT NULL,
proposed_text TEXT NOT NULL,
diff_text TEXT,
rationale TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'proposed',
decision_id UUID REFERENCES decisions(id),
proposed_by_id UUID REFERENCES duniter_identities(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS vote_sessions (
id UUID PRIMARY KEY,
decision_id UUID REFERENCES decisions(id),
item_version_id UUID REFERENCES item_versions(id),
voting_protocol_id UUID NOT NULL REFERENCES voting_protocols(id),
wot_size INTEGER NOT NULL DEFAULT 0,
smith_size INTEGER NOT NULL DEFAULT 0,
techcomm_size INTEGER NOT NULL DEFAULT 0,
starts_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ends_at TIMESTAMPTZ NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'open',
votes_for INTEGER NOT NULL DEFAULT 0,
votes_against INTEGER NOT NULL DEFAULT 0,
votes_total INTEGER NOT NULL DEFAULT 0,
smith_votes_for INTEGER NOT NULL DEFAULT 0,
techcomm_votes_for INTEGER NOT NULL DEFAULT 0,
threshold_required FLOAT NOT NULL DEFAULT 0.0,
result VARCHAR(32),
chain_recorded BOOLEAN NOT NULL DEFAULT FALSE,
chain_tx_hash VARCHAR(128),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS decision_steps (
id UUID PRIMARY KEY,
decision_id UUID NOT NULL REFERENCES decisions(id),
step_order INTEGER NOT NULL,
step_type VARCHAR(32) NOT NULL,
title VARCHAR(256),
description TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
vote_session_id UUID REFERENCES vote_sessions(id),
outcome TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS document_items (
id UUID PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id),
position VARCHAR(16) NOT NULL,
item_type VARCHAR(32) NOT NULL DEFAULT 'clause',
title VARCHAR(256),
current_text TEXT NOT NULL,
voting_protocol_id UUID REFERENCES voting_protocols(id),
sort_order INTEGER NOT NULL DEFAULT 0,
section_tag VARCHAR(64),
inertia_preset VARCHAR(16) NOT NULL DEFAULT 'standard',
is_permanent_vote BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'item_versions_item_id_fkey'
AND table_name = 'item_versions'
) THEN
ALTER TABLE item_versions
ADD CONSTRAINT item_versions_item_id_fkey
FOREIGN KEY (item_id) REFERENCES document_items(id);
END IF;
END $$
""")
op.execute("""
CREATE TABLE IF NOT EXISTS votes (
id UUID PRIMARY KEY,
session_id UUID NOT NULL REFERENCES vote_sessions(id),
voter_id UUID NOT NULL REFERENCES duniter_identities(id),
vote_value VARCHAR(32) NOT NULL,
nuanced_level INTEGER,
comment TEXT,
signature TEXT NOT NULL,
signed_payload TEXT NOT NULL,
voter_wot_status VARCHAR(32) NOT NULL DEFAULT 'member',
voter_is_smith BOOLEAN NOT NULL DEFAULT FALSE,
voter_is_techcomm BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS mandates (
id UUID PRIMARY KEY,
title VARCHAR(256) NOT NULL,
description TEXT,
mandate_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'draft',
mandatee_id UUID REFERENCES duniter_identities(id),
decision_id UUID REFERENCES decisions(id),
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS mandate_steps (
id UUID PRIMARY KEY,
mandate_id UUID NOT NULL REFERENCES mandates(id),
step_order INTEGER NOT NULL,
step_type VARCHAR(32) NOT NULL,
title VARCHAR(256),
description TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
vote_session_id UUID REFERENCES vote_sessions(id),
outcome TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
def downgrade() -> None:
# Intentionally left empty — dropping the initial schema would destroy all data.
pass
@@ -12,22 +12,21 @@ import sqlalchemy as sa
revision: str = '70914b334cfb' revision: str = '70914b334cfb'
down_revision: Union[str, None] = None down_revision: Union[str, None] = '0b9c1d2e3f4a'
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# SQLite does not support ADD CONSTRAINT via ALTER TABLE — FK constraints # ADD COLUMN IF NOT EXISTS — idempotent (safe on DBs bootstrapped via create_all)
# are declared in models only; integrity is enforced at app layer. op.execute("ALTER TABLE decisions ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
op.add_column('decisions', sa.Column('organization_id', sa.Uuid(), nullable=True)) op.execute("CREATE INDEX IF NOT EXISTS ix_decisions_organization_id ON decisions (organization_id)")
op.create_index(op.f('ix_decisions_organization_id'), 'decisions', ['organization_id'], unique=False) op.execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
op.add_column('documents', sa.Column('organization_id', sa.Uuid(), nullable=True)) op.execute("CREATE INDEX IF NOT EXISTS ix_documents_organization_id ON documents (organization_id)")
op.create_index(op.f('ix_documents_organization_id'), 'documents', ['organization_id'], unique=False) op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
op.add_column('mandates', sa.Column('organization_id', sa.Uuid(), nullable=True)) op.execute("CREATE INDEX IF NOT EXISTS ix_mandates_organization_id ON mandates (organization_id)")
op.create_index(op.f('ix_mandates_organization_id'), 'mandates', ['organization_id'], unique=False) op.execute("ALTER TABLE voting_protocols ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
op.add_column('voting_protocols', sa.Column('organization_id', sa.Uuid(), nullable=True)) op.execute("CREATE INDEX IF NOT EXISTS ix_voting_protocols_organization_id ON voting_protocols (organization_id)")
op.create_index(op.f('ix_voting_protocols_organization_id'), 'voting_protocols', ['organization_id'], unique=False)
def downgrade() -> None: def downgrade() -> None:
@@ -16,23 +16,18 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
op.create_table( op.execute("""
'qualification_protocols', CREATE TABLE IF NOT EXISTS qualification_protocols (
sa.Column('id', sa.Uuid(), nullable=False), id UUID PRIMARY KEY,
sa.Column('name', sa.String(128), nullable=False), name VARCHAR(128) NOT NULL,
sa.Column('description', sa.Text(), nullable=True), description TEXT,
sa.Column('small_group_max', sa.Integer(), nullable=False, server_default='5'), small_group_max INTEGER NOT NULL DEFAULT 5,
sa.Column('collective_wot_min', sa.Integer(), nullable=False, server_default='50'), collective_wot_min INTEGER NOT NULL DEFAULT 50,
sa.Column( default_modalities_json TEXT NOT NULL DEFAULT '["vote_wot","vote_smith","consultation_avis","election"]',
'default_modalities_json', is_active BOOLEAN NOT NULL DEFAULT TRUE,
sa.Text(), created_at TIMESTAMPTZ DEFAULT now()
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: def downgrade() -> None:
@@ -17,31 +17,28 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
op.create_table( op.execute("""
"groups", CREATE TABLE IF NOT EXISTS groups (
sa.Column("id", sa.Uuid(), nullable=False), id UUID PRIMARY KEY,
sa.Column("name", sa.String(128), nullable=False), name VARCHAR(128) NOT NULL,
sa.Column("description", sa.Text(), nullable=True), description TEXT,
sa.Column("organization_id", sa.Uuid(), nullable=True), organization_id UUID REFERENCES organizations(id),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
sa.PrimaryKeyConstraint("id"),
) )
op.create_index("ix_groups_organization_id", "groups", ["organization_id"]) """)
op.execute("CREATE INDEX IF NOT EXISTS ix_groups_organization_id ON groups (organization_id)")
op.create_table( op.execute("""
"group_members", CREATE TABLE IF NOT EXISTS group_members (
sa.Column("id", sa.Uuid(), nullable=False), id UUID PRIMARY KEY,
sa.Column("group_id", sa.Uuid(), nullable=False), group_id UUID NOT NULL REFERENCES groups(id),
sa.Column("identity_id", sa.Uuid(), nullable=True), identity_id UUID REFERENCES duniter_identities(id),
sa.Column("display_name", sa.String(128), nullable=False), display_name VARCHAR(128) NOT NULL,
sa.Column("added_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), added_at TIMESTAMPTZ NOT NULL DEFAULT now()
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"]) """)
op.execute("CREATE INDEX IF NOT EXISTS ix_group_members_group_id ON group_members (group_id)")
def downgrade() -> None: def downgrade() -> None:
@@ -17,7 +17,7 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
op.add_column("mandates", sa.Column("origin", sa.Text(), nullable=True)) op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin TEXT")
def downgrade() -> None: def downgrade() -> None:
@@ -0,0 +1,27 @@
"""Mandate origin: replace free-text with FK to duniter_identities.
Revision ID: e3f4a5b6c7d8
Revises: d91a3c7f8b02
Create Date: 2026-04-25 10:00:00.000000
"""
from __future__ import annotations
from alembic import op
revision = "e3f4a5b6c7d8"
down_revision = "d91a3c7f8b02"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE mandates DROP COLUMN IF EXISTS origin")
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin_id UUID REFERENCES duniter_identities(id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_mandates_origin_id ON mandates (origin_id)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_mandates_origin_id")
op.execute("ALTER TABLE mandates DROP COLUMN IF EXISTS origin_id")
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin TEXT")
+30 -6
View File
@@ -12,10 +12,10 @@ class Mandate(Base):
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(256), nullable=False) title: Mapped[str] = mapped_column(String(256), nullable=False)
origin: Mapped[str | None] = mapped_column(Text) # contexte / déclencheur du mandat origin_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"), nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom mandate_type: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, candidacy, voting, active, reporting, completed, revoked status: Mapped[str] = mapped_column(String(32), default="draft")
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True) organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id")) mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id")) decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
@@ -24,7 +24,27 @@ class Mandate(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
steps: Mapped[list["MandateStep"]] = relationship(back_populates="mandate", cascade="all, delete-orphan", order_by="MandateStep.step_order") steps: Mapped[list["MandateStep"]] = relationship(
back_populates="mandate", cascade="all, delete-orphan", order_by="MandateStep.step_order"
)
origin_identity: Mapped["DuniterIdentity | None"] = relationship( # type: ignore[name-defined]
"DuniterIdentity", foreign_keys=[origin_id]
)
mandatee: Mapped["DuniterIdentity | None"] = relationship( # type: ignore[name-defined]
"DuniterIdentity", foreign_keys=[mandatee_id]
)
@property
def origin_display_name(self) -> str | None:
if self.origin_identity is not None:
return self.origin_identity.display_name or self.origin_identity.address
return None
@property
def mandatee_display_name(self) -> str | None:
if self.mandatee is not None:
return self.mandatee.display_name or self.mandatee.address
return None
class MandateStep(Base): class MandateStep(Base):
@@ -33,12 +53,16 @@ class MandateStep(Base):
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False) mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False)
step_order: Mapped[int] = mapped_column(Integer, nullable=False) step_order: Mapped[int] = mapped_column(Integer, nullable=False)
step_type: Mapped[str] = mapped_column(String(32), nullable=False) # formulation, candidacy, vote, assignment, reporting, completion, revocation step_type: Mapped[str] = mapped_column(String(32), nullable=False)
title: Mapped[str | None] = mapped_column(String(256)) title: Mapped[str | None] = mapped_column(String(256))
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(32), default="pending") # pending, active, completed, skipped status: Mapped[str] = mapped_column(String(32), default="pending")
vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id")) vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id"))
outcome: Mapped[str | None] = mapped_column(Text) outcome: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
mandate: Mapped["Mandate"] = relationship(back_populates="steps") mandate: Mapped["Mandate"] = relationship(back_populates="steps")
# Avoid circular import — DuniterIdentity imported at runtime by SQLAlchemy relationship resolution
from app.models.user import DuniterIdentity # noqa: E402, F401
+28 -2
View File
@@ -5,7 +5,8 @@ from __future__ import annotations
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -132,7 +133,11 @@ async def verify_challenge(
detail="Challenge invalide", detail="Challenge invalide",
) )
# 4. Verify signature (bypass for demo profiles in dev/demo mode) # 4. Verify signature
# TODO: trustWallet — déléguer la vérification au protocole trustWallet (librodrome)
# Quand trustWallet sera disponible : remplacer le bloc ci-dessous par une vérification
# du token signé fourni par trustWallet (JWT ou preuve Ed25519 via iframe postMessage).
# Le bypass DEMO_MODE sera alors supprimé.
_demo_addresses = {p["address"] for p in DEV_PROFILES} _demo_addresses = {p["address"] for p in DEV_PROFILES}
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses
@@ -228,3 +233,24 @@ async def logout(
for session in sessions: for session in sessions:
await db.delete(session) await db.delete(session)
await db.commit() await db.commit()
@router.get("/identities", response_model=list[IdentityOut])
async def search_identities(
q: str = Query(..., min_length=1, description="Recherche par adresse ou nom"),
limit: int = Query(default=10, ge=1, le=50),
db: AsyncSession = Depends(get_db),
) -> list[IdentityOut]:
"""Search Duniter identities by address prefix or display_name."""
result = await db.execute(
select(DuniterIdentity)
.where(
or_(
DuniterIdentity.address.ilike(f"{q}%"),
DuniterIdentity.display_name.ilike(f"%{q}%"),
)
)
.order_by(DuniterIdentity.display_name)
.limit(limit)
)
return [IdentityOut.model_validate(i) for i in result.scalars().all()]
+25 -10
View File
@@ -31,13 +31,20 @@ router = APIRouter()
# ── Helpers ───────────────────────────────────────────────────────────────── # ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision: async def _get_decision(
"""Fetch a decision by ID with its steps eagerly loaded, or raise 404.""" db: AsyncSession, decision_id: uuid.UUID, org_id: uuid.UUID | None = None
result = await db.execute( ) -> Decision:
"""Fetch a decision by ID within the active org scope, or raise 404."""
stmt = (
select(Decision) select(Decision)
.options(selectinload(Decision.steps)) .options(selectinload(Decision.steps))
.where(Decision.id == decision_id) .where(Decision.id == decision_id)
) )
if org_id is not None:
stmt = stmt.where(Decision.organization_id == org_id)
else:
stmt = stmt.where(Decision.organization_id.is_(None))
result = await db.execute(stmt)
decision = result.scalar_one_or_none() decision = result.scalar_one_or_none()
if decision is None: if decision is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Decision introuvable") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Decision introuvable")
@@ -61,6 +68,8 @@ async def list_decisions(
if org_id is not None: if org_id is not None:
stmt = stmt.where(Decision.organization_id == org_id) stmt = stmt.where(Decision.organization_id == org_id)
else:
stmt = stmt.where(Decision.organization_id.is_(None))
if decision_type is not None: if decision_type is not None:
stmt = stmt.where(Decision.decision_type == decision_type) stmt = stmt.where(Decision.decision_type == decision_type)
if status_filter is not None: if status_filter is not None:
@@ -91,7 +100,7 @@ async def create_decision(
await db.refresh(decision) await db.refresh(decision)
# Reload with steps (empty at creation) # Reload with steps (empty at creation)
decision = await _get_decision(db, decision.id) decision = await _get_decision(db, decision.id, org_id)
return DecisionOut.model_validate(decision) return DecisionOut.model_validate(decision)
@@ -99,9 +108,10 @@ async def create_decision(
async def get_decision( async def get_decision(
id: uuid.UUID, id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut: ) -> DecisionOut:
"""Get a single decision with all its steps.""" """Get a single decision with all its steps."""
decision = await _get_decision(db, id) decision = await _get_decision(db, id, org_id)
return DecisionOut.model_validate(decision) return DecisionOut.model_validate(decision)
@@ -111,9 +121,10 @@ async def update_decision(
payload: DecisionUpdate, payload: DecisionUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut: ) -> DecisionOut:
"""Update a decision's metadata (title, description, status, protocol).""" """Update a decision's metadata (title, description, status, protocol)."""
decision = await _get_decision(db, id) decision = await _get_decision(db, id, org_id)
update_data = payload.model_dump(exclude_unset=True) update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
@@ -123,7 +134,7 @@ async def update_decision(
await db.refresh(decision) await db.refresh(decision)
# Reload with steps # Reload with steps
decision = await _get_decision(db, decision.id) decision = await _get_decision(db, decision.id, org_id)
return DecisionOut.model_validate(decision) return DecisionOut.model_validate(decision)
@@ -136,10 +147,11 @@ async def add_step(
payload: DecisionStepCreate, payload: DecisionStepCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionStepOut: ) -> DecisionStepOut:
"""Add a step to a decision process.""" """Add a step to a decision process."""
# Verify decision exists # Verify decision exists
decision = await _get_decision(db, id) decision = await _get_decision(db, id, org_id)
step = DecisionStep( step = DecisionStep(
decision_id=decision.id, decision_id=decision.id,
@@ -160,6 +172,7 @@ async def advance_decision_endpoint(
id: uuid.UUID, id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionAdvanceOut: ) -> DecisionAdvanceOut:
"""Advance a decision to its next step or status.""" """Advance a decision to its next step or status."""
try: try:
@@ -168,7 +181,7 @@ async def advance_decision_endpoint(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps for complete output # Reload with steps for complete output
decision = await _get_decision(db, decision.id) decision = await _get_decision(db, decision.id, org_id)
data = DecisionOut.model_validate(decision).model_dump() data = DecisionOut.model_validate(decision).model_dump()
data["message"] = f"Decision avancee au statut : {decision.status}" data["message"] = f"Decision avancee au statut : {decision.status}"
return DecisionAdvanceOut(**data) return DecisionAdvanceOut(**data)
@@ -184,6 +197,7 @@ async def create_vote_session_for_step_endpoint(
step_id: uuid.UUID, step_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VoteSessionOut: ) -> VoteSessionOut:
"""Create a vote session linked to a decision step.""" """Create a vote session linked to a decision step."""
try: try:
@@ -199,9 +213,10 @@ async def delete_decision(
id: uuid.UUID, id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> None: ) -> None:
"""Delete a decision (only if in draft status).""" """Delete a decision (only if in draft status)."""
decision = await _get_decision(db, id) decision = await _get_decision(db, id, org_id)
if decision.status != "draft": if decision.status != "draft":
raise HTTPException( raise HTTPException(
+2
View File
@@ -77,6 +77,8 @@ async def list_documents(
if org_id is not None: if org_id is not None:
stmt = stmt.where(Document.organization_id == org_id) stmt = stmt.where(Document.organization_id == org_id)
else:
stmt = stmt.where(Document.organization_id.is_(None))
if doc_type is not None: if doc_type is not None:
stmt = stmt.where(Document.doc_type == doc_type) stmt = stmt.where(Document.doc_type == doc_type)
if status_filter is not None: if status_filter is not None:
+11 -15
View File
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.database import get_db from app.database import get_db
from app.dependencies.org import get_active_org_id
from app.models.group import Group, GroupMember from app.models.group import Group, GroupMember
from app.schemas.group import GroupCreate, GroupMemberCreate, GroupMemberOut, GroupOut, GroupSummary from app.schemas.group import GroupCreate, GroupMemberCreate, GroupMemberOut, GroupOut, GroupSummary
from app.services.auth_service import get_current_identity from app.services.auth_service import get_current_identity
@@ -18,24 +19,18 @@ from app.services.auth_service import get_current_identity
router = APIRouter() 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]) @router.get("/", response_model=list[GroupSummary])
async def list_groups( async def list_groups(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> list[GroupSummary]: ) -> list[GroupSummary]:
"""List all groups. No auth required — groups are public within the workspace.""" """List groups within the active workspace."""
result = await db.execute( stmt = select(Group).options(selectinload(Group.members)).order_by(Group.name)
select(Group).options(selectinload(Group.members)).order_by(Group.name) if org_id is not None:
) stmt = stmt.where(Group.organization_id == org_id)
else:
stmt = stmt.where(Group.organization_id.is_(None))
result = await db.execute(stmt)
groups = result.scalars().all() groups = result.scalars().all()
return [ return [
GroupSummary( GroupSummary(
@@ -54,8 +49,9 @@ async def create_group(
payload: GroupCreate, payload: GroupCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity), _identity=Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> GroupOut: ) -> GroupOut:
group = Group(name=payload.name, description=payload.description) group = Group(name=payload.name, description=payload.description, organization_id=org_id)
db.add(group) db.add(group)
await db.commit() await db.commit()
await db.refresh(group) await db.refresh(group)
+70 -51
View File
@@ -37,19 +37,36 @@ router = APIRouter()
# ── Helpers ───────────────────────────────────────────────────────────────── # ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate: async def _get_mandate(
"""Fetch a mandate by ID with its steps eagerly loaded, or raise 404.""" db: AsyncSession, mandate_id: uuid.UUID, org_id: uuid.UUID | None = None
result = await db.execute( ) -> Mandate:
stmt = (
select(Mandate) select(Mandate)
.options(selectinload(Mandate.steps)) .options(
selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
.where(Mandate.id == mandate_id) .where(Mandate.id == mandate_id)
) )
if org_id is not None:
stmt = stmt.where(Mandate.organization_id == org_id)
else:
stmt = stmt.where(Mandate.organization_id.is_(None))
result = await db.execute(stmt)
mandate = result.scalar_one_or_none() mandate = result.scalar_one_or_none()
if mandate is None: if mandate is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandat introuvable") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandat introuvable")
return mandate return mandate
def _mandate_out(mandate: Mandate) -> MandateOut:
out = MandateOut.model_validate(mandate)
out.origin_display_name = mandate.origin_display_name
out.mandatee_display_name = mandate.mandatee_display_name
return out
# ── Mandate routes ────────────────────────────────────────────────────────── # ── Mandate routes ──────────────────────────────────────────────────────────
@@ -57,16 +74,21 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
async def list_mandates( async def list_mandates(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id), org_id: uuid.UUID | None = Depends(get_active_org_id),
mandate_type: str | None = Query(default=None, description="Filtrer par type de mandat"), mandate_type: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"), status_filter: str | None = Query(default=None, alias="status"),
skip: int = Query(default=0, ge=0), skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200), limit: int = Query(default=50, ge=1, le=200),
) -> list[MandateOut]: ) -> list[MandateOut]:
"""List all mandates with optional filters.""" stmt = select(Mandate).options(
stmt = select(Mandate).options(selectinload(Mandate.steps)) selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
if org_id is not None: if org_id is not None:
stmt = stmt.where(Mandate.organization_id == org_id) stmt = stmt.where(Mandate.organization_id == org_id)
else:
stmt = stmt.where(Mandate.organization_id.is_(None))
if mandate_type is not None: if mandate_type is not None:
stmt = stmt.where(Mandate.mandate_type == mandate_type) stmt = stmt.where(Mandate.mandate_type == mandate_type)
if status_filter is not None: if status_filter is not None:
@@ -76,7 +98,7 @@ async def list_mandates(
result = await db.execute(stmt) result = await db.execute(stmt)
mandates = result.scalars().unique().all() mandates = result.scalars().unique().all()
return [MandateOut.model_validate(m) for m in mandates] return [_mandate_out(m) for m in mandates]
@router.post("/", response_model=MandateOut, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=MandateOut, status_code=status.HTTP_201_CREATED)
@@ -86,25 +108,30 @@ async def create_mandate(
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id), org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut: ) -> MandateOut:
"""Create a new mandate.""" data = payload.model_dump()
mandate = Mandate(**payload.model_dump(), organization_id=org_id) nomination_mode = data.pop("nomination_mode", "postpone")
mandate = Mandate(**data, organization_id=org_id)
if nomination_mode == "auto":
mandate.mandatee_id = identity.id
db.add(mandate) db.add(mandate)
await db.commit() await db.commit()
await db.refresh(mandate) await db.refresh(mandate)
# Reload with steps (empty at creation) mandate = await _get_mandate(db, mandate.id, org_id)
mandate = await _get_mandate(db, mandate.id) return _mandate_out(mandate)
return MandateOut.model_validate(mandate)
@router.get("/{id}", response_model=MandateOut) @router.get("/{id}", response_model=MandateOut)
async def get_mandate( async def get_mandate(
id: uuid.UUID, id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut: ) -> MandateOut:
"""Get a single mandate with all its steps.""" mandate = await _get_mandate(db, id, org_id)
mandate = await _get_mandate(db, id) return _mandate_out(mandate)
return MandateOut.model_validate(mandate)
@router.put("/{id}", response_model=MandateOut) @router.put("/{id}", response_model=MandateOut)
@@ -113,20 +140,16 @@ async def update_mandate(
payload: MandateUpdate, payload: MandateUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut: ) -> MandateOut:
"""Update a mandate's metadata.""" mandate = await _get_mandate(db, id, org_id)
mandate = await _get_mandate(db, id)
update_data = payload.model_dump(exclude_unset=True) for field, value in payload.model_dump(exclude_unset=True).items():
for field, value in update_data.items():
setattr(mandate, field, value) setattr(mandate, field, value)
await db.commit() await db.commit()
await db.refresh(mandate) mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
# Reload with steps
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None) @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
@@ -134,9 +157,9 @@ async def delete_mandate(
id: uuid.UUID, id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> None: ) -> None:
"""Delete a mandate (only if in draft status).""" mandate = await _get_mandate(db, id, org_id)
mandate = await _get_mandate(db, id)
if mandate.status != "draft": if mandate.status != "draft":
raise HTTPException( raise HTTPException(
@@ -157,14 +180,11 @@ async def add_step(
payload: MandateStepCreate, payload: MandateStepCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateStepOut: ) -> MandateStepOut:
"""Add a step to a mandate process.""" mandate = await _get_mandate(db, id, org_id)
mandate = await _get_mandate(db, id)
step = MandateStep( step = MandateStep(mandate_id=mandate.id, **payload.model_dump())
mandate_id=mandate.id,
**payload.model_dump(),
)
db.add(step) db.add(step)
await db.commit() await db.commit()
await db.refresh(step) await db.refresh(step)
@@ -176,9 +196,9 @@ async def add_step(
async def list_steps( async def list_steps(
id: uuid.UUID, id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> list[MandateStepOut]: ) -> list[MandateStepOut]:
"""List all steps for a mandate, ordered by step_order.""" mandate = await _get_mandate(db, id, org_id)
mandate = await _get_mandate(db, id)
return [MandateStepOut.model_validate(s) for s in mandate.steps] return [MandateStepOut.model_validate(s) for s in mandate.steps]
@@ -190,18 +210,19 @@ async def advance_mandate_endpoint(
id: uuid.UUID, id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateAdvanceOut: ) -> MandateAdvanceOut:
"""Advance a mandate to its next step or status."""
try: try:
mandate = await advance_mandate(id, db) mandate = await advance_mandate(id, db)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps for complete output mandate = await _get_mandate(db, mandate.id, org_id)
mandate = await _get_mandate(db, mandate.id) out = _mandate_out(mandate)
data = MandateOut.model_validate(mandate).model_dump() return MandateAdvanceOut(
data["message"] = f"Mandat avance au statut : {mandate.status}" **out.model_dump(),
return MandateAdvanceOut(**data) message=f"Mandat avance au statut : {mandate.status}",
)
@router.post("/{id}/assign", response_model=MandateOut) @router.post("/{id}/assign", response_model=MandateOut)
@@ -210,16 +231,15 @@ async def assign_mandatee_endpoint(
payload: MandateAssignRequest, payload: MandateAssignRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut: ) -> MandateOut:
"""Assign a mandatee to a mandate."""
try: try:
mandate = await assign_mandatee(id, payload.mandatee_id, db) mandate = await assign_mandatee(id, payload.mandatee_id, db)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps mandate = await _get_mandate(db, mandate.id, org_id)
mandate = await _get_mandate(db, mandate.id) return _mandate_out(mandate)
return MandateOut.model_validate(mandate)
@router.post("/{id}/revoke", response_model=MandateOut) @router.post("/{id}/revoke", response_model=MandateOut)
@@ -227,16 +247,15 @@ async def revoke_mandate_endpoint(
id: uuid.UUID, id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut: ) -> MandateOut:
"""Revoke an active mandate."""
try: try:
mandate = await revoke_mandate(id, db) mandate = await revoke_mandate(id, db)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps mandate = await _get_mandate(db, mandate.id, org_id)
mandate = await _get_mandate(db, mandate.id) return _mandate_out(mandate)
return MandateOut.model_validate(mandate)
@router.post( @router.post(
@@ -249,8 +268,8 @@ async def create_vote_session_for_step_endpoint(
step_id: uuid.UUID, step_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VoteSessionOut: ) -> VoteSessionOut:
"""Create a vote session linked to a mandate step."""
try: try:
session = await create_vote_session_for_step(id, step_id, db) session = await create_vote_session_for_step(id, step_id, db)
except ValueError as exc: except ValueError as exc:
+18 -7
View File
@@ -34,13 +34,20 @@ router = APIRouter()
# ── Helpers ───────────────────────────────────────────────────────────────── # ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_protocol(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol: async def _get_protocol(
"""Fetch a voting protocol by ID with its formula config, or raise 404.""" db: AsyncSession, protocol_id: uuid.UUID, org_id: uuid.UUID | None = None
result = await db.execute( ) -> VotingProtocol:
"""Fetch a voting protocol by ID within the active org scope, or raise 404."""
stmt = (
select(VotingProtocol) select(VotingProtocol)
.options(selectinload(VotingProtocol.formula_config)) .options(selectinload(VotingProtocol.formula_config))
.where(VotingProtocol.id == protocol_id) .where(VotingProtocol.id == protocol_id)
) )
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
result = await db.execute(stmt)
protocol = result.scalar_one_or_none() protocol = result.scalar_one_or_none()
if protocol is None: if protocol is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole introuvable") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole introuvable")
@@ -74,6 +81,8 @@ async def list_protocols(
if org_id is not None: if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id) stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
if vote_type is not None: if vote_type is not None:
stmt = stmt.where(VotingProtocol.vote_type == vote_type) stmt = stmt.where(VotingProtocol.vote_type == vote_type)
@@ -111,7 +120,7 @@ async def create_protocol(
await db.refresh(protocol) await db.refresh(protocol)
# Reload with formula config # Reload with formula config
protocol = await _get_protocol(db, protocol.id) protocol = await _get_protocol(db, protocol.id, org_id)
return VotingProtocolOut.model_validate(protocol) return VotingProtocolOut.model_validate(protocol)
@@ -119,9 +128,10 @@ async def create_protocol(
async def get_protocol( async def get_protocol(
id: uuid.UUID, id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut: ) -> VotingProtocolOut:
"""Get a single voting protocol with its formula configuration.""" """Get a single voting protocol with its formula configuration."""
protocol = await _get_protocol(db, id) protocol = await _get_protocol(db, id, org_id)
return VotingProtocolOut.model_validate(protocol) return VotingProtocolOut.model_validate(protocol)
@@ -131,12 +141,13 @@ async def update_protocol(
payload: VotingProtocolUpdate, payload: VotingProtocolUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut: ) -> VotingProtocolOut:
"""Update a voting protocol (meta-governance). """Update a voting protocol (meta-governance).
Only provided fields will be updated. Requires authentication. Only provided fields will be updated. Requires authentication.
""" """
protocol = await _get_protocol(db, id) protocol = await _get_protocol(db, id, org_id)
update_data = payload.model_dump(exclude_unset=True) update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
@@ -145,7 +156,7 @@ async def update_protocol(
await db.commit() await db.commit()
# Reload with formula config # Reload with formula config
protocol = await _get_protocol(db, protocol.id) protocol = await _get_protocol(db, protocol.id, org_id)
return VotingProtocolOut.model_validate(protocol) return VotingProtocolOut.model_validate(protocol)
+10 -1
View File
@@ -25,6 +25,7 @@ from app.schemas.vote import (
VoteSessionListOut, VoteSessionListOut,
VoteSessionOut, VoteSessionOut,
) )
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity from app.services.auth_service import get_current_identity
from app.services.vote_service import ( from app.services.vote_service import (
close_session as svc_close_session, close_session as svc_close_session,
@@ -170,14 +171,22 @@ def _compute_result(
@router.get("/sessions", response_model=list[VoteSessionListOut]) @router.get("/sessions", response_model=list[VoteSessionListOut])
async def list_vote_sessions( async def list_vote_sessions(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
session_status: str | None = Query(default=None, alias="status", description="Filtrer par statut (open, closed, tallied)"), session_status: str | None = Query(default=None, alias="status", description="Filtrer par statut (open, closed, tallied)"),
decision_id: uuid.UUID | None = Query(default=None, description="Filtrer par decision_id"), decision_id: uuid.UUID | None = Query(default=None, description="Filtrer par decision_id"),
skip: int = Query(default=0, ge=0), skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200), limit: int = Query(default=50, ge=1, le=200),
) -> list[VoteSessionListOut]: ) -> list[VoteSessionListOut]:
"""List all vote sessions with optional filters by status and decision_id.""" """List all vote sessions with optional filters by status and decision_id."""
stmt = select(VoteSession) stmt = (
select(VoteSession)
.join(VotingProtocol, VoteSession.voting_protocol_id == VotingProtocol.id)
)
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
if session_status is not None: if session_status is not None:
stmt = stmt.where(VoteSession.status == session_status) stmt = stmt.where(VoteSession.status == session_status)
if decision_id is not None: if decision_id is not None:
+15 -39
View File
@@ -10,21 +10,13 @@ from pydantic import BaseModel, ConfigDict, Field
class MandateStepCreate(BaseModel): class MandateStepCreate(BaseModel):
"""Payload for creating a step within a mandate process."""
step_order: int = Field(..., ge=0) step_order: int = Field(..., ge=0)
step_type: str = Field( step_type: str = Field(..., max_length=32)
...,
max_length=32,
description="formulation, candidacy, vote, assignment, reporting, completion, revocation",
)
title: str | None = Field(default=None, max_length=256) title: str | None = Field(default=None, max_length=256)
description: str | None = None description: str | None = None
class MandateStepOut(BaseModel): class MandateStepOut(BaseModel):
"""Full mandate step representation."""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: UUID id: UUID
@@ -43,44 +35,45 @@ class MandateStepOut(BaseModel):
class MandateCreate(BaseModel): class MandateCreate(BaseModel):
"""Payload for creating a new mandate."""
title: str = Field(..., min_length=1, max_length=256) title: str = Field(..., min_length=1, max_length=256)
origin: str | None = None origin_id: UUID | None = None
description: str | None = None description: str | None = None
mandate_type: str = Field(..., max_length=64, description="techcomm, smith, custom") mandate_type: str = Field(..., max_length=64)
nomination_mode: str = Field(
default="postpone",
description="auto (auto-assign author), collective, postpone",
)
decision_id: UUID | None = None decision_id: UUID | None = None
starts_at: datetime | None = None starts_at: datetime | None = None
ends_at: datetime | None = None ends_at: datetime | None = None
class MandateUpdate(BaseModel): class MandateUpdate(BaseModel):
"""Partial update for a mandate."""
title: str | None = Field(default=None, max_length=256) title: str | None = Field(default=None, max_length=256)
origin_id: UUID | None = None
description: str | None = None description: str | None = None
mandate_type: str | None = Field(default=None, max_length=64) mandate_type: str | None = Field(default=None, max_length=64)
decision_id: UUID | None = None decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
class MandateAssignRequest(BaseModel): class MandateAssignRequest(BaseModel):
"""Request body for assigning a mandatee to a mandate.""" mandatee_id: UUID = Field(..., description="UUID de l'identite Duniter du mandataire")
mandatee_id: UUID = Field(..., description="ID de l'identite Duniter du mandataire")
class MandateOut(BaseModel): class MandateOut(BaseModel):
"""Full mandate representation returned by the API."""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: UUID id: UUID
title: str title: str
origin: str | None = None origin_id: UUID | None = None
origin_display_name: str | None = None
description: str | None = None description: str | None = None
mandate_type: str mandate_type: str
status: str status: str
mandatee_id: UUID | None = None mandatee_id: UUID | None = None
mandatee_display_name: str | None = None
decision_id: UUID | None = None decision_id: UUID | None = None
starts_at: datetime | None = None starts_at: datetime | None = None
ends_at: datetime | None = None ends_at: datetime | None = None
@@ -89,22 +82,5 @@ class MandateOut(BaseModel):
steps: list[MandateStepOut] = Field(default_factory=list) steps: list[MandateStepOut] = Field(default_factory=list)
class MandateAdvanceOut(BaseModel): class MandateAdvanceOut(MandateOut):
"""Output after advancing a mandate through its workflow."""
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
origin: str | None = None
description: str | None = None
mandate_type: str
status: str
mandatee_id: UUID | None = None
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
created_at: datetime
updated_at: datetime
steps: list[MandateStepOut] = Field(default_factory=list)
message: str = Field(..., description="Message decrivant l'avancement effectue") message: str = Field(..., description="Message decrivant l'avancement effectue")
+405
View File
@@ -0,0 +1,405 @@
"""Integration tests for mandate flows: nomination, lifecycle, assignment, revocation.
Uses a real in-memory SQLite database no mocks of the DB layer.
Tests the service functions directly to verify interconnected business logic.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import selectinload
from sqlalchemy import select
import app.models # noqa: F401 — registers all models with Base.metadata
from app.database import Base
from app.models.mandate import Mandate, MandateStep
from app.models.user import DuniterIdentity
from app.services.mandate_service import (
advance_mandate,
assign_mandatee,
revoke_mandate,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def engine():
eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield eng
await eng.dispose()
@pytest_asyncio.fixture
async def db(engine):
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
yield session
async def _mk_identity(db: AsyncSession, display_name: str = "Alice") -> DuniterIdentity:
ident = DuniterIdentity(
id=uuid.uuid4(),
address=f"5{uuid.uuid4().hex[:46]}",
display_name=display_name,
wot_status="member",
is_smith=False,
is_techcomm=False,
)
db.add(ident)
await db.commit()
await db.refresh(ident)
return ident
async def _mk_mandate(
db: AsyncSession,
*,
mandatee_id: uuid.UUID | None = None,
origin_id: uuid.UUID | None = None,
status: str = "draft",
steps: list[dict] | None = None,
) -> Mandate:
mandate = Mandate(
id=uuid.uuid4(),
title="Mandat test",
mandate_type="functional",
status=status,
mandatee_id=mandatee_id,
origin_id=origin_id,
)
db.add(mandate)
await db.flush()
for i, s in enumerate(steps or []):
step = MandateStep(
id=uuid.uuid4(),
mandate_id=mandate.id,
step_order=i,
step_type=s.get("step_type", "formulation"),
title=s.get("title"),
status=s.get("status", "pending"),
)
db.add(step)
await db.commit()
await db.refresh(mandate)
return mandate
async def _reload(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
result = await db.execute(
select(Mandate)
.options(
selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
.where(Mandate.id == mandate_id)
)
return result.scalar_one()
# ---------------------------------------------------------------------------
# TestMandateOrigin
# ---------------------------------------------------------------------------
class TestMandateOrigin:
"""origin_id must link to a real DuniterIdentity and expose display_name."""
@pytest.mark.asyncio
async def test_origin_id_linked(self, db: AsyncSession):
author = await _mk_identity(db, "Baptiste")
mandate = await _mk_mandate(db, origin_id=author.id)
loaded = await _reload(db, mandate.id)
assert loaded.origin_id == author.id
assert loaded.origin_display_name == "Baptiste"
@pytest.mark.asyncio
async def test_origin_id_optional(self, db: AsyncSession):
mandate = await _mk_mandate(db)
loaded = await _reload(db, mandate.id)
assert loaded.origin_id is None
assert loaded.origin_display_name is None
# ---------------------------------------------------------------------------
# TestAutoNomination
# ---------------------------------------------------------------------------
class TestAutoNomination:
"""Auto-désignation: mandatee = author, no candidacy steps needed."""
@pytest.mark.asyncio
async def test_auto_assign_author(self, db: AsyncSession):
author = await _mk_identity(db, "Constance")
mandate = await _mk_mandate(db, mandatee_id=author.id)
loaded = await _reload(db, mandate.id)
assert loaded.mandatee_id == author.id
assert loaded.mandatee_display_name == "Constance"
@pytest.mark.asyncio
async def test_auto_assign_then_advance_to_active(self, db: AsyncSession):
author = await _mk_identity(db, "David")
mandate = await _mk_mandate(
db,
mandatee_id=author.id,
steps=[
{"step_type": "formulation", "status": "pending"},
{"step_type": "assignment", "status": "pending"},
{"step_type": "reporting", "status": "pending"},
],
)
# First advance: draft → candidacy, activates step 0
result = await advance_mandate(mandate.id, db)
assert result.status == "candidacy"
loaded = await _reload(db, mandate.id)
assert loaded.steps[0].status == "active"
assert loaded.steps[1].status == "pending"
# Second advance: step 0 → completed, step 1 → active
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[0].status == "completed"
assert loaded.steps[1].status == "active"
# ---------------------------------------------------------------------------
# TestMandateAssign
# ---------------------------------------------------------------------------
class TestMandateAssign:
"""assign_mandatee service: proper UUID lookup, display_name populated."""
@pytest.mark.asyncio
async def test_assign_sets_mandatee_and_starts_at(self, db: AsyncSession):
identity = await _mk_identity(db, "Elodie")
mandate = await _mk_mandate(db, status="active")
await assign_mandatee(mandate.id, identity.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.mandatee_id == identity.id
assert loaded.starts_at is not None
assert loaded.mandatee_display_name == "Elodie"
@pytest.mark.asyncio
async def test_assign_unknown_identity_raises(self, db: AsyncSession):
mandate = await _mk_mandate(db, status="active")
with pytest.raises(ValueError, match="Identite Duniter introuvable"):
await assign_mandatee(mandate.id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_assign_completed_mandate_raises(self, db: AsyncSession):
identity = await _mk_identity(db, "Fabien")
mandate = await _mk_mandate(db, status="completed")
with pytest.raises(ValueError, match="statut terminal"):
await assign_mandatee(mandate.id, identity.id, db)
@pytest.mark.asyncio
async def test_reassign_replaces_mandatee(self, db: AsyncSession):
first = await _mk_identity(db, "Gilles")
second = await _mk_identity(db, "Hélène")
mandate = await _mk_mandate(db, mandatee_id=first.id, status="active")
await assign_mandatee(mandate.id, second.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.mandatee_id == second.id
# ---------------------------------------------------------------------------
# TestMandateLifecycle
# ---------------------------------------------------------------------------
class TestMandateLifecycle:
"""advance_mandate: full lifecycle with and without steps."""
@pytest.mark.asyncio
async def test_full_lifecycle_no_steps(self, db: AsyncSession):
mandate = await _mk_mandate(db)
statuses = [mandate.status]
for _ in range(10):
m = await advance_mandate(mandate.id, db)
statuses.append(m.status)
if m.status == "completed":
break
assert statuses == ["draft", "candidacy", "voting", "active", "reporting", "completed"]
@pytest.mark.asyncio
async def test_steps_activate_in_order(self, db: AsyncSession):
mandate = await _mk_mandate(
db,
steps=[
{"step_type": "formulation", "status": "pending"},
{"step_type": "candidacy", "status": "pending"},
{"step_type": "vote", "status": "pending"},
],
)
# Advance 1: activates step 0, moves to candidacy
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.status == "candidacy"
assert loaded.steps[0].status == "active"
assert loaded.steps[1].status == "pending"
# Advance 2: step 0 → completed, step 1 → active
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[0].status == "completed"
assert loaded.steps[1].status == "active"
# Advance 3: step 1 → completed, step 2 → active
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[1].status == "completed"
assert loaded.steps[2].status == "active"
# Advance 4: step 2 → completed, no more pending → advance mandate status
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[2].status == "completed"
assert loaded.status == "voting"
@pytest.mark.asyncio
async def test_advance_terminal_raises(self, db: AsyncSession):
for terminal in ("completed", "revoked"):
mandate = await _mk_mandate(db, status=terminal)
with pytest.raises(ValueError, match="statut terminal"):
await advance_mandate(mandate.id, db)
# ---------------------------------------------------------------------------
# TestMandateRevocation
# ---------------------------------------------------------------------------
class TestMandateRevocation:
"""revoke_mandate: active/pending steps cancelled, completed steps preserved."""
@pytest.mark.asyncio
async def test_revoke_cancels_active_and_pending(self, db: AsyncSession):
mandate = await _mk_mandate(
db,
status="active",
steps=[
{"step_type": "formulation", "status": "completed"},
{"step_type": "assignment", "status": "active"},
{"step_type": "reporting", "status": "pending"},
],
)
await revoke_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.status == "revoked"
assert loaded.ends_at is not None
assert loaded.steps[0].status == "completed"
assert loaded.steps[1].status == "cancelled"
assert loaded.steps[2].status == "cancelled"
@pytest.mark.asyncio
async def test_revoke_sets_ends_at(self, db: AsyncSession):
mandate = await _mk_mandate(db, status="draft")
await revoke_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.ends_at is not None
@pytest.mark.asyncio
async def test_revoke_already_revoked_raises(self, db: AsyncSession):
mandate = await _mk_mandate(db, status="revoked")
with pytest.raises(ValueError, match="statut terminal"):
await revoke_mandate(mandate.id, db)
# ---------------------------------------------------------------------------
# TestNominationInteractions
# ---------------------------------------------------------------------------
class TestNominationInteractions:
"""Cross-process: nomination + assignment + lifecycle are consistent."""
@pytest.mark.asyncio
async def test_assign_then_advance_full_cycle(self, db: AsyncSession):
"""Assigning a mandatee then running the full lifecycle completes cleanly."""
mandatee = await _mk_identity(db, "Isabelle")
mandate = await _mk_mandate(
db,
steps=[
{"step_type": "formulation", "status": "pending"},
{"step_type": "assignment", "status": "pending"},
{"step_type": "completion", "status": "pending"},
],
)
# Assign before starting
await assign_mandatee(mandate.id, mandatee.id, db)
# Run through all steps
for _ in range(5):
m = await advance_mandate(mandate.id, db)
if m.status in ("completed", "revoked"):
break
loaded = await _reload(db, mandate.id)
# All steps completed, mandate advanced beyond steps
assert all(s.status == "completed" for s in loaded.steps)
assert loaded.mandatee_id == mandatee.id
@pytest.mark.asyncio
async def test_revoke_after_assign_preserves_mandatee_id(self, db: AsyncSession):
"""Revoking a mandate keeps mandatee_id (for audit trail)."""
mandatee = await _mk_identity(db, "Jacques")
mandate = await _mk_mandate(db, mandatee_id=mandatee.id, status="active")
await revoke_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.status == "revoked"
assert loaded.mandatee_id == mandatee.id
@pytest.mark.asyncio
async def test_origin_and_mandatee_can_differ(self, db: AsyncSession):
"""The person who proposed the mandate (origin) is different from the mandatee."""
proposer = await _mk_identity(db, "Kim")
mandatee = await _mk_identity(db, "Laurent")
mandate = await _mk_mandate(db, origin_id=proposer.id)
await assign_mandatee(mandate.id, mandatee.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.origin_id == proposer.id
assert loaded.mandatee_id == mandatee.id
assert loaded.origin_display_name == "Kim"
assert loaded.mandatee_display_name == "Laurent"
+13
View File
@@ -14,8 +14,21 @@ from __future__ import annotations
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
import app.models # noqa: F401 — registers all models with Base.metadata before create_all
from app.database import init_db
from app.main import app from app.main import app
@pytest.fixture(scope="module", autouse=True)
async def _create_tables():
"""Create DB tables once for this module.
ASGITransport does not trigger the FastAPI lifespan, so init_db() would
never run. Tests that hit endpoints backed by the DB need the tables to
exist beforehand.
"""
await init_db()
ORIGIN = "http://localhost:3002" ORIGIN = "http://localhost:3002"
CHALLENGE_URL = "/api/v1/auth/challenge" CHALLENGE_URL = "/api/v1/auth/challenge"
VERIFY_URL = "/api/v1/auth/verify" VERIFY_URL = "/api/v1/auth/verify"
+1 -1
View File
@@ -41,4 +41,4 @@ COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
WORKDIR /app WORKDIR /app
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002", "--reload"]
+40 -25
View File
@@ -100,6 +100,8 @@ const activeStepIndex = computed(() => {
} }
}) })
const isProtoMode = computed(() => devProfiles.value.length > 0)
onMounted(() => { onMounted(() => {
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
router.push('/') router.push('/')
@@ -166,24 +168,14 @@ onMounted(() => {
<span>Connecte. Redirection...</span> <span>Connecte. Redirection...</span>
</div> </div>
<!-- Button --> <!-- Mode prototype : profils démo -->
<button <template v-if="isProtoMode">
class="login-card__btn" <div class="proto-panel">
:disabled="!address.trim() || step === 'success' || auth.loading" <div class="proto-panel__header">
@click="handleLogin" <UIcon name="i-lucide-flask-conical" />
> <span>Mode prototype sélectionnez un profil</span>
<UIcon v-if="auth.loading" name="i-lucide-loader-2" class="animate-spin" />
<UIcon v-else name="i-lucide-log-in" />
<span>{{ auth.loading ? 'Verification...' : 'Se connecter' }}</span>
</button>
<!-- Dev Mode Panel -->
<div v-if="devProfiles.length" class="dev-panel">
<div class="dev-panel__header">
<UIcon name="i-lucide-bug" />
<span>Mode Dev Connexion rapide</span>
</div> </div>
<div class="dev-panel__profiles"> <div class="proto-panel__profiles">
<button <button
v-for="p in devProfiles" v-for="p in devProfiles"
:key="p.address" :key="p.address"
@@ -199,12 +191,27 @@ onMounted(() => {
<span class="dev-profile__addr">{{ p.address.slice(0, 8) }}...</span> <span class="dev-profile__addr">{{ p.address.slice(0, 8) }}...</span>
</button> </button>
</div> </div>
<p class="proto-panel__note">
Authentification trustWallet à venir intégration librodrome
</p>
</div> </div>
</template>
<!-- Note --> <!-- Mode production : formulaire + extension -->
<template v-else>
<button
class="login-card__btn"
:disabled="!address.trim() || step === 'success' || auth.loading"
@click="handleLogin"
>
<UIcon v-if="auth.loading" name="i-lucide-loader-2" class="animate-spin" />
<UIcon v-else name="i-lucide-log-in" />
<span>{{ auth.loading ? 'Verification...' : 'Se connecter' }}</span>
</button>
<p class="login-card__note"> <p class="login-card__note">
Aucun mot de passe. Authentification par signature cryptographique. Aucun mot de passe. Authentification par signature cryptographique.
</p> </p>
</template>
</div> </div>
</div> </div>
</template> </template>
@@ -453,32 +460,40 @@ onMounted(() => {
cursor: not-allowed; cursor: not-allowed;
} }
/* Dev panel */ /* Proto panel */
.dev-panel { .proto-panel {
border: 2px dashed var(--mood-warning, #f59e0b);
border-radius: 16px; border-radius: 16px;
padding: 1rem; padding: 1rem;
background: rgba(245, 158, 11, 0.04); background: var(--mood-accent-soft);
box-shadow: 0 2px 12px var(--mood-shadow, rgba(0,0,0,0.06));
} }
.dev-panel__header { .proto-panel__header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 700; font-weight: 700;
color: var(--mood-warning, #f59e0b); color: var(--mood-accent);
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.dev-panel__profiles { .proto-panel__profiles {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.proto-panel__note {
margin-top: 0.75rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
opacity: 0.7;
text-align: center;
}
.dev-profile { .dev-profile {
display: flex; display: flex;
align-items: center; align-items: center;
+212 -247
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const route = useRoute() const route = useRoute()
const mandates = useMandatesStore() const mandates = useMandatesStore()
const { $api } = useApi()
const mandateId = computed(() => route.params.id as string) const mandateId = computed(() => route.params.id as string)
@@ -13,77 +14,95 @@ onUnmounted(() => {
}) })
watch(mandateId, async (newId) => { watch(mandateId, async (newId) => {
if (newId) { if (newId) await mandates.fetchById(newId)
await mandates.fetchById(newId)
}
}) })
// --- Status helpers --- // --- Helpers ---
const typeLabel = (mandateType: string) => { const typeLabel = (t: string) => ({ statutory: 'Statutaire', functional: 'Fonctionnel' }[t] ?? t)
switch (mandateType) {
case 'techcomm': return 'Comite technique'
case 'smith': return 'Forgeron'
case 'custom': return 'Personnalise'
default: return mandateType
}
}
function formatDate(dateStr: string | null): string { function formatDate(d: string | null): string {
if (!dateStr) return '-' if (!d) return '-'
return new Date(dateStr).toLocaleDateString('fr-FR', { return new Date(d).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
day: 'numeric',
month: 'long',
year: 'numeric',
})
} }
// --- Terminal state check ---
const terminalStatuses = ['completed', 'revoked'] const terminalStatuses = ['completed', 'revoked']
const isTerminal = computed(() => { const isTerminal = computed(() => !mandates.current || terminalStatuses.includes(mandates.current.status))
if (!mandates.current) return true const canRevoke = computed(() => mandates.current?.status === 'active')
return terminalStatuses.includes(mandates.current.status) const isDraft = computed(() => mandates.current?.status === 'draft')
})
const canRevoke = computed(() => { // --- Advance ---
if (!mandates.current) return false
return mandates.current.status === 'active'
})
// --- Advance action ---
const advancing = ref(false) const advancing = ref(false)
async function handleAdvance() { async function handleAdvance() {
advancing.value = true advancing.value = true
try { try { await mandates.advance(mandateId.value) } catch { /* store holds error */ } finally { advancing.value = false }
await mandates.advance(mandateId.value)
} catch {
// Error handled by store
} finally {
advancing.value = false
} }
// --- Identity search (shared for assign + edit) ---
interface IdentityResult { id: string; address: string; display_name: string | null }
function useIdentitySearch() {
const query = ref('')
const results = ref<IdentityResult[]>([])
const searching = ref(false)
const selectedId = ref<string | null>(null)
const selectedLabel = ref('')
let timer: ReturnType<typeof setTimeout> | null = null
async function search(q: string) {
if (q.length < 2) { results.value = []; return }
searching.value = true
try {
results.value = await $api<IdentityResult[]>('/auth/identities', { query: { q } })
} catch { results.value = [] } finally { searching.value = false }
}
function onInput(q: string) {
query.value = q
selectedId.value = null
if (timer) clearTimeout(timer)
timer = setTimeout(() => search(q), 300)
}
function select(i: IdentityResult) {
selectedId.value = i.id
selectedLabel.value = i.display_name || i.address
query.value = i.display_name || i.address
results.value = []
}
function reset() {
query.value = ''
results.value = []
selectedId.value = null
selectedLabel.value = ''
}
return { query, results, searching, selectedId, selectedLabel, onInput, select, reset }
} }
// --- Assign mandatee --- // --- Assign mandatee ---
const showAssignModal = ref(false) const showAssignModal = ref(false)
const mandateeAddress = ref('')
const assigning = ref(false) const assigning = ref(false)
const assignSearch = useIdentitySearch()
async function handleAssign() { async function handleAssign() {
if (!mandateeAddress.value.trim()) return if (!assignSearch.selectedId.value) return
assigning.value = true assigning.value = true
try { try {
await mandates.assignMandatee(mandateId.value, mandateeAddress.value.trim()) await mandates.assignMandatee(mandateId.value, assignSearch.selectedId.value)
showAssignModal.value = false showAssignModal.value = false
mandateeAddress.value = '' assignSearch.reset()
} catch { } catch { /* store holds error */ } finally { assigning.value = false }
// Error handled by store
} finally {
assigning.value = false
} }
function openAssign() {
assignSearch.reset()
showAssignModal.value = true
} }
// --- Revoke --- // --- Revoke ---
@@ -96,27 +115,28 @@ async function handleRevoke() {
try { try {
await mandates.revoke(mandateId.value) await mandates.revoke(mandateId.value)
showRevokeConfirm.value = false showRevokeConfirm.value = false
} catch { } catch { /* store holds error */ } finally { revoking.value = false }
// Error handled by store
} finally {
revoking.value = false
}
} }
// --- Edit modal --- // --- Edit ---
const showEditModal = ref(false) const showEditModal = ref(false)
const editData = ref({ const editData = ref({ title: '', origin_id: null as string | null, description: '' })
title: '', const editOriginSearch = useIdentitySearch()
description: '' as string | null,
})
const saving = ref(false) const saving = ref(false)
function openEdit() { function openEdit() {
if (!mandates.current) return if (!mandates.current) return
editData.value = { editData.value = {
title: mandates.current.title, title: mandates.current.title,
description: mandates.current.description, origin_id: mandates.current.origin_id,
description: mandates.current.description ?? '',
}
if (mandates.current.origin_display_name) {
editOriginSearch.query.value = mandates.current.origin_display_name
editOriginSearch.selectedId.value = mandates.current.origin_id
} else {
editOriginSearch.reset()
} }
showEditModal.value = true showEditModal.value = true
} }
@@ -124,50 +144,35 @@ function openEdit() {
async function saveEdit() { async function saveEdit() {
saving.value = true saving.value = true
try { try {
await mandates.update(mandateId.value, editData.value) await mandates.update(mandateId.value, {
title: editData.value.title,
origin_id: editOriginSearch.selectedId.value ?? editData.value.origin_id,
description: editData.value.description || null,
})
showEditModal.value = false showEditModal.value = false
} catch { } catch { /* store holds error */ } finally { saving.value = false }
// Error handled by store
} finally {
saving.value = false
}
} }
// --- Delete --- // --- Delete ---
const showDeleteConfirm = ref(false) const showDeleteConfirm = ref(false)
const deleting = ref(false) const deleting = ref(false)
const isDraft = computed(() => mandates.current?.status === 'draft')
async function handleDelete() { async function handleDelete() {
deleting.value = true deleting.value = true
try { try {
await mandates.delete(mandateId.value) await mandates.delete(mandateId.value)
navigateTo('/mandates') navigateTo('/mandates')
} catch { } catch { /* store holds error */ } finally { deleting.value = false; showDeleteConfirm.value = false }
// Error handled by store
} finally {
deleting.value = false
showDeleteConfirm.value = false
}
} }
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<!-- Back link -->
<div> <div>
<UButton <UButton to="/mandates" variant="ghost" color="neutral" icon="i-lucide-arrow-left" label="Retour aux mandats" size="sm" />
to="/mandates"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour aux mandats"
size="sm"
/>
</div> </div>
<!-- Loading state -->
<template v-if="mandates.loading"> <template v-if="mandates.loading">
<div class="space-y-4"> <div class="space-y-4">
<USkeleton class="h-8 w-96" /> <USkeleton class="h-8 w-96" />
@@ -178,7 +183,6 @@ async function handleDelete() {
</div> </div>
</template> </template>
<!-- Error state -->
<template v-else-if="mandates.error"> <template v-else-if="mandates.error">
<UCard> <UCard>
<div class="flex items-center gap-3 text-red-500"> <div class="flex items-center gap-3 text-red-500">
@@ -188,79 +192,35 @@ async function handleDelete() {
</UCard> </UCard>
</template> </template>
<!-- Mandate detail -->
<template v-else-if="mandates.current"> <template v-else-if="mandates.current">
<!-- Header with actions --> <!-- Header -->
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white"> <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ mandates.current.title }}</h1>
{{ mandates.current.title }}
</h1>
<div class="flex items-center gap-3 mt-2"> <div class="flex items-center gap-3 mt-2">
<UBadge variant="subtle" color="primary"> <UBadge variant="subtle" color="primary">{{ typeLabel(mandates.current.mandate_type) }}</UBadge>
{{ typeLabel(mandates.current.mandate_type) }}
</UBadge>
<StatusBadge :status="mandates.current.status" type="mandate" /> <StatusBadge :status="mandates.current.status" type="mandate" />
</div> </div>
</div> </div>
<!-- Action buttons -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<UButton <UButton v-if="!isTerminal" icon="i-lucide-fast-forward" label="Avancer" color="primary" variant="soft" size="sm" :loading="advancing" @click="handleAdvance" />
v-if="!isTerminal" <UButton v-if="!isTerminal && !mandates.current.mandatee_id" icon="i-lucide-user-plus" label="Assigner un mandataire" variant="soft" color="primary" size="sm" @click="openAssign" />
icon="i-lucide-fast-forward" <UButton icon="i-lucide-pen-line" label="Modifier" variant="soft" color="neutral" size="sm" @click="openEdit" />
label="Avancer" <UButton v-if="canRevoke" icon="i-lucide-shield-off" label="Revoquer" variant="soft" color="error" size="sm" @click="showRevokeConfirm = true" />
color="primary" <UButton v-if="isDraft" icon="i-lucide-trash-2" label="Supprimer" variant="soft" color="error" size="sm" @click="showDeleteConfirm = true" />
variant="soft"
size="sm"
:loading="advancing"
@click="handleAdvance"
/>
<UButton
v-if="!isTerminal && !mandates.current.mandatee_id"
icon="i-lucide-user-plus"
label="Assigner un mandataire"
variant="soft"
color="primary"
size="sm"
@click="showAssignModal = true"
/>
<UButton
icon="i-lucide-pen-line"
label="Modifier"
variant="soft"
color="neutral"
size="sm"
@click="openEdit"
/>
<UButton
v-if="canRevoke"
icon="i-lucide-shield-off"
label="Revoquer"
variant="soft"
color="error"
size="sm"
@click="showRevokeConfirm = true"
/>
<UButton
v-if="isDraft"
icon="i-lucide-trash-2"
label="Supprimer"
variant="soft"
color="error"
size="sm"
@click="showDeleteConfirm = true"
/>
</div> </div>
</div> </div>
<!-- Description --> <!-- Error feedback -->
<div v-if="mandates.error" class="text-sm text-red-500 bg-red-50 dark:bg-red-950 px-4 py-2 rounded-lg">
{{ mandates.error }}
</div>
<UCard v-if="mandates.current.description"> <UCard v-if="mandates.current.description">
<div> <div>
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Description</h3> <h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Description</h3>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap"> <p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{{ mandates.current.description }}</p>
{{ mandates.current.description }}
</p>
</div> </div>
</UCard> </UCard>
@@ -270,199 +230,204 @@ async function handleDelete() {
<div> <div>
<p class="text-gray-500">Mandataire</p> <p class="text-gray-500">Mandataire</p>
<p class="font-medium text-gray-900 dark:text-white"> <p class="font-medium text-gray-900 dark:text-white">
<template v-if="mandates.current.mandatee_id"> <template v-if="mandates.current.mandatee_display_name">{{ mandates.current.mandatee_display_name }}</template>
<span class="font-mono text-xs">{{ mandates.current.mandatee_id.slice(0, 12) }}...</span> <template v-else-if="mandates.current.mandatee_id"><span class="font-mono text-xs">{{ mandates.current.mandatee_id.slice(0, 12) }}</span></template>
</template> <template v-else><span class="text-gray-400 italic">Non assigne</span></template>
<template v-else> </p>
<span class="text-gray-400 italic">Non assigne</span> </div>
</template> <div>
<p class="text-gray-500">Origine</p>
<p class="font-medium text-gray-900 dark:text-white">
<template v-if="mandates.current.origin_display_name">{{ mandates.current.origin_display_name }}</template>
<template v-else><span class="text-gray-400 italic">Non renseigné</span></template>
</p> </p>
</div> </div>
<div> <div>
<p class="text-gray-500">Debut</p> <p class="text-gray-500">Debut</p>
<p class="font-medium text-gray-900 dark:text-white"> <p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.starts_at) }}</p>
{{ formatDate(mandates.current.starts_at) }}
</p>
</div> </div>
<div> <div>
<p class="text-gray-500">Fin</p> <p class="text-gray-500">Fin</p>
<p class="font-medium text-gray-900 dark:text-white"> <p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.ends_at) }}</p>
{{ formatDate(mandates.current.ends_at) }}
</p>
</div>
<div>
<p class="text-gray-500">Nombre d'etapes</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ mandates.current.steps.length }}
</p>
</div> </div>
</div> </div>
</UCard> </UCard>
<!-- Dates metadata -->
<UCard> <UCard>
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<p class="text-gray-500">Cree le</p> <p class="text-gray-500">Cree le</p>
<p class="font-medium text-gray-900 dark:text-white"> <p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.created_at) }}</p>
{{ formatDate(mandates.current.created_at) }}
</p>
</div> </div>
<div> <div>
<p class="text-gray-500">Mis a jour le</p> <p class="text-gray-500">Mis a jour le</p>
<p class="font-medium text-gray-900 dark:text-white"> <p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.updated_at) }}</p>
{{ formatDate(mandates.current.updated_at) }}
</p>
</div> </div>
</div> </div>
</UCard> </UCard>
<!-- Steps timeline --> <!-- Steps -->
<div> <div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4"> <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Etapes du mandat</h2>
Etapes du mandat <MandateTimeline :steps="mandates.current.steps" :current-status="mandates.current.status" />
</h2>
<MandateTimeline
:steps="mandates.current.steps"
:current-status="mandates.current.status"
/>
</div> </div>
</template> </template>
<!-- Assign mandatee modal --> <!-- Modal : Assigner un mandataire -->
<UModal v-model:open="showAssignModal"> <UModal v-model:open="showAssignModal">
<template #content> <template #content>
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white"> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Assigner un mandataire</h3>
Assigner un mandataire
</h3>
<div class="space-y-1"> <div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Adresse du mandataire <span class="text-red-500">*</span> Rechercher un membre <span class="text-red-500">*</span>
</label> </label>
<UInput <div class="relative">
v-model="mandateeAddress" <input
placeholder="Adresse Duniter (ex: 5Grw...) :value="assignSearch.query.value"
" type="text"
class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Nom ou adresse Duniter…"
@input="assignSearch.onInput(($event.target as HTMLInputElement).value)"
/> />
<p class="text-xs text-gray-500"> <div
Adresse SS58 du membre de la toile de confiance v-if="assignSearch.results.value.length"
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
>
<button
v-for="r in assignSearch.results.value"
:key="r.id"
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
@click="assignSearch.select(r)"
>
<UIcon name="i-lucide-user" class="text-gray-400 shrink-0" />
<div>
<p class="font-medium text-gray-900 dark:text-white">{{ r.display_name || r.address }}</p>
<p class="text-xs text-gray-500 font-mono">{{ r.address.slice(0, 20) }}</p>
</div>
</button>
</div>
</div>
<p v-if="assignSearch.selectedId.value" class="text-xs text-green-600 flex items-center gap-1">
<UIcon name="i-lucide-check-circle" /> {{ assignSearch.selectedLabel.value }} sélectionné
</p> </p>
</div> </div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700"> <div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton <button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showAssignModal = false">Annuler</button>
label="Annuler" <button
variant="ghost" class="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
color="neutral" :disabled="!assignSearch.selectedId.value || assigning"
@click="showAssignModal = false"
/>
<UButton
label="Assigner"
icon="i-lucide-user-plus"
color="primary"
:loading="assigning"
:disabled="!mandateeAddress.trim()"
@click="handleAssign" @click="handleAssign"
/> >
<UIcon v-if="assigning" name="i-lucide-loader-2" class="animate-spin text-sm" />
Assigner
</button>
</div> </div>
</div> </div>
</template> </template>
</UModal> </UModal>
<!-- Revoke confirmation modal --> <!-- Modal : Révoquer -->
<UModal v-model:open="showRevokeConfirm"> <UModal v-model:open="showRevokeConfirm">
<template #content> <template #content>
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600"> <h3 class="text-lg font-semibold text-red-600">Confirmer la revocation</h3>
Confirmer la revocation
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400"> <p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir revoquer ce mandat ? Le mandataire perdra ses droits et responsabilites. Etes-vous sur de vouloir revoquer ce mandat ? Le mandataire perdra ses droits et responsabilites.
</p> </p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700"> <div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton <button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showRevokeConfirm = false">Annuler</button>
label="Annuler" <button
variant="ghost" class="px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-xl hover:bg-red-700 flex items-center gap-2"
color="neutral" :disabled="revoking"
@click="showRevokeConfirm = false"
/>
<UButton
label="Revoquer"
icon="i-lucide-shield-off"
color="error"
:loading="revoking"
@click="handleRevoke" @click="handleRevoke"
/> >
<UIcon v-if="revoking" name="i-lucide-loader-2" class="animate-spin text-sm" />
Revoquer
</button>
</div> </div>
</div> </div>
</template> </template>
</UModal> </UModal>
<!-- Edit modal --> <!-- Modal : Modifier -->
<UModal v-model:open="showEditModal"> <UModal v-model:open="showEditModal">
<template #content> <template #content>
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white"> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Modifier le mandat</h3>
Modifier le mandat
</h3>
<div class="space-y-1"> <div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
<UInput v-model="editData.title" /> <input v-model="editData.title" type="text" class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Origine</label>
<div class="relative">
<input
:value="editOriginSearch.query.value"
type="text"
class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Rechercher un membre…"
@input="editOriginSearch.onInput(($event.target as HTMLInputElement).value)"
/>
<div
v-if="editOriginSearch.results.value.length"
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
>
<button
v-for="r in editOriginSearch.results.value"
:key="r.id"
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
@click="editOriginSearch.select(r)"
>
<UIcon name="i-lucide-user" class="text-gray-400 shrink-0" />
<span>{{ r.display_name || r.address }}</span>
</button>
</div>
</div>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<UTextarea v-model="editData.description" :rows="4" /> <textarea v-model="editData.description" rows="4" class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none" />
</div> </div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700"> <div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton <button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showEditModal = false">Annuler</button>
label="Annuler" <button
variant="ghost" class="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
color="neutral" :disabled="!editData.title?.trim() || saving"
@click="showEditModal = false"
/>
<UButton
label="Enregistrer"
icon="i-lucide-save"
color="primary"
:loading="saving"
:disabled="!editData.title?.trim()"
@click="saveEdit" @click="saveEdit"
/> >
<UIcon v-if="saving" name="i-lucide-loader-2" class="animate-spin text-sm" />
Enregistrer
</button>
</div> </div>
</div> </div>
</template> </template>
</UModal> </UModal>
<!-- Delete confirmation modal --> <!-- Modal : Supprimer -->
<UModal v-model:open="showDeleteConfirm"> <UModal v-model:open="showDeleteConfirm">
<template #content> <template #content>
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600"> <h3 class="text-lg font-semibold text-red-600">Confirmer la suppression</h3>
Confirmer la suppression
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400"> <p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir supprimer ce mandat ? Cette action est irreversible. Etes-vous sur de vouloir supprimer ce mandat ? Cette action est irreversible.
</p> </p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700"> <div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton <button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showDeleteConfirm = false">Annuler</button>
label="Annuler" <button
variant="ghost" class="px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-xl hover:bg-red-700 flex items-center gap-2"
color="neutral" :disabled="deleting"
@click="showDeleteConfirm = false"
/>
<UButton
label="Supprimer"
icon="i-lucide-trash-2"
color="error"
:loading="deleting"
@click="handleDelete" @click="handleDelete"
/> >
<UIcon v-if="deleting" name="i-lucide-loader-2" class="animate-spin text-sm" />
Supprimer
</button>
</div> </div>
</div> </div>
</template> </template>
+111 -13
View File
@@ -152,9 +152,35 @@ const canGoToInfo = computed(() => {
type MandateType = 'statutory' | 'functional' type MandateType = 'statutory' | 'functional'
const mandateType = ref<MandateType>('functional') const mandateType = ref<MandateType>('functional')
const title = ref('') const title = ref('')
const origin = ref('')
const description = ref('') const description = ref('')
// Origin : identité Duniter (non texte libre)
interface IdentityResult { id: string; address: string; display_name: string | null }
const originQuery = ref('')
const originResults = ref<IdentityResult[]>([])
const originId = ref<string | null>(null)
const originSearching = ref(false)
let originTimer: ReturnType<typeof setTimeout> | null = null
async function searchOrigin(q: string) {
if (q.length < 2) { originResults.value = []; return }
originSearching.value = true
try {
originResults.value = await $api<IdentityResult[]>('/auth/identities', { query: { q } })
} catch { originResults.value = [] } finally { originSearching.value = false }
}
function onOriginInput(q: string) {
originQuery.value = q
originId.value = null
if (originTimer) clearTimeout(originTimer)
originTimer = setTimeout(() => searchOrigin(q), 300)
}
function selectOrigin(r: IdentityResult) {
originId.value = r.id
originQuery.value = r.display_name || r.address
originResults.value = []
}
type DurationMode = 'relative' | 'dates' type DurationMode = 'relative' | 'dates'
const durationMode = ref<DurationMode>('relative') const durationMode = ref<DurationMode>('relative')
const durationValue = ref(3) const durationValue = ref(3)
@@ -265,24 +291,31 @@ const nominationSummary = computed(() => {
}) })
// Création // Création
const startImmediately = ref(true)
const submitting = ref(false) const submitting = ref(false)
const submitError = ref<string | null>(null) const submitError = ref<string | null>(null)
const auth = useAuthStore()
async function createMandate() { async function createMandate() {
submitting.value = true submitting.value = true
submitError.value = null submitError.value = null
try { try {
const dates = computedDates() const dates = computedDates()
const nominationMode = nominationCase.value === 'self' ? 'auto' : 'collective'
const mandate = await mandates.create({ const mandate = await mandates.create({
title: title.value.trim(), title: title.value.trim(),
origin: origin.value.trim() || null, origin_id: originId.value,
description: description.value.trim() || null, description: description.value.trim() || null,
mandate_type: mandateType.value, mandate_type: mandateType.value,
nomination_mode: nominationMode,
starts_at: dates.starts_at, starts_at: dates.starts_at,
ends_at: dates.ends_at, ends_at: dates.ends_at,
}) })
if (!mandate) throw new Error('Erreur création mandat') if (!mandate) throw new Error('Erreur création mandat')
// Créer les étapes
for (let i = 0; i < stepsToCreate.value.length; i++) { for (let i = 0; i < stepsToCreate.value.length; i++) {
const s = stepsToCreate.value[i]! const s = stepsToCreate.value[i]!
await $api(`/mandates/${mandate.id}/steps`, { await $api(`/mandates/${mandate.id}/steps`, {
@@ -290,9 +323,15 @@ async function createMandate() {
body: { step_order: i, step_type: s.step_type, title: s.title, description: s.description }, body: { step_order: i, step_type: s.step_type, title: s.title, description: s.description },
}) })
} }
// Démarrer le processus si demandé
if (startImmediately.value) {
await $api(`/mandates/${mandate.id}/advance`, { method: 'POST' })
}
navigateTo(`/mandates/${mandate.id}`) navigateTo(`/mandates/${mandate.id}`)
} catch (e: any) { } catch (e: any) {
submitError.value = e?.message ?? 'Erreur lors de la création' submitError.value = e?.data?.detail ?? e?.message ?? 'Erreur lors de la création'
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -620,17 +659,38 @@ function selectNomination(c: NominationCase) {
/> />
</div> </div>
<!-- Origine --> <!-- Origine (personne) -->
<div class="mwiz__section"> <div class="mwiz__section">
<label class="mwiz__label">Origine <span class="mwiz__optional">(optionnel)</span></label> <label class="mwiz__label">Proposé par <span class="mwiz__optional">(optionnel)</span></label>
<textarea <div class="relative">
v-model="origin" <input
class="mwiz__textarea" :value="originQuery"
rows="2" type="text"
lang="fr" class="mwiz__input"
spellcheck="true" placeholder="Rechercher un membre de la communauté…"
placeholder="Qui propose ce mandat, dans quel contexte, suite à quelle décision ou besoin ?" @input="onOriginInput(($event.target as HTMLInputElement).value)"
/> />
<div
v-if="originResults.length"
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
>
<button
v-for="r in originResults"
:key="r.id"
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
@click="selectOrigin(r)"
>
<UIcon name="i-lucide-user" class="text-gray-400 shrink-0" />
<div>
<p class="font-medium text-gray-900 dark:text-white">{{ r.display_name || r.address }}</p>
<p class="text-xs text-gray-500 font-mono">{{ r.address.slice(0, 20) }}</p>
</div>
</button>
</div>
</div>
<p v-if="originId" class="mwiz__hint text-green-600">
<UIcon name="i-lucide-check-circle" class="text-xs" /> {{ originQuery }} sélectionné
</p>
</div> </div>
<!-- Description --> <!-- Description -->
@@ -709,7 +769,7 @@ function selectNomination(c: NominationCase) {
<UIcon :name="MANDATE_TYPE_OPTIONS.find(o => o.value === mandateType)?.icon ?? 'i-lucide-circle'" class="text-xs" /> <UIcon :name="MANDATE_TYPE_OPTIONS.find(o => o.value === mandateType)?.icon ?? 'i-lucide-circle'" class="text-xs" />
{{ MANDATE_TYPE_OPTIONS.find(o => o.value === mandateType)?.label }} {{ MANDATE_TYPE_OPTIONS.find(o => o.value === mandateType)?.label }}
</p> </p>
<p v-if="origin" class="mwiz__recap-meta">Origine : {{ origin }}</p> <p v-if="originId" class="mwiz__recap-meta">Proposé par : {{ originQuery }}</p>
<p v-if="description" class="mwiz__recap-meta">{{ description }}</p> <p v-if="description" class="mwiz__recap-meta">{{ description }}</p>
<p class="mwiz__recap-meta"> <p class="mwiz__recap-meta">
<UIcon name="i-lucide-clock" class="text-xs" /> <UIcon name="i-lucide-clock" class="text-xs" />
@@ -732,6 +792,18 @@ function selectNomination(c: NominationCase) {
</div> </div>
</div> </div>
<!-- Option démarrage -->
<div class="mwiz__start-option">
<label class="mwiz__start-label">
<input v-model="startImmediately" type="checkbox" class="mwiz__checkbox" />
<span>Démarrer le processus de nomination immédiatement</span>
</label>
<p class="mwiz__start-hint">
<template v-if="startImmediately">Le mandat passera en phase de nomination dès la création.</template>
<template v-else>Le mandat restera en brouillon à démarrer manuellement depuis la fiche.</template>
</p>
</div>
<p v-if="submitError" class="mwiz__error">{{ submitError }}</p> <p v-if="submitError" class="mwiz__error">{{ submitError }}</p>
<div class="mwiz__recap-actions"> <div class="mwiz__recap-actions">
@@ -1173,6 +1245,32 @@ function selectNomination(c: NominationCase) {
.mwiz__error { color: var(--mood-danger, #e53e3e); font-size: 0.875rem; padding: 0.5rem 0; } .mwiz__error { color: var(--mood-danger, #e53e3e); font-size: 0.875rem; padding: 0.5rem 0; }
/* Start option */
.mwiz__start-option {
background: var(--mood-surface);
border-radius: 16px;
padding: 1.125rem 1.25rem;
margin-bottom: 1rem;
}
.mwiz__start-label {
display: flex;
align-items: center;
gap: 0.625rem;
font-weight: 600;
font-size: 0.9375rem;
color: var(--mood-text);
cursor: pointer;
}
.mwiz__checkbox {
width: 1.125rem;
height: 1.125rem;
accent-color: var(--mood-accent);
cursor: pointer;
flex-shrink: 0;
}
.mwiz__start-hint { font-size: 0.8125rem; color: var(--mood-muted); margin-top: 0.375rem; padding-left: 1.75rem; }
.mwiz__hint { font-size: 0.8125rem; margin-top: 0.25rem; }
/* Transitions */ /* Transitions */
.slide-fade-enter-active, .slide-fade-leave-active { transition: all 0.2s ease; } .slide-fade-enter-active, .slide-fade-leave-active { transition: all 0.2s ease; }
.slide-fade-enter-from { opacity: 0; transform: translateX(20px); } .slide-fade-enter-from { opacity: 0; transform: translateX(20px); }
+3
View File
@@ -11,6 +11,9 @@
* *
* The extension signs <Bytes>{challenge}</Bytes> to match the backend verifier. * The extension signs <Bytes>{challenge}</Bytes> to match the backend verifier.
*/ */
// TODO: trustWallet — remplacer par postMessage vers l'iframe trustWallet (librodrome)
// Protocole prévu : window.postMessage({ type: 'LD_SIGN_REQUEST', address, challenge })
// → trustWallet répond { type: 'LD_SIGN_RESPONSE', signature }
async function _signWithExtension(address: string, challenge: string): Promise<string> { async function _signWithExtension(address: string, challenge: string): Promise<string> {
const { web3Enable, web3FromAddress } = await import('@polkadot/extension-dapp') const { web3Enable, web3FromAddress } = await import('@polkadot/extension-dapp')
const { stringToHex } = await import('@polkadot/util') const { stringToHex } = await import('@polkadot/util')
+16 -78
View File
@@ -1,9 +1,3 @@
/**
* Mandates store: governance mandates and their lifecycle steps.
*
* Maps to the backend /api/v1/mandates endpoints.
*/
export interface MandateStep { export interface MandateStep {
id: string id: string
mandate_id: string mandate_id: string
@@ -20,11 +14,13 @@ export interface MandateStep {
export interface Mandate { export interface Mandate {
id: string id: string
title: string title: string
origin: string | null origin_id: string | null
origin_display_name: string | null
description: string | null description: string | null
mandate_type: string mandate_type: string
status: string status: string
mandatee_id: string | null mandatee_id: string | null
mandatee_display_name: string | null
decision_id: string | null decision_id: string | null
starts_at: string | null starts_at: string | null
ends_at: string | null ends_at: string | null
@@ -35,9 +31,10 @@ export interface Mandate {
export interface MandateCreate { export interface MandateCreate {
title: string title: string
origin?: string | null origin_id?: string | null
description?: string | null description?: string | null
mandate_type: string mandate_type: string
nomination_mode?: string
decision_id?: string | null decision_id?: string | null
starts_at?: string | null starts_at?: string | null
ends_at?: string | null ends_at?: string | null
@@ -45,6 +42,7 @@ export interface MandateCreate {
export interface MandateUpdate { export interface MandateUpdate {
title?: string title?: string
origin_id?: string | null
description?: string | null description?: string | null
mandate_type?: string mandate_type?: string
starts_at?: string | null starts_at?: string | null
@@ -52,6 +50,7 @@ export interface MandateUpdate {
} }
export interface MandateStepCreate { export interface MandateStepCreate {
step_order: number
step_type: string step_type: string
title?: string | null title?: string | null
description?: string | null description?: string | null
@@ -73,31 +72,20 @@ export const useMandatesStore = defineStore('mandates', {
}), }),
getters: { getters: {
byStatus: (state) => { byStatus: (state) => (status: string) => state.list.filter(m => m.status === status),
return (status: string) => state.list.filter(m => m.status === status) activeMandates: (state): Mandate[] => state.list.filter(m => m.status === 'active'),
}, completedMandates: (state): Mandate[] => state.list.filter(m => m.status === 'completed'),
activeMandates: (state): Mandate[] => {
return state.list.filter(m => m.status === 'active')
},
completedMandates: (state): Mandate[] => {
return state.list.filter(m => m.status === 'completed')
},
}, },
actions: { actions: {
/**
* Fetch all mandates with optional filters.
*/
async fetchAll(params?: { mandate_type?: string; status?: string }) { async fetchAll(params?: { mandate_type?: string; status?: string }) {
this.loading = true this.loading = true
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
const query: Record<string, string> = {} const query: Record<string, string> = {}
if (params?.mandate_type) query.mandate_type = params.mandate_type if (params?.mandate_type) query.mandate_type = params.mandate_type
if (params?.status) query.status = params.status if (params?.status) query.status = params.status
this.list = await $api<Mandate[]>('/mandates/', { query }) this.list = await $api<Mandate[]>('/mandates/', { query })
} catch (err: any) { } catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des mandats' this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des mandats'
@@ -106,13 +94,9 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Fetch a single mandate by ID with all its steps.
*/
async fetchById(id: string) { async fetchById(id: string) {
this.loading = true this.loading = true
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
this.current = await $api<Mandate>(`/mandates/${id}`) this.current = await $api<Mandate>(`/mandates/${id}`)
@@ -123,19 +107,12 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Create a new mandate.
*/
async create(payload: MandateCreate) { async create(payload: MandateCreate) {
this.loading = true this.loading = true
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
const mandate = await $api<Mandate>('/mandates/', { const mandate = await $api<Mandate>('/mandates/', { method: 'POST', body: payload })
method: 'POST',
body: payload,
})
this.list.unshift(mandate) this.list.unshift(mandate)
return mandate return mandate
} catch (err: any) { } catch (err: any) {
@@ -146,18 +123,11 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Update an existing mandate.
*/
async update(id: string, data: MandateUpdate) { async update(id: string, data: MandateUpdate) {
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}`, { const updated = await $api<Mandate>(`/mandates/${id}`, { method: 'PUT', body: data })
method: 'PUT',
body: data,
})
if (this.current?.id === id) this.current = updated if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(m => m.id === id) const idx = this.list.findIndex(m => m.id === id)
if (idx >= 0) this.list[idx] = updated if (idx >= 0) this.list[idx] = updated
@@ -168,12 +138,8 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Delete a mandate.
*/
async delete(id: string) { async delete(id: string) {
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
await $api(`/mandates/${id}`, { method: 'DELETE' }) await $api(`/mandates/${id}`, { method: 'DELETE' })
@@ -185,17 +151,11 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Advance the mandate to the next step in its workflow.
*/
async advance(id: string) { async advance(id: string) {
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}/advance`, { const updated = await $api<Mandate>(`/mandates/${id}/advance`, { method: 'POST' })
method: 'POST',
})
if (this.current?.id === id) this.current = updated if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(m => m.id === id) const idx = this.list.findIndex(m => m.id === id)
if (idx >= 0) this.list[idx] = updated if (idx >= 0) this.list[idx] = updated
@@ -206,21 +166,12 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Add a step to a mandate.
*/
async addStep(id: string, step: MandateStepCreate) { async addStep(id: string, step: MandateStepCreate) {
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
const newStep = await $api<MandateStep>(`/mandates/${id}/steps`, { const newStep = await $api<MandateStep>(`/mandates/${id}/steps`, { method: 'POST', body: step })
method: 'POST', if (this.current?.id === id) this.current.steps.push(newStep)
body: step,
})
if (this.current?.id === id) {
this.current.steps.push(newStep)
}
return newStep return newStep
} catch (err: any) { } catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'ajout de l\'etape' this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'ajout de l\'etape'
@@ -228,12 +179,8 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Assign a mandatee to the mandate.
*/
async assignMandatee(id: string, mandateeId: string) { async assignMandatee(id: string, mandateeId: string) {
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}/assign`, { const updated = await $api<Mandate>(`/mandates/${id}/assign`, {
@@ -250,17 +197,11 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Revoke the mandate.
*/
async revoke(id: string) { async revoke(id: string) {
this.error = null this.error = null
try { try {
const { $api } = useApi() const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}/revoke`, { const updated = await $api<Mandate>(`/mandates/${id}/revoke`, { method: 'POST' })
method: 'POST',
})
if (this.current?.id === id) this.current = updated if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(m => m.id === id) const idx = this.list.findIndex(m => m.id === id)
if (idx >= 0) this.list[idx] = updated if (idx >= 0) this.list[idx] = updated
@@ -271,9 +212,6 @@ export const useMandatesStore = defineStore('mandates', {
} }
}, },
/**
* Clear the current mandate.
*/
clearCurrent() { clearCurrent() {
this.current = null this.current = null
}, },