forked from yvv/decision
Compare commits
15 Commits
224e5b0f5e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f56d84e76b | |||
| 3423ac2e7e | |||
| 2d2ac79cd5 | |||
| 21bca67e6a | |||
| 56d72eeec2 | |||
| 3b339b643c | |||
| 3ba9c43ce3 | |||
| 1f92f153c5 | |||
| 9b6322c546 | |||
| 9a8f10efdf | |||
| e2ae8b196e | |||
| 5c51cffc93 | |||
| 428299c9c8 | |||
| fc84600f97 | |||
| 79e468b40f |
@@ -26,6 +26,8 @@ from app.database import Base
|
|||||||
from app.models import ( # noqa: F401
|
from app.models import ( # noqa: F401
|
||||||
DuniterIdentity,
|
DuniterIdentity,
|
||||||
Session,
|
Session,
|
||||||
|
Organization,
|
||||||
|
OrgMember,
|
||||||
Document,
|
Document,
|
||||||
DocumentItem,
|
DocumentItem,
|
||||||
ItemVersion,
|
ItemVersion,
|
||||||
@@ -37,6 +39,7 @@ from app.models import ( # noqa: F401
|
|||||||
MandateStep,
|
MandateStep,
|
||||||
VotingProtocol,
|
VotingProtocol,
|
||||||
FormulaConfig,
|
FormulaConfig,
|
||||||
|
QualificationProtocol,
|
||||||
SanctuaryEntry,
|
SanctuaryEntry,
|
||||||
BlockchainCache,
|
BlockchainCache,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""add organizations
|
||||||
|
|
||||||
|
Revision ID: 70914b334cfb
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-04-23 12:27:56.220214+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = '70914b334cfb'
|
||||||
|
down_revision: Union[str, None] = '0b9c1d2e3f4a'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ADD COLUMN IF NOT EXISTS — idempotent (safe on DBs bootstrapped via create_all)
|
||||||
|
op.execute("ALTER TABLE decisions ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
|
||||||
|
op.execute("CREATE INDEX IF NOT EXISTS ix_decisions_organization_id ON decisions (organization_id)")
|
||||||
|
op.execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
|
||||||
|
op.execute("CREATE INDEX IF NOT EXISTS ix_documents_organization_id ON documents (organization_id)")
|
||||||
|
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
|
||||||
|
op.execute("CREATE INDEX IF NOT EXISTS ix_mandates_organization_id ON mandates (organization_id)")
|
||||||
|
op.execute("ALTER TABLE voting_protocols ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
|
||||||
|
op.execute("CREATE INDEX IF NOT EXISTS ix_voting_protocols_organization_id ON voting_protocols (organization_id)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f('ix_voting_protocols_organization_id'), table_name='voting_protocols')
|
||||||
|
op.drop_column('voting_protocols', 'organization_id')
|
||||||
|
op.drop_index(op.f('ix_mandates_organization_id'), table_name='mandates')
|
||||||
|
op.drop_column('mandates', 'organization_id')
|
||||||
|
op.drop_index(op.f('ix_documents_organization_id'), table_name='documents')
|
||||||
|
op.drop_column('documents', 'organization_id')
|
||||||
|
op.drop_index(op.f('ix_decisions_organization_id'), table_name='decisions')
|
||||||
|
op.drop_column('decisions', 'organization_id')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""add_qualification_protocol
|
||||||
|
|
||||||
|
Revision ID: b78571ae9e00
|
||||||
|
Revises: 70914b334cfb
|
||||||
|
Create Date: 2026-04-23 17:08:07.161306+00:00
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = 'b78571ae9e00'
|
||||||
|
down_revision: Union[str, None] = '70914b334cfb'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS qualification_protocols (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
small_group_max INTEGER NOT NULL DEFAULT 5,
|
||||||
|
collective_wot_min INTEGER NOT NULL DEFAULT 50,
|
||||||
|
default_modalities_json TEXT NOT NULL DEFAULT '["vote_wot","vote_smith","consultation_avis","election"]',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('qualification_protocols')
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Add groups and group_members tables.
|
||||||
|
|
||||||
|
Revision ID: c4e812fb3a01
|
||||||
|
Revises: b78571ae9e00
|
||||||
|
Create Date: 2026-04-23 19:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "c4e812fb3a01"
|
||||||
|
down_revision = "b78571ae9e00"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS groups (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
organization_id UUID REFERENCES organizations(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("CREATE INDEX IF NOT EXISTS ix_groups_organization_id ON groups (organization_id)")
|
||||||
|
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS group_members (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id),
|
||||||
|
identity_id UUID REFERENCES duniter_identities(id),
|
||||||
|
display_name VARCHAR(128) NOT NULL,
|
||||||
|
added_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("CREATE INDEX IF NOT EXISTS ix_group_members_group_id ON group_members (group_id)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_group_members_group_id", table_name="group_members")
|
||||||
|
op.drop_table("group_members")
|
||||||
|
op.drop_index("ix_groups_organization_id", table_name="groups")
|
||||||
|
op.drop_table("groups")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Add origin column to mandates table.
|
||||||
|
|
||||||
|
Revision ID: d91a3c7f8b02
|
||||||
|
Revises: c4e812fb3a01
|
||||||
|
Create Date: 2026-04-24 10:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "d91a3c7f8b02"
|
||||||
|
down_revision = "c4e812fb3a01"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin TEXT")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("mandates", "origin")
|
||||||
@@ -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")
|
||||||
@@ -39,6 +39,9 @@ class Settings(BaseSettings):
|
|||||||
RATE_LIMIT_AUTH: int = 10
|
RATE_LIMIT_AUTH: int = 10
|
||||||
RATE_LIMIT_VOTE: int = 30
|
RATE_LIMIT_VOTE: int = 30
|
||||||
|
|
||||||
|
# AI — Qwen3.6 (MacStudio) endpoint, branché plus tard
|
||||||
|
QWEN_API_URL: str = ""
|
||||||
|
|
||||||
# Blockchain cache
|
# Blockchain cache
|
||||||
BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600
|
BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""FastAPI dependency: resolve X-Organization header → org UUID."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import Depends, Header
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.org_service import get_organization_by_slug
|
||||||
|
|
||||||
|
|
||||||
|
async def get_active_org_id(
|
||||||
|
x_organization: str | None = Header(default=None, alias="X-Organization"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> uuid.UUID | None:
|
||||||
|
"""Return the UUID of the org named in the X-Organization header, or None.
|
||||||
|
|
||||||
|
None means no org filter — used for backward compat and internal tooling.
|
||||||
|
An unknown slug is silently treated as None (don't break the client).
|
||||||
|
"""
|
||||||
|
if not x_organization:
|
||||||
|
return None
|
||||||
|
org = await get_organization_by_slug(db, x_organization)
|
||||||
|
return org.id if org else None
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Decision qualification engine.
|
||||||
|
|
||||||
|
Pure functions — no database, no I/O.
|
||||||
|
Takes a QualificationInput + QualificationConfig and returns a QualificationResult.
|
||||||
|
|
||||||
|
LLM integration (suggest_modalities_from_context) is stubbed pending local Qwen deployment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionType(str, Enum):
|
||||||
|
INDIVIDUAL = "individual"
|
||||||
|
COLLECTIVE = "collective"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration (thresholds — stored as QualificationProtocol in DB)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QualificationConfig:
|
||||||
|
"""Configurable thresholds for the qualification engine.
|
||||||
|
|
||||||
|
Seeded as a QualificationProtocol record so they can be adjusted
|
||||||
|
through the admin interface without code changes.
|
||||||
|
|
||||||
|
small_group_max: affected_count <= this → individual recommended, collective available
|
||||||
|
collective_wot_min: affected_count > this → WoT formula applicable (still recommended, not required)
|
||||||
|
|
||||||
|
affected_count must be >= 2 — decisions affecting only the author
|
||||||
|
have no place in this tool.
|
||||||
|
"""
|
||||||
|
small_group_max: int = 5
|
||||||
|
collective_wot_min: int = 50
|
||||||
|
|
||||||
|
default_modalities: list[str] = field(default_factory=lambda: [
|
||||||
|
"vote_wot",
|
||||||
|
"vote_smith",
|
||||||
|
"consultation_avis",
|
||||||
|
"election",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Input / Output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QualificationInput:
|
||||||
|
within_mandate: bool = False
|
||||||
|
affected_count: int | None = None # must be >= 2 when within_mandate=False
|
||||||
|
is_structural: bool = False
|
||||||
|
context_description: str | None = None # reserved for LLM suggestion
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QualificationResult:
|
||||||
|
decision_type: DecisionType
|
||||||
|
process: str
|
||||||
|
recommended_modalities: list[str]
|
||||||
|
recommend_onchain: bool
|
||||||
|
onchain_reason: str | None
|
||||||
|
confidence: str # "required" | "recommended" | "optional"
|
||||||
|
collective_available: bool
|
||||||
|
record_in_observatory: bool # True → decision must be logged in Observatoire
|
||||||
|
reasons: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLM stub
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_modalities_from_context(
|
||||||
|
context: str,
|
||||||
|
config: QualificationConfig,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Suggest voting modalities based on a natural-language context description.
|
||||||
|
|
||||||
|
Stub — returns empty list until local Qwen (qwen3.6, MacStudio) is integrated.
|
||||||
|
When implemented, will call the LLM API and return an ordered subset of
|
||||||
|
config.default_modalities ranked by contextual relevance.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core engine
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def qualify(inp: QualificationInput, config: QualificationConfig) -> QualificationResult:
|
||||||
|
"""Qualify a decision and recommend a type, process, and modalities.
|
||||||
|
|
||||||
|
Rules (in priority order):
|
||||||
|
R1/R2 within_mandate → individual + consultation_avis, no vote modalities,
|
||||||
|
decision must be recorded in Observatoire des décisions
|
||||||
|
R4 2 ≤ affected_count ≤ small_group_max → individual recommended, collective available
|
||||||
|
R5 small_group_max < affected_count ≤ collective_wot_min → collective recommended
|
||||||
|
R6 affected_count > collective_wot_min → collective recommended (WoT formula applicable)
|
||||||
|
R7/R8 is_structural → recommend_onchain with reason
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
# ── R1/R2: mandate scope overrides everything ───────────────────────────
|
||||||
|
if inp.within_mandate:
|
||||||
|
reasons.append("Décision dans le périmètre d'un mandat existant.")
|
||||||
|
return QualificationResult(
|
||||||
|
decision_type=DecisionType.INDIVIDUAL,
|
||||||
|
process="consultation_avis",
|
||||||
|
recommended_modalities=[],
|
||||||
|
recommend_onchain=_onchain(inp, reasons),
|
||||||
|
onchain_reason=_onchain_reason(inp),
|
||||||
|
confidence="required",
|
||||||
|
collective_available=False,
|
||||||
|
record_in_observatory=True,
|
||||||
|
reasons=reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
count = inp.affected_count if inp.affected_count is not None else 2
|
||||||
|
|
||||||
|
# ── R4: small group → individual recommended, collective available ───────
|
||||||
|
if count <= config.small_group_max:
|
||||||
|
reasons.append(
|
||||||
|
f"{count} personnes concernées : décision individuelle recommandée, "
|
||||||
|
"vote collectif possible."
|
||||||
|
)
|
||||||
|
return QualificationResult(
|
||||||
|
decision_type=DecisionType.INDIVIDUAL,
|
||||||
|
process="personal",
|
||||||
|
recommended_modalities=[],
|
||||||
|
recommend_onchain=_onchain(inp, reasons),
|
||||||
|
onchain_reason=_onchain_reason(inp),
|
||||||
|
confidence="recommended",
|
||||||
|
collective_available=True,
|
||||||
|
record_in_observatory=False,
|
||||||
|
reasons=reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── R5/R6: medium or large group → collective ────────────────────────────
|
||||||
|
modalities = _collect_modalities(inp, config)
|
||||||
|
|
||||||
|
if count <= config.collective_wot_min:
|
||||||
|
reasons.append(f"{count} personnes concernées : vote collectif recommandé.")
|
||||||
|
confidence = "recommended"
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
f"{count} personnes concernées : vote collectif recommandé "
|
||||||
|
"(formule WoT applicable à cette échelle)."
|
||||||
|
)
|
||||||
|
confidence = "recommended"
|
||||||
|
if "vote_wot" not in modalities:
|
||||||
|
modalities = ["vote_wot"] + modalities
|
||||||
|
|
||||||
|
return QualificationResult(
|
||||||
|
decision_type=DecisionType.COLLECTIVE,
|
||||||
|
process="vote_collective",
|
||||||
|
recommended_modalities=modalities,
|
||||||
|
recommend_onchain=_onchain(inp, reasons),
|
||||||
|
onchain_reason=_onchain_reason(inp),
|
||||||
|
confidence=confidence,
|
||||||
|
collective_available=True,
|
||||||
|
record_in_observatory=False,
|
||||||
|
reasons=reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _onchain(inp: QualificationInput, reasons: list[str]) -> bool:
|
||||||
|
if inp.is_structural:
|
||||||
|
reasons.append(
|
||||||
|
"Décision structurante : gravure on-chain recommandée "
|
||||||
|
"(a force de loi ou déclenche une action machine)."
|
||||||
|
)
|
||||||
|
return inp.is_structural
|
||||||
|
|
||||||
|
|
||||||
|
def _onchain_reason(inp: QualificationInput) -> str | None:
|
||||||
|
if not inp.is_structural:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
"Cette décision est structurante : elle a valeur de loi au sein de la "
|
||||||
|
"communauté ou déclenche une action machine (ex : runtime upgrade). "
|
||||||
|
"La gravure on-chain (IPFS + system.remark) garantit son immuabilité "
|
||||||
|
"et sa vérifiabilité publique."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_modalities(inp: QualificationInput, config: QualificationConfig) -> list[str]:
|
||||||
|
"""Combine default modalities with any LLM suggestions (stub for now)."""
|
||||||
|
llm_suggestions = []
|
||||||
|
if inp.context_description:
|
||||||
|
llm_suggestions = suggest_modalities_from_context(inp.context_description, config)
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for m in llm_suggestions + config.default_modalities:
|
||||||
|
if m not in seen:
|
||||||
|
seen.add(m)
|
||||||
|
result.append(m)
|
||||||
|
return result
|
||||||
+28
-11
@@ -13,6 +13,9 @@ from app.middleware.rate_limiter import RateLimiterMiddleware
|
|||||||
from app.middleware.security_headers import SecurityHeadersMiddleware
|
from app.middleware.security_headers import SecurityHeadersMiddleware
|
||||||
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
|
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
|
||||||
from app.routers import public
|
from app.routers import public
|
||||||
|
from app.routers import organizations
|
||||||
|
from app.routers import qualify
|
||||||
|
from app.routers import groups
|
||||||
|
|
||||||
|
|
||||||
# ── Structured logging setup ───────────────────────────────────────────────
|
# ── Structured logging setup ───────────────────────────────────────────────
|
||||||
@@ -85,8 +88,28 @@ app = FastAPI(
|
|||||||
|
|
||||||
|
|
||||||
# ── Middleware stack ──────────────────────────────────────────────────────
|
# ── Middleware stack ──────────────────────────────────────────────────────
|
||||||
# Middleware is applied in reverse order: last added = first executed.
|
# add_middleware is LIFO: last added = outermost = first to execute on request,
|
||||||
# Order: SecurityHeaders -> RateLimiter -> CORS -> Application
|
# last to execute on response (wraps everything inside it).
|
||||||
|
#
|
||||||
|
# Required order so CORS headers appear on ALL responses including 429:
|
||||||
|
# CORS (outermost) → RateLimiter → SecurityHeaders → Application
|
||||||
|
#
|
||||||
|
# If RateLimiter were outside CORS, its 429 responses would have no CORS
|
||||||
|
# headers and the browser would silently discard them as network errors.
|
||||||
|
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
|
# Prototype mode: use RATE_LIMIT_DEFAULT for auth so demos/testing don't hit
|
||||||
|
# the stricter RATE_LIMIT_AUTH (10/min). Set RATE_LIMIT_AUTH >= RATE_LIMIT_DEFAULT
|
||||||
|
# in .env only when going to real production.
|
||||||
|
_auth_rate_limit = settings.RATE_LIMIT_DEFAULT
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
RateLimiterMiddleware,
|
||||||
|
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
|
||||||
|
rate_limit_auth=_auth_rate_limit,
|
||||||
|
rate_limit_vote=settings.RATE_LIMIT_VOTE,
|
||||||
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -96,15 +119,6 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
|
||||||
RateLimiterMiddleware,
|
|
||||||
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
|
|
||||||
rate_limit_auth=settings.RATE_LIMIT_AUTH,
|
|
||||||
rate_limit_vote=settings.RATE_LIMIT_VOTE,
|
|
||||||
)
|
|
||||||
|
|
||||||
app.add_middleware(SecurityHeadersMiddleware)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Routers ──────────────────────────────────────────────────────────────
|
# ── Routers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -117,6 +131,9 @@ app.include_router(protocols.router, prefix="/api/v1/protocols", tags=["protocol
|
|||||||
app.include_router(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"])
|
app.include_router(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"])
|
||||||
app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
|
app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
|
||||||
app.include_router(public.router, prefix="/api/v1/public", tags=["public"])
|
app.include_router(public.router, prefix="/api/v1/public", tags=["public"])
|
||||||
|
app.include_router(organizations.router, prefix="/api/v1/organizations", tags=["organizations"])
|
||||||
|
app.include_router(qualify.router, prefix="/api/v1/qualify", tags=["qualify"])
|
||||||
|
app.include_router(groups.router, prefix="/api/v1/groups", tags=["groups"])
|
||||||
|
|
||||||
|
|
||||||
# ── Health check ─────────────────────────────────────────────────────────
|
# ── Health check ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -64,14 +64,6 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
self._last_cleanup: float = time.time()
|
self._last_cleanup: float = time.time()
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
def _get_limit_for_path(self, path: str) -> int:
|
|
||||||
"""Return the rate limit applicable to the given request path."""
|
|
||||||
if "/auth" in path:
|
|
||||||
return self.rate_limit_auth
|
|
||||||
if "/vote" in path:
|
|
||||||
return self.rate_limit_vote
|
|
||||||
return self.rate_limit_default
|
|
||||||
|
|
||||||
def _get_client_ip(self, request: Request) -> str:
|
def _get_client_ip(self, request: Request) -> str:
|
||||||
"""Extract the client IP from the request, respecting X-Forwarded-For."""
|
"""Extract the client IP from the request, respecting X-Forwarded-For."""
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
@@ -101,6 +93,22 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
if ips_to_delete:
|
if ips_to_delete:
|
||||||
logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(ips_to_delete))
|
logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(ips_to_delete))
|
||||||
|
|
||||||
|
def _get_limit_for_request(self, request: Request) -> int:
|
||||||
|
"""Return the rate limit applicable to the given request.
|
||||||
|
|
||||||
|
CORS preflight (OPTIONS) requests are never rate-limited — blocking them
|
||||||
|
breaks authenticated cross-origin requests in browsers.
|
||||||
|
Strict auth limit applies only to POST (login flows), not to GET /auth/me.
|
||||||
|
"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return 10_000 # effectively unlimited for preflights
|
||||||
|
path = request.url.path
|
||||||
|
if request.method == "POST" and "/auth" in path:
|
||||||
|
return self.rate_limit_auth
|
||||||
|
if "/vote" in path:
|
||||||
|
return self.rate_limit_vote
|
||||||
|
return self.rate_limit_default
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next) -> Response:
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
"""Check rate limit and either allow the request or return 429."""
|
"""Check rate limit and either allow the request or return 429."""
|
||||||
# Skip rate limiting for WebSocket upgrades
|
# Skip rate limiting for WebSocket upgrades
|
||||||
@@ -111,8 +119,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
await self._cleanup_old_entries()
|
await self._cleanup_old_entries()
|
||||||
|
|
||||||
client_ip = self._get_client_ip(request)
|
client_ip = self._get_client_ip(request)
|
||||||
path = request.url.path
|
limit = self._get_limit_for_request(request)
|
||||||
limit = self._get_limit_for_path(path)
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
window_start = now - 60
|
window_start = now - 60
|
||||||
|
|
||||||
@@ -133,7 +140,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Rate limit depasse pour %s sur %s (%d/%d)",
|
"Rate limit depasse pour %s sur %s (%d/%d)",
|
||||||
client_ip, path, request_count, limit,
|
client_ip, request.url.path, request_count, limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
from app.models.user import DuniterIdentity, Session
|
from app.models.user import DuniterIdentity, Session
|
||||||
|
from app.models.organization import Organization, OrgMember
|
||||||
from app.models.document import Document, DocumentItem, ItemVersion
|
from app.models.document import Document, DocumentItem, ItemVersion
|
||||||
from app.models.decision import Decision, DecisionStep
|
from app.models.decision import Decision, DecisionStep
|
||||||
from app.models.vote import VoteSession, Vote
|
from app.models.vote import VoteSession, Vote
|
||||||
from app.models.mandate import Mandate, MandateStep
|
from app.models.mandate import Mandate, MandateStep
|
||||||
from app.models.protocol import VotingProtocol, FormulaConfig
|
from app.models.protocol import VotingProtocol, FormulaConfig
|
||||||
|
from app.models.qualification import QualificationProtocol
|
||||||
|
from app.models.group import Group, GroupMember
|
||||||
from app.models.sanctuary import SanctuaryEntry
|
from app.models.sanctuary import SanctuaryEntry
|
||||||
from app.models.cache import BlockchainCache
|
from app.models.cache import BlockchainCache
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DuniterIdentity", "Session",
|
"DuniterIdentity", "Session",
|
||||||
|
"Organization", "OrgMember",
|
||||||
"Document", "DocumentItem", "ItemVersion",
|
"Document", "DocumentItem", "ItemVersion",
|
||||||
"Decision", "DecisionStep",
|
"Decision", "DecisionStep",
|
||||||
"VoteSession", "Vote",
|
"VoteSession", "Vote",
|
||||||
"Mandate", "MandateStep",
|
"Mandate", "MandateStep",
|
||||||
"VotingProtocol", "FormulaConfig",
|
"VotingProtocol", "FormulaConfig",
|
||||||
|
"QualificationProtocol",
|
||||||
|
"Group", "GroupMember",
|
||||||
"SanctuaryEntry",
|
"SanctuaryEntry",
|
||||||
"BlockchainCache",
|
"BlockchainCache",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Decision(Base):
|
|||||||
context: Mapped[str | None] = mapped_column(Text)
|
context: Mapped[str | None] = mapped_column(Text)
|
||||||
decision_type: Mapped[str] = mapped_column(String(64), nullable=False) # runtime_upgrade, document_change, mandate_vote, custom
|
decision_type: Mapped[str] = mapped_column(String(64), nullable=False) # runtime_upgrade, document_change, mandate_vote, custom
|
||||||
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, qualification, review, voting, executed, closed
|
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, qualification, review, voting, executed, closed
|
||||||
|
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
|
||||||
voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id"))
|
voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id"))
|
||||||
created_by_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
|
created_by_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class Document(Base):
|
|||||||
version: Mapped[str] = mapped_column(String(32), default="0.1.0")
|
version: Mapped[str] = mapped_column(String(32), default="0.1.0")
|
||||||
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
|
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
|
||||||
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
|
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
|
||||||
chain_anchor: Mapped[str | None] = mapped_column(String(128))
|
chain_anchor: Mapped[str | None] = mapped_column(String(128))
|
||||||
genesis_json: Mapped[str | None] = mapped_column(Text) # JSON: source files, repos, forum URLs, formula trigger
|
genesis_json: Mapped[str | None] = mapped_column(Text) # JSON: source files, repos, forum URLs, formula trigger
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Uuid, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Group(Base):
|
||||||
|
__tablename__ = "groups"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
organization_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("organizations.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
members: Mapped[list["GroupMember"]] = relationship(
|
||||||
|
back_populates="group", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMember(Base):
|
||||||
|
__tablename__ = "group_members"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
group_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groups.id"), nullable=False, index=True)
|
||||||
|
# FK to duniter_identities when the member is a known WoT member; nullable for free-text entries
|
||||||
|
identity_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("duniter_identities.id"), nullable=True
|
||||||
|
)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
group: Mapped["Group"] = relationship(back_populates="members")
|
||||||
@@ -12,9 +12,11 @@ class Mandate(Base):
|
|||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||||
|
origin_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"), nullable=True, index=True)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # 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)
|
||||||
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
|
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
|
||||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
|
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
|
||||||
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
@@ -22,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):
|
||||||
@@ -31,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
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, Uuid, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Organization(Base):
|
||||||
|
__tablename__ = "organizations"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||||
|
# commune, enterprise, association, collective, basin, intercommunality, community
|
||||||
|
org_type: Mapped[str] = mapped_column(String(64), default="community")
|
||||||
|
# True = all authenticated users see & interact with content (Duniter G1, Axiom Team)
|
||||||
|
# False = membership required
|
||||||
|
is_transparent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
color: Mapped[str | None] = mapped_column(String(32)) # CSS color or mood token
|
||||||
|
icon: Mapped[str | None] = mapped_column(String(64)) # lucide icon name
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
members: Mapped[list["OrgMember"]] = relationship(
|
||||||
|
back_populates="organization", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrgMember(Base):
|
||||||
|
__tablename__ = "org_members"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
org_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("organizations.id"), nullable=False)
|
||||||
|
identity_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("duniter_identities.id"), nullable=False
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(String(32), default="member") # admin, member, observer
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
organization: Mapped["Organization"] = relationship(back_populates="members")
|
||||||
@@ -44,6 +44,7 @@ class VotingProtocol(Base):
|
|||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced
|
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced
|
||||||
formula_config_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("formula_configs.id"), nullable=False)
|
formula_config_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("formula_configs.id"), nullable=False)
|
||||||
|
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
|
||||||
mode_params: Mapped[str | None] = mapped_column(String(64)) # e.g. "D30M50B.1G.2T.1"
|
mode_params: Mapped[str | None] = mapped_column(String(64)) # e.g. "D30M50B.1G.2T.1"
|
||||||
is_meta_governed: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_meta_governed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, String, Text, Uuid, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class QualificationProtocol(Base):
|
||||||
|
"""Active configuration for the decision qualification engine.
|
||||||
|
|
||||||
|
Thresholds stored here override the engine defaults and can be updated
|
||||||
|
through the admin interface (meta-governance).
|
||||||
|
Only one record should be active at a time (is_active=True).
|
||||||
|
"""
|
||||||
|
__tablename__ = "qualification_protocols"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
small_group_max: Mapped[int] = mapped_column(Integer, default=5)
|
||||||
|
collective_wot_min: Mapped[int] = mapped_column(Integer, default=50)
|
||||||
|
|
||||||
|
# JSON array of modality slugs, e.g. '["vote_wot","vote_smith","election"]'
|
||||||
|
default_modalities_json: Mapped[str] = mapped_column(
|
||||||
|
Text,
|
||||||
|
default='["vote_wot","vote_smith","consultation_avis","election"]',
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_modalities(self) -> list[str]:
|
||||||
|
return json.loads(self.default_modalities_json)
|
||||||
@@ -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,9 +133,13 @@ async def verify_challenge(
|
|||||||
detail="Challenge invalide",
|
detail="Challenge invalide",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Verify signature (bypass for demo profiles in 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 and payload.address in _demo_addresses
|
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses
|
||||||
|
|
||||||
if not is_demo_bypass:
|
if not is_demo_bypass:
|
||||||
# polkadot.js / Cesium2 signRaw(type='bytes') wraps: <Bytes>{challenge}</Bytes>
|
# polkadot.js / Cesium2 signRaw(type='bytes') wraps: <Bytes>{challenge}</Bytes>
|
||||||
@@ -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()]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.schemas.decision import (
|
|||||||
DecisionUpdate,
|
DecisionUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.vote import VoteSessionOut
|
from app.schemas.vote import VoteSessionOut
|
||||||
|
from app.dependencies.org import get_active_org_id
|
||||||
from app.services.auth_service import get_current_identity
|
from app.services.auth_service import get_current_identity
|
||||||
from app.services.decision_service import advance_decision, create_vote_session_for_step
|
from app.services.decision_service import advance_decision, create_vote_session_for_step
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
|
|||||||
@router.get("/", response_model=list[DecisionOut])
|
@router.get("/", response_model=list[DecisionOut])
|
||||||
async def list_decisions(
|
async def list_decisions(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
decision_type: str | None = Query(default=None, description="Filtrer par type de decision"),
|
decision_type: str | None = Query(default=None, description="Filtrer par type de decision"),
|
||||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
@@ -57,6 +59,8 @@ async def list_decisions(
|
|||||||
"""List all decisions with optional filters."""
|
"""List all decisions with optional filters."""
|
||||||
stmt = select(Decision).options(selectinload(Decision.steps))
|
stmt = select(Decision).options(selectinload(Decision.steps))
|
||||||
|
|
||||||
|
if org_id is not None:
|
||||||
|
stmt = stmt.where(Decision.organization_id == org_id)
|
||||||
if decision_type is not None:
|
if decision_type is not None:
|
||||||
stmt = stmt.where(Decision.decision_type == decision_type)
|
stmt = stmt.where(Decision.decision_type == decision_type)
|
||||||
if status_filter is not None:
|
if status_filter is not None:
|
||||||
@@ -74,11 +78,13 @@ async def create_decision(
|
|||||||
payload: DecisionCreate,
|
payload: DecisionCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> DecisionOut:
|
) -> DecisionOut:
|
||||||
"""Create a new decision process."""
|
"""Create a new decision process."""
|
||||||
decision = Decision(
|
decision = Decision(
|
||||||
**payload.model_dump(),
|
**payload.model_dump(),
|
||||||
created_by_id=identity.id,
|
created_by_id=identity.id,
|
||||||
|
organization_id=org_id,
|
||||||
)
|
)
|
||||||
db.add(decision)
|
db.add(decision)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from app.schemas.document import (
|
|||||||
ItemVersionCreate,
|
ItemVersionCreate,
|
||||||
ItemVersionOut,
|
ItemVersionOut,
|
||||||
)
|
)
|
||||||
|
from app.dependencies.org import get_active_org_id
|
||||||
from app.services import document_service
|
from app.services import document_service
|
||||||
from app.services.auth_service import get_current_identity
|
from app.services.auth_service import get_current_identity
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ async def _get_item(db: AsyncSession, document_id: uuid.UUID, item_id: uuid.UUID
|
|||||||
@router.get("/", response_model=list[DocumentOut])
|
@router.get("/", response_model=list[DocumentOut])
|
||||||
async def list_documents(
|
async def list_documents(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
|
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
|
||||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
@@ -73,6 +75,8 @@ async def list_documents(
|
|||||||
"""List all reference documents, with optional filters."""
|
"""List all reference documents, with optional filters."""
|
||||||
stmt = select(Document)
|
stmt = select(Document)
|
||||||
|
|
||||||
|
if org_id is not None:
|
||||||
|
stmt = stmt.where(Document.organization_id == org_id)
|
||||||
if doc_type is not None:
|
if doc_type is not None:
|
||||||
stmt = stmt.where(Document.doc_type == doc_type)
|
stmt = stmt.where(Document.doc_type == doc_type)
|
||||||
if status_filter is not None:
|
if status_filter is not None:
|
||||||
@@ -101,6 +105,7 @@ async def create_document(
|
|||||||
payload: DocumentCreate,
|
payload: DocumentCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> DocumentOut:
|
) -> DocumentOut:
|
||||||
"""Create a new reference document."""
|
"""Create a new reference document."""
|
||||||
# Check slug uniqueness
|
# Check slug uniqueness
|
||||||
@@ -111,7 +116,7 @@ async def create_document(
|
|||||||
detail="Un document avec ce slug existe deja",
|
detail="Un document avec ce slug existe deja",
|
||||||
)
|
)
|
||||||
|
|
||||||
doc = Document(**payload.model_dump())
|
doc = Document(**payload.model_dump(), organization_id=org_id)
|
||||||
db.add(doc)
|
db.add(doc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(doc)
|
await db.refresh(doc)
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Groups router — predefined sets of Duniter identities used in decision circles."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.group import Group, GroupMember
|
||||||
|
from app.schemas.group import GroupCreate, GroupMemberCreate, GroupMemberOut, GroupOut, GroupSummary
|
||||||
|
from app.services.auth_service import get_current_identity
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _org_id_from_header(request_headers) -> uuid.UUID | None:
|
||||||
|
raw = request_headers.get("x-organization")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return uuid.UUID(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[GroupSummary])
|
||||||
|
async def list_groups(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[GroupSummary]:
|
||||||
|
"""List all groups. No auth required — groups are public within the workspace."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Group).options(selectinload(Group.members)).order_by(Group.name)
|
||||||
|
)
|
||||||
|
groups = result.scalars().all()
|
||||||
|
return [
|
||||||
|
GroupSummary(
|
||||||
|
id=g.id,
|
||||||
|
name=g.name,
|
||||||
|
description=g.description,
|
||||||
|
organization_id=g.organization_id,
|
||||||
|
member_count=len(g.members),
|
||||||
|
)
|
||||||
|
for g in groups
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=GroupOut, status_code=201)
|
||||||
|
async def create_group(
|
||||||
|
payload: GroupCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_identity=Depends(get_current_identity),
|
||||||
|
) -> GroupOut:
|
||||||
|
group = Group(name=payload.name, description=payload.description)
|
||||||
|
db.add(group)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(group)
|
||||||
|
await db.execute(select(Group).where(Group.id == group.id).options(selectinload(Group.members)))
|
||||||
|
return GroupOut.model_validate(group)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{group_id}", response_model=GroupOut)
|
||||||
|
async def get_group(group_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> GroupOut:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Group).where(Group.id == group_id).options(selectinload(Group.members))
|
||||||
|
)
|
||||||
|
group = result.scalar_one_or_none()
|
||||||
|
if group is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Group not found")
|
||||||
|
return GroupOut.model_validate(group)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{group_id}", status_code=204, response_class=Response, response_model=None)
|
||||||
|
async def delete_group(
|
||||||
|
group_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_identity=Depends(get_current_identity),
|
||||||
|
) -> None:
|
||||||
|
result = await db.execute(select(Group).where(Group.id == group_id))
|
||||||
|
group = result.scalar_one_or_none()
|
||||||
|
if group is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Group not found")
|
||||||
|
await db.delete(group)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{group_id}/members", response_model=GroupMemberOut, status_code=201)
|
||||||
|
async def add_member(
|
||||||
|
group_id: uuid.UUID,
|
||||||
|
payload: GroupMemberCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_identity=Depends(get_current_identity),
|
||||||
|
) -> GroupMemberOut:
|
||||||
|
result = await db.execute(select(Group).where(Group.id == group_id))
|
||||||
|
if result.scalar_one_or_none() is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Group not found")
|
||||||
|
member = GroupMember(
|
||||||
|
group_id=group_id,
|
||||||
|
display_name=payload.display_name,
|
||||||
|
identity_id=payload.identity_id,
|
||||||
|
)
|
||||||
|
db.add(member)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(member)
|
||||||
|
return GroupMemberOut.model_validate(member)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{group_id}/members/{member_id}", status_code=204, response_class=Response, response_model=None)
|
||||||
|
async def remove_member(
|
||||||
|
group_id: uuid.UUID,
|
||||||
|
member_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_identity=Depends(get_current_identity),
|
||||||
|
) -> None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(GroupMember).where(GroupMember.id == member_id, GroupMember.group_id == group_id)
|
||||||
|
)
|
||||||
|
member = result.scalar_one_or_none()
|
||||||
|
if member is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Member not found")
|
||||||
|
await db.delete(member)
|
||||||
|
await db.commit()
|
||||||
@@ -22,6 +22,7 @@ from app.schemas.mandate import (
|
|||||||
MandateUpdate,
|
MandateUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.vote import VoteSessionOut
|
from app.schemas.vote import VoteSessionOut
|
||||||
|
from app.dependencies.org import get_active_org_id
|
||||||
from app.services.auth_service import get_current_identity
|
from app.services.auth_service import get_current_identity
|
||||||
from app.services.mandate_service import (
|
from app.services.mandate_service import (
|
||||||
advance_mandate,
|
advance_mandate,
|
||||||
@@ -37,10 +38,13 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
|
async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
|
||||||
"""Fetch a mandate by ID with its steps eagerly loaded, or raise 404."""
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Mandate)
|
select(Mandate)
|
||||||
.options(selectinload(Mandate.steps))
|
.options(
|
||||||
|
selectinload(Mandate.steps),
|
||||||
|
selectinload(Mandate.origin_identity),
|
||||||
|
selectinload(Mandate.mandatee),
|
||||||
|
)
|
||||||
.where(Mandate.id == mandate_id)
|
.where(Mandate.id == mandate_id)
|
||||||
)
|
)
|
||||||
mandate = result.scalar_one_or_none()
|
mandate = result.scalar_one_or_none()
|
||||||
@@ -49,20 +53,33 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
|
|||||||
return mandate
|
return mandate
|
||||||
|
|
||||||
|
|
||||||
|
def _mandate_out(mandate: Mandate) -> MandateOut:
|
||||||
|
out = MandateOut.model_validate(mandate)
|
||||||
|
out.origin_display_name = mandate.origin_display_name
|
||||||
|
out.mandatee_display_name = mandate.mandatee_display_name
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ── Mandate routes ──────────────────────────────────────────────────────────
|
# ── Mandate routes ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[MandateOut])
|
@router.get("/", response_model=list[MandateOut])
|
||||||
async def list_mandates(
|
async def list_mandates(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
mandate_type: str | None = Query(default=None, description="Filtrer par type de mandat"),
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
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),
|
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:
|
||||||
|
stmt = stmt.where(Mandate.organization_id == org_id)
|
||||||
if mandate_type is not None:
|
if mandate_type is not None:
|
||||||
stmt = stmt.where(Mandate.mandate_type == mandate_type)
|
stmt = stmt.where(Mandate.mandate_type == mandate_type)
|
||||||
if status_filter is not None:
|
if status_filter is not None:
|
||||||
@@ -72,7 +89,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)
|
||||||
@@ -80,16 +97,22 @@ async def create_mandate(
|
|||||||
payload: MandateCreate,
|
payload: MandateCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Create a new mandate."""
|
data = payload.model_dump()
|
||||||
mandate = Mandate(**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)
|
db.add(mandate)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(mandate)
|
await db.refresh(mandate)
|
||||||
|
|
||||||
# Reload with steps (empty at creation)
|
|
||||||
mandate = await _get_mandate(db, mandate.id)
|
mandate = await _get_mandate(db, mandate.id)
|
||||||
return MandateOut.model_validate(mandate)
|
return _mandate_out(mandate)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}", response_model=MandateOut)
|
@router.get("/{id}", response_model=MandateOut)
|
||||||
@@ -97,9 +120,8 @@ async def get_mandate(
|
|||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Get a single mandate with all its steps."""
|
|
||||||
mandate = await _get_mandate(db, id)
|
mandate = await _get_mandate(db, id)
|
||||||
return MandateOut.model_validate(mandate)
|
return _mandate_out(mandate)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{id}", response_model=MandateOut)
|
@router.put("/{id}", response_model=MandateOut)
|
||||||
@@ -109,19 +131,14 @@ async def update_mandate(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Update a mandate's metadata."""
|
|
||||||
mandate = await _get_mandate(db, id)
|
mandate = await _get_mandate(db, id)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Reload with steps
|
|
||||||
mandate = await _get_mandate(db, mandate.id)
|
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)
|
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
|
||||||
@@ -130,7 +147,6 @@ async def delete_mandate(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a mandate (only if in draft status)."""
|
|
||||||
mandate = await _get_mandate(db, id)
|
mandate = await _get_mandate(db, id)
|
||||||
|
|
||||||
if mandate.status != "draft":
|
if mandate.status != "draft":
|
||||||
@@ -153,13 +169,9 @@ async def add_step(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
) -> MandateStepOut:
|
) -> MandateStepOut:
|
||||||
"""Add a step to a mandate process."""
|
|
||||||
mandate = await _get_mandate(db, id)
|
mandate = await _get_mandate(db, id)
|
||||||
|
|
||||||
step = MandateStep(
|
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)
|
||||||
@@ -172,7 +184,6 @@ async def list_steps(
|
|||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> list[MandateStepOut]:
|
) -> list[MandateStepOut]:
|
||||||
"""List all steps for a mandate, ordered by step_order."""
|
|
||||||
mandate = await _get_mandate(db, id)
|
mandate = await _get_mandate(db, id)
|
||||||
return [MandateStepOut.model_validate(s) for s in mandate.steps]
|
return [MandateStepOut.model_validate(s) for s in mandate.steps]
|
||||||
|
|
||||||
@@ -186,17 +197,17 @@ async def advance_mandate_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
) -> MandateAdvanceOut:
|
) -> MandateAdvanceOut:
|
||||||
"""Advance a mandate to its next step or status."""
|
|
||||||
try:
|
try:
|
||||||
mandate = await advance_mandate(id, db)
|
mandate = await advance_mandate(id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
# Reload with steps for complete output
|
|
||||||
mandate = await _get_mandate(db, mandate.id)
|
mandate = await _get_mandate(db, mandate.id)
|
||||||
data = MandateOut.model_validate(mandate).model_dump()
|
out = _mandate_out(mandate)
|
||||||
data["message"] = f"Mandat avance au statut : {mandate.status}"
|
return MandateAdvanceOut(
|
||||||
return MandateAdvanceOut(**data)
|
**out.model_dump(),
|
||||||
|
message=f"Mandat avance au statut : {mandate.status}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{id}/assign", response_model=MandateOut)
|
@router.post("/{id}/assign", response_model=MandateOut)
|
||||||
@@ -206,15 +217,13 @@ async def assign_mandatee_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Assign a mandatee to a mandate."""
|
|
||||||
try:
|
try:
|
||||||
mandate = await assign_mandatee(id, payload.mandatee_id, db)
|
mandate = await assign_mandatee(id, payload.mandatee_id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
# Reload with steps
|
|
||||||
mandate = await _get_mandate(db, mandate.id)
|
mandate = await _get_mandate(db, mandate.id)
|
||||||
return MandateOut.model_validate(mandate)
|
return _mandate_out(mandate)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{id}/revoke", response_model=MandateOut)
|
@router.post("/{id}/revoke", response_model=MandateOut)
|
||||||
@@ -223,15 +232,13 @@ async def revoke_mandate_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Revoke an active mandate."""
|
|
||||||
try:
|
try:
|
||||||
mandate = await revoke_mandate(id, db)
|
mandate = await revoke_mandate(id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
# Reload with steps
|
|
||||||
mandate = await _get_mandate(db, mandate.id)
|
mandate = await _get_mandate(db, mandate.id)
|
||||||
return MandateOut.model_validate(mandate)
|
return _mandate_out(mandate)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -245,7 +252,6 @@ async def create_vote_session_for_step_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
) -> VoteSessionOut:
|
) -> VoteSessionOut:
|
||||||
"""Create a vote session linked to a mandate step."""
|
|
||||||
try:
|
try:
|
||||||
session = await create_vote_session_for_step(id, step_id, db)
|
session = await create_vote_session_for_step(id, step_id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Organizations router: list, create, membership."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import DuniterIdentity
|
||||||
|
from app.schemas.organization import OrgMemberOut, OrganizationCreate, OrganizationOut
|
||||||
|
from app.services.auth_service import get_current_identity
|
||||||
|
from app.services.org_service import (
|
||||||
|
add_member,
|
||||||
|
create_organization,
|
||||||
|
get_organization,
|
||||||
|
get_organization_by_slug,
|
||||||
|
is_member,
|
||||||
|
list_members,
|
||||||
|
list_organizations,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[OrganizationOut])
|
||||||
|
async def get_organizations(db: AsyncSession = Depends(get_db)) -> list[OrganizationOut]:
|
||||||
|
"""List all organizations (public — transparent ones need no auth)."""
|
||||||
|
orgs = await list_organizations(db)
|
||||||
|
return [OrganizationOut.model_validate(o) for o in orgs]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=OrganizationOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def post_organization(
|
||||||
|
payload: OrganizationCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
) -> OrganizationOut:
|
||||||
|
"""Create a new organization (authenticated users only)."""
|
||||||
|
existing = await get_organization_by_slug(db, payload.slug)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Slug '{payload.slug}' déjà utilisé",
|
||||||
|
)
|
||||||
|
org = await create_organization(db, **payload.model_dump())
|
||||||
|
# Creator becomes admin
|
||||||
|
await add_member(db, org.id, identity.id, role="admin")
|
||||||
|
return OrganizationOut.model_validate(org)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{org_id}", response_model=OrganizationOut)
|
||||||
|
async def get_organization_detail(
|
||||||
|
org_id: uuid.UUID, db: AsyncSession = Depends(get_db)
|
||||||
|
) -> OrganizationOut:
|
||||||
|
org = await get_organization(db, org_id)
|
||||||
|
if not org:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
|
||||||
|
return OrganizationOut.model_validate(org)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{org_id}/members", response_model=list[OrgMemberOut])
|
||||||
|
async def get_members(
|
||||||
|
org_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
) -> list[OrgMemberOut]:
|
||||||
|
org = await get_organization(db, org_id)
|
||||||
|
if not org:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
|
||||||
|
if not org.is_transparent and not await is_member(db, org_id, identity.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Accès refusé")
|
||||||
|
members = await list_members(db, org_id)
|
||||||
|
return [OrgMemberOut.model_validate(m) for m in members]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{org_id}/join", response_model=OrgMemberOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def join_organization(
|
||||||
|
org_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
) -> OrgMemberOut:
|
||||||
|
"""Join a transparent organization."""
|
||||||
|
org = await get_organization(db, org_id)
|
||||||
|
if not org:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
|
||||||
|
if not org.is_transparent:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Rejoindre cette organisation nécessite une invitation",
|
||||||
|
)
|
||||||
|
if await is_member(db, org_id, identity.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Déjà membre")
|
||||||
|
member = await add_member(db, org_id, identity.id)
|
||||||
|
return OrgMemberOut.model_validate(member)
|
||||||
@@ -25,6 +25,7 @@ from app.schemas.protocol import (
|
|||||||
VotingProtocolOut,
|
VotingProtocolOut,
|
||||||
VotingProtocolUpdate,
|
VotingProtocolUpdate,
|
||||||
)
|
)
|
||||||
|
from app.dependencies.org import get_active_org_id
|
||||||
from app.services.auth_service import get_current_identity
|
from app.services.auth_service import get_current_identity
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -63,6 +64,7 @@ async def _get_formula(db: AsyncSession, formula_id: uuid.UUID) -> FormulaConfig
|
|||||||
@router.get("/", response_model=list[VotingProtocolOut])
|
@router.get("/", response_model=list[VotingProtocolOut])
|
||||||
async def list_protocols(
|
async def list_protocols(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
|
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
@@ -70,6 +72,8 @@ async def list_protocols(
|
|||||||
"""List all voting protocols with their formula configurations."""
|
"""List all voting protocols with their formula configurations."""
|
||||||
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
|
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
|
||||||
|
|
||||||
|
if org_id is not None:
|
||||||
|
stmt = stmt.where(VotingProtocol.organization_id == org_id)
|
||||||
if vote_type is not None:
|
if vote_type is not None:
|
||||||
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
|
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
|
||||||
|
|
||||||
@@ -85,6 +89,7 @@ async def create_protocol(
|
|||||||
payload: VotingProtocolCreate,
|
payload: VotingProtocolCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> VotingProtocolOut:
|
) -> VotingProtocolOut:
|
||||||
"""Create a new voting protocol.
|
"""Create a new voting protocol.
|
||||||
|
|
||||||
@@ -100,7 +105,7 @@ async def create_protocol(
|
|||||||
detail="Configuration de formule introuvable",
|
detail="Configuration de formule introuvable",
|
||||||
)
|
)
|
||||||
|
|
||||||
protocol = VotingProtocol(**payload.model_dump())
|
protocol = VotingProtocol(**payload.model_dump(), organization_id=org_id)
|
||||||
db.add(protocol)
|
db.add(protocol)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(protocol)
|
await db.refresh(protocol)
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Qualify router: decision qualification engine endpoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.engine.qualifier import QualificationConfig, QualificationInput, qualify
|
||||||
|
from app.models.qualification import QualificationProtocol
|
||||||
|
from app.schemas.qualification import (
|
||||||
|
QualificationProtocolCreate,
|
||||||
|
QualificationProtocolOut,
|
||||||
|
QualifyRequest,
|
||||||
|
QualifyResponse,
|
||||||
|
)
|
||||||
|
from app.services.auth_service import get_current_identity
|
||||||
|
from app.services.qualify_ai_service import (
|
||||||
|
AIFrameRequest,
|
||||||
|
AIFrameResponse,
|
||||||
|
AIMessage,
|
||||||
|
AIQuestion,
|
||||||
|
AIQualifyResult,
|
||||||
|
ai_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic wrappers for AI chat (FastAPI needs Pydantic, not dataclasses) ──
|
||||||
|
|
||||||
|
|
||||||
|
class AIMessagePayload(BaseModel):
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class AIChatRequest(BaseModel):
|
||||||
|
within_mandate: bool = False
|
||||||
|
affected_count: int | None = None
|
||||||
|
is_structural: bool = False
|
||||||
|
context: str | None = None
|
||||||
|
messages: list[AIMessagePayload] = []
|
||||||
|
|
||||||
|
|
||||||
|
class AIQuestionOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
text: str
|
||||||
|
options: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class AIQualifyResultOut(BaseModel):
|
||||||
|
decision_type: str
|
||||||
|
process: str
|
||||||
|
recommended_modalities: list[str]
|
||||||
|
recommend_onchain: bool
|
||||||
|
onchain_reason: str | None
|
||||||
|
confidence: str
|
||||||
|
collective_available: bool
|
||||||
|
record_in_observatory: bool
|
||||||
|
reasons: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class AIChatResponse(BaseModel):
|
||||||
|
done: bool
|
||||||
|
questions: list[AIQuestionOut] = []
|
||||||
|
result: AIQualifyResultOut | None = None
|
||||||
|
explanation: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_config(db: AsyncSession) -> QualificationConfig:
|
||||||
|
"""Load the active QualificationProtocol from DB, or fall back to defaults."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(QualificationProtocol)
|
||||||
|
.where(QualificationProtocol.is_active == True) # noqa: E712
|
||||||
|
.order_by(QualificationProtocol.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
proto = result.scalar_one_or_none()
|
||||||
|
if proto is None:
|
||||||
|
return QualificationConfig()
|
||||||
|
return QualificationConfig(
|
||||||
|
small_group_max=proto.small_group_max,
|
||||||
|
collective_wot_min=proto.collective_wot_min,
|
||||||
|
default_modalities=proto.default_modalities,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=QualifyResponse)
|
||||||
|
async def qualify_decision(
|
||||||
|
payload: QualifyRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> QualifyResponse:
|
||||||
|
"""Qualify a decision: determine type, process, and modalities.
|
||||||
|
|
||||||
|
No authentication required — this is an advisory endpoint that helps
|
||||||
|
users understand which decision pathway fits their situation.
|
||||||
|
"""
|
||||||
|
config = await _load_config(db)
|
||||||
|
inp = QualificationInput(
|
||||||
|
within_mandate=payload.within_mandate,
|
||||||
|
affected_count=payload.affected_count,
|
||||||
|
is_structural=payload.is_structural,
|
||||||
|
context_description=payload.context_description,
|
||||||
|
)
|
||||||
|
result = qualify(inp, config)
|
||||||
|
return QualifyResponse(**asdict(result))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ai-chat", response_model=AIChatResponse)
|
||||||
|
async def ai_chat(payload: AIChatRequest) -> AIChatResponse:
|
||||||
|
"""Run one round of AI-assisted qualification framing.
|
||||||
|
|
||||||
|
Round 1 (messages=[]) → returns 2 clarifying questions.
|
||||||
|
Round 2 (messages set) → returns final qualification result.
|
||||||
|
|
||||||
|
No auth required — advisory endpoint.
|
||||||
|
"""
|
||||||
|
req = AIFrameRequest(
|
||||||
|
within_mandate=payload.within_mandate,
|
||||||
|
affected_count=payload.affected_count,
|
||||||
|
is_structural=payload.is_structural,
|
||||||
|
context=payload.context,
|
||||||
|
messages=[AIMessage(role=m.role, content=m.content) for m in payload.messages],
|
||||||
|
)
|
||||||
|
resp = ai_frame(req)
|
||||||
|
|
||||||
|
return AIChatResponse(
|
||||||
|
done=resp.done,
|
||||||
|
questions=[AIQuestionOut(id=q.id, text=q.text, options=q.options) for q in resp.questions],
|
||||||
|
result=AIQualifyResultOut(**asdict(resp.result)) if resp.result else None,
|
||||||
|
explanation=resp.explanation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/protocol", response_model=QualificationProtocolOut | None)
|
||||||
|
async def get_active_protocol(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> QualificationProtocolOut | None:
|
||||||
|
"""Return the currently active qualification protocol (thresholds)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(QualificationProtocol)
|
||||||
|
.where(QualificationProtocol.is_active == True) # noqa: E712
|
||||||
|
.order_by(QualificationProtocol.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
proto = result.scalar_one_or_none()
|
||||||
|
if proto is None:
|
||||||
|
return None
|
||||||
|
return QualificationProtocolOut.model_validate(proto)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/protocol", response_model=QualificationProtocolOut, status_code=201)
|
||||||
|
async def create_protocol(
|
||||||
|
payload: QualificationProtocolCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_identity=Depends(get_current_identity),
|
||||||
|
) -> QualificationProtocolOut:
|
||||||
|
"""Create a new qualification protocol (requires auth).
|
||||||
|
|
||||||
|
Deactivates the current active protocol before saving the new one.
|
||||||
|
"""
|
||||||
|
# Deactivate current
|
||||||
|
current = await db.execute(
|
||||||
|
select(QualificationProtocol).where(QualificationProtocol.is_active == True) # noqa: E712
|
||||||
|
)
|
||||||
|
for proto in current.scalars().all():
|
||||||
|
proto.is_active = False
|
||||||
|
|
||||||
|
import json
|
||||||
|
proto = QualificationProtocol(
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
small_group_max=payload.small_group_max,
|
||||||
|
collective_wot_min=payload.collective_wot_min,
|
||||||
|
default_modalities_json=json.dumps(payload.default_modalities),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(proto)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(proto)
|
||||||
|
return QualificationProtocolOut.model_validate(proto)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMemberCreate(BaseModel):
|
||||||
|
display_name: str
|
||||||
|
identity_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMemberOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
display_name: str
|
||||||
|
identity_id: uuid.UUID | None
|
||||||
|
added_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class GroupCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GroupOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
organization_id: uuid.UUID | None
|
||||||
|
created_at: datetime
|
||||||
|
members: list[GroupMemberOut] = []
|
||||||
|
|
||||||
|
|
||||||
|
class GroupSummary(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
organization_id: uuid.UUID | None
|
||||||
|
member_count: int = 0
|
||||||
@@ -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,40 +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_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
|
||||||
|
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_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
|
||||||
@@ -85,21 +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
|
|
||||||
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")
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Pydantic v2 schemas for organizations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
org_type: str
|
||||||
|
is_transparent: bool
|
||||||
|
color: str | None
|
||||||
|
icon: str | None
|
||||||
|
description: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
org_type: str = "community"
|
||||||
|
is_transparent: bool = False
|
||||||
|
color: str | None = None
|
||||||
|
icon: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrgMemberOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
org_id: uuid.UUID
|
||||||
|
identity_id: uuid.UUID
|
||||||
|
role: str
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class QualifyRequest(BaseModel):
|
||||||
|
within_mandate: bool = False
|
||||||
|
affected_count: int | None = Field(default=None, ge=2, description="Nombre de personnes concernées (minimum 2)")
|
||||||
|
is_structural: bool = False
|
||||||
|
context_description: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class QualifyResponse(BaseModel):
|
||||||
|
decision_type: str
|
||||||
|
process: str
|
||||||
|
recommended_modalities: list[str]
|
||||||
|
recommend_onchain: bool
|
||||||
|
onchain_reason: str | None
|
||||||
|
confidence: str
|
||||||
|
collective_available: bool
|
||||||
|
record_in_observatory: bool
|
||||||
|
reasons: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class QualificationProtocolOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
small_group_max: int
|
||||||
|
collective_wot_min: int
|
||||||
|
default_modalities: list[str]
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class QualificationProtocolCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=128)
|
||||||
|
description: str | None = None
|
||||||
|
small_group_max: int = Field(default=5, ge=1)
|
||||||
|
collective_wot_min: int = Field(default=50, ge=1)
|
||||||
|
default_modalities: list[str] = Field(
|
||||||
|
default=["vote_wot", "vote_smith", "consultation_avis", "election"]
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Organization service: CRUD + membership helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.organization import OrgMember, Organization
|
||||||
|
|
||||||
|
|
||||||
|
async def list_organizations(db: AsyncSession) -> Sequence[Organization]:
|
||||||
|
result = await db.execute(select(Organization).order_by(Organization.name))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_organization(db: AsyncSession, org_id: uuid.UUID) -> Organization | None:
|
||||||
|
return await db.get(Organization, org_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_organization_by_slug(db: AsyncSession, slug: str) -> Organization | None:
|
||||||
|
result = await db.execute(select(Organization).where(Organization.slug == slug))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_organization(db: AsyncSession, **kwargs) -> Organization:
|
||||||
|
org = Organization(**kwargs)
|
||||||
|
db.add(org)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(org)
|
||||||
|
return org
|
||||||
|
|
||||||
|
|
||||||
|
async def is_member(db: AsyncSession, org_id: uuid.UUID, identity_id: uuid.UUID) -> bool:
|
||||||
|
result = await db.execute(
|
||||||
|
select(OrgMember).where(
|
||||||
|
OrgMember.org_id == org_id,
|
||||||
|
OrgMember.identity_id == identity_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def add_member(
|
||||||
|
db: AsyncSession, org_id: uuid.UUID, identity_id: uuid.UUID, role: str = "member"
|
||||||
|
) -> OrgMember:
|
||||||
|
member = OrgMember(org_id=org_id, identity_id=identity_id, role=role)
|
||||||
|
db.add(member)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(member)
|
||||||
|
return member
|
||||||
|
|
||||||
|
|
||||||
|
async def list_members(db: AsyncSession, org_id: uuid.UUID) -> Sequence[OrgMember]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(OrgMember).where(OrgMember.org_id == org_id).order_by(OrgMember.created_at)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""AI framing service for decision qualification.
|
||||||
|
|
||||||
|
Orchestrates a 2-round conversation that clarifies reversibility and urgency
|
||||||
|
before producing a final QualificationResult.
|
||||||
|
|
||||||
|
Rule-based stub — Qwen3.6 (MacStudio) calls will replace ai_frame() internals
|
||||||
|
once the local endpoint is available. The interface is stable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schemas (dataclasses — no Pydantic dependency in the engine layer)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIMessage:
|
||||||
|
role: str # "user" | "assistant"
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIQuestion:
|
||||||
|
id: str
|
||||||
|
text: str
|
||||||
|
options: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIQualifyResult:
|
||||||
|
decision_type: str
|
||||||
|
process: str
|
||||||
|
recommended_modalities: list[str]
|
||||||
|
recommend_onchain: bool
|
||||||
|
onchain_reason: str | None
|
||||||
|
confidence: str
|
||||||
|
collective_available: bool
|
||||||
|
record_in_observatory: bool
|
||||||
|
reasons: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIFrameRequest:
|
||||||
|
within_mandate: bool = False
|
||||||
|
affected_count: int | None = None
|
||||||
|
is_structural: bool = False
|
||||||
|
context: str | None = None
|
||||||
|
messages: list[AIMessage] | None = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.messages is None:
|
||||||
|
self.messages = []
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIFrameResponse:
|
||||||
|
done: bool
|
||||||
|
questions: list[AIQuestion]
|
||||||
|
result: AIQualifyResult | None
|
||||||
|
explanation: str | None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Standard clarifying questions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_CLARIFYING_QUESTIONS: list[AIQuestion] = [
|
||||||
|
AIQuestion(
|
||||||
|
id="reversibility",
|
||||||
|
text="Si cette décision s'avère inappropriée dans 6 mois, peut-on facilement revenir en arrière ?",
|
||||||
|
options=[
|
||||||
|
"Oui, facilement",
|
||||||
|
"Difficilement",
|
||||||
|
"Non, c'est irréversible",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
AIQuestion(
|
||||||
|
id="urgency",
|
||||||
|
text="Y a-t-il une contrainte temporelle sur cette décision ?",
|
||||||
|
options=[
|
||||||
|
"Urgente (< 1 semaine)",
|
||||||
|
"Délai raisonnable (quelques semaines)",
|
||||||
|
"Pas d'urgence",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core function
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def ai_frame(request: AIFrameRequest) -> AIFrameResponse:
|
||||||
|
"""Run one round of AI framing.
|
||||||
|
|
||||||
|
Round 1 (messages=[]) → return 2 clarifying questions, done=False
|
||||||
|
Round 2 (messages set) → parse answers, qualify, return result, done=True
|
||||||
|
"""
|
||||||
|
messages = request.messages or []
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
return AIFrameResponse(
|
||||||
|
done=False,
|
||||||
|
questions=list(_CLARIFYING_QUESTIONS),
|
||||||
|
result=None,
|
||||||
|
explanation=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
answers = _parse_answers(messages)
|
||||||
|
result = _build_result(request, answers)
|
||||||
|
explanation = _build_explanation(answers)
|
||||||
|
|
||||||
|
return AIFrameResponse(
|
||||||
|
done=True,
|
||||||
|
questions=[],
|
||||||
|
result=result,
|
||||||
|
explanation=explanation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_answers(messages: list[AIMessage]) -> dict[str, str]:
|
||||||
|
"""Extract question answers from the last user message.
|
||||||
|
|
||||||
|
Expected format: "reversibility:<answer>|urgency:<answer>"
|
||||||
|
"""
|
||||||
|
answers: dict[str, str] = {}
|
||||||
|
for msg in reversed(messages):
|
||||||
|
if msg.role == "user" and "|" in msg.content and ":" in msg.content:
|
||||||
|
for part in msg.content.split("|"):
|
||||||
|
if ":" in part:
|
||||||
|
key, _, val = part.partition(":")
|
||||||
|
answers[key.strip()] = val.strip()
|
||||||
|
break
|
||||||
|
return answers
|
||||||
|
|
||||||
|
|
||||||
|
def _build_result(request: AIFrameRequest, answers: dict[str, str]) -> AIQualifyResult:
|
||||||
|
"""Produce a qualification result enriched by the AI answers."""
|
||||||
|
from app.engine.qualifier import (
|
||||||
|
QualificationConfig,
|
||||||
|
QualificationInput,
|
||||||
|
qualify,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = QualificationConfig()
|
||||||
|
inp = QualificationInput(
|
||||||
|
within_mandate=request.within_mandate,
|
||||||
|
affected_count=request.affected_count,
|
||||||
|
is_structural=request.is_structural,
|
||||||
|
context_description=request.context,
|
||||||
|
)
|
||||||
|
base = qualify(inp, config)
|
||||||
|
|
||||||
|
reasons = list(base.reasons)
|
||||||
|
|
||||||
|
reversibility = answers.get("reversibility", "")
|
||||||
|
if "irréversible" in reversibility.lower():
|
||||||
|
reasons.append("Décision irréversible : consensus élevé recommandé.")
|
||||||
|
|
||||||
|
urgency = answers.get("urgency", "")
|
||||||
|
if "urgente" in urgency.lower() or "< 1" in urgency:
|
||||||
|
reasons.append("Urgence signalée : privilégier un protocole à délai court.")
|
||||||
|
|
||||||
|
return AIQualifyResult(
|
||||||
|
decision_type=base.decision_type.value,
|
||||||
|
process=base.process,
|
||||||
|
recommended_modalities=base.recommended_modalities,
|
||||||
|
recommend_onchain=base.recommend_onchain,
|
||||||
|
onchain_reason=base.onchain_reason,
|
||||||
|
confidence=base.confidence,
|
||||||
|
collective_available=base.collective_available,
|
||||||
|
record_in_observatory=base.record_in_observatory,
|
||||||
|
reasons=reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_explanation(answers: dict[str, str]) -> str:
|
||||||
|
parts = []
|
||||||
|
rev = answers.get("reversibility", "")
|
||||||
|
urg = answers.get("urgency", "")
|
||||||
|
if rev:
|
||||||
|
parts.append(f"Réversibilité : {rev}.")
|
||||||
|
if urg:
|
||||||
|
parts.append(f"Urgence : {urg}.")
|
||||||
|
return " ".join(parts) if parts else "Qualification basée sur les éléments fournis."
|
||||||
@@ -0,0 +1,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"
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""Tests for middleware stack: CORS headers, rate limiting, dev auth flow.
|
||||||
|
|
||||||
|
Critical invariants:
|
||||||
|
- ALL responses (including 429) must carry CORS headers when origin is allowed
|
||||||
|
- Dev login flow must survive repeated logins without hitting rate limit
|
||||||
|
- OPTIONS preflight must never be rate-limited
|
||||||
|
|
||||||
|
Note: each test uses a unique X-Forwarded-For IP to isolate rate limit counters,
|
||||||
|
since the rate limiter is in-memory and shared across the app instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
import app.models # noqa: F401 — registers all models with Base.metadata before create_all
|
||||||
|
from app.database import init_db
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
|
async def _create_tables():
|
||||||
|
"""Create DB tables once for this module.
|
||||||
|
|
||||||
|
ASGITransport does not trigger the FastAPI lifespan, so init_db() would
|
||||||
|
never run. Tests that hit endpoints backed by the DB need the tables to
|
||||||
|
exist beforehand.
|
||||||
|
"""
|
||||||
|
await init_db()
|
||||||
|
|
||||||
|
ORIGIN = "http://localhost:3002"
|
||||||
|
CHALLENGE_URL = "/api/v1/auth/challenge"
|
||||||
|
VERIFY_URL = "/api/v1/auth/verify"
|
||||||
|
ME_URL = "/api/v1/auth/me"
|
||||||
|
|
||||||
|
DEV_ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# INVARIANT 1: 429 responses must include CORS headers
|
||||||
|
# Without this, the browser sees "Failed to fetch" instead of "Too Many Requests"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_rate_limited_response_has_cors_headers():
|
||||||
|
"""A 429 from the rate limiter must still carry Access-Control-Allow-Origin.
|
||||||
|
|
||||||
|
Root cause of the "no response / Failed to fetch" bug: the rate limiter
|
||||||
|
sits outside CORS in the middleware stack, so its 429 responses have no
|
||||||
|
CORS headers and the browser discards them as network errors.
|
||||||
|
"""
|
||||||
|
# dev auth limit = 60/min (RATE_LIMIT_DEFAULT), prod = 10/min (RATE_LIMIT_AUTH)
|
||||||
|
# Send 65 requests to guarantee 429 regardless of environment.
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
last_response = None
|
||||||
|
for _ in range(65):
|
||||||
|
resp = await client.post(
|
||||||
|
CHALLENGE_URL,
|
||||||
|
json={"address": DEV_ADDRESS},
|
||||||
|
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.1.1"},
|
||||||
|
)
|
||||||
|
last_response = resp
|
||||||
|
if resp.status_code == 429:
|
||||||
|
break
|
||||||
|
|
||||||
|
assert last_response is not None
|
||||||
|
assert last_response.status_code == 429, (
|
||||||
|
"Expected 429 after exceeding auth rate limit"
|
||||||
|
)
|
||||||
|
assert "access-control-allow-origin" in last_response.headers, (
|
||||||
|
"429 response must include CORS headers so the browser can read the error"
|
||||||
|
)
|
||||||
|
assert last_response.headers["access-control-allow-origin"] == ORIGIN
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# INVARIANT 2: OPTIONS preflight must never be rate-limited
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_options_preflight_never_rate_limited():
|
||||||
|
"""OPTIONS requests must pass through regardless of request count.
|
||||||
|
|
||||||
|
Browsers send a preflight before every cross-origin POST with custom headers.
|
||||||
|
A 429 on OPTIONS prevents the real request from ever being sent.
|
||||||
|
"""
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
for i in range(20):
|
||||||
|
resp = await client.options(
|
||||||
|
CHALLENGE_URL,
|
||||||
|
headers={
|
||||||
|
"Origin": ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "POST",
|
||||||
|
"Access-Control-Request-Headers": "content-type",
|
||||||
|
"X-Forwarded-For": "10.0.2.1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code != 429, (
|
||||||
|
f"OPTIONS request #{i + 1} was rate-limited (429) — preflights must never be blocked"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# INVARIANT 3: Dev login flow must survive ≥ 10 consecutive logins
|
||||||
|
# (challenge + verify cycle, dev profile bypass)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_dev_login_survives_repeated_cycles():
|
||||||
|
"""Complete login cycle (challenge → verify) must work ≥ 10 times in a row.
|
||||||
|
|
||||||
|
In dev mode, the developer disconnects and reconnects frequently.
|
||||||
|
With auth rate limit = 10/min, the 6th challenge request would be blocked.
|
||||||
|
Dev mode must use a higher limit (≥ 60/min) to prevent login lockout.
|
||||||
|
"""
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
tokens = []
|
||||||
|
for i in range(10):
|
||||||
|
# Step 1: get challenge
|
||||||
|
ch_resp = await client.post(
|
||||||
|
CHALLENGE_URL,
|
||||||
|
json={"address": DEV_ADDRESS},
|
||||||
|
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.3.1"},
|
||||||
|
)
|
||||||
|
assert ch_resp.status_code == 200, (
|
||||||
|
f"Login cycle #{i + 1}: challenge returned {ch_resp.status_code} — "
|
||||||
|
f"rate limit likely hit. Dev mode requires RATE_LIMIT_AUTH ≥ 60/min."
|
||||||
|
)
|
||||||
|
challenge = ch_resp.json()["challenge"]
|
||||||
|
|
||||||
|
# Step 2: verify (dev bypass — any signature accepted for dev addresses)
|
||||||
|
v_resp = await client.post(
|
||||||
|
VERIFY_URL,
|
||||||
|
json={
|
||||||
|
"address": DEV_ADDRESS,
|
||||||
|
"challenge": challenge,
|
||||||
|
"signature": "0x" + "ab" * 64,
|
||||||
|
},
|
||||||
|
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.3.1"},
|
||||||
|
)
|
||||||
|
assert v_resp.status_code == 200, (
|
||||||
|
f"Login cycle #{i + 1}: verify returned {v_resp.status_code}"
|
||||||
|
)
|
||||||
|
tokens.append(v_resp.json()["token"])
|
||||||
|
|
||||||
|
assert len(tokens) == 10, "Expected 10 successful login cycles"
|
||||||
|
assert len(set(tokens)) == 10, "Each login cycle must produce a unique token"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# INVARIANT 4: /auth/me OPTIONS preflight must return CORS headers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_auth_me_options_preflight_returns_cors():
|
||||||
|
"""OPTIONS preflight for /auth/me must return 200 with CORS headers.
|
||||||
|
|
||||||
|
This was the root cause of the session-lost-on-reload bug:
|
||||||
|
repeated /auth/me calls would exhaust the auth rate limit,
|
||||||
|
the 429 OPTIONS response had no CORS headers,
|
||||||
|
and the browser threw 'Failed to fetch'.
|
||||||
|
"""
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.options(
|
||||||
|
ME_URL,
|
||||||
|
headers={
|
||||||
|
"Origin": ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
"Access-Control-Request-Headers": "authorization",
|
||||||
|
"X-Forwarded-For": "10.0.4.1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (200, 204), (
|
||||||
|
f"OPTIONS /auth/me returned {resp.status_code}"
|
||||||
|
)
|
||||||
|
assert "access-control-allow-origin" in resp.headers
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""TDD — Moteur de qualification des décisions.
|
||||||
|
|
||||||
|
Source de vérité exécutable des règles métier du tunnel "Décider".
|
||||||
|
|
||||||
|
Règles testées :
|
||||||
|
R1 within_mandate → individual + consultation_avis
|
||||||
|
R2 within_mandate → aucune modalité de vote + consignation Observatoire
|
||||||
|
R4 2 ≤ affected_count ≤ small_group_max → individual recommandé, collectif disponible
|
||||||
|
R5 small_group_max < affected_count ≤ collective_wot_min → collective recommandé
|
||||||
|
R6 affected_count > collective_wot_min → collective recommandé (WoT applicable, non obligatoire)
|
||||||
|
R7 is_structural → recommend_onchain + raison explicite
|
||||||
|
R8 is_structural=False → recommend_onchain=False
|
||||||
|
|
||||||
|
GARDE-FOUS (invariants internes qui ne doivent jamais régresser) :
|
||||||
|
G1 decision_type est toujours dans l'enum autorisé
|
||||||
|
G2 individual n'expose jamais de modalités de vote
|
||||||
|
G3 collective expose au moins une modalité
|
||||||
|
G4 les seuils sont lus depuis QualificationConfig (configurables)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.qualifier import (
|
||||||
|
DecisionType,
|
||||||
|
QualificationConfig,
|
||||||
|
QualificationInput,
|
||||||
|
QualificationResult,
|
||||||
|
qualify,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = QualificationConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R1 — within_mandate → individual + consultation_avis
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_r1_within_mandate_gives_individual():
|
||||||
|
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
|
||||||
|
assert result.decision_type == DecisionType.INDIVIDUAL
|
||||||
|
|
||||||
|
|
||||||
|
def test_r1_within_mandate_gives_consultation_avis():
|
||||||
|
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
|
||||||
|
assert result.process == "consultation_avis"
|
||||||
|
|
||||||
|
|
||||||
|
def test_r1_within_mandate_overrides_large_affected_count():
|
||||||
|
"""Même si de nombreuses personnes sont concernées, un mandat impose individual."""
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=True, affected_count=500),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.decision_type == DecisionType.INDIVIDUAL
|
||||||
|
assert result.process == "consultation_avis"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R2 — within_mandate → aucune modalité de vote + consignation Observatoire
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_r2_within_mandate_no_vote_modalities():
|
||||||
|
"""Le mandataire décide seul après consultation — pas de vote collectif."""
|
||||||
|
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
|
||||||
|
assert result.recommended_modalities == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_r2_within_mandate_records_in_observatory():
|
||||||
|
"""Une décision dans un mandat doit être consignée dans l'Observatoire."""
|
||||||
|
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
|
||||||
|
assert result.record_in_observatory is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_r2_out_of_mandate_does_not_force_observatory():
|
||||||
|
"""Hors mandat, la consignation dans l'Observatoire n'est pas imposée."""
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=10),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.record_in_observatory is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R4 — 2 ≤ affected_count ≤ small_group_max → individual recommandé
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_r4_small_group_recommends_individual():
|
||||||
|
for count in range(2, DEFAULT_CONFIG.small_group_max + 1):
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=count),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.decision_type == DecisionType.INDIVIDUAL, (
|
||||||
|
f"affected_count={count} devrait recommander individual"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_r4_small_group_collective_is_available():
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=3),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.collective_available is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_r4_small_group_confidence_is_recommended():
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=3),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.confidence == "recommended"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R5 — small_group_max < affected_count ≤ collective_wot_min → collective recommandé
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_r5_medium_group_recommends_collective():
|
||||||
|
mid = DEFAULT_CONFIG.small_group_max + 1
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=mid),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.decision_type == DecisionType.COLLECTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_r5_medium_group_confidence_is_recommended():
|
||||||
|
mid = DEFAULT_CONFIG.small_group_max + 1
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=mid),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.confidence == "recommended"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R6 — affected_count > collective_wot_min → collective recommandé (pas obligatoire)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_r6_large_group_recommends_collective():
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.decision_type == DecisionType.COLLECTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_r6_large_group_confidence_is_recommended_not_required():
|
||||||
|
"""Au-delà du seuil WoT, le vote collectif est recommandé — pas imposé."""
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.confidence == "recommended"
|
||||||
|
|
||||||
|
|
||||||
|
def test_r6_large_group_includes_vote_wot_modality():
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert "vote_wot" in result.recommended_modalities
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R7 — is_structural=True → recommend_onchain + raison
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_r7_structural_recommends_onchain():
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.recommend_onchain is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_r7_structural_provides_onchain_reason():
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.onchain_reason is not None and len(result.onchain_reason) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_r7_structural_within_mandate_can_also_recommend_onchain():
|
||||||
|
"""Même une décision dans un mandat peut être gravée si structurante."""
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=True, is_structural=True),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.recommend_onchain is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R8 — is_structural=False → recommend_onchain=False
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_r8_non_structural_never_recommends_onchain():
|
||||||
|
for count in [2, 3, 10, 100]:
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=count, is_structural=False),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.recommend_onchain is False, (
|
||||||
|
f"affected_count={count} non structurant : on-chain ne doit pas être proposé"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GARDE-FOUS internes (régressions silencieuses)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_g1_decision_type_always_valid():
|
||||||
|
valid_types = set(DecisionType)
|
||||||
|
for inp in [
|
||||||
|
QualificationInput(within_mandate=True),
|
||||||
|
QualificationInput(within_mandate=False, affected_count=2),
|
||||||
|
QualificationInput(within_mandate=False, affected_count=10),
|
||||||
|
QualificationInput(within_mandate=False, affected_count=100),
|
||||||
|
]:
|
||||||
|
result = qualify(inp, DEFAULT_CONFIG)
|
||||||
|
assert result.decision_type in valid_types
|
||||||
|
|
||||||
|
|
||||||
|
def test_g2_individual_never_has_vote_modalities():
|
||||||
|
for inp in [
|
||||||
|
QualificationInput(within_mandate=True),
|
||||||
|
QualificationInput(within_mandate=False, affected_count=2),
|
||||||
|
QualificationInput(within_mandate=False, affected_count=3),
|
||||||
|
]:
|
||||||
|
result = qualify(inp, DEFAULT_CONFIG)
|
||||||
|
if result.decision_type == DecisionType.INDIVIDUAL:
|
||||||
|
assert result.recommended_modalities == [], (
|
||||||
|
f"Individual ne doit pas exposer de modalités : {inp}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_g3_collective_has_at_least_one_modality():
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=20),
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
)
|
||||||
|
assert result.decision_type == DecisionType.COLLECTIVE
|
||||||
|
assert len(result.recommended_modalities) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_g4_custom_config_overrides_thresholds():
|
||||||
|
"""Les seuils viennent de QualificationConfig — pas de constantes hardcodées."""
|
||||||
|
custom = QualificationConfig(small_group_max=2, collective_wot_min=10)
|
||||||
|
result = qualify(
|
||||||
|
QualificationInput(within_mandate=False, affected_count=3),
|
||||||
|
custom,
|
||||||
|
)
|
||||||
|
assert result.decision_type == DecisionType.COLLECTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_g4_default_thresholds_are_stable():
|
||||||
|
cfg = QualificationConfig()
|
||||||
|
assert cfg.small_group_max == 5
|
||||||
|
assert cfg.collective_wot_min == 50
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""TDD — Service AI de cadrage des décisions (qualify/ai-chat).
|
||||||
|
|
||||||
|
Invariants testés :
|
||||||
|
A1 Premier appel (messages=[]) → retourne toujours 2 questions, done=False
|
||||||
|
A2 Les 2 questions couvrent réversibilité et urgence (ids stables)
|
||||||
|
A3 Deuxième appel (messages=[q+réponse]) → done=True, résultat qualifié
|
||||||
|
A4 Réponse "irréversible" → recommend_onchain conservé si is_structural
|
||||||
|
A5 Réponse "urgente" → raison "urgence" présente dans le résultat
|
||||||
|
A6 La qualification finale respecte les règles du moteur (R1/R2/R4/R5/R6)
|
||||||
|
A7 Sans contexte, les questions restent les mêmes (stub ne dépend pas du LLM)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.qualify_ai_service import (
|
||||||
|
AIFrameRequest,
|
||||||
|
AIMessage,
|
||||||
|
ai_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_REQUEST = AIFrameRequest(
|
||||||
|
context="Révision du règlement intérieur de l'association",
|
||||||
|
within_mandate=False,
|
||||||
|
affected_count=20,
|
||||||
|
is_structural=False,
|
||||||
|
messages=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A1 — Premier appel → 2 questions, done=False
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a1_first_call_returns_questions():
|
||||||
|
resp = ai_frame(DEFAULT_REQUEST)
|
||||||
|
assert resp.done is False
|
||||||
|
assert len(resp.questions) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_a1_first_call_result_is_none():
|
||||||
|
resp = ai_frame(DEFAULT_REQUEST)
|
||||||
|
assert resp.result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A2 — Questions couvrent réversibilité et urgence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a2_questions_have_stable_ids():
|
||||||
|
resp = ai_frame(DEFAULT_REQUEST)
|
||||||
|
ids = {q.id for q in resp.questions}
|
||||||
|
assert "reversibility" in ids
|
||||||
|
assert "urgency" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_a2_questions_have_options():
|
||||||
|
resp = ai_frame(DEFAULT_REQUEST)
|
||||||
|
for q in resp.questions:
|
||||||
|
assert len(q.options) >= 2, f"Question '{q.id}' doit avoir au moins 2 options"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A3 — Deuxième appel (avec réponses) → done=True + résultat
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_second_request(reversibility_ans: str, urgency_ans: str, **kwargs) -> AIFrameRequest:
|
||||||
|
questions = ai_frame(DEFAULT_REQUEST).questions
|
||||||
|
messages = []
|
||||||
|
for q in questions:
|
||||||
|
messages.append(AIMessage(role="assistant", content=q.text))
|
||||||
|
# One user message bundling all answers
|
||||||
|
messages.append(AIMessage(
|
||||||
|
role="user",
|
||||||
|
content=f"reversibility:{reversibility_ans}|urgency:{urgency_ans}",
|
||||||
|
))
|
||||||
|
return AIFrameRequest(
|
||||||
|
**{**vars(DEFAULT_REQUEST), "messages": messages, **kwargs}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a3_second_call_is_done():
|
||||||
|
req = _make_second_request("Difficilement", "Pas d'urgence")
|
||||||
|
resp = ai_frame(req)
|
||||||
|
assert resp.done is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a3_second_call_has_result():
|
||||||
|
req = _make_second_request("Difficilement", "Pas d'urgence")
|
||||||
|
resp = ai_frame(req)
|
||||||
|
assert resp.result is not None
|
||||||
|
assert resp.result.decision_type in ("individual", "collective")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A4 — Irréversible + structurant → recommend_onchain
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a4_irreversible_structural_recommends_onchain():
|
||||||
|
req = _make_second_request(
|
||||||
|
"Non, c'est irréversible",
|
||||||
|
"Pas d'urgence",
|
||||||
|
is_structural=True,
|
||||||
|
)
|
||||||
|
resp = ai_frame(req)
|
||||||
|
assert resp.result is not None
|
||||||
|
assert resp.result.recommend_onchain is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A5 — Urgence → raison présente
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a5_urgent_adds_urgency_reason():
|
||||||
|
req = _make_second_request("Oui, facilement", "Urgente (< 1 semaine)")
|
||||||
|
resp = ai_frame(req)
|
||||||
|
assert resp.result is not None
|
||||||
|
reasons_text = " ".join(resp.result.reasons).lower()
|
||||||
|
assert "urgence" in reasons_text or "urgent" in reasons_text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A6 — Résultat respecte les règles du moteur
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a6_within_mandate_gives_individual():
|
||||||
|
req = AIFrameRequest(
|
||||||
|
within_mandate=True,
|
||||||
|
affected_count=None,
|
||||||
|
messages=[
|
||||||
|
AIMessage(role="assistant", content="q"),
|
||||||
|
AIMessage(role="user", content="reversibility:Facilement|urgency:Pas d'urgence"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
resp = ai_frame(req)
|
||||||
|
assert resp.done is True
|
||||||
|
assert resp.result is not None
|
||||||
|
assert resp.result.decision_type == "individual"
|
||||||
|
assert resp.result.process == "consultation_avis"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a6_large_group_gives_collective():
|
||||||
|
req = _make_second_request("Difficilement", "Pas d'urgence", affected_count=100)
|
||||||
|
resp = ai_frame(req)
|
||||||
|
assert resp.result is not None
|
||||||
|
assert resp.result.decision_type == "collective"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A7 — Sans contexte, mêmes questions (stub ne dépend pas du LLM)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a7_no_context_same_question_ids():
|
||||||
|
req_with = DEFAULT_REQUEST
|
||||||
|
req_without = AIFrameRequest(
|
||||||
|
context=None,
|
||||||
|
within_mandate=False,
|
||||||
|
affected_count=20,
|
||||||
|
messages=[],
|
||||||
|
)
|
||||||
|
ids_with = {q.id for q in ai_frame(req_with).questions}
|
||||||
|
ids_without = {q.id for q in ai_frame(req_without).questions}
|
||||||
|
assert ids_with == ids_without
|
||||||
+95
-9
@@ -32,6 +32,8 @@ from app.models.protocol import FormulaConfig, VotingProtocol
|
|||||||
from app.models.document import Document, DocumentItem
|
from app.models.document import Document, DocumentItem
|
||||||
from app.models.decision import Decision, DecisionStep
|
from app.models.decision import Decision, DecisionStep
|
||||||
from app.models.mandate import Mandate, MandateStep
|
from app.models.mandate import Mandate, MandateStep
|
||||||
|
from app.models.organization import Organization
|
||||||
|
from app.models.qualification import QualificationProtocol
|
||||||
from app.models.user import DuniterIdentity
|
from app.models.user import DuniterIdentity
|
||||||
from app.models.vote import VoteSession, Vote
|
from app.models.vote import VoteSession, Vote
|
||||||
|
|
||||||
@@ -161,6 +163,7 @@ async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig
|
|||||||
async def seed_voting_protocols(
|
async def seed_voting_protocols(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
formulas: dict[str, FormulaConfig],
|
formulas: dict[str, FormulaConfig],
|
||||||
|
org_id: uuid.UUID | None = None,
|
||||||
) -> dict[str, VotingProtocol]:
|
) -> dict[str, VotingProtocol]:
|
||||||
protocols: dict[str, dict] = {
|
protocols: dict[str, dict] = {
|
||||||
"Vote WoT standard": {
|
"Vote WoT standard": {
|
||||||
@@ -206,6 +209,7 @@ async def seed_voting_protocols(
|
|||||||
instance, created = await get_or_create(
|
instance, created = await get_or_create(
|
||||||
session, VotingProtocol, "name", name, **params,
|
session, VotingProtocol, "name", name, **params,
|
||||||
)
|
)
|
||||||
|
instance.organization_id = org_id
|
||||||
status = "created" if created else "exists"
|
status = "created" if created else "exists"
|
||||||
print(f" VotingProtocol '{name}': {status}")
|
print(f" VotingProtocol '{name}': {status}")
|
||||||
result[name] = instance
|
result[name] = instance
|
||||||
@@ -829,6 +833,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
|
|||||||
async def seed_document_engagement_certification(
|
async def seed_document_engagement_certification(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
protocols: dict[str, VotingProtocol],
|
protocols: dict[str, VotingProtocol],
|
||||||
|
org_id: uuid.UUID | None = None,
|
||||||
) -> Document:
|
) -> Document:
|
||||||
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
|
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@@ -850,6 +855,7 @@ async def seed_document_engagement_certification(
|
|||||||
),
|
),
|
||||||
genesis_json=genesis,
|
genesis_json=genesis,
|
||||||
)
|
)
|
||||||
|
doc.organization_id = org_id
|
||||||
print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
|
print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@@ -1893,6 +1899,7 @@ ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
|
|||||||
async def seed_document_engagement_forgeron(
|
async def seed_document_engagement_forgeron(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
protocols: dict[str, VotingProtocol],
|
protocols: dict[str, VotingProtocol],
|
||||||
|
org_id: uuid.UUID | None = None,
|
||||||
) -> Document:
|
) -> Document:
|
||||||
genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
|
genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@@ -1916,6 +1923,7 @@ async def seed_document_engagement_forgeron(
|
|||||||
),
|
),
|
||||||
genesis_json=genesis,
|
genesis_json=genesis,
|
||||||
)
|
)
|
||||||
|
doc.organization_id = org_id
|
||||||
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
|
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@@ -1988,7 +1996,7 @@ RUNTIME_UPGRADE_STEPS: list[dict] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
|
async def seed_decision_runtime_upgrade(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision:
|
||||||
decision, created = await get_or_create(
|
decision, created = await get_or_create(
|
||||||
session,
|
session,
|
||||||
Decision,
|
Decision,
|
||||||
@@ -2009,6 +2017,7 @@ async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
|
|||||||
decision_type="runtime_upgrade",
|
decision_type="runtime_upgrade",
|
||||||
status="draft",
|
status="draft",
|
||||||
)
|
)
|
||||||
|
decision.organization_id = org_id
|
||||||
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
|
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@@ -2148,7 +2157,7 @@ async def seed_votes_on_items(
|
|||||||
# Seed: Additional decisions (demo content)
|
# Seed: Additional decisions (demo content)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
|
async def seed_decision_licence_evolution(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision:
|
||||||
"""Seed a community decision: evolution of the G1 monetary license."""
|
"""Seed a community decision: evolution of the G1 monetary license."""
|
||||||
decision, created = await get_or_create(
|
decision, created = await get_or_create(
|
||||||
session,
|
session,
|
||||||
@@ -2170,6 +2179,7 @@ async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
|
|||||||
decision_type="community",
|
decision_type="community",
|
||||||
status="draft",
|
status="draft",
|
||||||
)
|
)
|
||||||
|
decision.organization_id = org_id
|
||||||
print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}")
|
print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@@ -2225,7 +2235,7 @@ async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
|
|||||||
# Seed: Mandates (Comité Technique + Admin Forgerons)
|
# Seed: Mandates (Comité Technique + Admin Forgerons)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) -> None:
|
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity], org_id: uuid.UUID | None = None) -> None:
|
||||||
"""Seed example mandates: TechComm and Smith Admin."""
|
"""Seed example mandates: TechComm and Smith Admin."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -2397,6 +2407,7 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) ->
|
|||||||
m_data["title"],
|
m_data["title"],
|
||||||
**{k: v for k, v in m_data.items() if k != "title"},
|
**{k: v for k, v in m_data.items() if k != "title"},
|
||||||
)
|
)
|
||||||
|
mandate.organization_id = org_id
|
||||||
status_str = "created" if created else "exists"
|
status_str = "created" if created else "exists"
|
||||||
print(f" Mandate '{mandate.title[:50]}': {status_str}")
|
print(f" Mandate '{mandate.title[:50]}': {status_str}")
|
||||||
|
|
||||||
@@ -2408,10 +2419,78 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) ->
|
|||||||
print(f" -> {len(steps_data)} steps created")
|
print(f" -> {len(steps_data)} steps created")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Seed: Organizations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def seed_organizations(session: AsyncSession) -> dict[str, Organization]:
|
||||||
|
"""Create the two base transparent organizations (idempotent)."""
|
||||||
|
orgs_data = [
|
||||||
|
{
|
||||||
|
"slug": "duniter-g1",
|
||||||
|
"name": "Duniter G1",
|
||||||
|
"org_type": "community",
|
||||||
|
"is_transparent": True,
|
||||||
|
"color": "#22c55e",
|
||||||
|
"icon": "i-lucide-globe",
|
||||||
|
"description": "Communauté Duniter — monnaie libre G1. Accessible à tous les membres authentifiés.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "axiom-team",
|
||||||
|
"name": "Axiom Team",
|
||||||
|
"org_type": "collective",
|
||||||
|
"is_transparent": True,
|
||||||
|
"color": "#3b82f6",
|
||||||
|
"icon": "i-lucide-users",
|
||||||
|
"description": "Équipe Axiom — développement et gouvernance des outils communs.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
orgs: dict[str, Organization] = {}
|
||||||
|
for data in orgs_data:
|
||||||
|
org, created = await get_or_create(session, Organization, "slug", data["slug"], **{k: v for k, v in data.items() if k != "slug"})
|
||||||
|
status_str = "created" if created else "exists"
|
||||||
|
print(f" Organisation '{org.name}': {status_str}")
|
||||||
|
orgs[org.slug] = org
|
||||||
|
|
||||||
|
return orgs
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main seed runner
|
# Main seed runner
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def seed_qualification_protocol(session: AsyncSession) -> QualificationProtocol:
|
||||||
|
"""Seed the default qualification protocol (thresholds for the Décider tunnel)."""
|
||||||
|
stmt = select(QualificationProtocol).where(QualificationProtocol.is_active == True) # noqa: E712
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
print(" [skip] Protocole de qualification déjà présent")
|
||||||
|
return existing
|
||||||
|
|
||||||
|
proto = QualificationProtocol(
|
||||||
|
name="Protocole de qualification par défaut",
|
||||||
|
description=(
|
||||||
|
"Seuils utilisés par le tunnel Décider pour router vers "
|
||||||
|
"individual/collective et proposer les modalités de vote."
|
||||||
|
),
|
||||||
|
small_group_max=5,
|
||||||
|
collective_wot_min=50,
|
||||||
|
default_modalities_json=json.dumps([
|
||||||
|
"vote_wot",
|
||||||
|
"vote_smith",
|
||||||
|
"consultation_avis",
|
||||||
|
"election",
|
||||||
|
]),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
session.add(proto)
|
||||||
|
await session.flush()
|
||||||
|
print(" [ok] Protocole de qualification créé")
|
||||||
|
return proto
|
||||||
|
|
||||||
|
|
||||||
async def run_seed():
|
async def run_seed():
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("libreDecision - Seed Database")
|
print("libreDecision - Seed Database")
|
||||||
@@ -2423,23 +2502,30 @@ async def run_seed():
|
|||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
|
print("\n[0/10] Organizations...")
|
||||||
|
orgs = await seed_organizations(session)
|
||||||
|
duniter_g1_id = orgs["duniter-g1"].id
|
||||||
|
|
||||||
|
print("\n[0b] Protocole de qualification...")
|
||||||
|
await seed_qualification_protocol(session)
|
||||||
|
|
||||||
print("\n[1/10] Formula Configs...")
|
print("\n[1/10] Formula Configs...")
|
||||||
formulas = await seed_formula_configs(session)
|
formulas = await seed_formula_configs(session)
|
||||||
|
|
||||||
print("\n[2/10] Voting Protocols...")
|
print("\n[2/10] Voting Protocols...")
|
||||||
protocols = await seed_voting_protocols(session, formulas)
|
protocols = await seed_voting_protocols(session, formulas, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[3/10] Document: Acte d'engagement Certification...")
|
print("\n[3/10] Document: Acte d'engagement Certification...")
|
||||||
await seed_document_engagement_certification(session, protocols)
|
await seed_document_engagement_certification(session, protocols, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[4/10] Document: Acte d'engagement forgeron v2.0.0...")
|
print("\n[4/10] Document: Acte d'engagement forgeron v2.0.0...")
|
||||||
doc_forgeron = await seed_document_engagement_forgeron(session, protocols)
|
doc_forgeron = await seed_document_engagement_forgeron(session, protocols, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[5/10] Decision: Runtime Upgrade...")
|
print("\n[5/10] Decision: Runtime Upgrade...")
|
||||||
await seed_decision_runtime_upgrade(session)
|
await seed_decision_runtime_upgrade(session, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[6/10] Decision: Évolution Licence G1 v0.4.0...")
|
print("\n[6/10] Decision: Évolution Licence G1 v0.4.0...")
|
||||||
await seed_decision_licence_evolution(session)
|
await seed_decision_licence_evolution(session, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[7/10] Simulated voters...")
|
print("\n[7/10] Simulated voters...")
|
||||||
voters = await seed_voters(session)
|
voters = await seed_voters(session)
|
||||||
@@ -2453,7 +2539,7 @@ async def run_seed():
|
|||||||
)
|
)
|
||||||
|
|
||||||
print("\n[9/10] Mandates...")
|
print("\n[9/10] Mandates...")
|
||||||
await seed_mandates(session, voters)
|
await seed_mandates(session, voters, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[10/10] Done.")
|
print("\n[10/10] Done.")
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,4 @@ COPY backend/requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
CMD ["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"]
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Méthode de travail TDD — libreDecision
|
||||||
|
|
||||||
|
## Principe fondamental
|
||||||
|
|
||||||
|
**Tu décris la règle métier. Claude traduit en test. Tu valides. Claude implémente.**
|
||||||
|
|
||||||
|
Jamais l'inverse. Le test est la source de vérité ; l'implémentation n'est que le moyen de le faire passer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow par itération
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Tu décris une règle en français naturel
|
||||||
|
→ "Si scope=personal, la décision est toujours individuelle"
|
||||||
|
|
||||||
|
2. Claude écrit le(s) test(s) — RED (le test échoue avant l'implémentation)
|
||||||
|
→ Tu valides que le test capture bien l'intention
|
||||||
|
|
||||||
|
3. Claude implémente le minimum pour que le test passe — GREEN
|
||||||
|
→ Rien de plus que ce que le test exige
|
||||||
|
|
||||||
|
4. Claude refactorise si nécessaire — REFACTOR
|
||||||
|
→ Sans casser les tests existants
|
||||||
|
|
||||||
|
5. Répétition avec la règle suivante
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commandes de prompt
|
||||||
|
|
||||||
|
| Commande | Action |
|
||||||
|
|---|---|
|
||||||
|
| `+test` | Écrire le(s) test(s) sans implémenter |
|
||||||
|
| `+impl` | Implémenter pour faire passer les tests en attente |
|
||||||
|
| `+test+impl` | Test + implémentation d'un coup (règle simple) |
|
||||||
|
| `+règle` | Ajouter une règle au moteur existant |
|
||||||
|
| `+règle remplace` | Une nouvelle règle remplace une précédente (précise laquelle) |
|
||||||
|
| `+régression` | Vérifier qu'aucun test existant n'est cassé après un changement |
|
||||||
|
| `+résumé` | Afficher l'état des règles implémentées et en attente |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Format d'une règle métier
|
||||||
|
|
||||||
|
Pour être efficace, une règle doit préciser :
|
||||||
|
|
||||||
|
```
|
||||||
|
ENTRÉES : les variables concernées et leurs valeurs
|
||||||
|
RÉSULTAT : ce que le système doit retourner ou faire
|
||||||
|
EXCEPTIONS : cas qui brisent la règle générale (si aucune, dire "aucune")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exemple :**
|
||||||
|
```
|
||||||
|
ENTRÉES : scope = "personal"
|
||||||
|
RÉSULTAT : decision_type = "individual", recommend_onchain = False
|
||||||
|
EXCEPTIONS : aucune — même si stakes = "critical", une décision personnelle reste individuelle
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure des tests dans ce projet
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/app/tests/
|
||||||
|
test_qualifier.py ← moteur de qualification (tunnel Décider)
|
||||||
|
test_middleware.py ← rate limiter, CORS, headers
|
||||||
|
test_threshold.py ← formules WoT existantes
|
||||||
|
test_votes.py ← logique de vote
|
||||||
|
test_decisions.py ← service décisions
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque fichier de test correspond à un module ou un bloc fonctionnel.
|
||||||
|
Les tests d'intégration (qui touchent la DB) sont marqués `@pytest.mark.integration`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Les 4 blocs algorithmiques
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ TUNNEL "DÉCIDER" │
|
||||||
|
│ │
|
||||||
|
│ 1. QUALIFIER → nature / enjeu / réversibilité │
|
||||||
|
│ ↓ │
|
||||||
|
│ 2. ROUTEUR → individual / collective / delegated │
|
||||||
|
│ ↓ ↓ │
|
||||||
|
│ 3. PROTOCOLE → sélection formule WoT + paramètres │
|
||||||
|
│ ↓ │
|
||||||
|
│ 4. GRAVURE → recommandation on-chain (IPFS+remark) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Les blocs sont testés indépendamment puis en intégration.
|
||||||
|
Un changement dans le bloc 1 ne doit jamais casser silencieusement le bloc 4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Invariants fondamentaux (ne jamais casser)
|
||||||
|
|
||||||
|
Ces règles doivent avoir un test dédié et rester vertes en permanence :
|
||||||
|
|
||||||
|
1. Une décision `individual` ne génère jamais de session de vote
|
||||||
|
2. Une décision `on_chain` implique toujours `recommend_onchain = True`
|
||||||
|
3. `recommend_onchain = True` requiert `reversibility = "impossible"` **ou** `stakes = "critical"`
|
||||||
|
4. Le qualificateur ne retourne jamais un type inconnu (enum strict)
|
||||||
|
5. Un protocole WoT sélectionné doit exister en base (slug valide)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Règles de régression
|
||||||
|
|
||||||
|
- Après chaque implémentation : `pytest backend/app/tests/ -v --tb=short`
|
||||||
|
- Avant tout commit : zéro test rouge
|
||||||
|
- Si un test existant casse après un nouveau changement → **stop, analyser, ne pas contourner**
|
||||||
|
- `RATE_LIMIT_AUTH` en dev = 60/min minimum (pas de blocage en développement)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Où sont les règles métier documentées
|
||||||
|
|
||||||
|
| Source | Contenu |
|
||||||
|
|---|---|
|
||||||
|
| `docs/dev/tdd-methode.md` | Cette méthode |
|
||||||
|
| `docs/dev/qualifier-rules.md` | Règles du moteur de qualification (créé au fil des itérations) |
|
||||||
|
| `backend/app/engine/qualifier.py` | Implémentation du qualificateur |
|
||||||
|
| `backend/app/tests/test_qualifier.py` | Tests — source de vérité exécutable |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## À propos de la mémoire de contexte
|
||||||
|
|
||||||
|
Entre les sessions, Claude peut perdre le contexte des règles en cours.
|
||||||
|
Pour reprendre efficacement :
|
||||||
|
|
||||||
|
```
|
||||||
|
"Résume les règles du qualificateur implémentées jusqu'ici"
|
||||||
|
→ Claude lit test_qualifier.py et qualifier.py et synthétise
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests sont leur propre documentation. Ne pas dupliquer les règles en commentaires.
|
||||||
+30
-9
@@ -1,24 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const orgsStore = useOrganizationsStore()
|
||||||
|
const documentsStore = useDocumentsStore()
|
||||||
|
const decisionsStore = useDecisionsStore()
|
||||||
|
const protocolsStore = useProtocolsStore()
|
||||||
|
const mandatesStore = useMandatesStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { initMood } = useMood()
|
const { initMood } = useMood()
|
||||||
|
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
{
|
{
|
||||||
label: 'Boîte à outils',
|
label: 'Décisions',
|
||||||
icon: 'i-lucide-wrench',
|
icon: 'i-lucide-scale',
|
||||||
to: '/tools',
|
to: '/decisions',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Documents',
|
label: 'Documents',
|
||||||
icon: 'i-lucide-book-open',
|
icon: 'i-lucide-book-open',
|
||||||
to: '/documents',
|
to: '/documents',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Decisions',
|
|
||||||
icon: 'i-lucide-scale',
|
|
||||||
to: '/decisions',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Mandats',
|
label: 'Mandats',
|
||||||
icon: 'i-lucide-user-check',
|
icon: 'i-lucide-user-check',
|
||||||
@@ -29,6 +29,11 @@ const navigationItems = [
|
|||||||
icon: 'i-lucide-settings',
|
icon: 'i-lucide-settings',
|
||||||
to: '/protocols',
|
to: '/protocols',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Outils',
|
||||||
|
icon: 'i-lucide-wrench',
|
||||||
|
to: '/tools',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Sanctuaire',
|
label: 'Sanctuaire',
|
||||||
icon: 'i-lucide-archive',
|
icon: 'i-lucide-archive',
|
||||||
@@ -47,6 +52,16 @@ watch(() => route.path, () => {
|
|||||||
mobileMenuOpen.value = false
|
mobileMenuOpen.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Refetch all content stores when the active workspace changes. */
|
||||||
|
watch(() => orgsStore.activeSlug, (newSlug, oldSlug) => {
|
||||||
|
if (oldSlug !== null && newSlug !== null && newSlug !== oldSlug) {
|
||||||
|
documentsStore.fetchAll()
|
||||||
|
decisionsStore.fetchAll()
|
||||||
|
protocolsStore.fetchProtocols()
|
||||||
|
mandatesStore.fetchAll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/** WebSocket connection and notifications. */
|
/** WebSocket connection and notifications. */
|
||||||
const ws = useWebSocket()
|
const ws = useWebSocket()
|
||||||
const { setupWsNotifications } = useNotifications()
|
const { setupWsNotifications } = useNotifications()
|
||||||
@@ -63,12 +78,18 @@ onMounted(async () => {
|
|||||||
if (auth.token) {
|
if (auth.token) {
|
||||||
try {
|
try {
|
||||||
await auth.fetchMe()
|
await auth.fetchMe()
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
|
// Déconnexion seulement sur session réellement invalide (401/403)
|
||||||
|
// Erreur réseau ou backend temporairement indisponible → conserver la session
|
||||||
|
if (err?.status === 401 || err?.status === 403) {
|
||||||
auth.logout()
|
auth.logout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
ws.connect()
|
ws.connect()
|
||||||
setupWsNotifications(ws)
|
setupWsNotifications(ws)
|
||||||
|
// Load organizations in parallel — non-blocking, no auth required
|
||||||
|
orgsStore.fetchOrganizations()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -1,52 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
const orgsStore = useOrganizationsStore()
|
||||||
* WorkspaceSelector — Sélecteur de collectif / espace de travail.
|
|
||||||
* Compartimentage multi-collectifs, multi-sites.
|
|
||||||
* UI-only pour l'instant, prêt pour le backend (collective_id sur toutes les entités).
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Workspace {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
icon: string
|
|
||||||
role?: string
|
|
||||||
color?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock data — sera remplacé par le store collectifs
|
|
||||||
const workspaces: Workspace[] = [
|
|
||||||
{
|
|
||||||
id: 'g1-main',
|
|
||||||
name: 'Duniter G1',
|
|
||||||
slug: 'duniter-g1',
|
|
||||||
icon: 'i-lucide-coins',
|
|
||||||
role: 'Membre',
|
|
||||||
color: 'accent',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'axiom',
|
|
||||||
name: 'Axiom Team',
|
|
||||||
slug: 'axiom-team',
|
|
||||||
icon: 'i-lucide-layers',
|
|
||||||
role: 'Admin',
|
|
||||||
color: 'secondary',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const activeId = ref('g1-main')
|
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const active = computed(() => workspaces.find(w => w.id === activeId.value) ?? workspaces[0])
|
const active = computed(() => orgsStore.active)
|
||||||
|
const organizations = computed(() => orgsStore.organizations)
|
||||||
|
|
||||||
function selectWorkspace(id: string) {
|
function selectOrg(slug: string) {
|
||||||
activeId.value = id
|
orgsStore.setActive(slug)
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
// TODO: store.setActiveCollective(id) + refetch all data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close on outside click
|
// Close on outside click
|
||||||
const containerRef = ref<HTMLElement | null>(null)
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
||||||
@@ -58,35 +24,46 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="containerRef" class="ws">
|
<div ref="containerRef" class="ws">
|
||||||
<button class="ws__trigger" :class="{ 'ws__trigger--open': isOpen }" @click="isOpen = !isOpen">
|
<button
|
||||||
<div class="ws__icon" :class="`ws__icon--${active.color}`">
|
class="ws__trigger"
|
||||||
<UIcon :name="active.icon" />
|
:class="{ 'ws__trigger--open': isOpen }"
|
||||||
|
:disabled="orgsStore.loading || !active"
|
||||||
|
@click="isOpen = !isOpen"
|
||||||
|
>
|
||||||
|
<div v-if="orgsStore.loading" class="ws__icon ws__icon--muted">
|
||||||
|
<UIcon name="i-lucide-loader-2" class="animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
<span class="ws__name">{{ active.name }}</span>
|
<div v-else-if="active" class="ws__icon" :style="{ background: active.color ? active.color + '22' : undefined, color: active.color || undefined }">
|
||||||
|
<UIcon :name="active.icon || 'i-lucide-building'" />
|
||||||
|
</div>
|
||||||
|
<span class="ws__name">{{ active?.name ?? '…' }}</span>
|
||||||
<UIcon name="i-lucide-chevrons-up-down" class="ws__caret" />
|
<UIcon name="i-lucide-chevrons-up-down" class="ws__caret" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Transition name="dropdown">
|
<Transition name="dropdown">
|
||||||
<div v-if="isOpen" class="ws__dropdown">
|
<div v-if="isOpen && organizations.length" class="ws__dropdown">
|
||||||
<div class="ws__dropdown-header">
|
<div class="ws__dropdown-header">
|
||||||
Espace de travail
|
Espace de travail
|
||||||
</div>
|
</div>
|
||||||
<div class="ws__items">
|
<div class="ws__items">
|
||||||
<button
|
<button
|
||||||
v-for="ws in workspaces"
|
v-for="org in organizations"
|
||||||
:key="ws.id"
|
:key="org.id"
|
||||||
class="ws__item"
|
class="ws__item"
|
||||||
:class="{ 'ws__item--active': ws.id === activeId }"
|
:class="{ 'ws__item--active': org.slug === orgsStore.activeSlug }"
|
||||||
@click="selectWorkspace(ws.id)"
|
@click="selectOrg(org.slug)"
|
||||||
>
|
>
|
||||||
<div class="ws__item-icon" :class="`ws__icon--${ws.color}`">
|
<div
|
||||||
<UIcon :name="ws.icon" />
|
class="ws__item-icon"
|
||||||
|
:style="{ background: org.color ? org.color + '22' : undefined, color: org.color || undefined }"
|
||||||
|
>
|
||||||
|
<UIcon :name="org.icon || 'i-lucide-building'" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ws__item-info">
|
<div class="ws__item-info">
|
||||||
<span class="ws__item-name">{{ ws.name }}</span>
|
<span class="ws__item-name">{{ org.name }}</span>
|
||||||
<span v-if="ws.role" class="ws__item-role">{{ ws.role }}</span>
|
<span class="ws__item-role">{{ org.is_transparent ? 'Public' : 'Membres' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<UIcon v-if="ws.id === activeId" name="i-lucide-check" class="ws__item-check" />
|
<UIcon v-if="org.slug === orgsStore.activeSlug" name="i-lucide-check" class="ws__item-check" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ws__dropdown-footer">
|
<div class="ws__dropdown-footer">
|
||||||
@@ -118,7 +95,7 @@ onMounted(() => {
|
|||||||
max-width: 11rem;
|
max-width: 11rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ws__trigger:hover {
|
.ws__trigger:hover:not(:disabled) {
|
||||||
background: color-mix(in srgb, var(--mood-accent-soft) 80%, var(--mood-accent) 20%);
|
background: color-mix(in srgb, var(--mood-accent-soft) 80%, var(--mood-accent) 20%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +103,11 @@ onMounted(() => {
|
|||||||
background: color-mix(in srgb, var(--mood-accent-soft) 60%, var(--mood-accent) 40%);
|
background: color-mix(in srgb, var(--mood-accent-soft) 60%, var(--mood-accent) 40%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ws__trigger:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.ws__icon {
|
.ws__icon {
|
||||||
width: 1.375rem;
|
width: 1.375rem;
|
||||||
height: 1.375rem;
|
height: 1.375rem;
|
||||||
@@ -137,10 +119,9 @@ onMounted(() => {
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ws__icon--accent { background: var(--mood-accent); color: var(--mood-accent-text); }
|
.ws__icon--muted {
|
||||||
.ws__icon--secondary {
|
background: var(--mood-accent-soft);
|
||||||
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 20%, transparent);
|
color: var(--mood-text-muted);
|
||||||
color: var(--mood-secondary, var(--mood-accent));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ws__name {
|
.ws__name {
|
||||||
@@ -202,7 +183,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ws__item:hover { background: var(--mood-accent-soft); }
|
.ws__item:hover { background: var(--mood-accent-soft); }
|
||||||
|
|
||||||
.ws__item--active { background: var(--mood-accent-soft); }
|
.ws__item--active { background: var(--mood-accent-soft); }
|
||||||
|
|
||||||
.ws__item-icon {
|
.ws__item-icon {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ function isRetryable(status: number): boolean {
|
|||||||
export function useApi() {
|
export function useApi() {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const orgsStore = useOrganizationsStore()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a typed fetch against the backend API.
|
* Perform a typed fetch against the backend API.
|
||||||
@@ -94,6 +95,9 @@ export function useApi() {
|
|||||||
if (auth.token) {
|
if (auth.token) {
|
||||||
headers.Authorization = `Bearer ${auth.token}`
|
headers.Authorization = `Bearer ${auth.token}`
|
||||||
}
|
}
|
||||||
|
if (orgsStore.activeSlug) {
|
||||||
|
headers['X-Organization'] = orgsStore.activeSlug
|
||||||
|
}
|
||||||
|
|
||||||
const maxAttempts = noRetry ? 1 : MAX_RETRIES
|
const maxAttempts = noRetry ? 1 : MAX_RETRIES
|
||||||
let lastError: any = null
|
let lastError: any = null
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -116,6 +116,22 @@ function formatDate(dateStr: string): string {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Décider CTA -->
|
||||||
|
<NuxtLink to="/decisions/new" class="dash__decide">
|
||||||
|
<div class="dash__decide-left">
|
||||||
|
<div class="dash__decide-icon">
|
||||||
|
<UIcon name="i-lucide-scale" class="text-xl" />
|
||||||
|
</div>
|
||||||
|
<div class="dash__decide-text">
|
||||||
|
<span class="dash__decide-label">Prendre une décision</span>
|
||||||
|
<span class="dash__decide-sub">Individuelle · collective · déléguée — le parcours s'adapte</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dash__decide-arrow">
|
||||||
|
<UIcon name="i-lucide-arrow-right" class="text-base" />
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Entry cards -->
|
<!-- Entry cards -->
|
||||||
<div class="dash__entries">
|
<div class="dash__entries">
|
||||||
<template v-if="loading">
|
<template v-if="loading">
|
||||||
@@ -177,7 +193,7 @@ function formatDate(dateStr: string): string {
|
|||||||
<span class="dash__toolbox-card-tag">Vote WoT</span>
|
<span class="dash__toolbox-card-tag">Vote WoT</span>
|
||||||
<span class="dash__toolbox-card-tag">Inertie</span>
|
<span class="dash__toolbox-card-tag">Inertie</span>
|
||||||
<span class="dash__toolbox-card-tag">Smith</span>
|
<span class="dash__toolbox-card-tag">Smith</span>
|
||||||
<span class="dash__toolbox-card-tag">Nuancé</span>
|
<span class="dash__toolbox-card-tag">Élection</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UIcon name="i-lucide-arrow-right" class="dash__toolbox-card-arrow" />
|
<UIcon name="i-lucide-arrow-right" class="dash__toolbox-card-arrow" />
|
||||||
@@ -292,6 +308,87 @@ function formatDate(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Décider CTA --- */
|
||||||
|
.dash__decide {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--mood-accent);
|
||||||
|
border-radius: 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.dash__decide:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 28px var(--mood-shadow);
|
||||||
|
}
|
||||||
|
.dash__decide:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
.dash__decide-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash__decide-icon {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255,255,255,0.18);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash__decide-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash__decide-label {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.dash__decide-label { font-size: 1.125rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash__decide-sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255,255,255,0.75);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.dash__decide-sub { font-size: 0.8125rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash__decide-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.18);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
transition: transform 0.12s;
|
||||||
|
}
|
||||||
|
.dash__decide:hover .dash__decide-arrow {
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Entry cards --- */
|
/* --- Entry cards --- */
|
||||||
.dash__entries {
|
.dash__entries {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ async function loginAsProfile(p: DevProfile) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
step.value = 'signing'
|
step.value = 'signing'
|
||||||
await auth.login(p.address)
|
// Dev mode: bypass extension — backend accepte toute signature pour les profils dev
|
||||||
|
await auth.login(p.address, () => Promise.resolve('0x' + 'a'.repeat(128)))
|
||||||
step.value = 'success'
|
step.value = 'success'
|
||||||
setTimeout(() => router.push('/'), 800)
|
setTimeout(() => router.push('/'), 800)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -99,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('/')
|
||||||
@@ -165,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"
|
||||||
@@ -198,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>
|
||||||
@@ -452,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;
|
||||||
|
|||||||
@@ -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'
|
function formatDate(d: string | null): string {
|
||||||
case 'smith': return 'Forgeron'
|
if (!d) return '-'
|
||||||
case 'custom': return 'Personnalise'
|
return new Date(d).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
default: return mandateType
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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 { await mandates.advance(mandateId.value) } catch { /* store holds error */ } finally { advancing.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Identity search (shared for assign + edit) ---
|
||||||
|
|
||||||
|
interface IdentityResult { id: string; address: string; display_name: string | null }
|
||||||
|
|
||||||
|
function useIdentitySearch() {
|
||||||
|
const query = ref('')
|
||||||
|
const results = ref<IdentityResult[]>([])
|
||||||
|
const searching = ref(false)
|
||||||
|
const selectedId = ref<string | null>(null)
|
||||||
|
const selectedLabel = ref('')
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
async function search(q: string) {
|
||||||
|
if (q.length < 2) { results.value = []; return }
|
||||||
|
searching.value = true
|
||||||
try {
|
try {
|
||||||
await mandates.advance(mandateId.value)
|
results.value = await $api<IdentityResult[]>('/auth/identities', { query: { q } })
|
||||||
} catch {
|
} catch { results.value = [] } finally { searching.value = false }
|
||||||
// Error handled by store
|
|
||||||
} finally {
|
|
||||||
advancing.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onInput(q: string) {
|
||||||
|
query.value = q
|
||||||
|
selectedId.value = null
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => search(q), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(i: IdentityResult) {
|
||||||
|
selectedId.value = i.id
|
||||||
|
selectedLabel.value = i.display_name || i.address
|
||||||
|
query.value = i.display_name || i.address
|
||||||
|
results.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
query.value = ''
|
||||||
|
results.value = []
|
||||||
|
selectedId.value = null
|
||||||
|
selectedLabel.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return { query, results, searching, selectedId, selectedLabel, onInput, select, reset }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Assign mandatee ---
|
// --- Assign mandatee ---
|
||||||
|
|
||||||
const showAssignModal = ref(false)
|
const showAssignModal = ref(false)
|
||||||
const mandateeAddress = ref('')
|
|
||||||
const assigning = ref(false)
|
const assigning = ref(false)
|
||||||
|
const assignSearch = useIdentitySearch()
|
||||||
|
|
||||||
async function handleAssign() {
|
async function handleAssign() {
|
||||||
if (!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>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const filteredMandates = computed(() => {
|
|||||||
|
|
||||||
// Filter by status group
|
// Filter by status group
|
||||||
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
|
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
|
||||||
const allowedStatuses = statusGroupMap[activeStatus.value]
|
const allowedStatuses = statusGroupMap[activeStatus.value]!
|
||||||
list = list.filter(m => allowedStatuses.includes(m.status))
|
list = list.filter(m => allowedStatuses.includes(m.status))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +95,8 @@ const filteredMandates = computed(() => {
|
|||||||
|
|
||||||
const typeLabel = (mandateType: string) => {
|
const typeLabel = (mandateType: string) => {
|
||||||
switch (mandateType) {
|
switch (mandateType) {
|
||||||
|
case 'statutory': return 'Statutaire'
|
||||||
|
case 'functional': return 'Fonctionnel'
|
||||||
case 'techcomm': return 'Comité technique'
|
case 'techcomm': return 'Comité technique'
|
||||||
case 'smith': return 'Forgeron'
|
case 'smith': return 'Forgeron'
|
||||||
case 'custom': return 'Personnalisé'
|
case 'custom': return 'Personnalisé'
|
||||||
@@ -154,14 +156,14 @@ async function handleCreate() {
|
|||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<NuxtLink
|
||||||
v-if="auth.isAuthenticated"
|
v-if="auth.isAuthenticated"
|
||||||
|
to="/mandates/new"
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
@click="showCreateModal = true"
|
|
||||||
>
|
>
|
||||||
<UIcon name="i-lucide-plus" class="text-xs" />
|
<UIcon name="i-lucide-plus" class="text-xs" />
|
||||||
<span>Nouveau</span>
|
<span>Nouveau</span>
|
||||||
</button>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Main content: mandates list -->
|
<!-- Main content: mandates list -->
|
||||||
@@ -199,11 +201,11 @@ async function handleCreate() {
|
|||||||
<div class="mandate-onboarding__actions">
|
<div class="mandate-onboarding__actions">
|
||||||
<UButton
|
<UButton
|
||||||
v-if="auth.isAuthenticated"
|
v-if="auth.isAuthenticated"
|
||||||
|
to="/mandates/new"
|
||||||
label="Créer un premier mandat"
|
label="Créer un premier mandat"
|
||||||
icon="i-lucide-plus"
|
icon="i-lucide-plus"
|
||||||
color="primary"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="showCreateModal = true"
|
|
||||||
/>
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
to="/protocols"
|
to="/protocols"
|
||||||
@@ -290,7 +292,7 @@ async function handleCreate() {
|
|||||||
:actions="[
|
:actions="[
|
||||||
{ label: 'Nouveau mandat', icon: 'i-lucide-plus', emit: 'create', primary: true },
|
{ label: 'Nouveau mandat', icon: 'i-lucide-plus', emit: 'create', primary: true },
|
||||||
]"
|
]"
|
||||||
@action="e => e === 'create' && (showCreateModal = true)"
|
@action="e => e === 'create' && navigateTo('/mandates/new')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Révocation -->
|
<!-- Révocation -->
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ onMounted(async () => {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
protocols.fetchProtocols(),
|
protocols.fetchProtocols(),
|
||||||
protocols.fetchFormulas(),
|
protocols.fetchFormulas(),
|
||||||
|
groupsStore.fetchAll(),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -136,24 +137,6 @@ interface OperationalProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const operationalProtocols: OperationalProtocol[] = [
|
const operationalProtocols: OperationalProtocol[] = [
|
||||||
{
|
|
||||||
slug: 'election-sociocratique',
|
|
||||||
name: 'Élection sociocratique',
|
|
||||||
description: 'Processus d\'élection d\'un rôle par consentement : clarification du rôle, nominations silencieuses, argumentaire, levée d\'objections. Garantit légitimité et clarté.',
|
|
||||||
category: 'gouvernance',
|
|
||||||
icon: 'i-lucide-users',
|
|
||||||
instancesLabel: 'Tout renouvellement de rôle',
|
|
||||||
linkedRefs: [
|
|
||||||
{ label: 'Mandats', icon: 'i-lucide-user-check', to: '/mandates', kind: 'decision' },
|
|
||||||
],
|
|
||||||
steps: [
|
|
||||||
{ label: 'Clarifier le rôle', actor: 'Cercle', icon: 'i-lucide-clipboard-list', type: 'checklist' },
|
|
||||||
{ label: 'Nominations silencieuses', actor: 'Tous les membres', icon: 'i-lucide-pencil', type: 'checklist' },
|
|
||||||
{ label: 'Recueil & argumentaire', actor: 'Facilitateur', icon: 'i-lucide-list-checks', type: 'checklist' },
|
|
||||||
{ label: 'Objections & consentement', actor: 'Cercle', icon: 'i-lucide-shield-check', type: 'certification' },
|
|
||||||
{ label: 'Proclamation', actor: 'Facilitateur', icon: 'i-lucide-star', type: 'on_chain' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
slug: 'embarquement-forgeron',
|
slug: 'embarquement-forgeron',
|
||||||
name: 'Embarquement Forgeron',
|
name: 'Embarquement Forgeron',
|
||||||
@@ -192,6 +175,73 @@ const operationalProtocols: OperationalProtocol[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// ── Groups ─────────────────────────────────────────────────────────────────
|
||||||
|
const groupsStore = useGroupsStore()
|
||||||
|
const showGroupModal = ref(false)
|
||||||
|
const newGroupName = ref('')
|
||||||
|
const newGroupDesc = ref('')
|
||||||
|
const creatingGroup = ref(false)
|
||||||
|
const expandedGroupId = ref<string | null>(null)
|
||||||
|
const expandedGroupDetail = ref<import('~/stores/groups').Group | null>(null)
|
||||||
|
const loadingGroupDetail = ref(false)
|
||||||
|
const newMemberName = ref('')
|
||||||
|
const addingMember = ref(false)
|
||||||
|
|
||||||
|
async function openGroupModal() {
|
||||||
|
newGroupName.value = ''
|
||||||
|
newGroupDesc.value = ''
|
||||||
|
showGroupModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGroup() {
|
||||||
|
if (!newGroupName.value.trim()) return
|
||||||
|
creatingGroup.value = true
|
||||||
|
try {
|
||||||
|
await groupsStore.create({ name: newGroupName.value.trim(), description: newGroupDesc.value.trim() || null })
|
||||||
|
showGroupModal.value = false
|
||||||
|
} finally {
|
||||||
|
creatingGroup.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleGroupDetail(groupId: string) {
|
||||||
|
if (expandedGroupId.value === groupId) {
|
||||||
|
expandedGroupId.value = null
|
||||||
|
expandedGroupDetail.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expandedGroupId.value = groupId
|
||||||
|
loadingGroupDetail.value = true
|
||||||
|
expandedGroupDetail.value = await groupsStore.getGroup(groupId)
|
||||||
|
loadingGroupDetail.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMember(groupId: string) {
|
||||||
|
if (!newMemberName.value.trim()) return
|
||||||
|
addingMember.value = true
|
||||||
|
const member = await groupsStore.addMember(groupId, { display_name: newMemberName.value.trim() })
|
||||||
|
if (member && expandedGroupDetail.value) {
|
||||||
|
expandedGroupDetail.value.members.push(member)
|
||||||
|
newMemberName.value = ''
|
||||||
|
}
|
||||||
|
addingMember.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMember(groupId: string, memberId: string) {
|
||||||
|
const ok = await groupsStore.removeMember(groupId, memberId)
|
||||||
|
if (ok && expandedGroupDetail.value) {
|
||||||
|
expandedGroupDetail.value.members = expandedGroupDetail.value.members.filter(m => m.id !== memberId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGroup(groupId: string) {
|
||||||
|
await groupsStore.remove(groupId)
|
||||||
|
if (expandedGroupId.value === groupId) {
|
||||||
|
expandedGroupId.value = null
|
||||||
|
expandedGroupDetail.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** n8n workflow demo items. */
|
/** n8n workflow demo items. */
|
||||||
const n8nWorkflows = [
|
const n8nWorkflows = [
|
||||||
{
|
{
|
||||||
@@ -348,6 +398,79 @@ const n8nWorkflows = [
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Groups ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="proto-groups">
|
||||||
|
<h3 class="proto-groups__title">
|
||||||
|
<UIcon name="i-lucide-users-round" class="text-sm" />
|
||||||
|
Groupes d'identités
|
||||||
|
<span class="proto-groups__count">{{ groupsStore.list.length }}</span>
|
||||||
|
<button v-if="auth.isAuthenticated" class="proto-groups__add-btn" @click="openGroupModal">
|
||||||
|
<UIcon name="i-lucide-plus" class="text-sm" />
|
||||||
|
Nouveau groupe
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div v-if="groupsStore.list.length === 0" class="proto-groups__empty">
|
||||||
|
<UIcon name="i-lucide-users" class="text-lg" />
|
||||||
|
<span>Aucun groupe défini. Les groupes permettent de pré-sélectionner des cercles dans les décisions.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="proto-groups__list">
|
||||||
|
<div v-for="g in groupsStore.list" :key="g.id" class="proto-groups__item">
|
||||||
|
<div class="proto-groups__item-head" @click="toggleGroupDetail(g.id)">
|
||||||
|
<div class="proto-groups__item-info">
|
||||||
|
<UIcon name="i-lucide-users" class="text-sm" />
|
||||||
|
<span class="proto-groups__item-name">{{ g.name }}</span>
|
||||||
|
<span class="proto-groups__item-count">{{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="proto-groups__item-actions">
|
||||||
|
<button v-if="auth.isAuthenticated" class="proto-groups__delete-btn" @click.stop="deleteGroup(g.id)">
|
||||||
|
<UIcon name="i-lucide-trash-2" class="text-xs" />
|
||||||
|
</button>
|
||||||
|
<UIcon :name="expandedGroupId === g.id ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="slide-down">
|
||||||
|
<div v-if="expandedGroupId === g.id" class="proto-groups__detail">
|
||||||
|
<p v-if="g.description" class="proto-groups__detail-desc">{{ g.description }}</p>
|
||||||
|
<div v-if="loadingGroupDetail" class="proto-groups__members-loading">
|
||||||
|
<UIcon name="i-lucide-loader-2" class="animate-spin text-sm" />
|
||||||
|
</div>
|
||||||
|
<ul v-else-if="expandedGroupDetail" class="proto-groups__members">
|
||||||
|
<li v-for="m in expandedGroupDetail.members" :key="m.id" class="proto-groups__member">
|
||||||
|
<UIcon name="i-lucide-user" class="text-xs" />
|
||||||
|
<span>{{ m.display_name }}</span>
|
||||||
|
<button v-if="auth.isAuthenticated" class="proto-groups__member-remove" @click="removeMember(g.id, m.id)">
|
||||||
|
<UIcon name="i-lucide-x" class="text-xs" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li v-if="expandedGroupDetail.members.length === 0" class="proto-groups__member proto-groups__member--empty">
|
||||||
|
Aucun membre
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="auth.isAuthenticated" class="proto-groups__add-member">
|
||||||
|
<input
|
||||||
|
v-model="newMemberName"
|
||||||
|
type="text"
|
||||||
|
lang="fr"
|
||||||
|
spellcheck="true"
|
||||||
|
class="proto-groups__member-input"
|
||||||
|
placeholder="Nom ou adresse Duniter"
|
||||||
|
@keydown.enter="addMember(g.id)"
|
||||||
|
/>
|
||||||
|
<button class="proto-groups__member-btn" :disabled="addingMember || !newMemberName.trim()" @click="addMember(g.id)">
|
||||||
|
<UIcon v-if="addingMember" name="i-lucide-loader-2" class="animate-spin text-xs" />
|
||||||
|
<UIcon v-else name="i-lucide-user-plus" class="text-xs" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Operational protocols (always visible, frontend-only data) -->
|
<!-- Operational protocols (always visible, frontend-only data) -->
|
||||||
<div class="proto-ops">
|
<div class="proto-ops">
|
||||||
<h3 class="proto-ops__title">
|
<h3 class="proto-ops__title">
|
||||||
@@ -524,6 +647,51 @@ const n8nWorkflows = [
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
|
|
||||||
|
<!-- Create group modal -->
|
||||||
|
<UModal v-model:open="showGroupModal">
|
||||||
|
<template #content>
|
||||||
|
<div class="proto-modal">
|
||||||
|
<h3 class="proto-modal__title">Nouveau groupe d'identités</h3>
|
||||||
|
<div class="proto-modal__fields">
|
||||||
|
<div class="proto-modal__field">
|
||||||
|
<label class="proto-modal__label">Nom du groupe</label>
|
||||||
|
<input
|
||||||
|
v-model="newGroupName"
|
||||||
|
type="text"
|
||||||
|
lang="fr"
|
||||||
|
spellcheck="true"
|
||||||
|
class="proto-modal__input"
|
||||||
|
placeholder="Ex: Comité technique, Forgerons actifs…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="proto-modal__field">
|
||||||
|
<label class="proto-modal__label">Description <span class="proto-modal__optional">(optionnel)</span></label>
|
||||||
|
<textarea
|
||||||
|
v-model="newGroupDesc"
|
||||||
|
class="proto-modal__textarea"
|
||||||
|
lang="fr"
|
||||||
|
spellcheck="true"
|
||||||
|
placeholder="Rôle ou périmètre de ce groupe…"
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="proto-modal__actions">
|
||||||
|
<button class="proto-modal__cancel" @click="showGroupModal = false">Annuler</button>
|
||||||
|
<button
|
||||||
|
class="proto-modal__submit"
|
||||||
|
:disabled="!newGroupName.trim() || creatingGroup"
|
||||||
|
@click="createGroup"
|
||||||
|
>
|
||||||
|
<UIcon v-if="creatingGroup" name="i-lucide-loader-2" class="animate-spin" />
|
||||||
|
<UIcon v-else name="i-lucide-users-round" />
|
||||||
|
Créer le groupe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1203,4 +1371,175 @@ const n8nWorkflows = [
|
|||||||
box-shadow: 0 4px 12px var(--mood-shadow);
|
box-shadow: 0 4px 12px var(--mood-shadow);
|
||||||
}
|
}
|
||||||
.proto-modal__submit:disabled { opacity: 0.4; cursor: not-allowed; }
|
.proto-modal__submit:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.proto-modal__optional { font-size: 0.8125rem; opacity: 0.55; font-weight: 400; }
|
||||||
|
|
||||||
|
/* --- Groups --- */
|
||||||
|
.proto-groups {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.proto-groups__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.proto-groups__count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.375rem;
|
||||||
|
height: 1.375rem;
|
||||||
|
padding: 0 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
.proto-groups__add-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
.proto-groups__add-btn:hover { transform: translateY(-1px); }
|
||||||
|
.proto-groups__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
background: var(--mood-surface);
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.proto-groups__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.proto-groups__item {
|
||||||
|
background: var(--mood-surface);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.proto-groups__item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
.proto-groups__item-head:hover { background: var(--mood-hover); }
|
||||||
|
.proto-groups__item-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
.proto-groups__item-name { font-weight: 600; font-size: 0.9375rem; }
|
||||||
|
.proto-groups__item-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
}
|
||||||
|
.proto-groups__item-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
}
|
||||||
|
.proto-groups__delete-btn {
|
||||||
|
color: var(--mood-danger, #e53e3e);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
.proto-groups__delete-btn:hover { opacity: 1; }
|
||||||
|
.proto-groups__detail {
|
||||||
|
padding: 0.75rem 1rem 1rem;
|
||||||
|
border-top: 1px solid var(--mood-border, rgba(0,0,0,0.06));
|
||||||
|
}
|
||||||
|
.proto-groups__detail-desc {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.proto-groups__members-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
}
|
||||||
|
.proto-groups__members {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.proto-groups__member {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
background: var(--mood-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.proto-groups__member--empty { color: var(--mood-muted); font-style: italic; }
|
||||||
|
.proto-groups__member-remove {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mood-danger, #e53e3e);
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
.proto-groups__member-remove:hover { opacity: 1; }
|
||||||
|
.proto-groups__add-member {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.proto-groups__member-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.4375rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
background: var(--mood-bg);
|
||||||
|
border-radius: 10px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.proto-groups__member-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.4375rem 0.875rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
background: var(--mood-accent);
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.proto-groups__member-btn:hover:not(:disabled) { transform: translateY(-1px); }
|
||||||
|
.proto-groups__member-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface ToolSection {
|
|||||||
tools: Tool[]
|
tools: Tool[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expandSocio = ref(false)
|
||||||
|
|
||||||
const sections: ToolSection[] = [
|
const sections: ToolSection[] = [
|
||||||
{
|
{
|
||||||
key: 'documents',
|
key: 'documents',
|
||||||
@@ -149,6 +151,29 @@ const sections: ToolSection[] = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Election sociocratique — modalité d'élection, accessible depuis mandats -->
|
||||||
|
<div v-if="section.key === 'mandats'" class="socio-expand">
|
||||||
|
<button class="socio-expand__trigger" @click="expandSocio = !expandSocio">
|
||||||
|
<div class="socio-expand__icon">
|
||||||
|
<UIcon name="i-lucide-users" class="text-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="socio-expand__info">
|
||||||
|
<span class="socio-expand__title">Élection sociocratique</span>
|
||||||
|
<span class="socio-expand__meta">6 étapes · clarification · consentement collectif</span>
|
||||||
|
</div>
|
||||||
|
<span class="socio-expand__tag">Modalité d'élection</span>
|
||||||
|
<UIcon
|
||||||
|
:name="expandSocio ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||||
|
class="socio-expand__toggle"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<Transition name="socio-expand">
|
||||||
|
<div v-if="expandSocio" class="socio-expand__content">
|
||||||
|
<SocioElection />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,4 +354,99 @@ const sections: ToolSection[] = [
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
color: var(--section-color);
|
color: var(--section-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Élection sociocratique expandable --- */
|
||||||
|
.socio-expand {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--mood-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand__trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.socio-expand__trigger:hover { background: var(--mood-accent-soft); }
|
||||||
|
|
||||||
|
.socio-expand__icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--mood-success) 12%, transparent);
|
||||||
|
color: var(--mood-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.0625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand__title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand__meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand__tag {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: color-mix(in srgb, var(--mood-success) 12%, transparent);
|
||||||
|
color: var(--mood-success);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.socio-expand__tag { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand__toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand__content {
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand-enter-active,
|
||||||
|
.socio-expand-leave-active {
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.socio-expand-enter-from,
|
||||||
|
.socio-expand-leave-to {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.socio-expand-enter-to,
|
||||||
|
.socio-expand-leave-from {
|
||||||
|
max-height: 2000px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -149,10 +152,15 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
const identity = await $api<DuniterIdentity>('/auth/me')
|
const identity = await $api<DuniterIdentity>('/auth/me')
|
||||||
this.identity = identity
|
this.identity = identity
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.error = err?.data?.detail || err?.message || 'Session invalide'
|
const status = (err as any)?.status ?? 0
|
||||||
|
this.error = err?.message || 'Session invalide'
|
||||||
|
// N'effacer le token que sur 401/403 (session réellement invalide)
|
||||||
|
// Les erreurs réseau ou 5xx sont transitoires — conserver la session
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
this.token = null
|
this.token = null
|
||||||
this.identity = null
|
this.identity = null
|
||||||
this._clearToken()
|
this._clearToken()
|
||||||
|
}
|
||||||
throw err
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
export interface GroupMember {
|
||||||
|
id: string
|
||||||
|
display_name: string
|
||||||
|
identity_id: string | null
|
||||||
|
added_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
organization_id: string | null
|
||||||
|
created_at: string
|
||||||
|
members: GroupMember[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupSummary {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
organization_id: string | null
|
||||||
|
member_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupCreate {
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupMemberCreate {
|
||||||
|
display_name: string
|
||||||
|
identity_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGroupsStore = defineStore('groups', () => {
|
||||||
|
const { $api } = useApi()
|
||||||
|
|
||||||
|
const list = ref<GroupSummary[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetchAll() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
list.value = await $api<GroupSummary[]>('/groups/')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message ?? 'Erreur chargement groupes'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGroup(id: string): Promise<Group | null> {
|
||||||
|
try {
|
||||||
|
return await $api<Group>(`/groups/${id}`)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: GroupCreate): Promise<Group | null> {
|
||||||
|
try {
|
||||||
|
const group = await $api<Group>('/groups/', { method: 'POST', body: payload })
|
||||||
|
await fetchAll()
|
||||||
|
return group
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message ?? 'Erreur création groupe'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await $api(`/groups/${id}`, { method: 'DELETE' })
|
||||||
|
list.value = list.value.filter(g => g.id !== id)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMember(groupId: string, payload: GroupMemberCreate): Promise<GroupMember | null> {
|
||||||
|
try {
|
||||||
|
const member = await $api<GroupMember>(`/groups/${groupId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
const g = list.value.find(g => g.id === groupId)
|
||||||
|
if (g) g.member_count++
|
||||||
|
return member
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMember(groupId: string, memberId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await $api(`/groups/${groupId}/members/${memberId}`, { method: 'DELETE' })
|
||||||
|
const g = list.value.find(g => g.id === groupId)
|
||||||
|
if (g) g.member_count--
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { list, loading, error, fetchAll, getGroup, create, remove, addMember, removeMember }
|
||||||
|
})
|
||||||
@@ -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,10 +14,13 @@ export interface MandateStep {
|
|||||||
export interface Mandate {
|
export interface Mandate {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
origin_id: string | null
|
||||||
|
origin_display_name: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
mandate_type: string
|
mandate_type: string
|
||||||
status: string
|
status: string
|
||||||
mandatee_id: string | null
|
mandatee_id: string | null
|
||||||
|
mandatee_display_name: string | null
|
||||||
decision_id: string | null
|
decision_id: string | null
|
||||||
starts_at: string | null
|
starts_at: string | null
|
||||||
ends_at: string | null
|
ends_at: string | null
|
||||||
@@ -34,8 +31,10 @@ export interface Mandate {
|
|||||||
|
|
||||||
export interface MandateCreate {
|
export interface MandateCreate {
|
||||||
title: string
|
title: string
|
||||||
|
origin_id?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
mandate_type: string
|
mandate_type: string
|
||||||
|
nomination_mode?: string
|
||||||
decision_id?: string | null
|
decision_id?: string | null
|
||||||
starts_at?: string | null
|
starts_at?: string | null
|
||||||
ends_at?: string | null
|
ends_at?: string | null
|
||||||
@@ -43,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
|
||||||
@@ -50,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
|
||||||
@@ -71,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'
|
||||||
@@ -104,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}`)
|
||||||
@@ -121,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) {
|
||||||
@@ -144,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
|
||||||
@@ -166,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' })
|
||||||
@@ -183,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
|
||||||
@@ -204,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'
|
||||||
@@ -226,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`, {
|
||||||
@@ -248,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
|
||||||
@@ -269,9 +212,6 @@ export const useMandatesStore = defineStore('mandates', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the current mandate.
|
|
||||||
*/
|
|
||||||
clearCurrent() {
|
clearCurrent() {
|
||||||
this.current = null
|
this.current = null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export interface Organization {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
org_type: string
|
||||||
|
is_transparent: boolean
|
||||||
|
color: string | null
|
||||||
|
icon: string | null
|
||||||
|
description: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrgState {
|
||||||
|
organizations: Organization[]
|
||||||
|
activeSlug: string | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOrganizationsStore = defineStore('organizations', {
|
||||||
|
state: (): OrgState => ({
|
||||||
|
organizations: [],
|
||||||
|
activeSlug: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
active: (state): Organization | null =>
|
||||||
|
state.organizations.find(o => o.slug === state.activeSlug) ?? state.organizations[0] ?? null,
|
||||||
|
|
||||||
|
hasOrganizations: (state): boolean => state.organizations.length > 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetchOrganizations() {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
const { $api } = useApi()
|
||||||
|
const orgs = await $api<Organization[]>('/organizations/')
|
||||||
|
// Duniter G1 first, then alphabetical
|
||||||
|
this.organizations = orgs.sort((a, b) => {
|
||||||
|
if (a.slug === 'duniter-g1') return -1
|
||||||
|
if (b.slug === 'duniter-g1') return 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
// Restore persisted active slug, or default to first org
|
||||||
|
const stored = import.meta.client ? localStorage.getItem('libredecision_org') : null
|
||||||
|
if (stored && this.organizations.some(o => o.slug === stored)) {
|
||||||
|
this.activeSlug = stored
|
||||||
|
} else if (this.organizations.length > 0) {
|
||||||
|
this.activeSlug = this.organizations[0].slug
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.error = err?.message || 'Erreur lors du chargement des organisations'
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setActive(slug: string) {
|
||||||
|
if (this.organizations.some(o => o.slug === slug)) {
|
||||||
|
this.activeSlug = slug
|
||||||
|
if (import.meta.client) {
|
||||||
|
localStorage.setItem('libredecision_org', slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
ssr: false,
|
ssr: false,
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: false },
|
||||||
devServer: { port: 3002, host: '0.0.0.0' },
|
devServer: { port: 3002, host: '0.0.0.0' },
|
||||||
components: [{ path: '~/components', pathPrefix: false }],
|
components: [{ path: '~/components', pathPrefix: false }],
|
||||||
css: ['~/assets/css/moods.css'],
|
css: ['~/assets/css/moods.css'],
|
||||||
|
|||||||
Reference in New Issue
Block a user