Mandats : origin→FK identité + nomination auto + boutons + tests intégration

- 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>
This commit is contained in:
Yvv
2026-04-25 20:48:27 +02:00
parent 3423ac2e7e
commit f56d84e76b
9 changed files with 883 additions and 427 deletions
@@ -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)
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)
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, candidacy, voting, active, reporting, completed, revoked
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(String(32), default="draft")
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
@@ -24,7 +24,27 @@ class Mandate(Base):
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())
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):
@@ -33,12 +53,16 @@ class MandateStep(Base):
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)
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))
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"))
outcome: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
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
+23 -1
View File
@@ -5,7 +5,8 @@ from __future__ import annotations
import secrets
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 app.config import settings
@@ -232,3 +233,24 @@ async def logout(
for session in sessions:
await db.delete(session)
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()]
+40 -39
View File
@@ -38,10 +38,13 @@ router = APIRouter()
async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
"""Fetch a mandate by ID with its steps eagerly loaded, or raise 404."""
result = await db.execute(
select(Mandate)
.options(selectinload(Mandate.steps))
.options(
selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
.where(Mandate.id == mandate_id)
)
mandate = result.scalar_one_or_none()
@@ -50,6 +53,13 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> 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 ──────────────────────────────────────────────────────────
@@ -57,13 +67,16 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
async def list_mandates(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
mandate_type: str | None = Query(default=None, description="Filtrer par type de mandat"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
mandate_type: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[MandateOut]:
"""List all mandates with optional filters."""
stmt = select(Mandate).options(selectinload(Mandate.steps))
stmt = select(Mandate).options(
selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
if org_id is not None:
stmt = stmt.where(Mandate.organization_id == org_id)
@@ -76,7 +89,7 @@ async def list_mandates(
result = await db.execute(stmt)
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)
@@ -86,15 +99,20 @@ async def create_mandate(
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
"""Create a new mandate."""
mandate = Mandate(**payload.model_dump(), organization_id=org_id)
data = payload.model_dump()
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)
await db.commit()
await db.refresh(mandate)
# Reload with steps (empty at creation)
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
return _mandate_out(mandate)
@router.get("/{id}", response_model=MandateOut)
@@ -102,9 +120,8 @@ async def get_mandate(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> MandateOut:
"""Get a single mandate with all its steps."""
mandate = await _get_mandate(db, id)
return MandateOut.model_validate(mandate)
return _mandate_out(mandate)
@router.put("/{id}", response_model=MandateOut)
@@ -114,19 +131,14 @@ async def update_mandate(
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> MandateOut:
"""Update a mandate's metadata."""
mandate = await _get_mandate(db, id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(mandate, field, value)
await db.commit()
await db.refresh(mandate)
# Reload with steps
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
return _mandate_out(mandate)
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
@@ -135,7 +147,6 @@ async def delete_mandate(
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> None:
"""Delete a mandate (only if in draft status)."""
mandate = await _get_mandate(db, id)
if mandate.status != "draft":
@@ -158,13 +169,9 @@ async def add_step(
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> MandateStepOut:
"""Add a step to a mandate process."""
mandate = await _get_mandate(db, id)
step = MandateStep(
mandate_id=mandate.id,
**payload.model_dump(),
)
step = MandateStep(mandate_id=mandate.id, **payload.model_dump())
db.add(step)
await db.commit()
await db.refresh(step)
@@ -177,7 +184,6 @@ async def list_steps(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> list[MandateStepOut]:
"""List all steps for a mandate, ordered by step_order."""
mandate = await _get_mandate(db, id)
return [MandateStepOut.model_validate(s) for s in mandate.steps]
@@ -191,17 +197,17 @@ async def advance_mandate_endpoint(
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> MandateAdvanceOut:
"""Advance a mandate to its next step or status."""
try:
mandate = await advance_mandate(id, db)
except ValueError as 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)
data = MandateOut.model_validate(mandate).model_dump()
data["message"] = f"Mandat avance au statut : {mandate.status}"
return MandateAdvanceOut(**data)
out = _mandate_out(mandate)
return MandateAdvanceOut(
**out.model_dump(),
message=f"Mandat avance au statut : {mandate.status}",
)
@router.post("/{id}/assign", response_model=MandateOut)
@@ -211,15 +217,13 @@ async def assign_mandatee_endpoint(
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> MandateOut:
"""Assign a mandatee to a mandate."""
try:
mandate = await assign_mandatee(id, payload.mandatee_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
return _mandate_out(mandate)
@router.post("/{id}/revoke", response_model=MandateOut)
@@ -228,15 +232,13 @@ async def revoke_mandate_endpoint(
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> MandateOut:
"""Revoke an active mandate."""
try:
mandate = await revoke_mandate(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
return _mandate_out(mandate)
@router.post(
@@ -250,7 +252,6 @@ async def create_vote_session_for_step_endpoint(
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> VoteSessionOut:
"""Create a vote session linked to a mandate step."""
try:
session = await create_vote_session_for_step(id, step_id, db)
except ValueError as exc:
+15 -39
View File
@@ -10,21 +10,13 @@ from pydantic import BaseModel, ConfigDict, Field
class MandateStepCreate(BaseModel):
"""Payload for creating a step within a mandate process."""
step_order: int = Field(..., ge=0)
step_type: str = Field(
...,
max_length=32,
description="formulation, candidacy, vote, assignment, reporting, completion, revocation",
)
step_type: str = Field(..., max_length=32)
title: str | None = Field(default=None, max_length=256)
description: str | None = None
class MandateStepOut(BaseModel):
"""Full mandate step representation."""
model_config = ConfigDict(from_attributes=True)
id: UUID
@@ -43,44 +35,45 @@ class MandateStepOut(BaseModel):
class MandateCreate(BaseModel):
"""Payload for creating a new mandate."""
title: str = Field(..., min_length=1, max_length=256)
origin: str | None = None
origin_id: UUID | 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
starts_at: datetime | None = None
ends_at: datetime | None = None
class MandateUpdate(BaseModel):
"""Partial update for a mandate."""
title: str | None = Field(default=None, max_length=256)
origin_id: UUID | None = None
description: str | None = None
mandate_type: str | None = Field(default=None, max_length=64)
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
class MandateAssignRequest(BaseModel):
"""Request body for assigning a mandatee to a mandate."""
mandatee_id: UUID = Field(..., description="ID de l'identite Duniter du mandataire")
mandatee_id: UUID = Field(..., description="UUID de l'identite Duniter du mandataire")
class MandateOut(BaseModel):
"""Full mandate representation returned by the API."""
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
origin: str | None = None
origin_id: UUID | None = None
origin_display_name: str | None = None
description: str | None = None
mandate_type: str
status: str
mandatee_id: UUID | None = None
mandatee_display_name: str | None = None
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
@@ -89,22 +82,5 @@ class MandateOut(BaseModel):
steps: list[MandateStepOut] = Field(default_factory=list)
class MandateAdvanceOut(BaseModel):
"""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)
class MandateAdvanceOut(MandateOut):
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"
+215 -250
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
const route = useRoute()
const mandates = useMandatesStore()
const { $api } = useApi()
const mandateId = computed(() => route.params.id as string)
@@ -13,77 +14,95 @@ onUnmounted(() => {
})
watch(mandateId, async (newId) => {
if (newId) {
await mandates.fetchById(newId)
}
if (newId) await mandates.fetchById(newId)
})
// --- Status helpers ---
// --- Helpers ---
const typeLabel = (mandateType: string) => {
switch (mandateType) {
case 'techcomm': return 'Comite technique'
case 'smith': return 'Forgeron'
case 'custom': return 'Personnalise'
default: return mandateType
}
const typeLabel = (t: string) => ({ statutory: 'Statutaire', functional: 'Fonctionnel' }[t] ?? t)
function formatDate(d: string | null): string {
if (!d) return '-'
return new Date(d).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
// --- Terminal state check ---
const terminalStatuses = ['completed', 'revoked']
const isTerminal = computed(() => {
if (!mandates.current) return true
return terminalStatuses.includes(mandates.current.status)
})
const isTerminal = computed(() => !mandates.current || terminalStatuses.includes(mandates.current.status))
const canRevoke = computed(() => mandates.current?.status === 'active')
const isDraft = computed(() => mandates.current?.status === 'draft')
const canRevoke = computed(() => {
if (!mandates.current) return false
return mandates.current.status === 'active'
})
// --- Advance action ---
// --- Advance ---
const advancing = ref(false)
async function handleAdvance() {
advancing.value = true
try {
await mandates.advance(mandateId.value)
} catch {
// Error handled by store
} finally {
advancing.value = false
try { await mandates.advance(mandateId.value) } catch { /* store holds error */ } finally { advancing.value = false }
}
// --- Identity search (shared for assign + edit) ---
interface IdentityResult { id: string; address: string; display_name: string | null }
function useIdentitySearch() {
const query = ref('')
const results = ref<IdentityResult[]>([])
const searching = ref(false)
const selectedId = ref<string | null>(null)
const selectedLabel = ref('')
let timer: ReturnType<typeof setTimeout> | null = null
async function search(q: string) {
if (q.length < 2) { results.value = []; return }
searching.value = true
try {
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 ---
const showAssignModal = ref(false)
const mandateeAddress = ref('')
const assigning = ref(false)
const assignSearch = useIdentitySearch()
async function handleAssign() {
if (!mandateeAddress.value.trim()) return
if (!assignSearch.selectedId.value) return
assigning.value = true
try {
await mandates.assignMandatee(mandateId.value, mandateeAddress.value.trim())
await mandates.assignMandatee(mandateId.value, assignSearch.selectedId.value)
showAssignModal.value = false
mandateeAddress.value = ''
} catch {
// Error handled by store
} finally {
assigning.value = false
}
assignSearch.reset()
} catch { /* store holds error */ } finally { assigning.value = false }
}
function openAssign() {
assignSearch.reset()
showAssignModal.value = true
}
// --- Revoke ---
@@ -96,27 +115,28 @@ async function handleRevoke() {
try {
await mandates.revoke(mandateId.value)
showRevokeConfirm.value = false
} catch {
// Error handled by store
} finally {
revoking.value = false
}
} catch { /* store holds error */ } finally { revoking.value = false }
}
// --- Edit modal ---
// --- Edit ---
const showEditModal = ref(false)
const editData = ref({
title: '',
description: '' as string | null,
})
const editData = ref({ title: '', origin_id: null as string | null, description: '' })
const editOriginSearch = useIdentitySearch()
const saving = ref(false)
function openEdit() {
if (!mandates.current) return
editData.value = {
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
}
@@ -124,50 +144,35 @@ function openEdit() {
async function saveEdit() {
saving.value = true
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
} catch {
// Error handled by store
} finally {
saving.value = false
}
} catch { /* store holds error */ } finally { saving.value = false }
}
// --- Delete ---
const showDeleteConfirm = ref(false)
const deleting = ref(false)
const isDraft = computed(() => mandates.current?.status === 'draft')
async function handleDelete() {
deleting.value = true
try {
await mandates.delete(mandateId.value)
navigateTo('/mandates')
} catch {
// Error handled by store
} finally {
deleting.value = false
showDeleteConfirm.value = false
}
} catch { /* store holds error */ } finally { deleting.value = false; showDeleteConfirm.value = false }
}
</script>
<template>
<div class="space-y-6">
<!-- Back link -->
<div>
<UButton
to="/mandates"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour aux mandats"
size="sm"
/>
<UButton to="/mandates" variant="ghost" color="neutral" icon="i-lucide-arrow-left" label="Retour aux mandats" size="sm" />
</div>
<!-- Loading state -->
<template v-if="mandates.loading">
<div class="space-y-4">
<USkeleton class="h-8 w-96" />
@@ -178,7 +183,6 @@ async function handleDelete() {
</div>
</template>
<!-- Error state -->
<template v-else-if="mandates.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
@@ -188,79 +192,35 @@ async function handleDelete() {
</UCard>
</template>
<!-- Mandate detail -->
<template v-else-if="mandates.current">
<!-- Header with actions -->
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ mandates.current.title }}
</h1>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ mandates.current.title }}</h1>
<div class="flex items-center gap-3 mt-2">
<UBadge variant="subtle" color="primary">
{{ typeLabel(mandates.current.mandate_type) }}
</UBadge>
<UBadge variant="subtle" color="primary">{{ typeLabel(mandates.current.mandate_type) }}</UBadge>
<StatusBadge :status="mandates.current.status" type="mandate" />
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<UButton
v-if="!isTerminal"
icon="i-lucide-fast-forward"
label="Avancer"
color="primary"
variant="soft"
size="sm"
:loading="advancing"
@click="handleAdvance"
/>
<UButton
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"
/>
<UButton v-if="!isTerminal" icon="i-lucide-fast-forward" label="Avancer" color="primary" variant="soft" size="sm" :loading="advancing" @click="handleAdvance" />
<UButton v-if="!isTerminal && !mandates.current.mandatee_id" icon="i-lucide-user-plus" label="Assigner un mandataire" variant="soft" color="primary" size="sm" @click="openAssign" />
<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>
<!-- 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">
<div>
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Description</h3>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{{ mandates.current.description }}
</p>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{{ mandates.current.description }}</p>
</div>
</UCard>
@@ -270,199 +230,204 @@ async function handleDelete() {
<div>
<p class="text-gray-500">Mandataire</p>
<p class="font-medium text-gray-900 dark:text-white">
<template v-if="mandates.current.mandatee_id">
<span class="font-mono text-xs">{{ mandates.current.mandatee_id.slice(0, 12) }}...</span>
</template>
<template v-else>
<span class="text-gray-400 italic">Non assigne</span>
</template>
<template v-if="mandates.current.mandatee_display_name">{{ mandates.current.mandatee_display_name }}</template>
<template v-else-if="mandates.current.mandatee_id"><span class="font-mono text-xs">{{ mandates.current.mandatee_id.slice(0, 12) }}</span></template>
<template v-else><span class="text-gray-400 italic">Non assigne</span></template>
</p>
</div>
<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>
</div>
<div>
<p class="text-gray-500">Debut</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(mandates.current.starts_at) }}
</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.starts_at) }}</p>
</div>
<div>
<p class="text-gray-500">Fin</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(mandates.current.ends_at) }}
</p>
</div>
<div>
<p class="text-gray-500">Nombre d'etapes</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ mandates.current.steps.length }}
</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.ends_at) }}</p>
</div>
</div>
</UCard>
<!-- Dates metadata -->
<UCard>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-500">Cree le</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(mandates.current.created_at) }}
</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.created_at) }}</p>
</div>
<div>
<p class="text-gray-500">Mis a jour le</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(mandates.current.updated_at) }}
</p>
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.updated_at) }}</p>
</div>
</div>
</UCard>
<!-- Steps timeline -->
<!-- Steps -->
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Etapes du mandat
</h2>
<MandateTimeline
:steps="mandates.current.steps"
:current-status="mandates.current.status"
/>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Etapes du mandat</h2>
<MandateTimeline :steps="mandates.current.steps" :current-status="mandates.current.status" />
</div>
</template>
<!-- Assign mandatee modal -->
<!-- Modal : Assigner un mandataire -->
<UModal v-model:open="showAssignModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Assigner un mandataire
</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Assigner un mandataire</h3>
<div class="space-y-1">
<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>
<UInput
v-model="mandateeAddress"
placeholder="Adresse Duniter (ex: 5Grw...)
"
/>
<p class="text-xs text-gray-500">
Adresse SS58 du membre de la toile de confiance
<div class="relative">
<input
: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)"
/>
<div
v-if="assignSearch.results.value.length"
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
>
<button
v-for="r in assignSearch.results.value"
:key="r.id"
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
@click="assignSearch.select(r)"
>
<UIcon name="i-lucide-user" class="text-gray-400 shrink-0" />
<div>
<p class="font-medium text-gray-900 dark:text-white">{{ r.display_name || r.address }}</p>
<p class="text-xs text-gray-500 font-mono">{{ r.address.slice(0, 20) }}</p>
</div>
</button>
</div>
</div>
<p v-if="assignSearch.selectedId.value" class="text-xs text-green-600 flex items-center gap-1">
<UIcon name="i-lucide-check-circle" /> {{ assignSearch.selectedLabel.value }} sélectionné
</p>
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showAssignModal = false"
/>
<UButton
label="Assigner"
icon="i-lucide-user-plus"
color="primary"
:loading="assigning"
:disabled="!mandateeAddress.trim()"
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showAssignModal = false">Annuler</button>
<button
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"
:disabled="!assignSearch.selectedId.value || assigning"
@click="handleAssign"
/>
>
<UIcon v-if="assigning" name="i-lucide-loader-2" class="animate-spin text-sm" />
Assigner
</button>
</div>
</div>
</template>
</UModal>
<!-- Revoke confirmation modal -->
<!-- Modal : Révoquer -->
<UModal v-model:open="showRevokeConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600">
Confirmer la revocation
</h3>
<h3 class="text-lg font-semibold text-red-600">Confirmer la revocation</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir revoquer ce mandat ? Le mandataire perdra ses droits et responsabilites.
</p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showRevokeConfirm = false"
/>
<UButton
label="Revoquer"
icon="i-lucide-shield-off"
color="error"
:loading="revoking"
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showRevokeConfirm = false">Annuler</button>
<button
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"
:disabled="revoking"
@click="handleRevoke"
/>
>
<UIcon v-if="revoking" name="i-lucide-loader-2" class="animate-spin text-sm" />
Revoquer
</button>
</div>
</div>
</template>
</UModal>
<!-- Edit modal -->
<!-- Modal : Modifier -->
<UModal v-model:open="showEditModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Modifier le mandat
</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Modifier le mandat</h3>
<div class="space-y-1">
<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 class="space-y-1">
<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 class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showEditModal = false"
/>
<UButton
label="Enregistrer"
icon="i-lucide-save"
color="primary"
:loading="saving"
:disabled="!editData.title?.trim()"
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showEditModal = false">Annuler</button>
<button
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"
:disabled="!editData.title?.trim() || saving"
@click="saveEdit"
/>
>
<UIcon v-if="saving" name="i-lucide-loader-2" class="animate-spin text-sm" />
Enregistrer
</button>
</div>
</div>
</template>
</UModal>
<!-- Delete confirmation modal -->
<!-- Modal : Supprimer -->
<UModal v-model:open="showDeleteConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600">
Confirmer la suppression
</h3>
<h3 class="text-lg font-semibold text-red-600">Confirmer la suppression</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir supprimer ce mandat ? Cette action est irreversible.
</p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showDeleteConfirm = false"
/>
<UButton
label="Supprimer"
icon="i-lucide-trash-2"
color="error"
:loading="deleting"
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showDeleteConfirm = false">Annuler</button>
<button
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"
:disabled="deleting"
@click="handleDelete"
/>
>
<UIcon v-if="deleting" name="i-lucide-loader-2" class="animate-spin text-sm" />
Supprimer
</button>
</div>
</div>
</template>
+112 -14
View File
@@ -152,9 +152,35 @@ const canGoToInfo = computed(() => {
type MandateType = 'statutory' | 'functional'
const mandateType = ref<MandateType>('functional')
const title = ref('')
const origin = 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'
const durationMode = ref<DurationMode>('relative')
const durationValue = ref(3)
@@ -265,24 +291,31 @@ const nominationSummary = computed(() => {
})
// ── Création ──────────────────────────────────────────────────────────────────
const startImmediately = ref(true)
const submitting = ref(false)
const submitError = ref<string | null>(null)
const auth = useAuthStore()
async function createMandate() {
submitting.value = true
submitError.value = null
try {
const dates = computedDates()
const nominationMode = nominationCase.value === 'self' ? 'auto' : 'collective'
const mandate = await mandates.create({
title: title.value.trim(),
origin: origin.value.trim() || null,
origin_id: originId.value,
description: description.value.trim() || null,
mandate_type: mandateType.value,
nomination_mode: nominationMode,
starts_at: dates.starts_at,
ends_at: dates.ends_at,
})
if (!mandate) throw new Error('Erreur création mandat')
// Créer les étapes
for (let i = 0; i < stepsToCreate.value.length; i++) {
const s = stepsToCreate.value[i]!
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 },
})
}
// Démarrer le processus si demandé
if (startImmediately.value) {
await $api(`/mandates/${mandate.id}/advance`, { method: 'POST' })
}
navigateTo(`/mandates/${mandate.id}`)
} 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 {
submitting.value = false
}
@@ -620,17 +659,38 @@ function selectNomination(c: NominationCase) {
/>
</div>
<!-- Origine -->
<!-- Origine (personne) -->
<div class="mwiz__section">
<label class="mwiz__label">Origine <span class="mwiz__optional">(optionnel)</span></label>
<textarea
v-model="origin"
class="mwiz__textarea"
rows="2"
lang="fr"
spellcheck="true"
placeholder="Qui propose ce mandat, dans quel contexte, suite à quelle décision ou besoin ?"
/>
<label class="mwiz__label">Proposé par <span class="mwiz__optional">(optionnel)</span></label>
<div class="relative">
<input
:value="originQuery"
type="text"
class="mwiz__input"
placeholder="Rechercher un membre de la communauté…"
@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>
<!-- 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" />
{{ MANDATE_TYPE_OPTIONS.find(o => o.value === mandateType)?.label }}
</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 class="mwiz__recap-meta">
<UIcon name="i-lucide-clock" class="text-xs" />
@@ -732,6 +792,18 @@ function selectNomination(c: NominationCase) {
</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>
<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; }
/* 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 */
.slide-fade-enter-active, .slide-fade-leave-active { transition: all 0.2s ease; }
.slide-fade-enter-from { opacity: 0; transform: translateX(20px); }
+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 {
id: string
mandate_id: string
@@ -20,11 +14,13 @@ export interface MandateStep {
export interface Mandate {
id: string
title: string
origin: string | null
origin_id: string | null
origin_display_name: string | null
description: string | null
mandate_type: string
status: string
mandatee_id: string | null
mandatee_display_name: string | null
decision_id: string | null
starts_at: string | null
ends_at: string | null
@@ -35,9 +31,10 @@ export interface Mandate {
export interface MandateCreate {
title: string
origin?: string | null
origin_id?: string | null
description?: string | null
mandate_type: string
nomination_mode?: string
decision_id?: string | null
starts_at?: string | null
ends_at?: string | null
@@ -45,6 +42,7 @@ export interface MandateCreate {
export interface MandateUpdate {
title?: string
origin_id?: string | null
description?: string | null
mandate_type?: string
starts_at?: string | null
@@ -52,6 +50,7 @@ export interface MandateUpdate {
}
export interface MandateStepCreate {
step_order: number
step_type: string
title?: string | null
description?: string | null
@@ -73,31 +72,20 @@ export const useMandatesStore = defineStore('mandates', {
}),
getters: {
byStatus: (state) => {
return (status: string) => state.list.filter(m => m.status === status)
},
activeMandates: (state): Mandate[] => {
return state.list.filter(m => m.status === 'active')
},
completedMandates: (state): Mandate[] => {
return state.list.filter(m => m.status === 'completed')
},
byStatus: (state) => (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'),
},
actions: {
/**
* Fetch all mandates with optional filters.
*/
async fetchAll(params?: { mandate_type?: string; status?: string }) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const query: Record<string, string> = {}
if (params?.mandate_type) query.mandate_type = params.mandate_type
if (params?.status) query.status = params.status
this.list = await $api<Mandate[]>('/mandates/', { query })
} catch (err: any) {
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) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
this.current = await $api<Mandate>(`/mandates/${id}`)
@@ -123,19 +107,12 @@ export const useMandatesStore = defineStore('mandates', {
}
},
/**
* Create a new mandate.
*/
async create(payload: MandateCreate) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const mandate = await $api<Mandate>('/mandates/', {
method: 'POST',
body: payload,
})
const mandate = await $api<Mandate>('/mandates/', { method: 'POST', body: payload })
this.list.unshift(mandate)
return mandate
} catch (err: any) {
@@ -146,18 +123,11 @@ export const useMandatesStore = defineStore('mandates', {
}
},
/**
* Update an existing mandate.
*/
async update(id: string, data: MandateUpdate) {
this.error = null
try {
const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}`, {
method: 'PUT',
body: data,
})
const updated = await $api<Mandate>(`/mandates/${id}`, { method: 'PUT', body: data })
if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(m => m.id === id)
if (idx >= 0) this.list[idx] = updated
@@ -168,12 +138,8 @@ export const useMandatesStore = defineStore('mandates', {
}
},
/**
* Delete a mandate.
*/
async delete(id: string) {
this.error = null
try {
const { $api } = useApi()
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) {
this.error = null
try {
const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}/advance`, {
method: 'POST',
})
const updated = await $api<Mandate>(`/mandates/${id}/advance`, { method: 'POST' })
if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(m => m.id === id)
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) {
this.error = null
try {
const { $api } = useApi()
const newStep = await $api<MandateStep>(`/mandates/${id}/steps`, {
method: 'POST',
body: step,
})
if (this.current?.id === id) {
this.current.steps.push(newStep)
}
const newStep = await $api<MandateStep>(`/mandates/${id}/steps`, { method: 'POST', body: step })
if (this.current?.id === id) this.current.steps.push(newStep)
return newStep
} catch (err: any) {
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) {
this.error = null
try {
const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}/assign`, {
@@ -250,17 +197,11 @@ export const useMandatesStore = defineStore('mandates', {
}
},
/**
* Revoke the mandate.
*/
async revoke(id: string) {
this.error = null
try {
const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}/revoke`, {
method: 'POST',
})
const updated = await $api<Mandate>(`/mandates/${id}/revoke`, { method: 'POST' })
if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(m => m.id === id)
if (idx >= 0) this.list[idx] = updated
@@ -271,9 +212,6 @@ export const useMandatesStore = defineStore('mandates', {
}
},
/**
* Clear the current mandate.
*/
clearCurrent() {
this.current = null
},