v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names
|
||||
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path prepend - ensures app is importable
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
timezone = UTC
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .hierarchical revisions
|
||||
# new_file_template =
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
output_encoding = utf-8
|
||||
|
||||
# DATABASE_URL is read from app.config.settings in env.py
|
||||
# (not from sqlalchemy.url here)
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Alembic async environment for libreDecision.
|
||||
|
||||
Uses asyncpg via SQLAlchemy's async engine.
|
||||
All models are imported so that Base.metadata is fully populated
|
||||
before autogenerate runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Import settings to get the DATABASE_URL
|
||||
from app.config import settings
|
||||
|
||||
# Import Base for target_metadata
|
||||
from app.database import Base
|
||||
|
||||
# Import ALL models so their tables are registered on Base.metadata
|
||||
from app.models import ( # noqa: F401
|
||||
DuniterIdentity,
|
||||
Session,
|
||||
Organization,
|
||||
OrgMember,
|
||||
Document,
|
||||
DocumentItem,
|
||||
ItemVersion,
|
||||
Decision,
|
||||
DecisionStep,
|
||||
VoteSession,
|
||||
Vote,
|
||||
Mandate,
|
||||
MandateStep,
|
||||
VotingProtocol,
|
||||
FormulaConfig,
|
||||
QualificationProtocol,
|
||||
SanctuaryEntry,
|
||||
BlockchainCache,
|
||||
)
|
||||
|
||||
# Alembic Config object
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Override sqlalchemy.url with the real DATABASE_URL from settings
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
# Target metadata for autogenerate support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
Configures the context with just a URL and not an Engine.
|
||||
Calls to context.execute() emit the given string to the script output.
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""Run migrations with an active connection."""
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""Run migrations in 'online' mode with an async engine."""
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -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')
|
||||
+34
@@ -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")
|
||||
@@ -0,0 +1,54 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "libreDecision"
|
||||
DEBUG: bool = True
|
||||
|
||||
# Environment
|
||||
ENVIRONMENT: str = "development" # development, staging, production
|
||||
DEMO_MODE: bool = False # Enable demo profiles (quick login) regardless of ENVIRONMENT
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
# Database — SQLite by default for local dev, PostgreSQL for Docker/prod
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./libredecision.db"
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 10
|
||||
|
||||
# Auth
|
||||
SECRET_KEY: str = "change-me-in-production-with-a-real-secret-key"
|
||||
CHALLENGE_EXPIRE_SECONDS: int = 300
|
||||
TOKEN_EXPIRE_HOURS: int = 24
|
||||
SESSION_TTL_HOURS: int = 24
|
||||
|
||||
# Duniter V2 RPC
|
||||
DUNITER_RPC_URL: str = "wss://gdev.p2p.legal/ws"
|
||||
DUNITER_RPC_TIMEOUT_SECONDS: int = 10
|
||||
|
||||
# IPFS
|
||||
IPFS_API_URL: str = "http://localhost:5001"
|
||||
IPFS_GATEWAY_URL: str = "http://localhost:8080"
|
||||
IPFS_TIMEOUT_SECONDS: int = 30
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:3002"]
|
||||
|
||||
# Rate limiting (requests per minute)
|
||||
RATE_LIMIT_DEFAULT: int = 60
|
||||
RATE_LIMIT_AUTH: int = 10
|
||||
RATE_LIMIT_VOTE: int = 30
|
||||
|
||||
# AI — Qwen3.6 (MacStudio) endpoint, branché plus tard
|
||||
QWEN_API_URL: str = ""
|
||||
|
||||
# Blockchain cache
|
||||
BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600
|
||||
|
||||
# Paths
|
||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,39 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_is_sqlite = settings.DATABASE_URL.startswith("sqlite")
|
||||
|
||||
# SQLite doesn't support pool_size/max_overflow/pool_pre_ping/pool_recycle
|
||||
if _is_sqlite:
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.ENVIRONMENT == "development",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
else:
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.ENVIRONMENT == "development",
|
||||
pool_size=settings.DATABASE_POOL_SIZE,
|
||||
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -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,107 @@
|
||||
"""Parse mode-parameter strings into structured dicts.
|
||||
|
||||
A mode-params string encodes voting formula parameters in a compact format.
|
||||
Example: ``"D30M50B.1G.2T.1"``
|
||||
|
||||
Supported codes:
|
||||
D = duration_days (int)
|
||||
M = majority_pct (int, 0-100)
|
||||
B = base_exponent (float)
|
||||
G = gradient_exponent (float)
|
||||
C = constant_base (float)
|
||||
S = smith_exponent (float)
|
||||
T = techcomm_exponent (float)
|
||||
N = ratio_multiplier (float)
|
||||
R = ratio_mode (bool, 0 or 1)
|
||||
|
||||
Values may start with a dot for decimals < 1, e.g. ``B.1`` means base_exponent=0.1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
# Ordered list of recognised codes and their target keys + types
|
||||
_CODES: dict[str, tuple[str, type]] = {
|
||||
"D": ("duration_days", int),
|
||||
"M": ("majority_pct", int),
|
||||
"B": ("base_exponent", float),
|
||||
"G": ("gradient_exponent", float),
|
||||
"C": ("constant_base", float),
|
||||
"S": ("smith_exponent", float),
|
||||
"T": ("techcomm_exponent", float),
|
||||
"N": ("ratio_multiplier", float),
|
||||
"R": ("is_ratio_mode", bool),
|
||||
}
|
||||
|
||||
# Regex: a single uppercase letter followed by a numeric value (int or float,
|
||||
# possibly starting with '.' for values like .1 meaning 0.1)
|
||||
_PARAM_RE = re.compile(r"([A-Z])(\d*\.?\d+)")
|
||||
|
||||
|
||||
def parse_mode_params(params_str: str) -> dict:
|
||||
"""Parse a mode-params string into a parameter dict.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
params_str:
|
||||
Compact parameter string, e.g. ``"D30M50B.1G.2T.1"``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Keys present depend on codes found in the string. Defaults are
|
||||
applied for any code not present::
|
||||
|
||||
{
|
||||
"duration_days": 30,
|
||||
"majority_pct": 50,
|
||||
"base_exponent": 0.1,
|
||||
"gradient_exponent": 0.2,
|
||||
"constant_base": 0.0,
|
||||
"smith_exponent": None,
|
||||
"techcomm_exponent": None,
|
||||
"ratio_multiplier": None,
|
||||
"is_ratio_mode": False,
|
||||
}
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If an unrecognised code letter is found.
|
||||
"""
|
||||
defaults: dict = {
|
||||
"duration_days": 30,
|
||||
"majority_pct": 50,
|
||||
"base_exponent": 0.1,
|
||||
"gradient_exponent": 0.2,
|
||||
"constant_base": 0.0,
|
||||
"smith_exponent": None,
|
||||
"techcomm_exponent": None,
|
||||
"ratio_multiplier": None,
|
||||
"is_ratio_mode": False,
|
||||
}
|
||||
|
||||
if not params_str or not params_str.strip():
|
||||
return dict(defaults)
|
||||
|
||||
result = dict(defaults)
|
||||
|
||||
for match in _PARAM_RE.finditer(params_str):
|
||||
code = match.group(1)
|
||||
raw_value = match.group(2)
|
||||
|
||||
if code not in _CODES:
|
||||
raise ValueError(f"Code de parametre inconnu : '{code}'")
|
||||
|
||||
key, target_type = _CODES[code]
|
||||
|
||||
if target_type is int:
|
||||
result[key] = int(float(raw_value))
|
||||
elif target_type is float:
|
||||
result[key] = float(raw_value)
|
||||
elif target_type is bool:
|
||||
result[key] = float(raw_value) != 0.0
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Six-level nuanced vote evaluation.
|
||||
|
||||
Levels:
|
||||
0 - CONTRE
|
||||
1 - PAS DU TOUT
|
||||
2 - PAS D'ACCORD
|
||||
3 - NEUTRE
|
||||
4 - D'ACCORD
|
||||
5 - TOUT A FAIT
|
||||
|
||||
Adoption rule:
|
||||
The sum of votes at levels 3 + 4 + 5 must be >= threshold_pct% of total votes.
|
||||
A minimum number of participants is also required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LEVEL_LABELS: dict[int, str] = {
|
||||
0: "CONTRE",
|
||||
1: "PAS DU TOUT",
|
||||
2: "PAS D'ACCORD",
|
||||
3: "NEUTRE",
|
||||
4: "D'ACCORD",
|
||||
5: "TOUT A FAIT",
|
||||
}
|
||||
|
||||
NUM_LEVELS = 6
|
||||
|
||||
|
||||
def evaluate_nuanced(
|
||||
votes: list[int],
|
||||
threshold_pct: int = 80,
|
||||
min_participants: int = 59,
|
||||
) -> dict:
|
||||
"""Evaluate a nuanced vote from a list of individual vote levels.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
votes:
|
||||
List of vote levels (each 0-5). One entry per voter.
|
||||
threshold_pct:
|
||||
Minimum percentage of positive votes (levels 3-5) out of total
|
||||
for adoption.
|
||||
min_participants:
|
||||
Minimum number of participants required for validity.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
{
|
||||
"total": int,
|
||||
"per_level_counts": {0: int, 1: int, ..., 5: int},
|
||||
"positive_count": int, # levels 3 + 4 + 5
|
||||
"positive_pct": float, # 0.0 - 100.0
|
||||
"threshold_met": bool,
|
||||
"min_participants_met": bool,
|
||||
"adopted": bool,
|
||||
}
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If any vote value is outside the 0-5 range.
|
||||
"""
|
||||
# Validate vote levels
|
||||
for v in votes:
|
||||
if v < 0 or v > 5:
|
||||
raise ValueError(
|
||||
f"Niveau de vote invalide : {v}. Les niveaux valides sont 0-5."
|
||||
)
|
||||
|
||||
total = len(votes)
|
||||
|
||||
per_level_counts: dict[int, int] = {level: 0 for level in range(NUM_LEVELS)}
|
||||
for v in votes:
|
||||
per_level_counts[v] += 1
|
||||
|
||||
# Positive = levels 3 (NEUTRE), 4 (D'ACCORD), 5 (TOUT A FAIT)
|
||||
positive_count = per_level_counts[3] + per_level_counts[4] + per_level_counts[5]
|
||||
|
||||
positive_pct = (positive_count / total * 100.0) if total > 0 else 0.0
|
||||
|
||||
threshold_met = positive_pct >= threshold_pct
|
||||
min_participants_met = total >= min_participants
|
||||
adopted = threshold_met and min_participants_met
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"per_level_counts": per_level_counts,
|
||||
"positive_count": positive_count,
|
||||
"positive_pct": round(positive_pct, 2),
|
||||
"threshold_met": threshold_met,
|
||||
"min_participants_met": min_participants_met,
|
||||
"adopted": adopted,
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Smith sub-WoT threshold criterion.
|
||||
|
||||
The Smith criterion requires a minimum number of votes from Smith members
|
||||
(forgerons) for certain decisions to be valid.
|
||||
|
||||
Formula: ceil(SmithWotSize ^ S)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def smith_threshold(smith_wot_size: int, exponent: float = 0.1) -> int:
|
||||
"""Compute the minimum number of Smith member votes required.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
smith_wot_size:
|
||||
Number of active Smith members.
|
||||
exponent:
|
||||
S in the formula ``ceil(smith_wot_size^S)``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Minimum Smith votes required.
|
||||
"""
|
||||
if smith_wot_size <= 0:
|
||||
raise ValueError("smith_wot_size doit etre strictement positif")
|
||||
return math.ceil(smith_wot_size ** exponent)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Technical Committee threshold criterion.
|
||||
|
||||
The TechComm criterion requires a minimum number of votes from
|
||||
Technical Committee members for certain decisions.
|
||||
|
||||
Formula: ceil(CoTecSize ^ T)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def techcomm_threshold(cotec_size: int, exponent: float = 0.1) -> int:
|
||||
"""Compute the minimum number of TechComm member votes required.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cotec_size:
|
||||
Number of Technical Committee members.
|
||||
exponent:
|
||||
T in the formula ``ceil(cotec_size^T)``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Minimum TechComm votes required.
|
||||
"""
|
||||
if cotec_size <= 0:
|
||||
raise ValueError("cotec_size doit etre strictement positif")
|
||||
return math.ceil(cotec_size ** exponent)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""WoT members threshold formula for binary votes.
|
||||
|
||||
Core formula:
|
||||
Result = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T - C)
|
||||
|
||||
Where:
|
||||
C = constant_base
|
||||
B = base_exponent
|
||||
W = wot_size (corpus of eligible voters)
|
||||
T = total_votes (for + against)
|
||||
M = majority_ratio (majority_pct / 100)
|
||||
G = gradient_exponent
|
||||
|
||||
Inertia behaviour:
|
||||
- Low participation (T << W) -> near-unanimity required
|
||||
- High participation (T -> W) -> simple majority M suffices
|
||||
|
||||
Reference test case:
|
||||
wot_size=7224, votes_for=97, votes_against=23 (total=120)
|
||||
params M50 B.1 G.2 => threshold=94, adopted (97 >= 94)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def wot_threshold(
|
||||
wot_size: int,
|
||||
total_votes: int,
|
||||
majority_pct: int = 50,
|
||||
base_exponent: float = 0.1,
|
||||
gradient_exponent: float = 0.2,
|
||||
constant_base: float = 0.0,
|
||||
) -> int:
|
||||
"""Compute the minimum number of *for* votes required for adoption.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
wot_size:
|
||||
Size of the eligible voter corpus (WoT members).
|
||||
total_votes:
|
||||
Number of votes cast (for + against).
|
||||
majority_pct:
|
||||
Majority percentage (0-100). 50 = simple majority at full participation.
|
||||
base_exponent:
|
||||
B in the formula. ``B^W`` contributes a vanishingly small offset
|
||||
when W is large (0 < B < 1).
|
||||
gradient_exponent:
|
||||
G controls how fast the required super-majority decays toward M as
|
||||
participation increases.
|
||||
constant_base:
|
||||
C, a fixed additive floor on the threshold.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The ceiling of the computed threshold. A vote passes when
|
||||
``votes_for >= wot_threshold(...)``.
|
||||
"""
|
||||
if wot_size <= 0:
|
||||
raise ValueError("wot_size doit etre strictement positif")
|
||||
if total_votes < 0:
|
||||
raise ValueError("total_votes ne peut pas etre negatif")
|
||||
if not (0 <= majority_pct <= 100):
|
||||
raise ValueError("majority_pct doit etre entre 0 et 100")
|
||||
|
||||
C = constant_base
|
||||
B = base_exponent
|
||||
W = wot_size
|
||||
T = total_votes
|
||||
M = majority_pct / 100.0
|
||||
G = gradient_exponent
|
||||
|
||||
# Guard: if no votes, threshold is at least ceil(C + B^W)
|
||||
if T == 0:
|
||||
return math.ceil(C + B ** W)
|
||||
|
||||
# Core formula
|
||||
participation_ratio = T / W
|
||||
inertia_factor = 1.0 - participation_ratio ** G
|
||||
required_ratio = M + (1.0 - M) * inertia_factor
|
||||
result = C + B ** W + required_ratio * max(0.0, T - C)
|
||||
|
||||
return math.ceil(result)
|
||||
@@ -0,0 +1,164 @@
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db, init_db
|
||||
from app.middleware.rate_limiter import RateLimiterMiddleware
|
||||
from app.middleware.security_headers import SecurityHeadersMiddleware
|
||||
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
|
||||
from app.routers import public
|
||||
from app.routers import organizations
|
||||
from app.routers import qualify
|
||||
from app.routers import groups
|
||||
|
||||
|
||||
# ── Structured logging setup ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
"""Configure structured logging based on environment.
|
||||
|
||||
- Production/staging: JSON-formatted log lines for log aggregation.
|
||||
- Development: human-readable format with colors.
|
||||
"""
|
||||
log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
||||
|
||||
if settings.ENVIRONMENT in ("production", "staging"):
|
||||
# JSON formatter for structured logging
|
||||
formatter = logging.Formatter(
|
||||
'{"timestamp":"%(asctime)s","level":"%(levelname)s",'
|
||||
'"logger":"%(name)s","message":"%(message)s"}',
|
||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||
)
|
||||
else:
|
||||
# Human-readable format for development
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(log_level)
|
||||
root_logger.handlers.clear()
|
||||
root_logger.addHandler(handler)
|
||||
|
||||
# Reduce noise from third-party libraries
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(
|
||||
logging.INFO if settings.ENVIRONMENT == "development" else logging.WARNING
|
||||
)
|
||||
|
||||
|
||||
_setup_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Application lifespan ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info(
|
||||
"Demarrage %s (env=%s, log_level=%s)",
|
||||
settings.APP_NAME, settings.ENVIRONMENT, settings.LOG_LEVEL,
|
||||
)
|
||||
await init_db()
|
||||
yield
|
||||
logger.info("Arret %s", settings.APP_NAME)
|
||||
|
||||
|
||||
# ── FastAPI application ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
description="Plateforme de decisions collectives pour la communaute Duniter/G1",
|
||||
version="0.5.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# ── Middleware stack ──────────────────────────────────────────────────────
|
||||
# add_middleware is LIFO: last added = outermost = first to execute on request,
|
||||
# last to execute on response (wraps everything inside it).
|
||||
#
|
||||
# Required order so CORS headers appear on ALL responses including 429:
|
||||
# CORS (outermost) → RateLimiter → SecurityHeaders → Application
|
||||
#
|
||||
# If RateLimiter were outside CORS, its 429 responses would have no CORS
|
||||
# headers and the browser would silently discard them as network errors.
|
||||
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# Prototype mode: use RATE_LIMIT_DEFAULT for auth so demos/testing don't hit
|
||||
# the stricter RATE_LIMIT_AUTH (10/min). Set RATE_LIMIT_AUTH >= RATE_LIMIT_DEFAULT
|
||||
# in .env only when going to real production.
|
||||
_auth_rate_limit = settings.RATE_LIMIT_DEFAULT
|
||||
|
||||
app.add_middleware(
|
||||
RateLimiterMiddleware,
|
||||
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
|
||||
rate_limit_auth=_auth_rate_limit,
|
||||
rate_limit_vote=settings.RATE_LIMIT_VOTE,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ── Routers ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(documents.router, prefix="/api/v1/documents", tags=["documents"])
|
||||
app.include_router(decisions.router, prefix="/api/v1/decisions", tags=["decisions"])
|
||||
app.include_router(votes.router, prefix="/api/v1/votes", tags=["votes"])
|
||||
app.include_router(mandates.router, prefix="/api/v1/mandates", tags=["mandates"])
|
||||
app.include_router(protocols.router, prefix="/api/v1/protocols", tags=["protocols"])
|
||||
app.include_router(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"])
|
||||
app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
|
||||
app.include_router(public.router, prefix="/api/v1/public", tags=["public"])
|
||||
app.include_router(organizations.router, prefix="/api/v1/organizations", tags=["organizations"])
|
||||
app.include_router(qualify.router, prefix="/api/v1/qualify", tags=["qualify"])
|
||||
app.include_router(groups.router, prefix="/api/v1/groups", tags=["groups"])
|
||||
|
||||
|
||||
# ── Health check ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health(db: AsyncSession = Depends(get_db)):
|
||||
"""Health check endpoint that verifies database connectivity.
|
||||
|
||||
Returns status "ok" with database connection info if healthy,
|
||||
or status "degraded" if the database is unreachable.
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(text("SELECT 1"))
|
||||
result.scalar()
|
||||
db_status = "connected"
|
||||
except Exception as exc:
|
||||
logger.warning("Health check: base de donnees inaccessible - %s", exc)
|
||||
db_status = "disconnected"
|
||||
|
||||
overall_status = "ok" if db_status == "connected" else "degraded"
|
||||
|
||||
return {
|
||||
"status": overall_status,
|
||||
"environment": settings.ENVIRONMENT,
|
||||
"database": db_status,
|
||||
"version": "0.5.0",
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Rate limiter middleware: in-memory IP-based request throttling.
|
||||
|
||||
Tracks requests per IP address using a sliding window approach.
|
||||
Configurable limits per endpoint category (general, auth, vote).
|
||||
Returns 429 Too Many Requests with Retry-After header when exceeded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cleanup interval: remove expired entries every 5 minutes
|
||||
_CLEANUP_INTERVAL_SECONDS = 300
|
||||
|
||||
|
||||
class RateLimiterMiddleware(BaseHTTPMiddleware):
|
||||
"""In-memory rate limiter middleware.
|
||||
|
||||
Tracks request timestamps per IP and enforces configurable limits:
|
||||
- General endpoints: ``rate_limit_default`` requests/min
|
||||
- Auth endpoints (``/auth``): ``rate_limit_auth`` requests/min
|
||||
- Vote endpoints (``/vote``): ``rate_limit_vote`` requests/min
|
||||
|
||||
Adds standard rate-limit headers to all responses:
|
||||
- ``X-RateLimit-Limit``
|
||||
- ``X-RateLimit-Remaining``
|
||||
- ``X-RateLimit-Reset``
|
||||
|
||||
Parameters
|
||||
----------
|
||||
app:
|
||||
The ASGI application.
|
||||
rate_limit_default:
|
||||
Maximum requests per minute for general endpoints.
|
||||
rate_limit_auth:
|
||||
Maximum requests per minute for auth endpoints.
|
||||
rate_limit_vote:
|
||||
Maximum requests per minute for vote endpoints.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
rate_limit_default: int = 60,
|
||||
rate_limit_auth: int = 10,
|
||||
rate_limit_vote: int = 30,
|
||||
) -> None:
|
||||
super().__init__(app)
|
||||
self.rate_limit_default = rate_limit_default
|
||||
self.rate_limit_auth = rate_limit_auth
|
||||
self.rate_limit_vote = rate_limit_vote
|
||||
|
||||
# IP -> list of timestamps (epoch seconds)
|
||||
self._requests: dict[str, list[float]] = defaultdict(list)
|
||||
self._last_cleanup: float = time.time()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def _get_client_ip(self, request: Request) -> str:
|
||||
"""Extract the client IP from the request, respecting X-Forwarded-For."""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
async def _cleanup_old_entries(self) -> None:
|
||||
"""Remove request timestamps older than 60 seconds for all IPs."""
|
||||
now = time.time()
|
||||
if now - self._last_cleanup < _CLEANUP_INTERVAL_SECONDS:
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
cutoff = now - 60
|
||||
ips_to_delete: list[str] = []
|
||||
|
||||
for ip, timestamps in self._requests.items():
|
||||
self._requests[ip] = [t for t in timestamps if t > cutoff]
|
||||
if not self._requests[ip]:
|
||||
ips_to_delete.append(ip)
|
||||
|
||||
for ip in ips_to_delete:
|
||||
del self._requests[ip]
|
||||
|
||||
self._last_cleanup = now
|
||||
if ips_to_delete:
|
||||
logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(ips_to_delete))
|
||||
|
||||
def _get_limit_for_request(self, request: Request) -> int:
|
||||
"""Return the rate limit applicable to the given request.
|
||||
|
||||
CORS preflight (OPTIONS) requests are never rate-limited — blocking them
|
||||
breaks authenticated cross-origin requests in browsers.
|
||||
Strict auth limit applies only to POST (login flows), not to GET /auth/me.
|
||||
"""
|
||||
if request.method == "OPTIONS":
|
||||
return 10_000 # effectively unlimited for preflights
|
||||
path = request.url.path
|
||||
if request.method == "POST" and "/auth" in path:
|
||||
return self.rate_limit_auth
|
||||
if "/vote" in path:
|
||||
return self.rate_limit_vote
|
||||
return self.rate_limit_default
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
"""Check rate limit and either allow the request or return 429."""
|
||||
# Skip rate limiting for WebSocket upgrades
|
||||
if request.headers.get("upgrade", "").lower() == "websocket":
|
||||
return await call_next(request)
|
||||
|
||||
# Trigger periodic cleanup
|
||||
await self._cleanup_old_entries()
|
||||
|
||||
client_ip = self._get_client_ip(request)
|
||||
limit = self._get_limit_for_request(request)
|
||||
now = time.time()
|
||||
window_start = now - 60
|
||||
|
||||
async with self._lock:
|
||||
# Filter to requests within the last 60 seconds
|
||||
self._requests[client_ip] = [
|
||||
t for t in self._requests[client_ip] if t > window_start
|
||||
]
|
||||
request_count = len(self._requests[client_ip])
|
||||
|
||||
if request_count >= limit:
|
||||
# Calculate when the oldest request in the window expires
|
||||
oldest = min(self._requests[client_ip]) if self._requests[client_ip] else now
|
||||
retry_after = int(oldest + 60 - now) + 1
|
||||
retry_after = max(retry_after, 1)
|
||||
|
||||
reset_at = int(oldest + 60)
|
||||
|
||||
logger.warning(
|
||||
"Rate limit depasse pour %s sur %s (%d/%d)",
|
||||
client_ip, request.url.path, request_count, limit,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "Trop de requetes. Veuillez reessayer plus tard."},
|
||||
headers={
|
||||
"Retry-After": str(retry_after),
|
||||
"X-RateLimit-Limit": str(limit),
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": str(reset_at),
|
||||
},
|
||||
)
|
||||
|
||||
# Record this request
|
||||
self._requests[client_ip].append(now)
|
||||
remaining = max(0, limit - request_count - 1)
|
||||
reset_at = int(now + 60)
|
||||
|
||||
# Process the request
|
||||
response = await call_next(request)
|
||||
|
||||
# Add rate limit headers to the response
|
||||
response.headers["X-RateLimit-Limit"] = str(limit)
|
||||
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||
response.headers["X-RateLimit-Reset"] = str(reset_at)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Security headers middleware: adds hardening headers to all responses.
|
||||
|
||||
Applies OWASP-recommended security headers to prevent common
|
||||
web vulnerabilities (clickjacking, MIME sniffing, XSS, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Add security headers to all HTTP responses.
|
||||
|
||||
Headers applied:
|
||||
- ``X-Content-Type-Options: nosniff``
|
||||
- ``X-Frame-Options: DENY``
|
||||
- ``X-XSS-Protection: 1; mode=block``
|
||||
- ``Referrer-Policy: strict-origin-when-cross-origin``
|
||||
- ``Content-Security-Policy: default-src 'self'``
|
||||
- ``Strict-Transport-Security: max-age=31536000; includeSubDomains``
|
||||
(only when the request was made over HTTPS)
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
response = await call_next(request)
|
||||
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Content-Security-Policy"] = "default-src 'self'"
|
||||
|
||||
# Only add HSTS header for HTTPS requests
|
||||
if request.url.scheme == "https":
|
||||
response.headers["Strict-Transport-Security"] = (
|
||||
"max-age=31536000; includeSubDomains"
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,25 @@
|
||||
from app.models.user import DuniterIdentity, Session
|
||||
from app.models.organization import Organization, OrgMember
|
||||
from app.models.document import Document, DocumentItem, ItemVersion
|
||||
from app.models.decision import Decision, DecisionStep
|
||||
from app.models.vote import VoteSession, Vote
|
||||
from app.models.mandate import Mandate, MandateStep
|
||||
from app.models.protocol import VotingProtocol, FormulaConfig
|
||||
from app.models.qualification import QualificationProtocol
|
||||
from app.models.group import Group, GroupMember
|
||||
from app.models.sanctuary import SanctuaryEntry
|
||||
from app.models.cache import BlockchainCache
|
||||
|
||||
__all__ = [
|
||||
"DuniterIdentity", "Session",
|
||||
"Organization", "OrgMember",
|
||||
"Document", "DocumentItem", "ItemVersion",
|
||||
"Decision", "DecisionStep",
|
||||
"VoteSession", "Vote",
|
||||
"Mandate", "MandateStep",
|
||||
"VotingProtocol", "FormulaConfig",
|
||||
"QualificationProtocol",
|
||||
"Group", "GroupMember",
|
||||
"SanctuaryEntry",
|
||||
"BlockchainCache",
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, JSON, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class BlockchainCache(Base):
|
||||
__tablename__ = "blockchain_cache"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
cache_key: Mapped[str] = mapped_column(String(256), unique=True, nullable=False, index=True)
|
||||
cache_value: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
@@ -0,0 +1,42 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Decision(Base):
|
||||
__tablename__ = "decisions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
description: 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
|
||||
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, qualification, review, voting, executed, closed
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
|
||||
voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id"))
|
||||
created_by_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
steps: Mapped[list["DecisionStep"]] = relationship(back_populates="decision", cascade="all, delete-orphan", order_by="DecisionStep.step_order")
|
||||
|
||||
|
||||
class DecisionStep(Base):
|
||||
__tablename__ = "decision_steps"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
decision_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("decisions.id"), nullable=False)
|
||||
step_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
step_type: Mapped[str] = mapped_column(String(32), nullable=False) # qualification, review, vote, execution, reporting
|
||||
title: Mapped[str | None] = mapped_column(String(256))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending") # pending, active, completed, skipped
|
||||
vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id"))
|
||||
outcome: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
decision: Mapped["Decision"] = relationship(back_populates="steps")
|
||||
@@ -0,0 +1,64 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, Boolean, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Document(Base):
|
||||
__tablename__ = "documents"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
doc_type: Mapped[str] = mapped_column(String(64), nullable=False) # licence, engagement, reglement, constitution
|
||||
version: Mapped[str] = mapped_column(String(32), default="0.1.0")
|
||||
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
|
||||
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
|
||||
chain_anchor: Mapped[str | None] = mapped_column(String(128))
|
||||
genesis_json: Mapped[str | None] = mapped_column(Text) # JSON: source files, repos, forum URLs, formula trigger
|
||||
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())
|
||||
|
||||
items: Mapped[list["DocumentItem"]] = relationship(back_populates="document", cascade="all, delete-orphan", order_by="DocumentItem.sort_order")
|
||||
|
||||
|
||||
class DocumentItem(Base):
|
||||
__tablename__ = "document_items"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
document_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("documents.id"), nullable=False)
|
||||
position: Mapped[str] = mapped_column(String(16), nullable=False) # "1", "1.1", "3.2"
|
||||
item_type: Mapped[str] = mapped_column(String(32), default="clause") # clause, rule, verification, preamble, section, genesis
|
||||
title: Mapped[str | None] = mapped_column(String(256))
|
||||
current_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id"))
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
section_tag: Mapped[str | None] = mapped_column(String(64)) # genesis, fondamental, technique, annexe, formule, inertie, ordonnancement
|
||||
inertia_preset: Mapped[str] = mapped_column(String(16), default="standard") # low, standard, high, very_high
|
||||
is_permanent_vote: Mapped[bool] = mapped_column(default=True) # permanent vote vs time-bounded
|
||||
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())
|
||||
|
||||
document: Mapped["Document"] = relationship(back_populates="items")
|
||||
versions: Mapped[list["ItemVersion"]] = relationship(back_populates="item", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ItemVersion(Base):
|
||||
__tablename__ = "item_versions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
item_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("document_items.id"), nullable=False)
|
||||
proposed_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
diff_text: Mapped[str | None] = mapped_column(Text)
|
||||
rationale: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(32), default="proposed") # proposed, voting, accepted, rejected
|
||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
|
||||
proposed_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())
|
||||
|
||||
item: Mapped["DocumentItem"] = relationship(back_populates="versions")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,68 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Mandate(Base):
|
||||
__tablename__ = "mandates"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
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)
|
||||
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), default="draft")
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
|
||||
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
|
||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
|
||||
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=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())
|
||||
|
||||
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):
|
||||
__tablename__ = "mandate_steps"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False)
|
||||
step_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
step_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
title: Mapped[str | None] = mapped_column(String(256))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id"))
|
||||
outcome: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
mandate: Mapped["Mandate"] = relationship(back_populates="steps")
|
||||
|
||||
|
||||
# Avoid circular import — DuniterIdentity imported at runtime by SQLAlchemy relationship resolution
|
||||
from app.models.user import DuniterIdentity # noqa: E402, F401
|
||||
@@ -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")
|
||||
@@ -0,0 +1,52 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Float, Boolean, DateTime, ForeignKey, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class FormulaConfig(Base):
|
||||
__tablename__ = "formula_configs"
|
||||
|
||||
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)
|
||||
|
||||
# WoT threshold params
|
||||
duration_days: Mapped[int] = mapped_column(Integer, default=30)
|
||||
majority_pct: Mapped[int] = mapped_column(Integer, default=50)
|
||||
base_exponent: Mapped[float] = mapped_column(Float, default=0.1)
|
||||
gradient_exponent: Mapped[float] = mapped_column(Float, default=0.2)
|
||||
constant_base: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
# Smith criterion
|
||||
smith_exponent: Mapped[float | None] = mapped_column(Float)
|
||||
|
||||
# TechComm criterion
|
||||
techcomm_exponent: Mapped[float | None] = mapped_column(Float)
|
||||
|
||||
# Nuanced vote
|
||||
nuanced_min_participants: Mapped[int | None] = mapped_column(Integer)
|
||||
nuanced_threshold_pct: Mapped[int | None] = mapped_column(Integer)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
protocols: Mapped[list["VotingProtocol"]] = relationship(back_populates="formula_config")
|
||||
|
||||
|
||||
class VotingProtocol(Base):
|
||||
__tablename__ = "voting_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)
|
||||
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced
|
||||
formula_config_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("formula_configs.id"), nullable=False)
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
|
||||
mode_params: Mapped[str | None] = mapped_column(String(64)) # e.g. "D30M50B.1G.2T.1"
|
||||
is_meta_governed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
formula_config: Mapped["FormulaConfig"] = relationship(back_populates="protocols")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,22 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Text, DateTime, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class SanctuaryEntry(Base):
|
||||
__tablename__ = "sanctuary_entries"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
entry_type: Mapped[str] = mapped_column(String(64), nullable=False) # document, decision, vote_result
|
||||
reference_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
|
||||
title: Mapped[str | None] = mapped_column(String(256))
|
||||
content_hash: Mapped[str] = mapped_column(String(128), nullable=False) # SHA-256
|
||||
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
|
||||
chain_tx_hash: Mapped[str | None] = mapped_column(String(128))
|
||||
chain_block: Mapped[int | None] = mapped_column(Integer)
|
||||
metadata_json: Mapped[str | None] = mapped_column(Text) # JSON string for extra data
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,34 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class DuniterIdentity(Base):
|
||||
__tablename__ = "duniter_identities"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
address: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(128))
|
||||
wot_status: Mapped[str] = mapped_column(String(32), default="unknown") # member, pending, revoked, unknown
|
||||
is_smith: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_techcomm: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
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())
|
||||
|
||||
sessions: Mapped[list["Session"]] = relationship(back_populates="identity", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
identity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("duniter_identities.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
identity: Mapped["DuniterIdentity"] = relationship(back_populates="sessions")
|
||||
@@ -0,0 +1,70 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Float, Boolean, Text, DateTime, ForeignKey, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class VoteSession(Base):
|
||||
__tablename__ = "vote_sessions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
|
||||
item_version_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("item_versions.id"))
|
||||
voting_protocol_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("voting_protocols.id"), nullable=False)
|
||||
|
||||
# Snapshot at session start
|
||||
wot_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
smith_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
techcomm_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Dates
|
||||
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
# Status
|
||||
status: Mapped[str] = mapped_column(String(32), default="open") # open, closed, tallied
|
||||
|
||||
# Tallies
|
||||
votes_for: Mapped[int] = mapped_column(Integer, default=0)
|
||||
votes_against: Mapped[int] = mapped_column(Integer, default=0)
|
||||
votes_total: Mapped[int] = mapped_column(Integer, default=0)
|
||||
smith_votes_for: Mapped[int] = mapped_column(Integer, default=0)
|
||||
techcomm_votes_for: Mapped[int] = mapped_column(Integer, default=0)
|
||||
threshold_required: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
result: Mapped[str | None] = mapped_column(String(32)) # adopted, rejected, null
|
||||
|
||||
# Chain recording
|
||||
chain_recorded: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
chain_tx_hash: Mapped[str | None] = mapped_column(String(128))
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
votes: Mapped[list["Vote"]] = relationship(back_populates="session", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Vote(Base):
|
||||
__tablename__ = "votes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("vote_sessions.id"), nullable=False)
|
||||
voter_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("duniter_identities.id"), nullable=False)
|
||||
vote_value: Mapped[str] = mapped_column(String(32), nullable=False) # for, against, or nuanced levels
|
||||
nuanced_level: Mapped[int | None] = mapped_column(Integer) # 0-5 for nuanced votes
|
||||
comment: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# Cryptographic proof
|
||||
signature: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
signed_payload: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# Voter status snapshot
|
||||
voter_wot_status: Mapped[str] = mapped_column(String(32), default="member")
|
||||
voter_is_smith: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
voter_is_techcomm: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
session: Mapped["VoteSession"] = relationship(back_populates="votes")
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Auth router: Ed25519 challenge-response authentication for Duniter V2 identities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.schemas.auth import (
|
||||
ChallengeRequest,
|
||||
ChallengeResponse,
|
||||
IdentityOut,
|
||||
TokenResponse,
|
||||
VerifyRequest,
|
||||
)
|
||||
from app.services.auth_service import (
|
||||
create_session,
|
||||
get_current_identity,
|
||||
get_or_create_identity,
|
||||
invalidate_session,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ── Dev profiles (only available when ENVIRONMENT == "development") ─────────
|
||||
DEV_PROFILES = [
|
||||
{
|
||||
"address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
|
||||
"display_name": "Alice (Membre WoT)",
|
||||
"wot_status": "member",
|
||||
"is_smith": False,
|
||||
"is_techcomm": False,
|
||||
},
|
||||
{
|
||||
"address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty",
|
||||
"display_name": "Bob (Forgeron)",
|
||||
"wot_status": "member",
|
||||
"is_smith": True,
|
||||
"is_techcomm": False,
|
||||
},
|
||||
{
|
||||
"address": "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmkP7j4bJa3zN7d8tY",
|
||||
"display_name": "Charlie (Référent structure)",
|
||||
"wot_status": "member",
|
||||
"is_smith": True,
|
||||
"is_techcomm": True,
|
||||
},
|
||||
{
|
||||
"address": "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy",
|
||||
"display_name": "Dave (Auteur)",
|
||||
"wot_status": "member",
|
||||
"is_smith": False,
|
||||
"is_techcomm": False,
|
||||
},
|
||||
]
|
||||
|
||||
# ── In-memory challenge store (short-lived, no persistence needed) ──────────
|
||||
# Structure: { address: { "challenge": str, "expires_at": datetime } }
|
||||
_pending_challenges: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _cleanup_expired_challenges() -> None:
|
||||
"""Remove expired challenges from the in-memory store."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = [addr for addr, data in _pending_challenges.items() if data["expires_at"] < now]
|
||||
for addr in expired:
|
||||
del _pending_challenges[addr]
|
||||
|
||||
|
||||
# ── Routes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/challenge", response_model=ChallengeResponse)
|
||||
async def request_challenge(payload: ChallengeRequest) -> ChallengeResponse:
|
||||
"""Generate a random Ed25519 challenge for the given Duniter address.
|
||||
|
||||
The client must sign this challenge with the private key corresponding
|
||||
to the address, then submit it via POST /verify.
|
||||
"""
|
||||
_cleanup_expired_challenges()
|
||||
|
||||
challenge = secrets.token_hex(32)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(seconds=settings.CHALLENGE_EXPIRE_SECONDS)
|
||||
|
||||
_pending_challenges[payload.address] = {
|
||||
"challenge": challenge,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
return ChallengeResponse(challenge=challenge, expires_at=expires_at)
|
||||
|
||||
|
||||
@router.post("/verify", response_model=TokenResponse)
|
||||
async def verify_challenge(
|
||||
payload: VerifyRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
"""Verify the Ed25519 signature of a challenge and return a session token.
|
||||
|
||||
Steps:
|
||||
1. Check that a pending challenge exists for the address.
|
||||
2. Verify the challenge string matches.
|
||||
3. Verify the Ed25519 signature against the address public key.
|
||||
4. Create or retrieve the DuniterIdentity.
|
||||
5. Create a session and return the bearer token.
|
||||
"""
|
||||
# 1. Retrieve pending challenge
|
||||
pending = _pending_challenges.get(payload.address)
|
||||
if pending is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Aucun challenge en attente pour cette adresse",
|
||||
)
|
||||
|
||||
# 2. Check expiry
|
||||
if pending["expires_at"] < datetime.now(timezone.utc):
|
||||
del _pending_challenges[payload.address]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Challenge expire, veuillez en demander un nouveau",
|
||||
)
|
||||
|
||||
# 3. Verify challenge string matches
|
||||
if pending["challenge"] != payload.challenge:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Challenge invalide",
|
||||
)
|
||||
|
||||
# 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}
|
||||
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses
|
||||
|
||||
if not is_demo_bypass:
|
||||
# polkadot.js / Cesium2 signRaw(type='bytes') wraps: <Bytes>{challenge}</Bytes>
|
||||
message = f"<Bytes>{payload.challenge}</Bytes>".encode("utf-8")
|
||||
sig_hex = payload.signature.removeprefix("0x")
|
||||
try:
|
||||
sig_bytes = bytes.fromhex(sig_hex)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Format de signature invalide (hex attendu)",
|
||||
)
|
||||
|
||||
from substrateinterface import Keypair, KeypairType
|
||||
|
||||
verified = False
|
||||
# Try Sr25519 first (default Substrate/Cesium2), then Ed25519 (Duniter v1 migration)
|
||||
for key_type in [KeypairType.SR25519, KeypairType.ED25519]:
|
||||
try:
|
||||
kp = Keypair(ss58_address=payload.address, crypto_type=key_type)
|
||||
if kp.verify(message, sig_bytes):
|
||||
verified = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Signature invalide",
|
||||
)
|
||||
|
||||
# 5. Consume the challenge
|
||||
del _pending_challenges[payload.address]
|
||||
|
||||
# 6. Get or create identity (apply dev profile if available)
|
||||
dev_profile = None
|
||||
if settings.ENVIRONMENT == "development" or settings.DEMO_MODE:
|
||||
dev_profile = next((p for p in DEV_PROFILES if p["address"] == payload.address), None)
|
||||
identity = await get_or_create_identity(db, payload.address, dev_profile=dev_profile)
|
||||
|
||||
# 7. Create session token
|
||||
token = await create_session(db, identity)
|
||||
|
||||
return TokenResponse(
|
||||
token=token,
|
||||
identity=IdentityOut.model_validate(identity),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dev/profiles")
|
||||
async def list_dev_profiles():
|
||||
"""List available demo profiles for quick login. Available in development or demo mode."""
|
||||
if settings.ENVIRONMENT != "development" and not settings.DEMO_MODE:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not available")
|
||||
return DEV_PROFILES
|
||||
|
||||
|
||||
@router.get("/me", response_model=IdentityOut)
|
||||
async def get_me(
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> IdentityOut:
|
||||
"""Return the currently authenticated identity."""
|
||||
return IdentityOut.model_validate(identity)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
|
||||
async def logout(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
):
|
||||
"""Invalidate the current session token.
|
||||
|
||||
Note: get_current_identity already validated the token, so we know it exists.
|
||||
We re-extract it from the Authorization header to invalidate it.
|
||||
"""
|
||||
# We need the raw token to invalidate -- re-extract from the dependency chain.
|
||||
# Since get_current_identity already validated, we know the request has a valid Bearer token.
|
||||
# We use a slightly different approach: delete all sessions for this identity
|
||||
# that match. For a cleaner approach, we accept the token via a dedicated dependency.
|
||||
from fastapi import Request
|
||||
|
||||
# This is handled by getting the token from the auth service
|
||||
# For simplicity, we delete all sessions for the identity
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.user import Session
|
||||
|
||||
result = await db.execute(select(Session).where(Session.identity_id == identity.id))
|
||||
sessions = result.scalars().all()
|
||||
for session in sessions:
|
||||
await db.delete(session)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/identities", response_model=list[IdentityOut])
|
||||
async def search_identities(
|
||||
q: str = Query(..., min_length=1, description="Recherche par adresse ou nom"),
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[IdentityOut]:
|
||||
"""Search Duniter identities by address prefix or display_name."""
|
||||
result = await db.execute(
|
||||
select(DuniterIdentity)
|
||||
.where(
|
||||
or_(
|
||||
DuniterIdentity.address.ilike(f"{q}%"),
|
||||
DuniterIdentity.display_name.ilike(f"%{q}%"),
|
||||
)
|
||||
)
|
||||
.order_by(DuniterIdentity.display_name)
|
||||
.limit(limit)
|
||||
)
|
||||
return [IdentityOut.model_validate(i) for i in result.scalars().all()]
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Decisions router: CRUD for decision processes and their steps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.decision import Decision, DecisionStep
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.schemas.decision import (
|
||||
DecisionAdvanceOut,
|
||||
DecisionCreate,
|
||||
DecisionOut,
|
||||
DecisionStepCreate,
|
||||
DecisionStepOut,
|
||||
DecisionUpdate,
|
||||
)
|
||||
from app.schemas.vote import VoteSessionOut
|
||||
from app.dependencies.org import get_active_org_id
|
||||
from app.services.auth_service import get_current_identity
|
||||
from app.services.decision_service import advance_decision, create_vote_session_for_step
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_decision(
|
||||
db: AsyncSession, decision_id: uuid.UUID, org_id: uuid.UUID | None = None
|
||||
) -> Decision:
|
||||
"""Fetch a decision by ID within the active org scope, or raise 404."""
|
||||
stmt = (
|
||||
select(Decision)
|
||||
.options(selectinload(Decision.steps))
|
||||
.where(Decision.id == decision_id)
|
||||
)
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Decision.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(Decision.organization_id.is_(None))
|
||||
result = await db.execute(stmt)
|
||||
decision = result.scalar_one_or_none()
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Decision introuvable")
|
||||
return decision
|
||||
|
||||
|
||||
# ── Decision routes ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DecisionOut])
|
||||
async def list_decisions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
decision_type: str | None = Query(default=None, description="Filtrer par type de decision"),
|
||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[DecisionOut]:
|
||||
"""List all decisions with optional filters."""
|
||||
stmt = select(Decision).options(selectinload(Decision.steps))
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Decision.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(Decision.organization_id.is_(None))
|
||||
if decision_type is not None:
|
||||
stmt = stmt.where(Decision.decision_type == decision_type)
|
||||
if status_filter is not None:
|
||||
stmt = stmt.where(Decision.status == status_filter)
|
||||
|
||||
stmt = stmt.order_by(Decision.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
decisions = result.scalars().unique().all()
|
||||
|
||||
return [DecisionOut.model_validate(d) for d in decisions]
|
||||
|
||||
|
||||
@router.post("/", response_model=DecisionOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_decision(
|
||||
payload: DecisionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> DecisionOut:
|
||||
"""Create a new decision process."""
|
||||
decision = Decision(
|
||||
**payload.model_dump(),
|
||||
created_by_id=identity.id,
|
||||
organization_id=org_id,
|
||||
)
|
||||
db.add(decision)
|
||||
await db.commit()
|
||||
await db.refresh(decision)
|
||||
|
||||
# Reload with steps (empty at creation)
|
||||
decision = await _get_decision(db, decision.id, org_id)
|
||||
return DecisionOut.model_validate(decision)
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=DecisionOut)
|
||||
async def get_decision(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> DecisionOut:
|
||||
"""Get a single decision with all its steps."""
|
||||
decision = await _get_decision(db, id, org_id)
|
||||
return DecisionOut.model_validate(decision)
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=DecisionOut)
|
||||
async def update_decision(
|
||||
id: uuid.UUID,
|
||||
payload: DecisionUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> DecisionOut:
|
||||
"""Update a decision's metadata (title, description, status, protocol)."""
|
||||
decision = await _get_decision(db, id, org_id)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(decision, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(decision)
|
||||
|
||||
# Reload with steps
|
||||
decision = await _get_decision(db, decision.id, org_id)
|
||||
return DecisionOut.model_validate(decision)
|
||||
|
||||
|
||||
# ── Decision Step routes ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{id}/steps", response_model=DecisionStepOut, status_code=status.HTTP_201_CREATED)
|
||||
async def add_step(
|
||||
id: uuid.UUID,
|
||||
payload: DecisionStepCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> DecisionStepOut:
|
||||
"""Add a step to a decision process."""
|
||||
# Verify decision exists
|
||||
decision = await _get_decision(db, id, org_id)
|
||||
|
||||
step = DecisionStep(
|
||||
decision_id=decision.id,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
db.add(step)
|
||||
await db.commit()
|
||||
await db.refresh(step)
|
||||
|
||||
return DecisionStepOut.model_validate(step)
|
||||
|
||||
|
||||
# ── Workflow routes ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{id}/advance", response_model=DecisionAdvanceOut)
|
||||
async def advance_decision_endpoint(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> DecisionAdvanceOut:
|
||||
"""Advance a decision to its next step or status."""
|
||||
try:
|
||||
decision = await advance_decision(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
# Reload with steps for complete output
|
||||
decision = await _get_decision(db, decision.id, org_id)
|
||||
data = DecisionOut.model_validate(decision).model_dump()
|
||||
data["message"] = f"Decision avancee au statut : {decision.status}"
|
||||
return DecisionAdvanceOut(**data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{id}/steps/{step_id}/create-vote-session",
|
||||
response_model=VoteSessionOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_vote_session_for_step_endpoint(
|
||||
id: uuid.UUID,
|
||||
step_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> VoteSessionOut:
|
||||
"""Create a vote session linked to a decision step."""
|
||||
try:
|
||||
session = await create_vote_session_for_step(id, step_id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
return VoteSessionOut.model_validate(session)
|
||||
|
||||
|
||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
|
||||
async def delete_decision(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> None:
|
||||
"""Delete a decision (only if in draft status)."""
|
||||
decision = await _get_decision(db, id, org_id)
|
||||
|
||||
if decision.status != "draft":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Seules les decisions en brouillon peuvent etre supprimees",
|
||||
)
|
||||
|
||||
await db.delete(decision)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,474 @@
|
||||
"""Documents router: CRUD for reference documents, items, and item versions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.document import Document, DocumentItem, ItemVersion
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.schemas.document import (
|
||||
DocumentCreate,
|
||||
DocumentFullOut,
|
||||
DocumentItemCreate,
|
||||
DocumentItemOut,
|
||||
DocumentItemUpdate,
|
||||
DocumentOut,
|
||||
DocumentUpdate,
|
||||
ItemReorderRequest,
|
||||
ItemVersionCreate,
|
||||
ItemVersionOut,
|
||||
)
|
||||
from app.dependencies.org import get_active_org_id
|
||||
from app.services import document_service
|
||||
from app.services.auth_service import get_current_identity
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_document_by_slug(db: AsyncSession, slug: str) -> Document:
|
||||
"""Fetch a document by slug or raise 404."""
|
||||
result = await db.execute(select(Document).where(Document.slug == slug))
|
||||
doc = result.scalar_one_or_none()
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document introuvable")
|
||||
return doc
|
||||
|
||||
|
||||
async def _get_item(db: AsyncSession, document_id: uuid.UUID, item_id: uuid.UUID) -> DocumentItem:
|
||||
"""Fetch a document item by ID within a document, or raise 404."""
|
||||
result = await db.execute(
|
||||
select(DocumentItem).where(
|
||||
DocumentItem.id == item_id,
|
||||
DocumentItem.document_id == document_id,
|
||||
)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item introuvable")
|
||||
return item
|
||||
|
||||
|
||||
# ── Document routes ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DocumentOut])
|
||||
async def list_documents(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
|
||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[DocumentOut]:
|
||||
"""List all reference documents, with optional filters."""
|
||||
stmt = select(Document)
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Document.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(Document.organization_id.is_(None))
|
||||
if doc_type is not None:
|
||||
stmt = stmt.where(Document.doc_type == doc_type)
|
||||
if status_filter is not None:
|
||||
stmt = stmt.where(Document.status == status_filter)
|
||||
|
||||
stmt = stmt.order_by(Document.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
documents = result.scalars().all()
|
||||
|
||||
# Compute items_count for each document
|
||||
out = []
|
||||
for doc in documents:
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id)
|
||||
)
|
||||
items_count = count_result.scalar() or 0
|
||||
doc_out = DocumentOut.model_validate(doc)
|
||||
doc_out.items_count = items_count
|
||||
out.append(doc_out)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/", response_model=DocumentOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_document(
|
||||
payload: DocumentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> DocumentOut:
|
||||
"""Create a new reference document."""
|
||||
# Check slug uniqueness
|
||||
existing = await db.execute(select(Document).where(Document.slug == payload.slug))
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Un document avec ce slug existe deja",
|
||||
)
|
||||
|
||||
doc = Document(**payload.model_dump(), organization_id=org_id)
|
||||
db.add(doc)
|
||||
await db.commit()
|
||||
await db.refresh(doc)
|
||||
|
||||
doc_out = DocumentOut.model_validate(doc)
|
||||
doc_out.items_count = 0
|
||||
return doc_out
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=DocumentOut)
|
||||
async def get_document(
|
||||
slug: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentOut:
|
||||
"""Get a single document by its slug."""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id)
|
||||
)
|
||||
items_count = count_result.scalar() or 0
|
||||
|
||||
doc_out = DocumentOut.model_validate(doc)
|
||||
doc_out.items_count = items_count
|
||||
return doc_out
|
||||
|
||||
|
||||
@router.put("/{slug}", response_model=DocumentOut)
|
||||
async def update_document(
|
||||
slug: str,
|
||||
payload: DocumentUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> DocumentOut:
|
||||
"""Update a document's metadata (title, status, description, version)."""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(doc, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(doc)
|
||||
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id)
|
||||
)
|
||||
items_count = count_result.scalar() or 0
|
||||
|
||||
doc_out = DocumentOut.model_validate(doc)
|
||||
doc_out.items_count = items_count
|
||||
return doc_out
|
||||
|
||||
|
||||
# ── Document Item routes ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{slug}/items", response_model=DocumentItemOut, status_code=status.HTTP_201_CREATED)
|
||||
async def add_item(
|
||||
slug: str,
|
||||
payload: DocumentItemCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> DocumentItemOut:
|
||||
"""Add a new item (clause, rule, etc.) to a document."""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
|
||||
# Determine sort_order: max existing + 1
|
||||
max_order_result = await db.execute(
|
||||
select(func.max(DocumentItem.sort_order)).where(DocumentItem.document_id == doc.id)
|
||||
)
|
||||
max_order = max_order_result.scalar() or 0
|
||||
|
||||
item = DocumentItem(
|
||||
document_id=doc.id,
|
||||
sort_order=max_order + 1,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
return DocumentItemOut.model_validate(item)
|
||||
|
||||
|
||||
@router.get("/{slug}/items", response_model=list[DocumentItemOut])
|
||||
async def list_items(
|
||||
slug: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[DocumentItemOut]:
|
||||
"""List all items in a document, ordered by sort_order."""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
|
||||
result = await db.execute(
|
||||
select(DocumentItem)
|
||||
.where(DocumentItem.document_id == doc.id)
|
||||
.order_by(DocumentItem.sort_order)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return [DocumentItemOut.model_validate(item) for item in items]
|
||||
|
||||
|
||||
# NOTE: reorder must be declared BEFORE /{slug}/items/{item_id} routes
|
||||
# to avoid "reorder" being parsed as a UUID path parameter.
|
||||
|
||||
|
||||
@router.put("/{slug}/items/reorder", response_model=list[DocumentItemOut])
|
||||
async def reorder_items(
|
||||
slug: str,
|
||||
payload: ItemReorderRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> list[DocumentItemOut]:
|
||||
"""Reorder items in a document by updating their sort_order values."""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
|
||||
items = await document_service.reorder_items(
|
||||
doc.id,
|
||||
[(entry.item_id, entry.sort_order) for entry in payload.items],
|
||||
db,
|
||||
)
|
||||
|
||||
return [DocumentItemOut.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get("/{slug}/items/{item_id}", response_model=DocumentItemOut)
|
||||
async def get_item(
|
||||
slug: str,
|
||||
item_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentItemOut:
|
||||
"""Get a single item with its version history."""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
item = await _get_item(db, doc.id, item_id)
|
||||
return DocumentItemOut.model_validate(item)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{slug}/items/{item_id}/versions",
|
||||
response_model=ItemVersionOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def propose_version(
|
||||
slug: str,
|
||||
item_id: uuid.UUID,
|
||||
payload: ItemVersionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> ItemVersionOut:
|
||||
"""Propose a new version of a document item.
|
||||
|
||||
Automatically computes a unified diff between the current text and the proposed text.
|
||||
"""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
item = await _get_item(db, doc.id, item_id)
|
||||
|
||||
# Compute diff
|
||||
diff_lines = difflib.unified_diff(
|
||||
item.current_text.splitlines(keepends=True),
|
||||
payload.proposed_text.splitlines(keepends=True),
|
||||
fromfile="actuel",
|
||||
tofile="propose",
|
||||
)
|
||||
diff_text = "".join(diff_lines) or None
|
||||
|
||||
version = ItemVersion(
|
||||
item_id=item.id,
|
||||
proposed_text=payload.proposed_text,
|
||||
diff_text=diff_text,
|
||||
rationale=payload.rationale,
|
||||
proposed_by_id=identity.id,
|
||||
)
|
||||
db.add(version)
|
||||
await db.commit()
|
||||
await db.refresh(version)
|
||||
|
||||
return ItemVersionOut.model_validate(version)
|
||||
|
||||
|
||||
# ── Item update & delete ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.put("/{slug}/items/{item_id}", response_model=DocumentItemOut)
|
||||
async def update_item(
|
||||
slug: str,
|
||||
item_id: uuid.UUID,
|
||||
payload: DocumentItemUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> DocumentItemOut:
|
||||
"""Update an item's text, title, position, or item_type."""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
item = await _get_item(db, doc.id, item_id)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(item, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
return DocumentItemOut.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/{slug}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
|
||||
async def delete_item(
|
||||
slug: str,
|
||||
item_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> None:
|
||||
"""Delete a document item.
|
||||
|
||||
Refuses deletion if the item has any active votes (status 'voting').
|
||||
"""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
item = await _get_item(db, doc.id, item_id)
|
||||
|
||||
# Check for active votes on this item's versions
|
||||
active_versions_result = await db.execute(
|
||||
select(func.count()).select_from(ItemVersion).where(
|
||||
ItemVersion.item_id == item.id,
|
||||
ItemVersion.status == "voting",
|
||||
)
|
||||
)
|
||||
active_count = active_versions_result.scalar() or 0
|
||||
if active_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Impossible de supprimer un element avec des votes en cours",
|
||||
)
|
||||
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Version accept & reject ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{slug}/items/{item_id}/versions/{version_id}/accept",
|
||||
response_model=DocumentItemOut,
|
||||
)
|
||||
async def accept_version(
|
||||
slug: str,
|
||||
item_id: uuid.UUID,
|
||||
version_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> DocumentItemOut:
|
||||
"""Accept a proposed version and apply it to the document item.
|
||||
|
||||
Replaces the item's current_text with the version's proposed_text
|
||||
and rejects all other pending/voting versions for this item.
|
||||
"""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
# Verify item belongs to document
|
||||
await _get_item(db, doc.id, item_id)
|
||||
|
||||
try:
|
||||
updated_item = await document_service.apply_version(item_id, version_id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
return DocumentItemOut.model_validate(updated_item)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{slug}/items/{item_id}/versions/{version_id}/reject",
|
||||
response_model=ItemVersionOut,
|
||||
)
|
||||
async def reject_version(
|
||||
slug: str,
|
||||
item_id: uuid.UUID,
|
||||
version_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> ItemVersionOut:
|
||||
"""Reject a proposed version."""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
# Verify item belongs to document
|
||||
await _get_item(db, doc.id, item_id)
|
||||
|
||||
try:
|
||||
version = await document_service.reject_version(item_id, version_id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
return ItemVersionOut.model_validate(version)
|
||||
|
||||
|
||||
# ── Version listing ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{slug}/items/{item_id}/versions",
|
||||
response_model=list[ItemVersionOut],
|
||||
)
|
||||
async def list_versions(
|
||||
slug: str,
|
||||
item_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[ItemVersionOut]:
|
||||
"""List all versions for a document item."""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
await _get_item(db, doc.id, item_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(ItemVersion)
|
||||
.where(ItemVersion.item_id == item_id)
|
||||
.order_by(ItemVersion.created_at.desc())
|
||||
)
|
||||
versions = result.scalars().all()
|
||||
return [ItemVersionOut.model_validate(v) for v in versions]
|
||||
|
||||
|
||||
# ── Document full view ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/{slug}/full", response_model=DocumentFullOut)
|
||||
async def get_document_full(
|
||||
slug: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentFullOut:
|
||||
"""Get a document with all its items (not just count)."""
|
||||
doc = await document_service.get_document_with_items(slug, db)
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document introuvable")
|
||||
|
||||
return DocumentFullOut.model_validate(doc)
|
||||
|
||||
|
||||
# ── Document archive to sanctuary ──────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{slug}/archive", status_code=status.HTTP_201_CREATED)
|
||||
async def archive_document(
|
||||
slug: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> dict:
|
||||
"""Archive a document to the sanctuary (IPFS + on-chain hash).
|
||||
|
||||
Serializes the full document text and sends it through the sanctuary pipeline.
|
||||
"""
|
||||
entry = await document_service.archive_document(slug, db)
|
||||
return {
|
||||
"message": "Document archive avec succes",
|
||||
"sanctuary_entry_id": str(entry.id),
|
||||
"content_hash": entry.content_hash,
|
||||
"ipfs_cid": entry.ipfs_cid,
|
||||
"chain_tx_hash": entry.chain_tx_hash,
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""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.dependencies.org import get_active_org_id
|
||||
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()
|
||||
|
||||
|
||||
@router.get("/", response_model=list[GroupSummary])
|
||||
async def list_groups(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> list[GroupSummary]:
|
||||
"""List groups within the active workspace."""
|
||||
stmt = select(Group).options(selectinload(Group.members)).order_by(Group.name)
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Group.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(Group.organization_id.is_(None))
|
||||
result = await db.execute(stmt)
|
||||
groups = result.scalars().all()
|
||||
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),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> GroupOut:
|
||||
group = Group(name=payload.name, description=payload.description, organization_id=org_id)
|
||||
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()
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Mandates router: CRUD for mandates and their steps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.mandate import Mandate, MandateStep
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.schemas.mandate import (
|
||||
MandateAdvanceOut,
|
||||
MandateAssignRequest,
|
||||
MandateCreate,
|
||||
MandateOut,
|
||||
MandateStepCreate,
|
||||
MandateStepOut,
|
||||
MandateUpdate,
|
||||
)
|
||||
from app.schemas.vote import VoteSessionOut
|
||||
from app.dependencies.org import get_active_org_id
|
||||
from app.services.auth_service import get_current_identity
|
||||
from app.services.mandate_service import (
|
||||
advance_mandate,
|
||||
assign_mandatee,
|
||||
create_vote_session_for_step,
|
||||
revoke_mandate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_mandate(
|
||||
db: AsyncSession, mandate_id: uuid.UUID, org_id: uuid.UUID | None = None
|
||||
) -> Mandate:
|
||||
stmt = (
|
||||
select(Mandate)
|
||||
.options(
|
||||
selectinload(Mandate.steps),
|
||||
selectinload(Mandate.origin_identity),
|
||||
selectinload(Mandate.mandatee),
|
||||
)
|
||||
.where(Mandate.id == mandate_id)
|
||||
)
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Mandate.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(Mandate.organization_id.is_(None))
|
||||
result = await db.execute(stmt)
|
||||
mandate = result.scalar_one_or_none()
|
||||
if mandate is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandat introuvable")
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/", response_model=list[MandateOut])
|
||||
async def list_mandates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
mandate_type: str | None = Query(default=None),
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[MandateOut]:
|
||||
stmt = select(Mandate).options(
|
||||
selectinload(Mandate.steps),
|
||||
selectinload(Mandate.origin_identity),
|
||||
selectinload(Mandate.mandatee),
|
||||
)
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Mandate.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(Mandate.organization_id.is_(None))
|
||||
if mandate_type is not None:
|
||||
stmt = stmt.where(Mandate.mandate_type == mandate_type)
|
||||
if status_filter is not None:
|
||||
stmt = stmt.where(Mandate.status == status_filter)
|
||||
|
||||
stmt = stmt.order_by(Mandate.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
mandates = result.scalars().unique().all()
|
||||
|
||||
return [_mandate_out(m) for m in mandates]
|
||||
|
||||
|
||||
@router.post("/", response_model=MandateOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_mandate(
|
||||
payload: MandateCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateOut:
|
||||
data = payload.model_dump()
|
||||
nomination_mode = data.pop("nomination_mode", "postpone")
|
||||
|
||||
mandate = Mandate(**data, organization_id=org_id)
|
||||
|
||||
if nomination_mode == "auto":
|
||||
mandate.mandatee_id = identity.id
|
||||
|
||||
db.add(mandate)
|
||||
await db.commit()
|
||||
await db.refresh(mandate)
|
||||
|
||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
||||
return _mandate_out(mandate)
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=MandateOut)
|
||||
async def get_mandate(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateOut:
|
||||
mandate = await _get_mandate(db, id, org_id)
|
||||
return _mandate_out(mandate)
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=MandateOut)
|
||||
async def update_mandate(
|
||||
id: uuid.UUID,
|
||||
payload: MandateUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateOut:
|
||||
mandate = await _get_mandate(db, id, org_id)
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(mandate, field, value)
|
||||
|
||||
await db.commit()
|
||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
||||
return _mandate_out(mandate)
|
||||
|
||||
|
||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
|
||||
async def delete_mandate(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> None:
|
||||
mandate = await _get_mandate(db, id, org_id)
|
||||
|
||||
if mandate.status != "draft":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Seuls les mandats en brouillon peuvent etre supprimes",
|
||||
)
|
||||
|
||||
await db.delete(mandate)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Mandate Step routes ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{id}/steps", response_model=MandateStepOut, status_code=status.HTTP_201_CREATED)
|
||||
async def add_step(
|
||||
id: uuid.UUID,
|
||||
payload: MandateStepCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateStepOut:
|
||||
mandate = await _get_mandate(db, id, org_id)
|
||||
|
||||
step = MandateStep(mandate_id=mandate.id, **payload.model_dump())
|
||||
db.add(step)
|
||||
await db.commit()
|
||||
await db.refresh(step)
|
||||
|
||||
return MandateStepOut.model_validate(step)
|
||||
|
||||
|
||||
@router.get("/{id}/steps", response_model=list[MandateStepOut])
|
||||
async def list_steps(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> list[MandateStepOut]:
|
||||
mandate = await _get_mandate(db, id, org_id)
|
||||
return [MandateStepOut.model_validate(s) for s in mandate.steps]
|
||||
|
||||
|
||||
# ── Workflow routes ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{id}/advance", response_model=MandateAdvanceOut)
|
||||
async def advance_mandate_endpoint(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateAdvanceOut:
|
||||
try:
|
||||
mandate = await advance_mandate(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
||||
out = _mandate_out(mandate)
|
||||
return MandateAdvanceOut(
|
||||
**out.model_dump(),
|
||||
message=f"Mandat avance au statut : {mandate.status}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{id}/assign", response_model=MandateOut)
|
||||
async def assign_mandatee_endpoint(
|
||||
id: uuid.UUID,
|
||||
payload: MandateAssignRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateOut:
|
||||
try:
|
||||
mandate = await assign_mandatee(id, payload.mandatee_id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
||||
return _mandate_out(mandate)
|
||||
|
||||
|
||||
@router.post("/{id}/revoke", response_model=MandateOut)
|
||||
async def revoke_mandate_endpoint(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateOut:
|
||||
try:
|
||||
mandate = await revoke_mandate(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
mandate = await _get_mandate(db, mandate.id, org_id)
|
||||
return _mandate_out(mandate)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{id}/steps/{step_id}/create-vote-session",
|
||||
response_model=VoteSessionOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_vote_session_for_step_endpoint(
|
||||
id: uuid.UUID,
|
||||
step_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> VoteSessionOut:
|
||||
try:
|
||||
session = await create_vote_session_for_step(id, step_id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
return VoteSessionOut.model_validate(session)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Protocols router: voting protocols, formula configurations, and simulation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.engine.smith_threshold import smith_threshold as compute_smith_threshold
|
||||
from app.engine.techcomm_threshold import techcomm_threshold as compute_techcomm_threshold
|
||||
from app.engine.threshold import wot_threshold as compute_wot_threshold
|
||||
from app.models.protocol import FormulaConfig, VotingProtocol
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.schemas.protocol import (
|
||||
FormulaConfigCreate,
|
||||
FormulaConfigOut,
|
||||
FormulaConfigUpdate,
|
||||
FormulaSimulationRequest,
|
||||
FormulaSimulationResult,
|
||||
VotingProtocolCreate,
|
||||
VotingProtocolOut,
|
||||
VotingProtocolUpdate,
|
||||
)
|
||||
from app.dependencies.org import get_active_org_id
|
||||
from app.services.auth_service import get_current_identity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_protocol(
|
||||
db: AsyncSession, protocol_id: uuid.UUID, org_id: uuid.UUID | None = None
|
||||
) -> VotingProtocol:
|
||||
"""Fetch a voting protocol by ID within the active org scope, or raise 404."""
|
||||
stmt = (
|
||||
select(VotingProtocol)
|
||||
.options(selectinload(VotingProtocol.formula_config))
|
||||
.where(VotingProtocol.id == protocol_id)
|
||||
)
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(VotingProtocol.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
|
||||
result = await db.execute(stmt)
|
||||
protocol = result.scalar_one_or_none()
|
||||
if protocol is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole introuvable")
|
||||
return protocol
|
||||
|
||||
|
||||
async def _get_formula(db: AsyncSession, formula_id: uuid.UUID) -> FormulaConfig:
|
||||
"""Fetch a formula config by ID, or raise 404."""
|
||||
result = await db.execute(
|
||||
select(FormulaConfig).where(FormulaConfig.id == formula_id)
|
||||
)
|
||||
formula = result.scalar_one_or_none()
|
||||
if formula is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Configuration de formule introuvable")
|
||||
return formula
|
||||
|
||||
|
||||
# ── Voting Protocol routes ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/", response_model=list[VotingProtocolOut])
|
||||
async def list_protocols(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[VotingProtocolOut]:
|
||||
"""List all voting protocols with their formula configurations."""
|
||||
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(VotingProtocol.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
|
||||
if vote_type is not None:
|
||||
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
|
||||
|
||||
stmt = stmt.order_by(VotingProtocol.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
protocols = result.scalars().unique().all()
|
||||
|
||||
return [VotingProtocolOut.model_validate(p) for p in protocols]
|
||||
|
||||
|
||||
@router.post("/", response_model=VotingProtocolOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_protocol(
|
||||
payload: VotingProtocolCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> VotingProtocolOut:
|
||||
"""Create a new voting protocol.
|
||||
|
||||
The formula_config_id must reference an existing FormulaConfig.
|
||||
"""
|
||||
# Verify formula config exists
|
||||
fc_result = await db.execute(
|
||||
select(FormulaConfig).where(FormulaConfig.id == payload.formula_config_id)
|
||||
)
|
||||
if fc_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Configuration de formule introuvable",
|
||||
)
|
||||
|
||||
protocol = VotingProtocol(**payload.model_dump(), organization_id=org_id)
|
||||
db.add(protocol)
|
||||
await db.commit()
|
||||
await db.refresh(protocol)
|
||||
|
||||
# Reload with formula config
|
||||
protocol = await _get_protocol(db, protocol.id, org_id)
|
||||
return VotingProtocolOut.model_validate(protocol)
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=VotingProtocolOut)
|
||||
async def get_protocol(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> VotingProtocolOut:
|
||||
"""Get a single voting protocol with its formula configuration."""
|
||||
protocol = await _get_protocol(db, id, org_id)
|
||||
return VotingProtocolOut.model_validate(protocol)
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=VotingProtocolOut)
|
||||
async def update_protocol(
|
||||
id: uuid.UUID,
|
||||
payload: VotingProtocolUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> VotingProtocolOut:
|
||||
"""Update a voting protocol (meta-governance).
|
||||
|
||||
Only provided fields will be updated. Requires authentication.
|
||||
"""
|
||||
protocol = await _get_protocol(db, id, org_id)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(protocol, field, value)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Reload with formula config
|
||||
protocol = await _get_protocol(db, protocol.id, org_id)
|
||||
return VotingProtocolOut.model_validate(protocol)
|
||||
|
||||
|
||||
# ── Formula Config routes ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/formulas", response_model=list[FormulaConfigOut])
|
||||
async def list_formulas(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[FormulaConfigOut]:
|
||||
"""List all formula configurations."""
|
||||
stmt = (
|
||||
select(FormulaConfig)
|
||||
.order_by(FormulaConfig.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
formulas = result.scalars().all()
|
||||
|
||||
return [FormulaConfigOut.model_validate(f) for f in formulas]
|
||||
|
||||
|
||||
@router.post("/formulas", response_model=FormulaConfigOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_formula(
|
||||
payload: FormulaConfigCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> FormulaConfigOut:
|
||||
"""Create a new formula configuration for WoT threshold computation."""
|
||||
formula = FormulaConfig(**payload.model_dump())
|
||||
db.add(formula)
|
||||
await db.commit()
|
||||
await db.refresh(formula)
|
||||
|
||||
return FormulaConfigOut.model_validate(formula)
|
||||
|
||||
|
||||
@router.get("/formulas/{id}", response_model=FormulaConfigOut)
|
||||
async def get_formula(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> FormulaConfigOut:
|
||||
"""Get a single formula configuration."""
|
||||
formula = await _get_formula(db, id)
|
||||
return FormulaConfigOut.model_validate(formula)
|
||||
|
||||
|
||||
@router.put("/formulas/{id}", response_model=FormulaConfigOut)
|
||||
async def update_formula(
|
||||
id: uuid.UUID,
|
||||
payload: FormulaConfigUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> FormulaConfigOut:
|
||||
"""Update a formula configuration (meta-governance).
|
||||
|
||||
Only provided fields will be updated. Requires authentication.
|
||||
"""
|
||||
formula = await _get_formula(db, id)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(formula, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(formula)
|
||||
|
||||
return FormulaConfigOut.model_validate(formula)
|
||||
|
||||
|
||||
# ── Simulation ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/simulate", response_model=FormulaSimulationResult)
|
||||
async def simulate_formula(
|
||||
payload: FormulaSimulationRequest,
|
||||
) -> FormulaSimulationResult:
|
||||
"""Simulate a WoT threshold formula computation.
|
||||
|
||||
Pure calculation endpoint -- no database access needed.
|
||||
Accepts WoT size, total votes, and formula parameters,
|
||||
returns the computed thresholds and derived values.
|
||||
"""
|
||||
# Compute WoT threshold
|
||||
wot_thresh = compute_wot_threshold(
|
||||
wot_size=payload.wot_size,
|
||||
total_votes=payload.total_votes,
|
||||
majority_pct=payload.majority_pct,
|
||||
base_exponent=payload.base_exponent,
|
||||
gradient_exponent=payload.gradient_exponent,
|
||||
constant_base=payload.constant_base,
|
||||
)
|
||||
|
||||
# Compute derived values for transparency
|
||||
w = payload.wot_size
|
||||
t = payload.total_votes
|
||||
m = payload.majority_pct / 100.0
|
||||
g = payload.gradient_exponent
|
||||
|
||||
participation_rate = t / w if w > 0 else 0.0
|
||||
turnout_ratio = min(t / w, 1.0) if w > 0 else 0.0
|
||||
inertia_factor = 1.0 - turnout_ratio ** g if t > 0 else 1.0
|
||||
required_ratio = m + (1.0 - m) * inertia_factor
|
||||
|
||||
# Smith threshold (optional)
|
||||
smith_thresh = None
|
||||
if payload.smith_wot_size is not None and payload.smith_exponent is not None:
|
||||
smith_thresh = compute_smith_threshold(payload.smith_wot_size, payload.smith_exponent)
|
||||
|
||||
# TechComm threshold (optional)
|
||||
techcomm_thresh = None
|
||||
if payload.techcomm_size is not None and payload.techcomm_exponent is not None:
|
||||
techcomm_thresh = compute_techcomm_threshold(payload.techcomm_size, payload.techcomm_exponent)
|
||||
|
||||
return FormulaSimulationResult(
|
||||
wot_threshold=wot_thresh,
|
||||
smith_threshold=smith_thresh,
|
||||
techcomm_threshold=techcomm_thresh,
|
||||
participation_rate=round(participation_rate, 6),
|
||||
required_ratio=round(required_ratio, 6),
|
||||
inertia_factor=round(inertia_factor, 6),
|
||||
)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Public API router: read-only endpoints for external consumption.
|
||||
|
||||
All endpoints are accessible without authentication.
|
||||
No mutations allowed -- strictly read-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.decision import Decision
|
||||
from app.models.document import Document, DocumentItem
|
||||
from app.models.sanctuary import SanctuaryEntry
|
||||
from app.models.vote import VoteSession
|
||||
from app.schemas.document import DocumentFullOut, DocumentItemOut, DocumentOut
|
||||
from app.schemas.sanctuary import SanctuaryEntryOut
|
||||
from app.services import document_service, sanctuary_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Documents (public, read-only) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/documents",
|
||||
response_model=list[DocumentOut],
|
||||
tags=["public-documents"],
|
||||
summary="Liste publique des documents actifs",
|
||||
)
|
||||
async def list_documents(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
|
||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[DocumentOut]:
|
||||
"""Liste les documents de reference avec leurs items (lecture seule)."""
|
||||
stmt = select(Document)
|
||||
|
||||
if doc_type is not None:
|
||||
stmt = stmt.where(Document.doc_type == doc_type)
|
||||
if status_filter is not None:
|
||||
stmt = stmt.where(Document.status == status_filter)
|
||||
|
||||
stmt = stmt.order_by(Document.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
documents = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for doc in documents:
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id)
|
||||
)
|
||||
items_count = count_result.scalar() or 0
|
||||
doc_out = DocumentOut.model_validate(doc)
|
||||
doc_out.items_count = items_count
|
||||
out.append(doc_out)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@router.get(
|
||||
"/documents/{slug}",
|
||||
response_model=DocumentFullOut,
|
||||
tags=["public-documents"],
|
||||
summary="Document complet avec ses items",
|
||||
)
|
||||
async def get_document(
|
||||
slug: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentFullOut:
|
||||
"""Recupere un document avec tous ses items (texte complet, serialise)."""
|
||||
doc = await document_service.get_document_with_items(slug, db)
|
||||
if doc is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document introuvable",
|
||||
)
|
||||
return DocumentFullOut.model_validate(doc)
|
||||
|
||||
|
||||
# ── Sanctuary (public, read-only) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sanctuary/entries",
|
||||
response_model=list[SanctuaryEntryOut],
|
||||
tags=["public-sanctuary"],
|
||||
summary="Liste des entrees du sanctuaire",
|
||||
)
|
||||
async def list_sanctuary_entries(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
entry_type: str | None = Query(default=None, description="Filtrer par type (document, decision, vote_result)"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[SanctuaryEntryOut]:
|
||||
"""Liste les entrees du sanctuaire (archives verifiees)."""
|
||||
stmt = select(SanctuaryEntry)
|
||||
|
||||
if entry_type is not None:
|
||||
stmt = stmt.where(SanctuaryEntry.entry_type == entry_type)
|
||||
|
||||
stmt = stmt.order_by(SanctuaryEntry.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
entries = result.scalars().all()
|
||||
|
||||
return [SanctuaryEntryOut.model_validate(e) for e in entries]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sanctuary/entries/{id}",
|
||||
response_model=SanctuaryEntryOut,
|
||||
tags=["public-sanctuary"],
|
||||
summary="Entree du sanctuaire par ID",
|
||||
)
|
||||
async def get_sanctuary_entry(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SanctuaryEntryOut:
|
||||
"""Recupere une entree du sanctuaire avec lien IPFS et ancrage on-chain."""
|
||||
result = await db.execute(
|
||||
select(SanctuaryEntry).where(SanctuaryEntry.id == id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Entree sanctuaire introuvable",
|
||||
)
|
||||
return SanctuaryEntryOut.model_validate(entry)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sanctuary/verify/{id}",
|
||||
tags=["public-sanctuary"],
|
||||
summary="Verification d'integrite d'une entree",
|
||||
)
|
||||
async def verify_sanctuary_entry(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Verifie l'integrite d'une entree du sanctuaire (comparaison de hash)."""
|
||||
try:
|
||||
result = await sanctuary_service.verify_entry(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── Votes (public, read-only) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/votes/sessions/{id}/result",
|
||||
tags=["public-votes"],
|
||||
summary="Resultat d'une session de vote",
|
||||
)
|
||||
async def get_vote_result(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Recupere le resultat d'une session de vote (lecture seule, public)."""
|
||||
result = await db.execute(
|
||||
select(VoteSession).where(VoteSession.id == id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if session is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session de vote introuvable",
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": str(session.id),
|
||||
"status": session.status,
|
||||
"votes_for": session.votes_for,
|
||||
"votes_against": session.votes_against,
|
||||
"votes_total": session.votes_total,
|
||||
"wot_size": session.wot_size,
|
||||
"smith_size": session.smith_size,
|
||||
"techcomm_size": session.techcomm_size,
|
||||
"threshold_required": session.threshold_required,
|
||||
"result": session.result,
|
||||
"starts_at": session.starts_at.isoformat() if session.starts_at else None,
|
||||
"ends_at": session.ends_at.isoformat() if session.ends_at else None,
|
||||
"chain_recorded": session.chain_recorded,
|
||||
"chain_tx_hash": session.chain_tx_hash,
|
||||
}
|
||||
|
||||
|
||||
# ── Platform status ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/status",
|
||||
tags=["public-status"],
|
||||
summary="Statut de la plateforme",
|
||||
)
|
||||
async def platform_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Statut general de la plateforme: compteurs de documents, decisions, votes actifs."""
|
||||
# Count documents
|
||||
doc_count_result = await db.execute(
|
||||
select(func.count()).select_from(Document)
|
||||
)
|
||||
documents_count = doc_count_result.scalar() or 0
|
||||
|
||||
# Count decisions
|
||||
decision_count_result = await db.execute(
|
||||
select(func.count()).select_from(Decision)
|
||||
)
|
||||
decisions_count = decision_count_result.scalar() or 0
|
||||
|
||||
# Count active vote sessions
|
||||
active_votes_result = await db.execute(
|
||||
select(func.count()).select_from(VoteSession).where(VoteSession.status == "open")
|
||||
)
|
||||
active_votes_count = active_votes_result.scalar() or 0
|
||||
|
||||
# Count total vote sessions
|
||||
total_votes_result = await db.execute(
|
||||
select(func.count()).select_from(VoteSession)
|
||||
)
|
||||
total_votes_count = total_votes_result.scalar() or 0
|
||||
|
||||
# Count sanctuary entries
|
||||
sanctuary_count_result = await db.execute(
|
||||
select(func.count()).select_from(SanctuaryEntry)
|
||||
)
|
||||
sanctuary_count = sanctuary_count_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"platform": "libreDecision",
|
||||
"documents_count": documents_count,
|
||||
"decisions_count": decisions_count,
|
||||
"active_votes_count": active_votes_count,
|
||||
"total_votes_count": total_votes_count,
|
||||
"sanctuary_entries_count": sanctuary_count,
|
||||
}
|
||||
@@ -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,105 @@
|
||||
"""Sanctuary router: IPFS + on-chain anchoring entries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.sanctuary import SanctuaryEntry
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.schemas.sanctuary import SanctuaryEntryCreate, SanctuaryEntryOut
|
||||
from app.services import sanctuary_service
|
||||
from app.services.auth_service import get_current_identity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SanctuaryEntryOut])
|
||||
async def list_entries(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
entry_type: str | None = Query(default=None, description="Filtrer par type (document, decision, vote_result)"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[SanctuaryEntryOut]:
|
||||
"""List all sanctuary entries with optional type filter."""
|
||||
stmt = select(SanctuaryEntry)
|
||||
|
||||
if entry_type is not None:
|
||||
stmt = stmt.where(SanctuaryEntry.entry_type == entry_type)
|
||||
|
||||
stmt = stmt.order_by(SanctuaryEntry.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
entries = result.scalars().all()
|
||||
|
||||
return [SanctuaryEntryOut.model_validate(e) for e in entries]
|
||||
|
||||
|
||||
@router.post("/", response_model=SanctuaryEntryOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_entry(
|
||||
payload: SanctuaryEntryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> SanctuaryEntryOut:
|
||||
"""Create a new sanctuary entry.
|
||||
|
||||
This endpoint is typically called by internal services after:
|
||||
1. Content is hashed (SHA-256)
|
||||
2. Content is pinned to IPFS
|
||||
3. Hash is anchored on-chain via system.remark
|
||||
|
||||
The IPFS CID and chain TX hash can be added later via updates.
|
||||
"""
|
||||
entry = SanctuaryEntry(**payload.model_dump())
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
|
||||
return SanctuaryEntryOut.model_validate(entry)
|
||||
|
||||
|
||||
@router.get("/by-reference/{reference_id}", response_model=list[SanctuaryEntryOut])
|
||||
async def get_entries_by_reference(
|
||||
reference_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[SanctuaryEntryOut]:
|
||||
"""Get all sanctuary entries for a given reference ID.
|
||||
|
||||
Useful for finding all sanctuary entries associated with a document,
|
||||
decision, or vote result.
|
||||
"""
|
||||
entries = await sanctuary_service.get_entries_by_reference(reference_id, db)
|
||||
return [SanctuaryEntryOut.model_validate(e) for e in entries]
|
||||
|
||||
|
||||
@router.get("/{id}/verify")
|
||||
async def verify_entry(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Verify integrity of a sanctuary entry.
|
||||
|
||||
Re-fetches the content (from IPFS if available), re-hashes it,
|
||||
and compares with the stored content_hash.
|
||||
"""
|
||||
try:
|
||||
result = await sanctuary_service.verify_entry(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=SanctuaryEntryOut)
|
||||
async def get_entry(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SanctuaryEntryOut:
|
||||
"""Get a single sanctuary entry by ID."""
|
||||
result = await db.execute(select(SanctuaryEntry).where(SanctuaryEntry.id == id))
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entree sanctuaire introuvable")
|
||||
return SanctuaryEntryOut.model_validate(entry)
|
||||
@@ -0,0 +1,505 @@
|
||||
"""Votes router: vote sessions, individual votes, result computation, and live updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.protocol import FormulaConfig, VotingProtocol
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.models.vote import Vote, VoteSession
|
||||
from app.routers.websocket import manager
|
||||
from app.schemas.vote import (
|
||||
ThresholdDetailOut,
|
||||
VoteCreate,
|
||||
VoteOut,
|
||||
VoteResultOut,
|
||||
VoteSessionCreate,
|
||||
VoteSessionListOut,
|
||||
VoteSessionOut,
|
||||
)
|
||||
from app.dependencies.org import get_active_org_id
|
||||
from app.services.auth_service import get_current_identity
|
||||
from app.services.vote_service import (
|
||||
close_session as svc_close_session,
|
||||
compute_result as svc_compute_result,
|
||||
get_threshold_details as svc_get_threshold_details,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_session(db: AsyncSession, session_id: uuid.UUID) -> VoteSession:
|
||||
"""Fetch a vote session by ID with votes eagerly loaded, or raise 404."""
|
||||
result = await db.execute(
|
||||
select(VoteSession)
|
||||
.options(selectinload(VoteSession.votes))
|
||||
.where(VoteSession.id == session_id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if session is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session de vote introuvable")
|
||||
return session
|
||||
|
||||
|
||||
async def _get_protocol_with_formula(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol:
|
||||
"""Fetch a voting protocol with its formula config, or raise 404."""
|
||||
result = await db.execute(
|
||||
select(VotingProtocol)
|
||||
.options(selectinload(VotingProtocol.formula_config))
|
||||
.where(VotingProtocol.id == protocol_id)
|
||||
)
|
||||
protocol = result.scalar_one_or_none()
|
||||
if protocol is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole de vote introuvable")
|
||||
return protocol
|
||||
|
||||
|
||||
async def _check_session_expired(session: VoteSession, db: AsyncSession) -> VoteSession:
|
||||
"""Check if a session has passed its ends_at deadline and auto-close it.
|
||||
|
||||
If the session is still marked 'open' but the deadline has passed,
|
||||
close it and compute the final tally via the vote service.
|
||||
Returns the (possibly updated) session.
|
||||
"""
|
||||
if session.status == "open" and datetime.now(timezone.utc) > session.ends_at:
|
||||
try:
|
||||
result = await svc_close_session(session.id, db)
|
||||
# Reload session to get updated fields
|
||||
db_result = await db.execute(
|
||||
select(VoteSession)
|
||||
.options(selectinload(VoteSession.votes))
|
||||
.where(VoteSession.id == session.id)
|
||||
)
|
||||
session = db_result.scalar_one()
|
||||
# Broadcast session closed event
|
||||
await manager.broadcast(session.id, {
|
||||
"type": "session_closed",
|
||||
"session_id": str(session.id),
|
||||
"result": result.get("result"),
|
||||
"votes_for": result.get("votes_for", 0),
|
||||
"votes_against": result.get("votes_against", 0),
|
||||
"votes_total": result.get("votes_total", 0),
|
||||
})
|
||||
except ValueError:
|
||||
pass # Session already closed by another process
|
||||
return session
|
||||
|
||||
|
||||
def _compute_threshold(formula: FormulaConfig, wot_size: int, votes_total: int) -> float:
|
||||
"""Compute the WoT-based threshold using the core formula.
|
||||
|
||||
Result = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
|
||||
|
||||
Where:
|
||||
- C = constant_base
|
||||
- B = base_exponent
|
||||
- W = wot_size
|
||||
- M = majority_pct / 100
|
||||
- G = gradient_exponent
|
||||
- T = votes_total (turnout)
|
||||
"""
|
||||
c = formula.constant_base
|
||||
b = formula.base_exponent
|
||||
w = max(wot_size, 1)
|
||||
m = formula.majority_pct / 100.0
|
||||
g = formula.gradient_exponent
|
||||
t = votes_total
|
||||
|
||||
# Inertia-based threshold
|
||||
base_power = b ** w if b > 0 else 0.0
|
||||
turnout_ratio = min(t / w, 1.0) if w > 0 else 0.0
|
||||
inertia = m + (1 - m) * (1 - turnout_ratio ** g)
|
||||
threshold = c + base_power + inertia * max(0, t - c)
|
||||
|
||||
return threshold
|
||||
|
||||
|
||||
def _compute_result(
|
||||
session: VoteSession,
|
||||
formula: FormulaConfig,
|
||||
) -> dict:
|
||||
"""Compute the vote result based on tallies and formula.
|
||||
|
||||
Returns a dict with threshold_required, result ("adopted" or "rejected"),
|
||||
and whether Smith/TechComm criteria are met.
|
||||
"""
|
||||
threshold = _compute_threshold(formula, session.wot_size, session.votes_total)
|
||||
|
||||
# Main criterion: votes_for >= threshold
|
||||
main_pass = session.votes_for >= threshold
|
||||
|
||||
# Smith criterion (if configured)
|
||||
smith_pass = True
|
||||
smith_threshold = None
|
||||
if formula.smith_exponent is not None and session.smith_size > 0:
|
||||
smith_threshold = math.ceil(session.smith_size ** formula.smith_exponent)
|
||||
smith_pass = session.smith_votes_for >= smith_threshold
|
||||
|
||||
# TechComm criterion (if configured)
|
||||
techcomm_pass = True
|
||||
techcomm_threshold = None
|
||||
if formula.techcomm_exponent is not None and session.techcomm_size > 0:
|
||||
techcomm_threshold = math.ceil(session.techcomm_size ** formula.techcomm_exponent)
|
||||
techcomm_pass = session.techcomm_votes_for >= techcomm_threshold
|
||||
|
||||
result = "adopted" if (main_pass and smith_pass and techcomm_pass) else "rejected"
|
||||
|
||||
return {
|
||||
"threshold_required": threshold,
|
||||
"result": result,
|
||||
"smith_threshold": smith_threshold,
|
||||
"smith_pass": smith_pass,
|
||||
"techcomm_threshold": techcomm_threshold,
|
||||
"techcomm_pass": techcomm_pass,
|
||||
}
|
||||
|
||||
|
||||
# ── Routes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[VoteSessionListOut])
|
||||
async def list_vote_sessions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
session_status: str | None = Query(default=None, alias="status", description="Filtrer par statut (open, closed, tallied)"),
|
||||
decision_id: uuid.UUID | None = Query(default=None, description="Filtrer par decision_id"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[VoteSessionListOut]:
|
||||
"""List all vote sessions with optional filters by status and decision_id."""
|
||||
stmt = (
|
||||
select(VoteSession)
|
||||
.join(VotingProtocol, VoteSession.voting_protocol_id == VotingProtocol.id)
|
||||
)
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(VotingProtocol.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
|
||||
if session_status is not None:
|
||||
stmt = stmt.where(VoteSession.status == session_status)
|
||||
if decision_id is not None:
|
||||
stmt = stmt.where(VoteSession.decision_id == decision_id)
|
||||
|
||||
stmt = stmt.order_by(VoteSession.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
sessions = result.scalars().all()
|
||||
|
||||
# Auto-close expired sessions before returning
|
||||
checked_sessions = []
|
||||
for s in sessions:
|
||||
s = await _check_session_expired(s, db)
|
||||
checked_sessions.append(s)
|
||||
|
||||
return [VoteSessionListOut.model_validate(s) for s in checked_sessions]
|
||||
|
||||
|
||||
@router.post("/sessions", response_model=VoteSessionOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_vote_session(
|
||||
payload: VoteSessionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> VoteSessionOut:
|
||||
"""Create a new vote session.
|
||||
|
||||
The session duration is derived from the linked protocol's formula config.
|
||||
WoT/Smith/TechComm sizes should be snapshotted from the blockchain at creation time.
|
||||
"""
|
||||
# Validate protocol exists and get formula for duration
|
||||
protocol = await _get_protocol_with_formula(db, payload.voting_protocol_id)
|
||||
formula = protocol.formula_config
|
||||
|
||||
starts_at = datetime.now(timezone.utc)
|
||||
ends_at = starts_at + timedelta(days=formula.duration_days)
|
||||
|
||||
session = VoteSession(
|
||||
decision_id=payload.decision_id,
|
||||
item_version_id=payload.item_version_id,
|
||||
voting_protocol_id=payload.voting_protocol_id,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
# TODO: Snapshot actual WoT sizes from blockchain via Duniter RPC
|
||||
wot_size=0,
|
||||
smith_size=0,
|
||||
techcomm_size=0,
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
return VoteSessionOut.model_validate(session)
|
||||
|
||||
|
||||
@router.get("/sessions/{id}", response_model=VoteSessionOut)
|
||||
async def get_vote_session(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> VoteSessionOut:
|
||||
"""Get a vote session with current tallies.
|
||||
|
||||
Automatically closes the session if its deadline has passed.
|
||||
"""
|
||||
session = await _get_session(db, id)
|
||||
session = await _check_session_expired(session, db)
|
||||
return VoteSessionOut.model_validate(session)
|
||||
|
||||
|
||||
@router.post("/sessions/{id}/close", response_model=VoteResultOut)
|
||||
async def close_vote_session(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> VoteResultOut:
|
||||
"""Manually close a vote session and compute the final result.
|
||||
|
||||
Requires authentication. The session must be in 'open' status.
|
||||
"""
|
||||
try:
|
||||
result = await svc_close_session(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
# Broadcast session closed event via WebSocket
|
||||
await manager.broadcast(id, {
|
||||
"type": "session_closed",
|
||||
"session_id": str(id),
|
||||
"result": result.get("result"),
|
||||
"votes_for": result.get("votes_for", 0),
|
||||
"votes_against": result.get("votes_against", 0),
|
||||
"votes_total": result.get("votes_total", 0),
|
||||
})
|
||||
|
||||
return VoteResultOut(
|
||||
session_id=id,
|
||||
result=result.get("result"),
|
||||
threshold_required=float(result.get("threshold", 0)),
|
||||
votes_for=result.get("votes_for", 0),
|
||||
votes_against=result.get("votes_against", 0),
|
||||
votes_total=result.get("votes_total", 0),
|
||||
adopted=result.get("adopted", False),
|
||||
nuanced_breakdown=result.get("nuanced_breakdown"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{id}/vote", response_model=VoteOut, status_code=status.HTTP_201_CREATED)
|
||||
async def submit_vote(
|
||||
id: uuid.UUID,
|
||||
payload: VoteCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> VoteOut:
|
||||
"""Submit a vote to a session.
|
||||
|
||||
Each identity can only vote once per session. Submitting again replaces the previous vote.
|
||||
The vote must include a cryptographic signature for on-chain proof.
|
||||
After submission, broadcasts a vote_update event via WebSocket.
|
||||
"""
|
||||
session = await _get_session(db, id)
|
||||
|
||||
# Auto-close check
|
||||
session = await _check_session_expired(session, db)
|
||||
|
||||
# Verify session is open
|
||||
if session.status != "open":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cette session de vote n'est pas ouverte",
|
||||
)
|
||||
|
||||
# Verify session hasn't ended
|
||||
if datetime.now(timezone.utc) > session.ends_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cette session de vote est terminee",
|
||||
)
|
||||
|
||||
# Check if voter already voted -- replace if so
|
||||
existing_result = await db.execute(
|
||||
select(Vote).where(
|
||||
Vote.session_id == session.id,
|
||||
Vote.voter_id == identity.id,
|
||||
)
|
||||
)
|
||||
existing_vote = existing_result.scalar_one_or_none()
|
||||
|
||||
if existing_vote is not None:
|
||||
# Deactivate old vote (keep for audit trail)
|
||||
existing_vote.is_active = False
|
||||
|
||||
# Update tallies: subtract old vote
|
||||
session.votes_total -= 1
|
||||
if existing_vote.vote_value == "for":
|
||||
session.votes_for -= 1
|
||||
if existing_vote.voter_is_smith:
|
||||
session.smith_votes_for -= 1
|
||||
if existing_vote.voter_is_techcomm:
|
||||
session.techcomm_votes_for -= 1
|
||||
elif existing_vote.vote_value == "against":
|
||||
session.votes_against -= 1
|
||||
|
||||
# Create new vote
|
||||
vote = Vote(
|
||||
session_id=session.id,
|
||||
voter_id=identity.id,
|
||||
vote_value=payload.vote_value,
|
||||
nuanced_level=payload.nuanced_level,
|
||||
comment=payload.comment,
|
||||
signature=payload.signature,
|
||||
signed_payload=payload.signed_payload,
|
||||
voter_wot_status=identity.wot_status,
|
||||
voter_is_smith=identity.is_smith,
|
||||
voter_is_techcomm=identity.is_techcomm,
|
||||
)
|
||||
db.add(vote)
|
||||
|
||||
# Update tallies: add new vote
|
||||
session.votes_total += 1
|
||||
if payload.vote_value == "for":
|
||||
session.votes_for += 1
|
||||
if identity.is_smith:
|
||||
session.smith_votes_for += 1
|
||||
if identity.is_techcomm:
|
||||
session.techcomm_votes_for += 1
|
||||
elif payload.vote_value == "against":
|
||||
session.votes_against += 1
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(vote)
|
||||
|
||||
# Broadcast vote update via WebSocket
|
||||
await manager.broadcast(session.id, {
|
||||
"type": "vote_update",
|
||||
"session_id": str(session.id),
|
||||
"votes_for": session.votes_for,
|
||||
"votes_against": session.votes_against,
|
||||
"votes_total": session.votes_total,
|
||||
})
|
||||
|
||||
return VoteOut.model_validate(vote)
|
||||
|
||||
|
||||
@router.get("/sessions/{id}/votes", response_model=list[VoteOut])
|
||||
async def list_votes(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
active_only: bool = Query(default=True, description="Ne montrer que les votes actifs"),
|
||||
) -> list[VoteOut]:
|
||||
"""List all votes in a session."""
|
||||
# Verify session exists
|
||||
await _get_session(db, id)
|
||||
|
||||
stmt = select(Vote).where(Vote.session_id == id)
|
||||
if active_only:
|
||||
stmt = stmt.where(Vote.is_active.is_(True))
|
||||
|
||||
stmt = stmt.order_by(Vote.created_at.asc())
|
||||
result = await db.execute(stmt)
|
||||
votes = result.scalars().all()
|
||||
|
||||
return [VoteOut.model_validate(v) for v in votes]
|
||||
|
||||
|
||||
@router.get("/sessions/{id}/threshold", response_model=ThresholdDetailOut)
|
||||
async def get_threshold_details(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ThresholdDetailOut:
|
||||
"""Return computed threshold details for a vote session.
|
||||
|
||||
Includes WoT/Smith/TechComm thresholds, pass/fail status,
|
||||
participation rate, and the formula parameters used.
|
||||
Automatically closes the session if its deadline has passed.
|
||||
"""
|
||||
session = await _get_session(db, id)
|
||||
session = await _check_session_expired(session, db)
|
||||
|
||||
try:
|
||||
details = await svc_get_threshold_details(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
return ThresholdDetailOut(**details)
|
||||
|
||||
|
||||
@router.get("/sessions/{id}/result")
|
||||
async def get_vote_result(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Compute and return the current result for a vote session.
|
||||
|
||||
Uses the WoT threshold formula linked through the voting protocol.
|
||||
Returns current tallies, computed threshold, and whether the vote passes.
|
||||
Automatically closes the session if its deadline has passed.
|
||||
"""
|
||||
session = await _get_session(db, id)
|
||||
session = await _check_session_expired(session, db)
|
||||
|
||||
# Get the protocol and formula
|
||||
protocol = await _get_protocol_with_formula(db, session.voting_protocol_id)
|
||||
formula = protocol.formula_config
|
||||
|
||||
result_data = _compute_result(session, formula)
|
||||
|
||||
return {
|
||||
"session_id": str(session.id),
|
||||
"status": session.status,
|
||||
"votes_for": session.votes_for,
|
||||
"votes_against": session.votes_against,
|
||||
"votes_total": session.votes_total,
|
||||
"wot_size": session.wot_size,
|
||||
"smith_size": session.smith_size,
|
||||
"techcomm_size": session.techcomm_size,
|
||||
"smith_votes_for": session.smith_votes_for,
|
||||
"techcomm_votes_for": session.techcomm_votes_for,
|
||||
**result_data,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sessions/{id}/tally", response_model=VoteResultOut)
|
||||
async def force_tally(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> VoteResultOut:
|
||||
"""Force a recount of a vote session.
|
||||
|
||||
Requires authentication. Useful after a chain snapshot update
|
||||
or when recalculation is needed. Works on any session status.
|
||||
"""
|
||||
try:
|
||||
result = await svc_compute_result(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
# Broadcast tally update via WebSocket
|
||||
await manager.broadcast(id, {
|
||||
"type": "tally_update",
|
||||
"session_id": str(id),
|
||||
"result": result.get("result"),
|
||||
"votes_for": result.get("votes_for", 0),
|
||||
"votes_against": result.get("votes_against", 0),
|
||||
"votes_total": result.get("votes_total", 0),
|
||||
})
|
||||
|
||||
return VoteResultOut(
|
||||
session_id=id,
|
||||
result=result.get("result"),
|
||||
threshold_required=float(result.get("threshold", 0)),
|
||||
votes_for=result.get("votes_for", 0),
|
||||
votes_against=result.get("votes_against", 0),
|
||||
votes_total=result.get("votes_total", 0),
|
||||
adopted=result.get("adopted", False),
|
||||
nuanced_breakdown=result.get("nuanced_breakdown"),
|
||||
)
|
||||
@@ -0,0 +1,319 @@
|
||||
"""WebSocket router: live vote updates with authentication and heartbeat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import async_session
|
||||
from app.models.user import Session as UserSession
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Heartbeat interval in seconds
|
||||
_HEARTBEAT_INTERVAL = 30
|
||||
|
||||
# Valid notification event types
|
||||
EVENT_TYPES = (
|
||||
"vote_submitted",
|
||||
"vote_update",
|
||||
"session_closed",
|
||||
"tally_update",
|
||||
"decision_advanced",
|
||||
"mandate_updated",
|
||||
"document_changed",
|
||||
"sanctuary_archived",
|
||||
)
|
||||
|
||||
|
||||
# ── Connection manager ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages active WebSocket connections grouped by vote session ID."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# session_id -> list of connected websockets
|
||||
self._connections: dict[uuid.UUID, list[WebSocket]] = {}
|
||||
# websocket -> authenticated identity_id (or None for anonymous)
|
||||
self._authenticated: dict[WebSocket, uuid.UUID | None] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, session_id: uuid.UUID) -> None:
|
||||
"""Accept a WebSocket connection and register it for a vote session."""
|
||||
await websocket.accept()
|
||||
if session_id not in self._connections:
|
||||
self._connections[session_id] = []
|
||||
self._connections[session_id].append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket, session_id: uuid.UUID) -> None:
|
||||
"""Remove a WebSocket connection from the session group."""
|
||||
if session_id in self._connections:
|
||||
self._connections[session_id] = [
|
||||
ws for ws in self._connections[session_id] if ws is not websocket
|
||||
]
|
||||
if not self._connections[session_id]:
|
||||
del self._connections[session_id]
|
||||
self._authenticated.pop(websocket, None)
|
||||
|
||||
async def broadcast(self, session_id: uuid.UUID, data: dict[str, Any]) -> None:
|
||||
"""Broadcast a message to all connections watching a given vote session."""
|
||||
if session_id not in self._connections:
|
||||
return
|
||||
|
||||
message = json.dumps(data, default=str)
|
||||
dead: list[WebSocket] = []
|
||||
|
||||
for ws in self._connections[session_id]:
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
|
||||
# Clean up dead connections
|
||||
for ws in dead:
|
||||
self.disconnect(ws, session_id)
|
||||
|
||||
async def broadcast_all(self, data: dict[str, Any]) -> None:
|
||||
"""Broadcast a message to all connected WebSockets across all sessions."""
|
||||
message = json.dumps(data, default=str)
|
||||
for session_id in list(self._connections.keys()):
|
||||
dead: list[WebSocket] = []
|
||||
for ws in self._connections.get(session_id, []):
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self.disconnect(ws, session_id)
|
||||
|
||||
def set_authenticated(self, websocket: WebSocket, identity_id: uuid.UUID) -> None:
|
||||
"""Mark a WebSocket connection as authenticated."""
|
||||
self._authenticated[websocket] = identity_id
|
||||
|
||||
def is_authenticated(self, websocket: WebSocket) -> bool:
|
||||
"""Check if a WebSocket connection is authenticated."""
|
||||
return self._authenticated.get(websocket) is not None
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
# ── Authentication helper ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _validate_token(token: str) -> uuid.UUID | None:
|
||||
"""Validate a bearer token and return the associated identity_id.
|
||||
|
||||
Uses the same token hashing as auth_service but without FastAPI
|
||||
dependency injection (since WebSocket doesn't use Depends the same way).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
token:
|
||||
The raw bearer token from the query parameter.
|
||||
|
||||
Returns
|
||||
-------
|
||||
uuid.UUID | None
|
||||
The identity_id if valid, or None if invalid/expired.
|
||||
"""
|
||||
import hashlib
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(UserSession).where(
|
||||
UserSession.token_hash == token_hash,
|
||||
UserSession.expires_at > datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if session is not None:
|
||||
return session.identity_id
|
||||
except Exception:
|
||||
logger.warning("Erreur lors de la validation du token WebSocket", exc_info=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── Broadcast event helper (importable by other routers) ──────────────────
|
||||
|
||||
|
||||
async def broadcast_event(
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
session_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Broadcast a notification event to connected WebSocket clients.
|
||||
|
||||
This function is designed to be imported and called from other routers
|
||||
(votes, decisions, mandates, etc.) to push real-time notifications.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
event_type:
|
||||
One of the valid EVENT_TYPES.
|
||||
payload:
|
||||
The event data to send.
|
||||
session_id:
|
||||
If provided, broadcast only to clients watching this specific session.
|
||||
If None, broadcast to all connected clients.
|
||||
"""
|
||||
data = {
|
||||
"event": event_type,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
**payload,
|
||||
}
|
||||
|
||||
if session_id is not None:
|
||||
await manager.broadcast(session_id, data)
|
||||
else:
|
||||
await manager.broadcast_all(data)
|
||||
|
||||
|
||||
# ── Heartbeat task ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _heartbeat(websocket: WebSocket) -> None:
|
||||
"""Send periodic ping messages to keep the connection alive.
|
||||
|
||||
Runs as a background task alongside the main message loop.
|
||||
Sends a JSON ping every _HEARTBEAT_INTERVAL seconds.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL)
|
||||
try:
|
||||
await websocket.send_text(
|
||||
json.dumps({"event": "ping", "timestamp": datetime.now(timezone.utc).isoformat()})
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
# ── WebSocket endpoint ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.websocket("/live")
|
||||
async def live_updates(websocket: WebSocket) -> None:
|
||||
"""WebSocket endpoint for live vote session updates.
|
||||
|
||||
Authentication (optional):
|
||||
Connect with ``?token=<bearer_token>`` query parameter to authenticate.
|
||||
If the token is valid, the connection is marked as authenticated.
|
||||
If missing or invalid, the connection is accepted but unauthenticated.
|
||||
|
||||
The client sends JSON messages:
|
||||
|
||||
{ "action": "subscribe", "session_id": "<uuid>" }
|
||||
{ "action": "unsubscribe", "session_id": "<uuid>" }
|
||||
|
||||
The server pushes events:
|
||||
|
||||
{ "event": "vote_update", "session_id": "...", ... }
|
||||
{ "event": "session_closed", "session_id": "...", ... }
|
||||
{ "event": "vote_submitted", ... }
|
||||
{ "event": "decision_advanced", ... }
|
||||
{ "event": "mandate_updated", ... }
|
||||
{ "event": "document_changed", ... }
|
||||
{ "event": "sanctuary_archived", ... }
|
||||
{ "event": "ping", "timestamp": "..." } (heartbeat)
|
||||
"""
|
||||
# Extract token from query parameters
|
||||
token = websocket.query_params.get("token")
|
||||
|
||||
await websocket.accept()
|
||||
subscribed_sessions: set[uuid.UUID] = set()
|
||||
|
||||
# Authenticate if token provided
|
||||
if token:
|
||||
identity_id = await _validate_token(token)
|
||||
if identity_id is not None:
|
||||
manager.set_authenticated(websocket, identity_id)
|
||||
await websocket.send_text(
|
||||
json.dumps({"event": "authenticated", "identity_id": str(identity_id)})
|
||||
)
|
||||
logger.debug("WebSocket authentifie: identity=%s", identity_id)
|
||||
else:
|
||||
await websocket.send_text(
|
||||
json.dumps({"event": "auth_failed", "detail": "Token invalide ou expire"})
|
||||
)
|
||||
logger.debug("Echec authentification WebSocket (token invalide)")
|
||||
|
||||
# Start heartbeat task
|
||||
heartbeat_task = asyncio.create_task(_heartbeat(websocket))
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
await websocket.send_text(json.dumps({"error": "JSON invalide"}))
|
||||
continue
|
||||
|
||||
action = data.get("action")
|
||||
session_id_str = data.get("session_id")
|
||||
|
||||
if not action or not session_id_str:
|
||||
await websocket.send_text(
|
||||
json.dumps({"error": "Champs 'action' et 'session_id' requis"})
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
session_id = uuid.UUID(session_id_str)
|
||||
except ValueError:
|
||||
await websocket.send_text(json.dumps({"error": "session_id invalide"}))
|
||||
continue
|
||||
|
||||
if action == "subscribe":
|
||||
if session_id not in subscribed_sessions:
|
||||
# Register this websocket in the manager for this session
|
||||
if session_id not in manager._connections:
|
||||
manager._connections[session_id] = []
|
||||
manager._connections[session_id].append(websocket)
|
||||
subscribed_sessions.add(session_id)
|
||||
|
||||
await websocket.send_text(
|
||||
json.dumps({"event": "subscribed", "session_id": str(session_id)})
|
||||
)
|
||||
|
||||
elif action == "unsubscribe":
|
||||
if session_id in subscribed_sessions:
|
||||
manager.disconnect(websocket, session_id)
|
||||
subscribed_sessions.discard(session_id)
|
||||
|
||||
await websocket.send_text(
|
||||
json.dumps({"event": "unsubscribed", "session_id": str(session_id)})
|
||||
)
|
||||
|
||||
else:
|
||||
await websocket.send_text(
|
||||
json.dumps({"error": f"Action inconnue: {action}"})
|
||||
)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
# Clean up all subscriptions for this client
|
||||
for session_id in subscribed_sessions:
|
||||
manager.disconnect(websocket, session_id)
|
||||
logger.debug("WebSocket deconnecte, %d souscriptions nettoyees", len(subscribed_sessions))
|
||||
finally:
|
||||
# Cancel the heartbeat task
|
||||
heartbeat_task.cancel()
|
||||
try:
|
||||
await heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ── Request schemas ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class ChallengeRequest(BaseModel):
|
||||
"""Request a challenge nonce for Ed25519 authentication."""
|
||||
|
||||
address: str = Field(..., min_length=1, max_length=64, description="Duniter V2 SS58 address")
|
||||
|
||||
|
||||
class VerifyRequest(BaseModel):
|
||||
"""Submit the signed challenge to obtain a session token."""
|
||||
|
||||
address: str = Field(..., min_length=1, max_length=64)
|
||||
signature: str = Field(..., description="Ed25519 signature of the challenge (hex)")
|
||||
challenge: str = Field(..., description="The challenge string that was signed")
|
||||
|
||||
|
||||
# ── Response schemas ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class ChallengeResponse(BaseModel):
|
||||
"""Returned after requesting a challenge."""
|
||||
|
||||
challenge: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class IdentityOut(BaseModel):
|
||||
"""Public representation of a Duniter identity."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
address: str
|
||||
display_name: str | None = None
|
||||
wot_status: str
|
||||
is_smith: bool
|
||||
is_techcomm: bool
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Returned after successful challenge verification."""
|
||||
|
||||
token: str
|
||||
identity: IdentityOut
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ── Decision ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DecisionStepCreate(BaseModel):
|
||||
"""Payload for creating a step within a decision process."""
|
||||
|
||||
step_order: int = Field(..., ge=0)
|
||||
step_type: str = Field(..., max_length=32, description="qualification, review, vote, execution, reporting")
|
||||
title: str | None = Field(default=None, max_length=256)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class DecisionStepOut(BaseModel):
|
||||
"""Full decision step representation."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
decision_id: UUID
|
||||
step_order: int
|
||||
step_type: str
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: str
|
||||
vote_session_id: UUID | None = None
|
||||
outcome: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class DecisionCreate(BaseModel):
|
||||
"""Payload for creating a new decision."""
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=256)
|
||||
description: str | None = None
|
||||
context: str | None = None
|
||||
decision_type: str = Field(..., max_length=64, description="runtime_upgrade, document_change, mandate_vote, custom")
|
||||
voting_protocol_id: UUID | None = None
|
||||
|
||||
|
||||
class DecisionUpdate(BaseModel):
|
||||
"""Partial update for a decision."""
|
||||
|
||||
title: str | None = Field(default=None, max_length=256)
|
||||
description: str | None = None
|
||||
status: str | None = Field(default=None, max_length=32)
|
||||
voting_protocol_id: UUID | None = None
|
||||
|
||||
|
||||
class DecisionOut(BaseModel):
|
||||
"""Full decision representation returned by the API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
title: str
|
||||
description: str | None = None
|
||||
context: str | None = None
|
||||
decision_type: str
|
||||
status: str
|
||||
voting_protocol_id: UUID | None = None
|
||||
created_by_id: UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
steps: list[DecisionStepOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DecisionAdvanceOut(BaseModel):
|
||||
"""Output after advancing a decision through its workflow."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
title: str
|
||||
description: str | None = None
|
||||
context: str | None = None
|
||||
decision_type: str
|
||||
status: str
|
||||
voting_protocol_id: UUID | None = None
|
||||
created_by_id: UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
steps: list[DecisionStepOut] = Field(default_factory=list)
|
||||
message: str = Field(..., description="Message decrivant l'avancement effectue")
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ── Document ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DocumentCreate(BaseModel):
|
||||
"""Payload for creating a new reference document."""
|
||||
|
||||
slug: str = Field(..., min_length=1, max_length=128)
|
||||
title: str = Field(..., min_length=1, max_length=256)
|
||||
doc_type: str = Field(..., max_length=64, description="licence, engagement, reglement, constitution")
|
||||
description: str | None = None
|
||||
version: str | None = Field(default="0.1.0", max_length=32)
|
||||
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
"""Partial update for a document."""
|
||||
|
||||
title: str | None = Field(default=None, max_length=256)
|
||||
status: str | None = Field(default=None, max_length=32)
|
||||
description: str | None = None
|
||||
version: str | None = Field(default=None, max_length=32)
|
||||
|
||||
|
||||
class DocumentOut(BaseModel):
|
||||
"""Full document representation returned by the API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
slug: str
|
||||
title: str
|
||||
doc_type: str
|
||||
version: str
|
||||
status: str
|
||||
description: str | None = None
|
||||
ipfs_cid: str | None = None
|
||||
chain_anchor: str | None = None
|
||||
genesis_json: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
items_count: int = Field(default=0, description="Number of items in this document")
|
||||
|
||||
|
||||
# ── Document Item ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DocumentItemCreate(BaseModel):
|
||||
"""Payload for creating a document item (clause, rule, etc.)."""
|
||||
|
||||
position: str = Field(..., max_length=16, description='Hierarchical position e.g. "1", "1.1", "3.2"')
|
||||
item_type: str = Field(default="clause", max_length=32, description="clause, rule, verification, preamble, section, genesis")
|
||||
title: str | None = Field(default=None, max_length=256)
|
||||
current_text: str = Field(..., min_length=1)
|
||||
voting_protocol_id: UUID | None = None
|
||||
section_tag: str | None = Field(default=None, max_length=64)
|
||||
inertia_preset: str = Field(default="standard", max_length=16)
|
||||
is_permanent_vote: bool = True
|
||||
|
||||
|
||||
class DocumentItemUpdate(BaseModel):
|
||||
"""Partial update for a document item."""
|
||||
|
||||
title: str | None = Field(default=None, max_length=256)
|
||||
current_text: str | None = Field(default=None, min_length=1)
|
||||
position: str | None = Field(default=None, max_length=16)
|
||||
item_type: str | None = Field(default=None, max_length=32)
|
||||
|
||||
|
||||
class DocumentItemOut(BaseModel):
|
||||
"""Full document item representation."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
document_id: UUID
|
||||
position: str
|
||||
item_type: str
|
||||
title: str | None = None
|
||||
current_text: str
|
||||
voting_protocol_id: UUID | None = None
|
||||
sort_order: int
|
||||
section_tag: str | None = None
|
||||
inertia_preset: str = "standard"
|
||||
is_permanent_vote: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DocumentItemFullOut(BaseModel):
|
||||
"""Document item with its full version history."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
document_id: UUID
|
||||
position: str
|
||||
item_type: str
|
||||
title: str | None = None
|
||||
current_text: str
|
||||
voting_protocol_id: UUID | None = None
|
||||
sort_order: int
|
||||
section_tag: str | None = None
|
||||
inertia_preset: str = "standard"
|
||||
is_permanent_vote: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
versions: list[ItemVersionOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DocumentFullOut(BaseModel):
|
||||
"""Document with full items list (not just count)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
slug: str
|
||||
title: str
|
||||
doc_type: str
|
||||
version: str
|
||||
status: str
|
||||
description: str | None = None
|
||||
ipfs_cid: str | None = None
|
||||
chain_anchor: str | None = None
|
||||
genesis_json: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
items: list[DocumentItemOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── Item Reorder ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ItemReorderEntry(BaseModel):
|
||||
"""A single item reorder entry."""
|
||||
|
||||
item_id: UUID
|
||||
sort_order: int = Field(..., ge=0)
|
||||
|
||||
|
||||
class ItemReorderRequest(BaseModel):
|
||||
"""Payload for reordering items in a document."""
|
||||
|
||||
items: list[ItemReorderEntry]
|
||||
|
||||
|
||||
# ── Item Version ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ItemVersionCreate(BaseModel):
|
||||
"""Payload for proposing a new version of a document item."""
|
||||
|
||||
proposed_text: str = Field(..., min_length=1)
|
||||
rationale: str | None = None
|
||||
|
||||
|
||||
class ItemVersionOut(BaseModel):
|
||||
"""Full item version representation."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
item_id: UUID
|
||||
proposed_text: str
|
||||
diff_text: str | None = None
|
||||
rationale: str | None = None
|
||||
status: str
|
||||
decision_id: UUID | None = None
|
||||
proposed_by_id: UUID | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ── Forward reference resolution ─────────────────────────────────
|
||||
# DocumentItemFullOut references ItemVersionOut which is defined after it.
|
||||
# With `from __future__ import annotations`, Pydantic needs explicit rebuild.
|
||||
|
||||
DocumentItemFullOut.model_rebuild()
|
||||
DocumentFullOut.model_rebuild()
|
||||
@@ -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
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ── Mandate Step ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MandateStepCreate(BaseModel):
|
||||
step_order: int = Field(..., ge=0)
|
||||
step_type: str = Field(..., max_length=32)
|
||||
title: str | None = Field(default=None, max_length=256)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class MandateStepOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
mandate_id: UUID
|
||||
step_order: int
|
||||
step_type: str
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: str
|
||||
vote_session_id: UUID | None = None
|
||||
outcome: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ── Mandate ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MandateCreate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=256)
|
||||
origin_id: UUID | None = None
|
||||
description: str | None = None
|
||||
mandate_type: str = Field(..., max_length=64)
|
||||
nomination_mode: str = Field(
|
||||
default="postpone",
|
||||
description="auto (auto-assign author), collective, postpone",
|
||||
)
|
||||
decision_id: UUID | None = None
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
|
||||
|
||||
class MandateUpdate(BaseModel):
|
||||
title: str | None = Field(default=None, max_length=256)
|
||||
origin_id: UUID | None = None
|
||||
description: str | None = None
|
||||
mandate_type: str | None = Field(default=None, max_length=64)
|
||||
decision_id: UUID | None = None
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
|
||||
|
||||
class MandateAssignRequest(BaseModel):
|
||||
mandatee_id: UUID = Field(..., description="UUID de l'identite Duniter du mandataire")
|
||||
|
||||
|
||||
class MandateOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
title: str
|
||||
origin_id: UUID | None = None
|
||||
origin_display_name: str | None = None
|
||||
description: str | None = None
|
||||
mandate_type: str
|
||||
status: str
|
||||
mandatee_id: UUID | None = None
|
||||
mandatee_display_name: str | None = None
|
||||
decision_id: UUID | None = None
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
steps: list[MandateStepOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MandateAdvanceOut(MandateOut):
|
||||
message: str = Field(..., description="Message decrivant l'avancement effectue")
|
||||
@@ -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,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ── Formula Config ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class FormulaConfigCreate(BaseModel):
|
||||
"""Payload for creating a WoT threshold formula configuration."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=128)
|
||||
description: str | None = None
|
||||
|
||||
# WoT threshold params
|
||||
duration_days: int = Field(default=30, ge=1, description="Duration of the vote in days")
|
||||
majority_pct: int = Field(default=50, ge=1, le=100, description="Majority percentage required")
|
||||
base_exponent: float = Field(default=0.1, ge=0.0, le=1.0, description="Base exponent B in the formula")
|
||||
gradient_exponent: float = Field(default=0.2, ge=0.0, le=2.0, description="Gradient exponent G in the formula")
|
||||
constant_base: float = Field(default=0.0, ge=0.0, le=1.0, description="Constant base C in the formula")
|
||||
|
||||
# Smith criterion
|
||||
smith_exponent: float | None = Field(default=None, ge=0.0, le=1.0, description="Smith criterion exponent S")
|
||||
|
||||
# TechComm criterion
|
||||
techcomm_exponent: float | None = Field(default=None, ge=0.0, le=1.0, description="TechComm criterion exponent T")
|
||||
|
||||
# Nuanced vote
|
||||
nuanced_min_participants: int | None = Field(default=None, ge=0, description="Minimum participants for nuanced vote")
|
||||
nuanced_threshold_pct: int | None = Field(default=None, ge=0, le=100, description="Threshold percentage for nuanced vote")
|
||||
|
||||
|
||||
class FormulaConfigUpdate(BaseModel):
|
||||
"""Partial update payload for a formula configuration (all fields optional)."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
description: str | None = None
|
||||
|
||||
duration_days: int | None = Field(default=None, ge=1)
|
||||
majority_pct: int | None = Field(default=None, ge=1, le=100)
|
||||
base_exponent: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
gradient_exponent: float | None = Field(default=None, ge=0.0, le=2.0)
|
||||
constant_base: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
|
||||
smith_exponent: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
techcomm_exponent: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
|
||||
nuanced_min_participants: int | None = Field(default=None, ge=0)
|
||||
nuanced_threshold_pct: int | None = Field(default=None, ge=0, le=100)
|
||||
|
||||
|
||||
class FormulaConfigOut(BaseModel):
|
||||
"""Full formula configuration representation."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
name: str
|
||||
description: str | None = None
|
||||
duration_days: int
|
||||
majority_pct: int
|
||||
base_exponent: float
|
||||
gradient_exponent: float
|
||||
constant_base: float
|
||||
smith_exponent: float | None = None
|
||||
techcomm_exponent: float | None = None
|
||||
nuanced_min_participants: int | None = None
|
||||
nuanced_threshold_pct: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ── Voting Protocol ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class VotingProtocolCreate(BaseModel):
|
||||
"""Payload for creating a voting protocol."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=128)
|
||||
description: str | None = None
|
||||
vote_type: str = Field(..., max_length=32, description="binary, nuanced")
|
||||
formula_config_id: UUID = Field(..., description="Reference to the formula configuration")
|
||||
mode_params: str | None = Field(default=None, max_length=64, description='e.g. "D30M50B.1G.2T.1"')
|
||||
is_meta_governed: bool = Field(default=False, description="Whether this protocol is itself governed by meta-vote")
|
||||
|
||||
|
||||
class VotingProtocolUpdate(BaseModel):
|
||||
"""Partial update payload for a voting protocol (meta-governance)."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
description: str | None = None
|
||||
vote_type: str | None = Field(default=None, max_length=32)
|
||||
mode_params: str | None = Field(default=None, max_length=64)
|
||||
is_meta_governed: bool | None = None
|
||||
|
||||
|
||||
class VotingProtocolOut(BaseModel):
|
||||
"""Full voting protocol representation including formula config."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
name: str
|
||||
description: str | None = None
|
||||
vote_type: str
|
||||
formula_config_id: UUID
|
||||
mode_params: str | None = None
|
||||
is_meta_governed: bool
|
||||
created_at: datetime
|
||||
formula_config: FormulaConfigOut
|
||||
|
||||
|
||||
# ── Formula Simulation ───────────────────────────────────────────
|
||||
|
||||
|
||||
class FormulaSimulationRequest(BaseModel):
|
||||
"""Request payload for simulating a WoT threshold formula computation."""
|
||||
|
||||
wot_size: int = Field(..., ge=1, description="Size of the WoT (eligible voter corpus)")
|
||||
total_votes: int = Field(..., ge=0, description="Number of votes cast (for + against)")
|
||||
majority_pct: int = Field(default=50, ge=1, le=100, description="Majority percentage M")
|
||||
base_exponent: float = Field(default=0.1, ge=0.0, le=1.0, description="Base exponent B")
|
||||
gradient_exponent: float = Field(default=0.2, ge=0.0, le=2.0, description="Gradient exponent G")
|
||||
constant_base: float = Field(default=0.0, ge=0.0, le=1.0, description="Constant base C")
|
||||
smith_wot_size: int | None = Field(default=None, ge=1, description="Smith sub-WoT size")
|
||||
smith_exponent: float | None = Field(default=None, ge=0.0, le=1.0, description="Smith exponent S")
|
||||
techcomm_size: int | None = Field(default=None, ge=1, description="TechComm size")
|
||||
techcomm_exponent: float | None = Field(default=None, ge=0.0, le=1.0, description="TechComm exponent T")
|
||||
|
||||
|
||||
class FormulaSimulationResult(BaseModel):
|
||||
"""Result of a formula simulation."""
|
||||
|
||||
wot_threshold: int
|
||||
smith_threshold: int | None = None
|
||||
techcomm_threshold: int | None = None
|
||||
participation_rate: float
|
||||
required_ratio: float
|
||||
inertia_factor: float
|
||||
@@ -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,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ── Sanctuary Entry ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class SanctuaryEntryCreate(BaseModel):
|
||||
"""Payload for creating a new sanctuary entry (IPFS + chain anchor)."""
|
||||
|
||||
entry_type: str = Field(..., max_length=64, description="document, decision, vote_result")
|
||||
reference_id: UUID = Field(..., description="ID of the referenced entity")
|
||||
title: str | None = Field(default=None, max_length=256)
|
||||
content_hash: str = Field(..., max_length=128, description="SHA-256 hash of the content")
|
||||
|
||||
|
||||
class SanctuaryEntryOut(BaseModel):
|
||||
"""Full sanctuary entry representation."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
entry_type: str
|
||||
reference_id: UUID
|
||||
title: str | None = None
|
||||
content_hash: str
|
||||
ipfs_cid: str | None = None
|
||||
chain_tx_hash: str | None = None
|
||||
chain_block: int | None = None
|
||||
metadata_json: str | None = None
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ── Vote Session ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class VoteSessionCreate(BaseModel):
|
||||
"""Payload for opening a new vote session."""
|
||||
|
||||
decision_id: UUID | None = None
|
||||
item_version_id: UUID | None = None
|
||||
voting_protocol_id: UUID = Field(..., description="ID of the voting protocol to apply")
|
||||
|
||||
|
||||
class VoteSessionOut(BaseModel):
|
||||
"""Full vote session representation including tallies."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
decision_id: UUID | None = None
|
||||
item_version_id: UUID | None = None
|
||||
voting_protocol_id: UUID
|
||||
|
||||
# Snapshot at session start
|
||||
wot_size: int
|
||||
smith_size: int
|
||||
techcomm_size: int
|
||||
|
||||
# Dates
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
|
||||
# Status
|
||||
status: str
|
||||
|
||||
# Tallies
|
||||
votes_for: int
|
||||
votes_against: int
|
||||
votes_total: int
|
||||
smith_votes_for: int
|
||||
techcomm_votes_for: int
|
||||
threshold_required: float
|
||||
result: str | None = None
|
||||
|
||||
# Chain recording
|
||||
chain_recorded: bool
|
||||
chain_tx_hash: str | None = None
|
||||
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class VoteSessionListOut(BaseModel):
|
||||
"""Lighter vote session representation for list endpoints (no nested votes)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
decision_id: UUID | None = None
|
||||
item_version_id: UUID | None = None
|
||||
voting_protocol_id: UUID
|
||||
|
||||
# Snapshot at session start
|
||||
wot_size: int
|
||||
smith_size: int
|
||||
techcomm_size: int
|
||||
|
||||
# Dates
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
|
||||
# Status
|
||||
status: str
|
||||
|
||||
# Tallies
|
||||
votes_for: int
|
||||
votes_against: int
|
||||
votes_total: int
|
||||
threshold_required: float
|
||||
result: str | None = None
|
||||
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ── Threshold Details ────────────────────────────────────────────
|
||||
|
||||
|
||||
class ThresholdDetailOut(BaseModel):
|
||||
"""Detailed threshold computation result for a vote session."""
|
||||
|
||||
wot_threshold: int
|
||||
smith_threshold: int | None = None
|
||||
techcomm_threshold: int | None = None
|
||||
votes_for: int
|
||||
votes_against: int
|
||||
votes_total: int
|
||||
wot_passed: bool
|
||||
smith_passed: bool | None = None
|
||||
techcomm_passed: bool | None = None
|
||||
overall_passed: bool
|
||||
participation_rate: float
|
||||
formula_params: dict
|
||||
|
||||
|
||||
# ── Vote Result ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class VoteResultOut(BaseModel):
|
||||
"""Structured vote result response."""
|
||||
|
||||
session_id: UUID
|
||||
result: str | None = None # adopted, rejected
|
||||
threshold_required: float
|
||||
votes_for: int
|
||||
votes_against: int
|
||||
votes_total: int
|
||||
adopted: bool
|
||||
nuanced_breakdown: dict | None = None # for nuanced votes
|
||||
|
||||
|
||||
# ── Vote ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class VoteCreate(BaseModel):
|
||||
"""Payload for casting a vote (with cryptographic proof)."""
|
||||
|
||||
session_id: UUID
|
||||
vote_value: str = Field(..., max_length=32, description="for, against, or nuanced level")
|
||||
nuanced_level: int | None = Field(default=None, ge=0, le=5, description="0-5 for nuanced votes")
|
||||
comment: str | None = None
|
||||
signature: str = Field(..., description="Ed25519 signature of signed_payload")
|
||||
signed_payload: str = Field(..., description="The exact payload that was signed")
|
||||
|
||||
|
||||
class VoteOut(BaseModel):
|
||||
"""Full vote representation."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
session_id: UUID
|
||||
voter_id: UUID
|
||||
vote_value: str
|
||||
nuanced_level: int | None = None
|
||||
comment: str | None = None
|
||||
signature: str
|
||||
signed_payload: str
|
||||
voter_wot_status: str
|
||||
voter_is_smith: bool
|
||||
voter_is_techcomm: bool
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Authentication service: challenge generation, token management, current user resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import DuniterIdentity, Session
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
"""SHA-256 hash of a bearer token for storage."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
async def create_session(db: AsyncSession, identity: DuniterIdentity) -> str:
|
||||
"""Create a new session for the given identity, return the raw bearer token."""
|
||||
raw_token = secrets.token_urlsafe(48)
|
||||
token_hash = _hash_token(raw_token)
|
||||
|
||||
session = Session(
|
||||
token_hash=token_hash,
|
||||
identity_id=identity.id,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=settings.TOKEN_EXPIRE_HOURS),
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
|
||||
return raw_token
|
||||
|
||||
|
||||
async def invalidate_session(db: AsyncSession, token: str) -> None:
|
||||
"""Delete the session matching the given raw token."""
|
||||
token_hash = _hash_token(token)
|
||||
result = await db.execute(select(Session).where(Session.token_hash == token_hash))
|
||||
session = result.scalar_one_or_none()
|
||||
if session:
|
||||
await db.delete(session)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_current_identity(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> DuniterIdentity:
|
||||
"""Dependency: resolve the current authenticated identity from the bearer token.
|
||||
|
||||
Raises 401 if the token is missing, invalid, or expired.
|
||||
"""
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentification requise")
|
||||
|
||||
token_hash = _hash_token(credentials.credentials)
|
||||
result = await db.execute(
|
||||
select(Session).where(
|
||||
Session.token_hash == token_hash,
|
||||
Session.expires_at > datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if session is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token invalide ou expire")
|
||||
|
||||
result = await db.execute(select(DuniterIdentity).where(DuniterIdentity.id == session.identity_id))
|
||||
identity = result.scalar_one_or_none()
|
||||
|
||||
if identity is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Identite introuvable")
|
||||
|
||||
return identity
|
||||
|
||||
|
||||
async def get_or_create_identity(
|
||||
db: AsyncSession,
|
||||
address: str,
|
||||
dev_profile: dict | None = None,
|
||||
) -> DuniterIdentity:
|
||||
"""Get an existing identity by address or create a new one.
|
||||
|
||||
If dev_profile is provided, apply the profile attributes on create or update.
|
||||
"""
|
||||
result = await db.execute(select(DuniterIdentity).where(DuniterIdentity.address == address))
|
||||
identity = result.scalar_one_or_none()
|
||||
|
||||
if identity is None:
|
||||
kwargs: dict = {"address": address}
|
||||
if dev_profile:
|
||||
kwargs.update({
|
||||
"display_name": dev_profile.get("display_name"),
|
||||
"wot_status": dev_profile.get("wot_status", "unknown"),
|
||||
"is_smith": dev_profile.get("is_smith", False),
|
||||
"is_techcomm": dev_profile.get("is_techcomm", False),
|
||||
})
|
||||
identity = DuniterIdentity(**kwargs)
|
||||
db.add(identity)
|
||||
await db.commit()
|
||||
await db.refresh(identity)
|
||||
elif dev_profile:
|
||||
# Update existing identity with dev profile data
|
||||
identity.display_name = dev_profile.get("display_name", identity.display_name)
|
||||
identity.wot_status = dev_profile.get("wot_status", identity.wot_status)
|
||||
identity.is_smith = dev_profile.get("is_smith", identity.is_smith)
|
||||
identity.is_techcomm = dev_profile.get("is_techcomm", identity.is_techcomm)
|
||||
await db.commit()
|
||||
await db.refresh(identity)
|
||||
|
||||
return identity
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Blockchain service: retrieve on-chain data from Duniter V2.
|
||||
|
||||
Provides functions to query WoT size, Smith sub-WoT size, and
|
||||
Technical Committee size from the Duniter V2 blockchain.
|
||||
|
||||
Architecture:
|
||||
1. Check database cache (via cache_service)
|
||||
2. Try JSON-RPC call to Duniter node
|
||||
3. Fall back to hardcoded GDev test values (with warning log)
|
||||
|
||||
All public functions accept a db session for cache access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.services import cache_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hardcoded fallback values from GDev snapshot
|
||||
_FALLBACK_WOT_SIZE = 7224
|
||||
_FALLBACK_SMITH_SIZE = 20
|
||||
_FALLBACK_TECHCOMM_SIZE = 5
|
||||
|
||||
# Cache key prefixes
|
||||
_CACHE_KEY_WOT = "blockchain:wot_size"
|
||||
_CACHE_KEY_SMITH = "blockchain:smith_size"
|
||||
_CACHE_KEY_TECHCOMM = "blockchain:techcomm_size"
|
||||
|
||||
|
||||
async def _fetch_from_rpc(method: str, params: list | None = None) -> dict | None:
|
||||
"""Send a JSON-RPC POST request to the Duniter node.
|
||||
|
||||
Uses the HTTP variant of the RPC URL. If DUNITER_RPC_URL starts with
|
||||
``wss://`` or ``ws://``, it is converted to ``https://`` or ``http://``
|
||||
for the HTTP JSON-RPC endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
method:
|
||||
The RPC method name (e.g. ``"state_getStorage"``).
|
||||
params:
|
||||
Optional list of parameters for the RPC call.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict | None
|
||||
The ``"result"`` field from the JSON-RPC response, or None on error.
|
||||
"""
|
||||
# Convert WebSocket URL to HTTP for JSON-RPC
|
||||
rpc_url = settings.DUNITER_RPC_URL
|
||||
if rpc_url.startswith("wss://"):
|
||||
rpc_url = rpc_url.replace("wss://", "https://", 1)
|
||||
elif rpc_url.startswith("ws://"):
|
||||
rpc_url = rpc_url.replace("ws://", "http://", 1)
|
||||
|
||||
# Strip /ws suffix if present
|
||||
if rpc_url.endswith("/ws"):
|
||||
rpc_url = rpc_url[:-3]
|
||||
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params or [],
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=settings.DUNITER_RPC_TIMEOUT_SECONDS
|
||||
) as client:
|
||||
response = await client.post(rpc_url, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if "error" in data:
|
||||
logger.warning(
|
||||
"Erreur RPC Duniter pour %s: %s",
|
||||
method, data["error"],
|
||||
)
|
||||
return None
|
||||
|
||||
return data.get("result")
|
||||
|
||||
except httpx.ConnectError:
|
||||
logger.warning(
|
||||
"Impossible de se connecter au noeud Duniter (%s)", rpc_url
|
||||
)
|
||||
return None
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(
|
||||
"Timeout lors de l'appel RPC Duniter pour %s (%s)",
|
||||
method, rpc_url,
|
||||
)
|
||||
return None
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning(
|
||||
"Erreur HTTP Duniter RPC pour %s: %s",
|
||||
method, exc.response.status_code,
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Erreur inattendue lors de l'appel RPC Duniter pour %s",
|
||||
method,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_membership_count(db: AsyncSession) -> int | None:
|
||||
"""Fetch WoT membership count from the Duniter RPC.
|
||||
|
||||
Queries ``membership_membershipsCount`` via state RPC.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int | None
|
||||
The membership count, or None if the RPC call failed.
|
||||
"""
|
||||
# Try runtime API call for membership count
|
||||
result = await _fetch_from_rpc("membership_membershipsCount", [])
|
||||
if result is not None:
|
||||
try:
|
||||
count = int(result)
|
||||
# Cache the result
|
||||
await cache_service.set_cached(
|
||||
_CACHE_KEY_WOT,
|
||||
{"value": count},
|
||||
db,
|
||||
ttl_seconds=settings.BLOCKCHAIN_CACHE_TTL_SECONDS,
|
||||
)
|
||||
return count
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Reponse RPC invalide pour membership count: %s", result)
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_smith_count(db: AsyncSession) -> int | None:
|
||||
"""Fetch Smith membership count from the Duniter RPC.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int | None
|
||||
The Smith member count, or None if the RPC call failed.
|
||||
"""
|
||||
result = await _fetch_from_rpc("smithMembers_smithMembersCount", [])
|
||||
if result is not None:
|
||||
try:
|
||||
count = int(result)
|
||||
await cache_service.set_cached(
|
||||
_CACHE_KEY_SMITH,
|
||||
{"value": count},
|
||||
db,
|
||||
ttl_seconds=settings.BLOCKCHAIN_CACHE_TTL_SECONDS,
|
||||
)
|
||||
return count
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Reponse RPC invalide pour smith count: %s", result)
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_techcomm_count(db: AsyncSession) -> int | None:
|
||||
"""Fetch Technical Committee member count from the Duniter RPC.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int | None
|
||||
The TechComm member count, or None if the RPC call failed.
|
||||
"""
|
||||
result = await _fetch_from_rpc("technicalCommittee_members", [])
|
||||
if result is not None:
|
||||
try:
|
||||
if isinstance(result, list):
|
||||
count = len(result)
|
||||
else:
|
||||
count = int(result)
|
||||
await cache_service.set_cached(
|
||||
_CACHE_KEY_TECHCOMM,
|
||||
{"value": count},
|
||||
db,
|
||||
ttl_seconds=settings.BLOCKCHAIN_CACHE_TTL_SECONDS,
|
||||
)
|
||||
return count
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Reponse RPC invalide pour techcomm count: %s", result)
|
||||
return None
|
||||
|
||||
|
||||
async def get_wot_size(db: AsyncSession) -> int:
|
||||
"""Return the current number of WoT members.
|
||||
|
||||
Resolution order:
|
||||
1. Database cache (if not expired)
|
||||
2. Duniter RPC call
|
||||
3. Hardcoded fallback (7224, GDev snapshot)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db:
|
||||
Async database session (for cache access).
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Number of WoT members.
|
||||
"""
|
||||
# 1. Try cache
|
||||
cached = await cache_service.get_cached(_CACHE_KEY_WOT, db)
|
||||
if cached is not None:
|
||||
return cached["value"]
|
||||
|
||||
# 2. Try RPC
|
||||
rpc_value = await _fetch_membership_count(db)
|
||||
if rpc_value is not None:
|
||||
return rpc_value
|
||||
|
||||
# 3. Fallback
|
||||
logger.warning(
|
||||
"Utilisation de la valeur WoT par defaut (%d) - "
|
||||
"cache et RPC indisponibles",
|
||||
_FALLBACK_WOT_SIZE,
|
||||
)
|
||||
return _FALLBACK_WOT_SIZE
|
||||
|
||||
|
||||
async def get_smith_size(db: AsyncSession) -> int:
|
||||
"""Return the current number of Smith members (forgerons).
|
||||
|
||||
Resolution order:
|
||||
1. Database cache (if not expired)
|
||||
2. Duniter RPC call
|
||||
3. Hardcoded fallback (20, GDev snapshot)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db:
|
||||
Async database session (for cache access).
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Number of Smith members.
|
||||
"""
|
||||
# 1. Try cache
|
||||
cached = await cache_service.get_cached(_CACHE_KEY_SMITH, db)
|
||||
if cached is not None:
|
||||
return cached["value"]
|
||||
|
||||
# 2. Try RPC
|
||||
rpc_value = await _fetch_smith_count(db)
|
||||
if rpc_value is not None:
|
||||
return rpc_value
|
||||
|
||||
# 3. Fallback
|
||||
logger.warning(
|
||||
"Utilisation de la valeur Smith par defaut (%d) - "
|
||||
"cache et RPC indisponibles",
|
||||
_FALLBACK_SMITH_SIZE,
|
||||
)
|
||||
return _FALLBACK_SMITH_SIZE
|
||||
|
||||
|
||||
async def get_techcomm_size(db: AsyncSession) -> int:
|
||||
"""Return the current number of Technical Committee members.
|
||||
|
||||
Resolution order:
|
||||
1. Database cache (if not expired)
|
||||
2. Duniter RPC call
|
||||
3. Hardcoded fallback (5, GDev snapshot)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db:
|
||||
Async database session (for cache access).
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Number of TechComm members.
|
||||
"""
|
||||
# 1. Try cache
|
||||
cached = await cache_service.get_cached(_CACHE_KEY_TECHCOMM, db)
|
||||
if cached is not None:
|
||||
return cached["value"]
|
||||
|
||||
# 2. Try RPC
|
||||
rpc_value = await _fetch_techcomm_count(db)
|
||||
if rpc_value is not None:
|
||||
return rpc_value
|
||||
|
||||
# 3. Fallback
|
||||
logger.warning(
|
||||
"Utilisation de la valeur TechComm par defaut (%d) - "
|
||||
"cache et RPC indisponibles",
|
||||
_FALLBACK_TECHCOMM_SIZE,
|
||||
)
|
||||
return _FALLBACK_TECHCOMM_SIZE
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Cache service: blockchain data caching with TTL expiry.
|
||||
|
||||
Uses the BlockchainCache model (PostgreSQL/JSONB) to cache
|
||||
on-chain data like WoT size, Smith size, and TechComm size.
|
||||
Avoids repeated RPC calls to the Duniter node.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.cache import BlockchainCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_cached(key: str, db: AsyncSession) -> dict | None:
|
||||
"""Retrieve a cached value by key if it has not expired.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key:
|
||||
The cache key to look up.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict | None
|
||||
The cached value as a dict, or None if missing/expired.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(BlockchainCache).where(
|
||||
BlockchainCache.cache_key == key,
|
||||
BlockchainCache.expires_at > datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
|
||||
if entry is None:
|
||||
logger.debug("Cache miss pour la cle '%s'", key)
|
||||
return None
|
||||
|
||||
logger.debug("Cache hit pour la cle '%s'", key)
|
||||
return entry.cache_value
|
||||
|
||||
|
||||
async def set_cached(
|
||||
key: str,
|
||||
value: dict,
|
||||
db: AsyncSession,
|
||||
ttl_seconds: int = 3600,
|
||||
) -> None:
|
||||
"""Store a value in the cache with the given TTL.
|
||||
|
||||
If the key already exists, it is replaced (upsert).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key:
|
||||
The cache key.
|
||||
value:
|
||||
The value to store (must be JSON-serializable).
|
||||
db:
|
||||
Async database session.
|
||||
ttl_seconds:
|
||||
Time-to-live in seconds (default: 1 hour).
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(seconds=ttl_seconds)
|
||||
|
||||
# Check if key already exists
|
||||
result = await db.execute(
|
||||
select(BlockchainCache).where(BlockchainCache.cache_key == key)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
existing.cache_value = value
|
||||
existing.fetched_at = now
|
||||
existing.expires_at = expires_at
|
||||
logger.debug("Cache mis a jour pour la cle '%s' (TTL=%ds)", key, ttl_seconds)
|
||||
else:
|
||||
entry = BlockchainCache(
|
||||
cache_key=key,
|
||||
cache_value=value,
|
||||
fetched_at=now,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(entry)
|
||||
logger.debug("Cache cree pour la cle '%s' (TTL=%ds)", key, ttl_seconds)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def invalidate(key: str, db: AsyncSession) -> None:
|
||||
"""Remove a specific cache entry by key.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key:
|
||||
The cache key to invalidate.
|
||||
db:
|
||||
Async database session.
|
||||
"""
|
||||
await db.execute(
|
||||
delete(BlockchainCache).where(BlockchainCache.cache_key == key)
|
||||
)
|
||||
await db.commit()
|
||||
logger.debug("Cache invalide pour la cle '%s'", key)
|
||||
|
||||
|
||||
async def cleanup_expired(db: AsyncSession) -> int:
|
||||
"""Remove all expired cache entries.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Number of entries removed.
|
||||
"""
|
||||
result = await db.execute(
|
||||
delete(BlockchainCache).where(
|
||||
BlockchainCache.expires_at <= datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
count = result.rowcount
|
||||
if count > 0:
|
||||
logger.info("Nettoyage cache: %d entrees expirees supprimees", count)
|
||||
return count
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Decision service: step advancement logic and vote session integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.decision import Decision, DecisionStep
|
||||
from app.models.protocol import VotingProtocol, FormulaConfig
|
||||
from app.models.vote import VoteSession
|
||||
|
||||
|
||||
# Valid status transitions for decisions
|
||||
_DECISION_STATUS_ORDER = [
|
||||
"draft",
|
||||
"qualification",
|
||||
"review",
|
||||
"voting",
|
||||
"executed",
|
||||
"closed",
|
||||
]
|
||||
|
||||
|
||||
async def advance_decision(decision_id: uuid.UUID, db: AsyncSession) -> Decision:
|
||||
"""Move a decision to its next step.
|
||||
|
||||
Completes the current active step and activates the next pending step.
|
||||
If no more steps remain, the decision status advances to the next phase.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
decision_id:
|
||||
UUID of the Decision to advance.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Decision
|
||||
The updated decision.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the decision is not found, or no further advancement is possible.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Decision)
|
||||
.options(selectinload(Decision.steps))
|
||||
.where(Decision.id == decision_id)
|
||||
)
|
||||
decision = result.scalar_one_or_none()
|
||||
if decision is None:
|
||||
raise ValueError(f"Decision introuvable : {decision_id}")
|
||||
|
||||
if decision.status == "closed":
|
||||
raise ValueError("La decision est deja cloturee")
|
||||
|
||||
steps: list[DecisionStep] = sorted(decision.steps, key=lambda s: s.step_order)
|
||||
|
||||
# Find the current active step
|
||||
active_step: DecisionStep | None = None
|
||||
for step in steps:
|
||||
if step.status == "active":
|
||||
active_step = step
|
||||
break
|
||||
|
||||
if active_step is not None:
|
||||
# Complete the active step
|
||||
active_step.status = "completed"
|
||||
|
||||
# Activate the next pending step
|
||||
next_step: DecisionStep | None = None
|
||||
for step in steps:
|
||||
if step.step_order > active_step.step_order and step.status == "pending":
|
||||
next_step = step
|
||||
break
|
||||
|
||||
if next_step is not None:
|
||||
next_step.status = "active"
|
||||
else:
|
||||
# No more steps: advance the decision status
|
||||
_advance_decision_status(decision)
|
||||
else:
|
||||
# No active step: try to activate the first pending step
|
||||
first_pending: DecisionStep | None = None
|
||||
for step in steps:
|
||||
if step.status == "pending":
|
||||
first_pending = step
|
||||
break
|
||||
|
||||
if first_pending is not None:
|
||||
first_pending.status = "active"
|
||||
# Also advance decision out of draft if needed
|
||||
if decision.status == "draft":
|
||||
decision.status = "qualification"
|
||||
else:
|
||||
# All steps are completed: advance the decision status
|
||||
_advance_decision_status(decision)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(decision)
|
||||
|
||||
return decision
|
||||
|
||||
|
||||
def _advance_decision_status(decision: Decision) -> None:
|
||||
"""Move a decision to its next status in the lifecycle."""
|
||||
try:
|
||||
current_index = _DECISION_STATUS_ORDER.index(decision.status)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
next_index = current_index + 1
|
||||
if next_index < len(_DECISION_STATUS_ORDER):
|
||||
decision.status = _DECISION_STATUS_ORDER[next_index]
|
||||
|
||||
|
||||
async def create_vote_session_for_step(
|
||||
decision_id: uuid.UUID,
|
||||
step_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> VoteSession:
|
||||
"""Create a VoteSession linked to a decision step.
|
||||
|
||||
Uses the decision's voting_protocol to configure the session.
|
||||
Raises ValueError if the decision or step is not found, the step
|
||||
already has a vote session, or no voting protocol is configured.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
decision_id:
|
||||
UUID of the Decision.
|
||||
step_id:
|
||||
UUID of the DecisionStep to attach the vote session to.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
VoteSession
|
||||
The newly created vote session.
|
||||
"""
|
||||
# Fetch decision with steps
|
||||
result = await db.execute(
|
||||
select(Decision)
|
||||
.options(selectinload(Decision.steps))
|
||||
.where(Decision.id == decision_id)
|
||||
)
|
||||
decision = result.scalar_one_or_none()
|
||||
if decision is None:
|
||||
raise ValueError(f"Decision introuvable : {decision_id}")
|
||||
|
||||
# Find the step
|
||||
step: DecisionStep | None = None
|
||||
for s in decision.steps:
|
||||
if s.id == step_id:
|
||||
step = s
|
||||
break
|
||||
|
||||
if step is None:
|
||||
raise ValueError(f"Etape introuvable : {step_id}")
|
||||
|
||||
# Check step doesn't already have a vote session
|
||||
if step.vote_session_id is not None:
|
||||
raise ValueError("Cette etape possede deja une session de vote")
|
||||
|
||||
# Check voting protocol is configured
|
||||
if decision.voting_protocol_id is None:
|
||||
raise ValueError("Aucun protocole de vote configure pour cette decision")
|
||||
|
||||
# Fetch the voting protocol and its formula config for duration
|
||||
proto_result = await db.execute(
|
||||
select(VotingProtocol)
|
||||
.options(selectinload(VotingProtocol.formula_config))
|
||||
.where(VotingProtocol.id == decision.voting_protocol_id)
|
||||
)
|
||||
protocol = proto_result.scalar_one_or_none()
|
||||
if protocol is None:
|
||||
raise ValueError("Protocole de vote introuvable")
|
||||
|
||||
duration_days = protocol.formula_config.duration_days if protocol.formula_config else 30
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Create the vote session with explicit UUID (needed before flush)
|
||||
session_id = uuid.uuid4()
|
||||
session = VoteSession(
|
||||
id=session_id,
|
||||
decision_id=decision.id,
|
||||
voting_protocol_id=protocol.id,
|
||||
starts_at=now,
|
||||
ends_at=now + timedelta(days=duration_days),
|
||||
status="open",
|
||||
)
|
||||
db.add(session)
|
||||
|
||||
# Link session to step
|
||||
step.vote_session_id = session_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
return session
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Document service: retrieval and version management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.document import Document, DocumentItem, ItemVersion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_document_with_items(slug: str, db: AsyncSession) -> Document | None:
|
||||
"""Load a document with all its items and their versions, eagerly.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
slug:
|
||||
Unique slug of the document.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Document | None
|
||||
The document with items and versions loaded, or None if not found.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Document)
|
||||
.options(
|
||||
selectinload(Document.items).selectinload(DocumentItem.versions)
|
||||
)
|
||||
.where(Document.slug == slug)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def apply_version(
|
||||
item_id: uuid.UUID,
|
||||
version_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> DocumentItem:
|
||||
"""Apply an accepted version to a document item.
|
||||
|
||||
This replaces the item's current_text with the version's proposed_text
|
||||
and marks the version as 'accepted'.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
item_id:
|
||||
UUID of the DocumentItem to update.
|
||||
version_id:
|
||||
UUID of the ItemVersion to apply.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
DocumentItem
|
||||
The updated document item.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the item or version is not found, or the version does not
|
||||
belong to the item.
|
||||
"""
|
||||
# Load item
|
||||
item_result = await db.execute(
|
||||
select(DocumentItem).where(DocumentItem.id == item_id)
|
||||
)
|
||||
item = item_result.scalar_one_or_none()
|
||||
if item is None:
|
||||
raise ValueError(f"Element de document introuvable : {item_id}")
|
||||
|
||||
# Load version
|
||||
version_result = await db.execute(
|
||||
select(ItemVersion).where(ItemVersion.id == version_id)
|
||||
)
|
||||
version = version_result.scalar_one_or_none()
|
||||
if version is None:
|
||||
raise ValueError(f"Version introuvable : {version_id}")
|
||||
|
||||
if version.item_id != item.id:
|
||||
raise ValueError(
|
||||
f"La version {version_id} n'appartient pas a l'element {item_id}"
|
||||
)
|
||||
|
||||
# Apply the version
|
||||
item.current_text = version.proposed_text
|
||||
version.status = "accepted"
|
||||
|
||||
# Mark all other pending/voting versions for this item as rejected
|
||||
other_versions_result = await db.execute(
|
||||
select(ItemVersion).where(
|
||||
ItemVersion.item_id == item_id,
|
||||
ItemVersion.id != version_id,
|
||||
ItemVersion.status.in_(["proposed", "voting"]),
|
||||
)
|
||||
)
|
||||
for other_version in other_versions_result.scalars():
|
||||
other_version.status = "rejected"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
return item
|
||||
|
||||
|
||||
async def reject_version(
|
||||
item_id: uuid.UUID,
|
||||
version_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> ItemVersion:
|
||||
"""Mark a version as rejected.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
item_id:
|
||||
UUID of the DocumentItem the version belongs to.
|
||||
version_id:
|
||||
UUID of the ItemVersion to reject.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ItemVersion
|
||||
The rejected version.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the item or version is not found, or the version does not
|
||||
belong to the item.
|
||||
"""
|
||||
# Load item to verify existence
|
||||
item_result = await db.execute(
|
||||
select(DocumentItem).where(DocumentItem.id == item_id)
|
||||
)
|
||||
item = item_result.scalar_one_or_none()
|
||||
if item is None:
|
||||
raise ValueError(f"Element de document introuvable : {item_id}")
|
||||
|
||||
# Load version
|
||||
version_result = await db.execute(
|
||||
select(ItemVersion).where(ItemVersion.id == version_id)
|
||||
)
|
||||
version = version_result.scalar_one_or_none()
|
||||
if version is None:
|
||||
raise ValueError(f"Version introuvable : {version_id}")
|
||||
|
||||
if version.item_id != item.id:
|
||||
raise ValueError(
|
||||
f"La version {version_id} n'appartient pas a l'element {item_id}"
|
||||
)
|
||||
|
||||
version.status = "rejected"
|
||||
await db.commit()
|
||||
await db.refresh(version)
|
||||
|
||||
return version
|
||||
|
||||
|
||||
async def get_item_with_versions(
|
||||
item_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> DocumentItem | None:
|
||||
"""Eager-load a document item with all its versions.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
item_id:
|
||||
UUID of the DocumentItem.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
DocumentItem | None
|
||||
The item with versions loaded, or None if not found.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(DocumentItem)
|
||||
.options(selectinload(DocumentItem.versions))
|
||||
.where(DocumentItem.id == item_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def reorder_items(
|
||||
document_id: uuid.UUID,
|
||||
items_order: list[tuple[uuid.UUID, int]],
|
||||
db: AsyncSession,
|
||||
) -> list[DocumentItem]:
|
||||
"""Update sort_order for multiple items in a document.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
document_id:
|
||||
UUID of the document.
|
||||
items_order:
|
||||
List of (item_id, sort_order) tuples.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[DocumentItem]
|
||||
The updated items, ordered by sort_order.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If any item is not found or does not belong to the document.
|
||||
"""
|
||||
for item_id, sort_order in items_order:
|
||||
result = await db.execute(
|
||||
select(DocumentItem).where(
|
||||
DocumentItem.id == item_id,
|
||||
DocumentItem.document_id == document_id,
|
||||
)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if item is None:
|
||||
raise ValueError(
|
||||
f"Element {item_id} introuvable dans le document {document_id}"
|
||||
)
|
||||
item.sort_order = sort_order
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Return all items in new order
|
||||
result = await db.execute(
|
||||
select(DocumentItem)
|
||||
.where(DocumentItem.document_id == document_id)
|
||||
.order_by(DocumentItem.sort_order)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
def serialize_document_to_text(doc: Document) -> str:
|
||||
"""Serialize a document and its items to a plain-text representation.
|
||||
|
||||
The items must be eagerly loaded on the document before calling this.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
doc:
|
||||
Document with items loaded.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Plain-text serialization suitable for hashing and archival.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
lines.append(f"# {doc.title}")
|
||||
lines.append(f"Version: {doc.version}")
|
||||
lines.append(f"Type: {doc.doc_type}")
|
||||
lines.append(f"Statut: {doc.status}")
|
||||
if doc.description:
|
||||
lines.append(f"Description: {doc.description}")
|
||||
lines.append("")
|
||||
|
||||
# Sort items by sort_order
|
||||
sorted_items = sorted(doc.items, key=lambda i: i.sort_order)
|
||||
for item in sorted_items:
|
||||
header = f"## {item.position}"
|
||||
if item.title:
|
||||
header += f" - {item.title}"
|
||||
header += f" [{item.item_type}]"
|
||||
lines.append(header)
|
||||
lines.append(item.current_text)
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def archive_document(slug: str, db: AsyncSession):
|
||||
"""Serialize a document to text and archive it to the sanctuary.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
slug:
|
||||
Slug of the document to archive.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
SanctuaryEntry
|
||||
The newly created sanctuary entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the document is not found.
|
||||
"""
|
||||
from app.services import sanctuary_service
|
||||
|
||||
doc = await get_document_with_items(slug, db)
|
||||
if doc is None:
|
||||
raise ValueError(f"Document introuvable : {slug}")
|
||||
|
||||
content = serialize_document_to_text(doc)
|
||||
|
||||
entry = await sanctuary_service.archive_to_sanctuary(
|
||||
entry_type="document",
|
||||
reference_id=doc.id,
|
||||
content=content,
|
||||
title=f"{doc.title} v{doc.version}",
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Update document with sanctuary references
|
||||
if entry.ipfs_cid:
|
||||
doc.ipfs_cid = entry.ipfs_cid
|
||||
if entry.chain_tx_hash:
|
||||
doc.chain_anchor = entry.chain_tx_hash
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(doc)
|
||||
|
||||
return entry
|
||||
@@ -0,0 +1,125 @@
|
||||
"""IPFS service: upload, retrieve, and pin content via kubo HTTP API.
|
||||
|
||||
Uses httpx async client to communicate with the local kubo node.
|
||||
All operations handle connection errors gracefully: they log a warning
|
||||
and return None instead of crashing the caller.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Timeout for IPFS operations (seconds)
|
||||
_IPFS_TIMEOUT = 30.0
|
||||
|
||||
|
||||
async def upload_to_ipfs(content: str | bytes) -> str | None:
|
||||
"""Upload content to IPFS via kubo HTTP API (POST /api/v0/add).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
content:
|
||||
The content to upload. Strings are encoded as UTF-8.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str | None
|
||||
The IPFS CID (Content Identifier) of the uploaded content,
|
||||
or None if the upload failed.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
content = content.encode("utf-8")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
|
||||
response = await client.post(
|
||||
f"{settings.IPFS_API_URL}/api/v0/add",
|
||||
files={"file": ("content.txt", content, "application/octet-stream")},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
cid = data.get("Hash")
|
||||
if cid:
|
||||
logger.info("Contenu uploade sur IPFS: CID=%s", cid)
|
||||
return cid
|
||||
except httpx.ConnectError:
|
||||
logger.warning("Impossible de se connecter au noeud IPFS (%s)", settings.IPFS_API_URL)
|
||||
return None
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning("Erreur HTTP IPFS lors de l'upload: %s", exc.response.status_code)
|
||||
return None
|
||||
except Exception:
|
||||
logger.warning("Erreur inattendue lors de l'upload IPFS", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def get_from_ipfs(cid: str) -> bytes | None:
|
||||
"""Retrieve content from IPFS by CID via the gateway.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cid:
|
||||
The IPFS Content Identifier to retrieve.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes | None
|
||||
The raw content bytes, or None if retrieval failed.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
|
||||
response = await client.post(
|
||||
f"{settings.IPFS_API_URL}/api/v0/cat",
|
||||
params={"arg": cid},
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info("Contenu recupere depuis IPFS: CID=%s", cid)
|
||||
return response.content
|
||||
except httpx.ConnectError:
|
||||
logger.warning("Impossible de se connecter au noeud IPFS (%s)", settings.IPFS_API_URL)
|
||||
return None
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning("Erreur HTTP IPFS lors de la recuperation (CID=%s): %s", cid, exc.response.status_code)
|
||||
return None
|
||||
except Exception:
|
||||
logger.warning("Erreur inattendue lors de la recuperation IPFS (CID=%s)", cid, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def pin(cid: str) -> bool:
|
||||
"""Pin content on the local IPFS node to prevent garbage collection.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cid:
|
||||
The IPFS Content Identifier to pin.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if pinning succeeded, False otherwise.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
|
||||
response = await client.post(
|
||||
f"{settings.IPFS_API_URL}/api/v0/pin/add",
|
||||
params={"arg": cid},
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info("Contenu epingle sur IPFS: CID=%s", cid)
|
||||
return True
|
||||
except httpx.ConnectError:
|
||||
logger.warning("Impossible de se connecter au noeud IPFS pour l'epinglage (%s)", settings.IPFS_API_URL)
|
||||
return False
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning("Erreur HTTP IPFS lors de l'epinglage (CID=%s): %s", cid, exc.response.status_code)
|
||||
return False
|
||||
except Exception:
|
||||
logger.warning("Erreur inattendue lors de l'epinglage IPFS (CID=%s)", cid, exc_info=True)
|
||||
return False
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Mandate service: step advancement logic, assignment and revocation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.mandate import Mandate, MandateStep
|
||||
from app.models.protocol import VotingProtocol
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.models.vote import VoteSession
|
||||
|
||||
|
||||
# Valid status transitions for mandates
|
||||
_MANDATE_STATUS_ORDER = [
|
||||
"draft",
|
||||
"candidacy",
|
||||
"voting",
|
||||
"active",
|
||||
"reporting",
|
||||
"completed",
|
||||
]
|
||||
|
||||
|
||||
async def advance_mandate(mandate_id: uuid.UUID, db: AsyncSession) -> Mandate:
|
||||
"""Move a mandate to its next step.
|
||||
|
||||
Completes the current active step and activates the next pending step.
|
||||
If no more steps remain, the mandate status advances to the next phase.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mandate_id:
|
||||
UUID of the Mandate to advance.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Mandate
|
||||
The updated mandate.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the mandate is not found, already completed/revoked, or
|
||||
no further advancement is possible.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Mandate)
|
||||
.options(selectinload(Mandate.steps))
|
||||
.where(Mandate.id == mandate_id)
|
||||
)
|
||||
mandate = result.scalar_one_or_none()
|
||||
if mandate is None:
|
||||
raise ValueError(f"Mandat introuvable : {mandate_id}")
|
||||
|
||||
if mandate.status in ("completed", "revoked"):
|
||||
raise ValueError(f"Le mandat est deja en statut terminal : {mandate.status}")
|
||||
|
||||
steps: list[MandateStep] = sorted(mandate.steps, key=lambda s: s.step_order)
|
||||
|
||||
# Find the current active step
|
||||
active_step: MandateStep | None = None
|
||||
for step in steps:
|
||||
if step.status == "active":
|
||||
active_step = step
|
||||
break
|
||||
|
||||
if active_step is not None:
|
||||
# Complete the active step
|
||||
active_step.status = "completed"
|
||||
|
||||
# Activate the next pending step
|
||||
next_step: MandateStep | None = None
|
||||
for step in steps:
|
||||
if step.step_order > active_step.step_order and step.status == "pending":
|
||||
next_step = step
|
||||
break
|
||||
|
||||
if next_step is not None:
|
||||
next_step.status = "active"
|
||||
else:
|
||||
# No more steps: advance mandate status
|
||||
_advance_mandate_status(mandate)
|
||||
else:
|
||||
# No active step: activate the first pending one
|
||||
first_pending: MandateStep | None = None
|
||||
for step in steps:
|
||||
if step.status == "pending":
|
||||
first_pending = step
|
||||
break
|
||||
|
||||
if first_pending is not None:
|
||||
first_pending.status = "active"
|
||||
# Move out of draft
|
||||
if mandate.status == "draft":
|
||||
mandate.status = "candidacy"
|
||||
else:
|
||||
# All steps completed: advance status
|
||||
_advance_mandate_status(mandate)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(mandate)
|
||||
|
||||
return mandate
|
||||
|
||||
|
||||
def _advance_mandate_status(mandate: Mandate) -> None:
|
||||
"""Move a mandate to its next status in the lifecycle."""
|
||||
try:
|
||||
current_index = _MANDATE_STATUS_ORDER.index(mandate.status)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
next_index = current_index + 1
|
||||
if next_index < len(_MANDATE_STATUS_ORDER):
|
||||
mandate.status = _MANDATE_STATUS_ORDER[next_index]
|
||||
|
||||
|
||||
async def assign_mandatee(
|
||||
mandate_id: uuid.UUID,
|
||||
mandatee_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> Mandate:
|
||||
"""Assign a mandatee (DuniterIdentity) to a mandate.
|
||||
|
||||
The mandate must be in a state that accepts assignment (not completed/revoked).
|
||||
The mandatee must exist in the duniter_identities table.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mandate_id:
|
||||
UUID of the Mandate.
|
||||
mandatee_id:
|
||||
UUID of the DuniterIdentity to assign.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Mandate
|
||||
The updated mandate.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the mandate or mandatee is not found, or the mandate
|
||||
is in a terminal state.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Mandate)
|
||||
.options(selectinload(Mandate.steps))
|
||||
.where(Mandate.id == mandate_id)
|
||||
)
|
||||
mandate = result.scalar_one_or_none()
|
||||
if mandate is None:
|
||||
raise ValueError(f"Mandat introuvable : {mandate_id}")
|
||||
|
||||
if mandate.status in ("completed", "revoked"):
|
||||
raise ValueError(f"Impossible d'assigner un mandataire : le mandat est en statut terminal ({mandate.status})")
|
||||
|
||||
# Verify mandatee exists
|
||||
identity_result = await db.execute(
|
||||
select(DuniterIdentity).where(DuniterIdentity.id == mandatee_id)
|
||||
)
|
||||
identity = identity_result.scalar_one_or_none()
|
||||
if identity is None:
|
||||
raise ValueError(f"Identite Duniter introuvable : {mandatee_id}")
|
||||
|
||||
mandate.mandatee_id = mandatee_id
|
||||
mandate.starts_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(mandate)
|
||||
|
||||
return mandate
|
||||
|
||||
|
||||
async def revoke_mandate(
|
||||
mandate_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> Mandate:
|
||||
"""Revoke an active mandate.
|
||||
|
||||
Sets the mandate status to 'revoked' and cancels any active/pending steps.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mandate_id:
|
||||
UUID of the Mandate to revoke.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Mandate
|
||||
The updated mandate.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the mandate is not found or already in a terminal state.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Mandate)
|
||||
.options(selectinload(Mandate.steps))
|
||||
.where(Mandate.id == mandate_id)
|
||||
)
|
||||
mandate = result.scalar_one_or_none()
|
||||
if mandate is None:
|
||||
raise ValueError(f"Mandat introuvable : {mandate_id}")
|
||||
|
||||
if mandate.status in ("completed", "revoked"):
|
||||
raise ValueError(f"Le mandat est deja en statut terminal : {mandate.status}")
|
||||
|
||||
# Cancel active and pending steps
|
||||
for step in mandate.steps:
|
||||
if step.status in ("active", "pending"):
|
||||
step.status = "cancelled"
|
||||
|
||||
mandate.status = "revoked"
|
||||
mandate.ends_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(mandate)
|
||||
|
||||
return mandate
|
||||
|
||||
|
||||
async def create_vote_session_for_step(
|
||||
mandate_id: uuid.UUID,
|
||||
step_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> VoteSession:
|
||||
"""Create a VoteSession linked to a mandate step.
|
||||
|
||||
Uses the mandate's linked decision's voting_protocol to configure
|
||||
the session, or requires a voting_protocol_id on the mandate's decision.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mandate_id:
|
||||
UUID of the Mandate.
|
||||
step_id:
|
||||
UUID of the MandateStep to attach the vote session to.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
VoteSession
|
||||
The newly created vote session.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the mandate, step, or protocol is not found, or the step
|
||||
already has a vote session.
|
||||
"""
|
||||
# Fetch mandate with steps
|
||||
result = await db.execute(
|
||||
select(Mandate)
|
||||
.options(selectinload(Mandate.steps))
|
||||
.where(Mandate.id == mandate_id)
|
||||
)
|
||||
mandate = result.scalar_one_or_none()
|
||||
if mandate is None:
|
||||
raise ValueError(f"Mandat introuvable : {mandate_id}")
|
||||
|
||||
# Find the step
|
||||
step: MandateStep | None = None
|
||||
for s in mandate.steps:
|
||||
if s.id == step_id:
|
||||
step = s
|
||||
break
|
||||
|
||||
if step is None:
|
||||
raise ValueError(f"Etape introuvable : {step_id}")
|
||||
|
||||
# Check step doesn't already have a vote session
|
||||
if step.vote_session_id is not None:
|
||||
raise ValueError("Cette etape possede deja une session de vote")
|
||||
|
||||
# Resolve the voting protocol via the linked decision
|
||||
if mandate.decision_id is None:
|
||||
raise ValueError("Aucune decision liee a ce mandat pour determiner le protocole de vote")
|
||||
|
||||
from app.models.decision import Decision
|
||||
|
||||
decision_result = await db.execute(
|
||||
select(Decision).where(Decision.id == mandate.decision_id)
|
||||
)
|
||||
decision = decision_result.scalar_one_or_none()
|
||||
if decision is None or decision.voting_protocol_id is None:
|
||||
raise ValueError("Aucun protocole de vote configure pour la decision liee")
|
||||
|
||||
# Fetch the voting protocol and its formula config for duration
|
||||
proto_result = await db.execute(
|
||||
select(VotingProtocol)
|
||||
.options(selectinload(VotingProtocol.formula_config))
|
||||
.where(VotingProtocol.id == decision.voting_protocol_id)
|
||||
)
|
||||
protocol = proto_result.scalar_one_or_none()
|
||||
if protocol is None:
|
||||
raise ValueError("Protocole de vote introuvable")
|
||||
|
||||
duration_days = protocol.formula_config.duration_days if protocol.formula_config else 30
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Create the vote session with explicit UUID (needed before flush)
|
||||
session_id = uuid.uuid4()
|
||||
session = VoteSession(
|
||||
id=session_id,
|
||||
decision_id=mandate.decision_id,
|
||||
voting_protocol_id=protocol.id,
|
||||
starts_at=now,
|
||||
ends_at=now + timedelta(days=duration_days),
|
||||
status="open",
|
||||
)
|
||||
db.add(session)
|
||||
|
||||
# Link session to step
|
||||
step.vote_session_id = session_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
return session
|
||||
@@ -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,260 @@
|
||||
"""Sanctuary service: immutable archival to IPFS + on-chain hash.
|
||||
|
||||
The sanctuary is the immutable layer of libreDecision. Every adopted
|
||||
document version, decision result, or vote tally is hashed (SHA-256),
|
||||
stored on IPFS, and anchored on-chain via system.remark.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.sanctuary import SanctuaryEntry
|
||||
from app.services import ipfs_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def archive_to_sanctuary(
|
||||
entry_type: str,
|
||||
reference_id: uuid.UUID,
|
||||
content: str,
|
||||
title: str,
|
||||
db: AsyncSession,
|
||||
) -> SanctuaryEntry:
|
||||
"""Hash content and create a sanctuary entry.
|
||||
|
||||
Pipeline:
|
||||
1. Hash content (SHA-256)
|
||||
2. Try to upload to IPFS via ipfs_service (catch errors, log, continue)
|
||||
3. Try to anchor on-chain via blockchain_service (catch errors, log, continue)
|
||||
4. Create SanctuaryEntry with whatever succeeded
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entry_type:
|
||||
Type of the archived entity (``"document"``, ``"decision"``,
|
||||
``"vote_result"``).
|
||||
reference_id:
|
||||
UUID of the source entity (document, decision, or vote session).
|
||||
content:
|
||||
The full text content to archive and hash.
|
||||
title:
|
||||
Human-readable title for the archive entry.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
SanctuaryEntry
|
||||
The newly created sanctuary entry with content_hash set.
|
||||
"""
|
||||
# 1. Compute SHA-256 hash of the content
|
||||
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
# Build metadata
|
||||
metadata: dict = {
|
||||
"archived_at": datetime.now(timezone.utc).isoformat(),
|
||||
"entry_type": entry_type,
|
||||
"content_length": len(content),
|
||||
}
|
||||
|
||||
ipfs_cid: str | None = None
|
||||
chain_tx_hash: str | None = None
|
||||
chain_block: int | None = None
|
||||
|
||||
# 2. Try to upload to IPFS
|
||||
try:
|
||||
ipfs_cid = await ipfs_service.upload_to_ipfs(content)
|
||||
if ipfs_cid:
|
||||
# Pin the content to keep it available
|
||||
await ipfs_service.pin(ipfs_cid)
|
||||
metadata["ipfs_cid"] = ipfs_cid
|
||||
logger.info("Contenu archive sur IPFS: CID=%s", ipfs_cid)
|
||||
else:
|
||||
logger.warning("Upload IPFS echoue (retour None) pour %s:%s", entry_type, reference_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Erreur lors de l'upload IPFS pour %s:%s",
|
||||
entry_type, reference_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 3. Try to anchor on-chain (still a structured stub)
|
||||
try:
|
||||
chain_tx_hash, chain_block = await _anchor_on_chain(content_hash)
|
||||
if chain_tx_hash:
|
||||
metadata["chain_tx_hash"] = chain_tx_hash
|
||||
metadata["chain_block"] = chain_block
|
||||
logger.info("Hash ancre on-chain: tx=%s block=%s", chain_tx_hash, chain_block)
|
||||
except NotImplementedError:
|
||||
logger.info("Ancrage on-chain pas encore implemente, etape ignoree")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Erreur lors de l'ancrage on-chain pour %s:%s",
|
||||
entry_type, reference_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 4. Create SanctuaryEntry with whatever succeeded
|
||||
entry = SanctuaryEntry(
|
||||
entry_type=entry_type,
|
||||
reference_id=reference_id,
|
||||
title=title,
|
||||
content_hash=content_hash,
|
||||
ipfs_cid=ipfs_cid,
|
||||
chain_tx_hash=chain_tx_hash,
|
||||
chain_block=chain_block,
|
||||
metadata_json=json.dumps(metadata, ensure_ascii=False),
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
async def verify_entry(
|
||||
entry_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""Verify the integrity of a sanctuary entry.
|
||||
|
||||
Re-fetches the content (from IPFS if available) and re-hashes it
|
||||
to compare with the stored content_hash.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entry_id:
|
||||
UUID of the SanctuaryEntry to verify.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Verification result with keys:
|
||||
- ``entry_id``: UUID of the entry
|
||||
- ``valid``: bool indicating if the hash matches
|
||||
- ``stored_hash``: the stored content_hash
|
||||
- ``computed_hash``: the re-computed hash (or None if content unavailable)
|
||||
- ``source``: where the content was fetched from (``"ipfs"`` or ``"unavailable"``)
|
||||
- ``detail``: human-readable detail message
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the entry is not found.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(SanctuaryEntry).where(SanctuaryEntry.id == entry_id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
raise ValueError(f"Entree sanctuaire introuvable : {entry_id}")
|
||||
|
||||
stored_hash = entry.content_hash
|
||||
computed_hash: str | None = None
|
||||
source = "unavailable"
|
||||
|
||||
# Try to re-fetch content from IPFS
|
||||
if entry.ipfs_cid:
|
||||
try:
|
||||
content_bytes = await ipfs_service.get_from_ipfs(entry.ipfs_cid)
|
||||
if content_bytes is not None:
|
||||
computed_hash = hashlib.sha256(content_bytes).hexdigest()
|
||||
source = "ipfs"
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Impossible de recuperer le contenu IPFS pour verification (CID=%s)",
|
||||
entry.ipfs_cid,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if computed_hash is None:
|
||||
return {
|
||||
"entry_id": entry.id,
|
||||
"valid": False,
|
||||
"stored_hash": stored_hash,
|
||||
"computed_hash": None,
|
||||
"source": source,
|
||||
"detail": "Contenu indisponible pour la verification",
|
||||
}
|
||||
|
||||
is_valid = computed_hash == stored_hash
|
||||
return {
|
||||
"entry_id": entry.id,
|
||||
"valid": is_valid,
|
||||
"stored_hash": stored_hash,
|
||||
"computed_hash": computed_hash,
|
||||
"source": source,
|
||||
"detail": "Integrite verifiee" if is_valid else "Hash different - contenu potentiellement altere",
|
||||
}
|
||||
|
||||
|
||||
async def get_entries_by_reference(
|
||||
reference_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> list[SanctuaryEntry]:
|
||||
"""Query all sanctuary entries for a given reference_id.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
reference_id:
|
||||
UUID of the referenced entity (document, decision, etc.).
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[SanctuaryEntry]
|
||||
All entries matching the reference_id, ordered by creation date desc.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(SanctuaryEntry)
|
||||
.where(SanctuaryEntry.reference_id == reference_id)
|
||||
.order_by(SanctuaryEntry.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _anchor_on_chain(content_hash: str) -> tuple[str | None, int | None]:
|
||||
"""Anchor a content hash on-chain via system.remark.
|
||||
|
||||
Currently a stub. When implemented, this will use substrate-interface
|
||||
to submit a system.remark extrinsic containing the content hash.
|
||||
|
||||
Example::
|
||||
|
||||
from substrateinterface import SubstrateInterface
|
||||
from app.config import settings
|
||||
|
||||
substrate = SubstrateInterface(url=settings.DUNITER_RPC_URL)
|
||||
call = substrate.compose_call(
|
||||
call_module="System",
|
||||
call_function="remark",
|
||||
call_params={"remark": f"libredecision:sanctuary:{content_hash}"},
|
||||
)
|
||||
extrinsic = substrate.create_signed_extrinsic(call=call, keypair=keypair)
|
||||
receipt = substrate.submit_extrinsic(extrinsic, wait_for_inclusion=True)
|
||||
return receipt.extrinsic_hash, receipt.block_number
|
||||
|
||||
Parameters
|
||||
----------
|
||||
content_hash:
|
||||
The SHA-256 hash to anchor.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[str | None, int | None]
|
||||
(tx_hash, block_number) or (None, None) if not implemented.
|
||||
"""
|
||||
raise NotImplementedError("Ancrage on-chain pas encore implemente")
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Vote service: compute results, close sessions, threshold details, and signature verification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.engine.mode_params import parse_mode_params
|
||||
from app.engine.nuanced_vote import evaluate_nuanced
|
||||
from app.engine.smith_threshold import smith_threshold
|
||||
from app.engine.techcomm_threshold import techcomm_threshold
|
||||
from app.engine.threshold import wot_threshold
|
||||
from app.models.protocol import FormulaConfig, VotingProtocol
|
||||
from app.models.vote import Vote, VoteSession
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _load_session_with_votes(db: AsyncSession, session_id: uuid.UUID) -> VoteSession:
|
||||
"""Load a vote session with its votes eagerly, raising ValueError if not found."""
|
||||
result = await db.execute(
|
||||
select(VoteSession)
|
||||
.options(selectinload(VoteSession.votes))
|
||||
.where(VoteSession.id == session_id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if session is None:
|
||||
raise ValueError(f"Session de vote introuvable : {session_id}")
|
||||
return session
|
||||
|
||||
|
||||
async def _load_protocol_with_formula(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol:
|
||||
"""Load a voting protocol with its formula config, raising ValueError if not found."""
|
||||
proto_result = await db.execute(
|
||||
select(VotingProtocol)
|
||||
.options(selectinload(VotingProtocol.formula_config))
|
||||
.where(VotingProtocol.id == protocol_id)
|
||||
)
|
||||
protocol = proto_result.scalar_one_or_none()
|
||||
if protocol is None:
|
||||
raise ValueError(f"Protocole de vote introuvable : {protocol_id}")
|
||||
return protocol
|
||||
|
||||
|
||||
def _extract_params(protocol: VotingProtocol, formula: FormulaConfig) -> dict:
|
||||
"""Extract formula parameters from mode_params or formula_config."""
|
||||
if protocol.mode_params:
|
||||
return parse_mode_params(protocol.mode_params)
|
||||
return {
|
||||
"majority_pct": formula.majority_pct,
|
||||
"base_exponent": formula.base_exponent,
|
||||
"gradient_exponent": formula.gradient_exponent,
|
||||
"constant_base": formula.constant_base,
|
||||
"smith_exponent": formula.smith_exponent,
|
||||
"techcomm_exponent": formula.techcomm_exponent,
|
||||
}
|
||||
|
||||
|
||||
# ── Compute Result ───────────────────────────────────────────────
|
||||
|
||||
|
||||
async def compute_result(session_id: uuid.UUID, db: AsyncSession) -> dict:
|
||||
"""Load a vote session, its protocol and formula, compute thresholds, and tally.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session_id:
|
||||
UUID of the VoteSession to tally.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Result dict with keys: threshold, votes_for, votes_against,
|
||||
votes_total, adopted, smith_ok, techcomm_ok, details.
|
||||
"""
|
||||
session = await _load_session_with_votes(db, session_id)
|
||||
protocol = await _load_protocol_with_formula(db, session.voting_protocol_id)
|
||||
formula: FormulaConfig = protocol.formula_config
|
||||
params = _extract_params(protocol, formula)
|
||||
|
||||
# Separate vote types
|
||||
active_votes: list[Vote] = [v for v in session.votes if v.is_active]
|
||||
|
||||
if protocol.vote_type == "nuanced":
|
||||
return await _compute_nuanced(session, active_votes, formula, params, db)
|
||||
|
||||
# --- Binary vote ---
|
||||
votes_for = sum(1 for v in active_votes if v.vote_value == "for")
|
||||
votes_against = sum(1 for v in active_votes if v.vote_value == "against")
|
||||
total = votes_for + votes_against
|
||||
|
||||
# WoT threshold
|
||||
threshold = wot_threshold(
|
||||
wot_size=session.wot_size,
|
||||
total_votes=total,
|
||||
majority_pct=params.get("majority_pct", 50),
|
||||
base_exponent=params.get("base_exponent", 0.1),
|
||||
gradient_exponent=params.get("gradient_exponent", 0.2),
|
||||
constant_base=params.get("constant_base", 0.0),
|
||||
)
|
||||
|
||||
# Smith criterion (optional)
|
||||
smith_ok = True
|
||||
smith_required = None
|
||||
if params.get("smith_exponent") is not None and session.smith_size > 0:
|
||||
smith_required = smith_threshold(session.smith_size, params["smith_exponent"])
|
||||
smith_votes = sum(1 for v in active_votes if v.voter_is_smith and v.vote_value == "for")
|
||||
smith_ok = smith_votes >= smith_required
|
||||
|
||||
# TechComm criterion (optional)
|
||||
techcomm_ok = True
|
||||
techcomm_required = None
|
||||
if params.get("techcomm_exponent") is not None and session.techcomm_size > 0:
|
||||
techcomm_required = techcomm_threshold(session.techcomm_size, params["techcomm_exponent"])
|
||||
techcomm_votes = sum(1 for v in active_votes if v.voter_is_techcomm and v.vote_value == "for")
|
||||
techcomm_ok = techcomm_votes >= techcomm_required
|
||||
|
||||
adopted = votes_for >= threshold and smith_ok and techcomm_ok
|
||||
vote_result = "adopted" if adopted else "rejected"
|
||||
|
||||
# Update session tallies
|
||||
session.votes_for = votes_for
|
||||
session.votes_against = votes_against
|
||||
session.votes_total = total
|
||||
session.threshold_required = float(threshold)
|
||||
session.result = vote_result
|
||||
session.status = "tallied"
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"threshold": threshold,
|
||||
"votes_for": votes_for,
|
||||
"votes_against": votes_against,
|
||||
"votes_total": total,
|
||||
"adopted": adopted,
|
||||
"smith_ok": smith_ok,
|
||||
"smith_required": smith_required,
|
||||
"techcomm_ok": techcomm_ok,
|
||||
"techcomm_required": techcomm_required,
|
||||
"result": vote_result,
|
||||
}
|
||||
|
||||
|
||||
async def _compute_nuanced(
|
||||
session: VoteSession,
|
||||
active_votes: list[Vote],
|
||||
formula: FormulaConfig,
|
||||
params: dict,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""Compute a nuanced vote result with full per-level breakdown."""
|
||||
vote_levels = [v.nuanced_level for v in active_votes if v.nuanced_level is not None]
|
||||
|
||||
threshold_pct = formula.nuanced_threshold_pct or 80
|
||||
min_participants = formula.nuanced_min_participants or 59
|
||||
|
||||
evaluation = evaluate_nuanced(
|
||||
votes=vote_levels,
|
||||
threshold_pct=threshold_pct,
|
||||
min_participants=min_participants,
|
||||
)
|
||||
|
||||
vote_result = "adopted" if evaluation["adopted"] else "rejected"
|
||||
|
||||
session.votes_total = evaluation["total"]
|
||||
session.votes_for = evaluation["positive_count"]
|
||||
session.votes_against = evaluation["total"] - evaluation["positive_count"]
|
||||
session.threshold_required = float(threshold_pct)
|
||||
session.result = vote_result
|
||||
session.status = "tallied"
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"vote_type": "nuanced",
|
||||
"result": vote_result,
|
||||
"nuanced_breakdown": {
|
||||
"per_level_counts": evaluation["per_level_counts"],
|
||||
"positive_count": evaluation["positive_count"],
|
||||
"positive_pct": evaluation["positive_pct"],
|
||||
"threshold_met": evaluation["threshold_met"],
|
||||
"min_participants_met": evaluation["min_participants_met"],
|
||||
"threshold_pct": threshold_pct,
|
||||
"min_participants": min_participants,
|
||||
},
|
||||
**evaluation,
|
||||
}
|
||||
|
||||
|
||||
# ── Close Session ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def close_session(session_id: uuid.UUID, db: AsyncSession) -> dict:
|
||||
"""Close a vote session and compute its final result.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session_id:
|
||||
UUID of the VoteSession to close.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Result dict from compute_result, augmented with close metadata.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the session is not found or already closed/tallied.
|
||||
"""
|
||||
session = await _load_session_with_votes(db, session_id)
|
||||
|
||||
if session.status not in ("open",):
|
||||
raise ValueError(
|
||||
f"Impossible de fermer la session {session_id} : statut actuel '{session.status}'"
|
||||
)
|
||||
|
||||
# Mark as closed before tallying
|
||||
session.status = "closed"
|
||||
await db.commit()
|
||||
|
||||
# Compute the final result (this will set status to "tallied")
|
||||
result = await compute_result(session_id, db)
|
||||
|
||||
return {
|
||||
"closed_at": datetime.now(timezone.utc).isoformat(),
|
||||
**result,
|
||||
}
|
||||
|
||||
|
||||
# ── Threshold Details ────────────────────────────────────────────
|
||||
|
||||
|
||||
async def get_threshold_details(session_id: uuid.UUID, db: AsyncSession) -> dict:
|
||||
"""Return detailed threshold computation for a vote session.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session_id:
|
||||
UUID of the VoteSession.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Detailed breakdown including wot/smith/techcomm thresholds,
|
||||
pass/fail booleans, participation rate, and formula params.
|
||||
"""
|
||||
session = await _load_session_with_votes(db, session_id)
|
||||
protocol = await _load_protocol_with_formula(db, session.voting_protocol_id)
|
||||
formula: FormulaConfig = protocol.formula_config
|
||||
params = _extract_params(protocol, formula)
|
||||
|
||||
active_votes: list[Vote] = [v for v in session.votes if v.is_active]
|
||||
votes_for = sum(1 for v in active_votes if v.vote_value == "for")
|
||||
votes_against = sum(1 for v in active_votes if v.vote_value == "against")
|
||||
total = votes_for + votes_against
|
||||
|
||||
# WoT threshold
|
||||
wot_thresh = wot_threshold(
|
||||
wot_size=session.wot_size,
|
||||
total_votes=total,
|
||||
majority_pct=params.get("majority_pct", 50),
|
||||
base_exponent=params.get("base_exponent", 0.1),
|
||||
gradient_exponent=params.get("gradient_exponent", 0.2),
|
||||
constant_base=params.get("constant_base", 0.0),
|
||||
)
|
||||
wot_passed = votes_for >= wot_thresh
|
||||
|
||||
# Smith criterion (optional)
|
||||
smith_thresh = None
|
||||
smith_passed = None
|
||||
if params.get("smith_exponent") is not None and session.smith_size > 0:
|
||||
smith_thresh = smith_threshold(session.smith_size, params["smith_exponent"])
|
||||
smith_votes = sum(1 for v in active_votes if v.voter_is_smith and v.vote_value == "for")
|
||||
smith_passed = smith_votes >= smith_thresh
|
||||
|
||||
# TechComm criterion (optional)
|
||||
techcomm_thresh = None
|
||||
techcomm_passed = None
|
||||
if params.get("techcomm_exponent") is not None and session.techcomm_size > 0:
|
||||
techcomm_thresh = techcomm_threshold(session.techcomm_size, params["techcomm_exponent"])
|
||||
techcomm_votes = sum(1 for v in active_votes if v.voter_is_techcomm and v.vote_value == "for")
|
||||
techcomm_passed = techcomm_votes >= techcomm_thresh
|
||||
|
||||
# Overall pass: all applicable criteria must pass
|
||||
overall_passed = wot_passed
|
||||
if smith_passed is not None:
|
||||
overall_passed = overall_passed and smith_passed
|
||||
if techcomm_passed is not None:
|
||||
overall_passed = overall_passed and techcomm_passed
|
||||
|
||||
# Participation rate
|
||||
participation_rate = (total / session.wot_size) if session.wot_size > 0 else 0.0
|
||||
|
||||
# Formula params used
|
||||
formula_params = {
|
||||
"M": params.get("majority_pct", 50),
|
||||
"B": params.get("base_exponent", 0.1),
|
||||
"G": params.get("gradient_exponent", 0.2),
|
||||
"C": params.get("constant_base", 0.0),
|
||||
"S": params.get("smith_exponent"),
|
||||
"T": params.get("techcomm_exponent"),
|
||||
}
|
||||
|
||||
return {
|
||||
"wot_threshold": wot_thresh,
|
||||
"smith_threshold": smith_thresh,
|
||||
"techcomm_threshold": techcomm_thresh,
|
||||
"votes_for": votes_for,
|
||||
"votes_against": votes_against,
|
||||
"votes_total": total,
|
||||
"wot_passed": wot_passed,
|
||||
"smith_passed": smith_passed,
|
||||
"techcomm_passed": techcomm_passed,
|
||||
"overall_passed": overall_passed,
|
||||
"participation_rate": round(participation_rate, 6),
|
||||
"formula_params": formula_params,
|
||||
}
|
||||
|
||||
|
||||
# ── Signature Verification ───────────────────────────────────────
|
||||
|
||||
|
||||
async def verify_vote_signature(address: str, signature: str, payload: str) -> bool:
|
||||
"""Verify an Ed25519 signature from a Duniter V2 address.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
address:
|
||||
SS58 address of the voter.
|
||||
signature:
|
||||
Hex-encoded Ed25519 signature.
|
||||
payload:
|
||||
The original message that was signed.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the signature is valid.
|
||||
|
||||
TODO
|
||||
----
|
||||
Implement actual Ed25519 verification using substrate-interface:
|
||||
|
||||
from substrateinterface import Keypair
|
||||
keypair = Keypair(ss58_address=address, crypto_type=KeypairType.ED25519)
|
||||
return keypair.verify(payload.encode(), bytes.fromhex(signature))
|
||||
"""
|
||||
# TODO: Implement real Ed25519 verification with substrate-interface
|
||||
# For now, accept all signatures in development mode
|
||||
if not address or not signature or not payload:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Tests for cache_service: get, set, invalidate, and cleanup.
|
||||
|
||||
Uses mock database sessions to test cache logic in isolation
|
||||
without requiring a real PostgreSQL connection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for cache service tests")
|
||||
|
||||
from app.services.cache_service import ( # noqa: E402
|
||||
cleanup_expired,
|
||||
get_cached,
|
||||
invalidate,
|
||||
set_cached,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers: mock objects for BlockchainCache entries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_cache_entry(
|
||||
key: str = "test:key",
|
||||
value: dict | None = None,
|
||||
expired: bool = False,
|
||||
) -> MagicMock:
|
||||
"""Create a mock BlockchainCache entry."""
|
||||
entry = MagicMock()
|
||||
entry.id = uuid.uuid4()
|
||||
entry.cache_key = key
|
||||
entry.cache_value = value or {"data": 42}
|
||||
entry.fetched_at = datetime.now(timezone.utc)
|
||||
|
||||
if expired:
|
||||
entry.expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
else:
|
||||
entry.expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def _make_async_db(entry: MagicMock | None = None) -> AsyncMock:
|
||||
"""Create a mock async database session.
|
||||
|
||||
The session's execute() returns a result with scalar_one_or_none()
|
||||
that returns the given entry (or None).
|
||||
"""
|
||||
db = AsyncMock()
|
||||
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = entry
|
||||
db.execute = AsyncMock(return_value=result)
|
||||
db.commit = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
|
||||
return db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: get_cached
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetCached:
|
||||
"""Test cache_service.get_cached."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_for_missing_key(self):
|
||||
"""get_cached returns None when no entry exists for the key."""
|
||||
db = _make_async_db(entry=None)
|
||||
result = await get_cached("nonexistent:key", db)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_value_for_valid_entry(self):
|
||||
"""get_cached returns the cached value when entry exists and is not expired."""
|
||||
entry = _make_cache_entry(key="blockchain:wot_size", value={"value": 7224})
|
||||
db = _make_async_db(entry=entry)
|
||||
|
||||
result = await get_cached("blockchain:wot_size", db)
|
||||
assert result == {"value": 7224}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_for_expired_entry(self):
|
||||
"""get_cached returns None when the entry is expired.
|
||||
|
||||
Note: In the real implementation, the SQL query filters by expires_at.
|
||||
Here we test that when the DB returns None (as it would for expired),
|
||||
get_cached returns None.
|
||||
"""
|
||||
db = _make_async_db(entry=None)
|
||||
result = await get_cached("expired:key", db)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: set_cached + get_cached roundtrip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSetCached:
|
||||
"""Test cache_service.set_cached."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_cached_creates_new_entry(self):
|
||||
"""set_cached creates a new entry when key does not exist."""
|
||||
db = _make_async_db(entry=None)
|
||||
|
||||
await set_cached("new:key", {"value": 100}, db, ttl_seconds=3600)
|
||||
|
||||
db.add.assert_called_once()
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_cached_updates_existing_entry(self):
|
||||
"""set_cached updates the value when key already exists."""
|
||||
existing = _make_cache_entry(key="existing:key", value={"value": 50})
|
||||
db = _make_async_db(entry=existing)
|
||||
|
||||
await set_cached("existing:key", {"value": 200}, db, ttl_seconds=7200)
|
||||
|
||||
# Should update in-place, not add new
|
||||
db.add.assert_not_called()
|
||||
assert existing.cache_value == {"value": 200}
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_cached_roundtrip(self):
|
||||
"""set_cached followed by get_cached returns the stored value."""
|
||||
# First call: set (no existing entry)
|
||||
db_set = _make_async_db(entry=None)
|
||||
await set_cached("roundtrip:key", {"data": "test"}, db_set, ttl_seconds=3600)
|
||||
db_set.add.assert_called_once()
|
||||
|
||||
# Extract the added entry from the mock
|
||||
added_entry = db_set.add.call_args[0][0]
|
||||
|
||||
# Second call: get (returns the added entry)
|
||||
db_get = _make_async_db(entry=added_entry)
|
||||
result = await get_cached("roundtrip:key", db_get)
|
||||
assert result == {"data": "test"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: invalidate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInvalidate:
|
||||
"""Test cache_service.invalidate."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_removes_entry(self):
|
||||
"""invalidate executes a delete and commits."""
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
await invalidate("some:key", db)
|
||||
|
||||
db.execute.assert_awaited_once()
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_then_get_returns_none(self):
|
||||
"""After invalidation, get_cached returns None."""
|
||||
db = _make_async_db(entry=None)
|
||||
result = await get_cached("invalidated:key", db)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: cleanup_expired
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCleanupExpired:
|
||||
"""Test cache_service.cleanup_expired."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_removes_expired_entries(self):
|
||||
"""cleanup_expired executes a delete for expired entries and returns count."""
|
||||
db = AsyncMock()
|
||||
|
||||
# Mock the execute result with rowcount
|
||||
exec_result = MagicMock()
|
||||
exec_result.rowcount = 3
|
||||
db.execute = AsyncMock(return_value=exec_result)
|
||||
db.commit = AsyncMock()
|
||||
|
||||
count = await cleanup_expired(db)
|
||||
assert count == 3
|
||||
db.execute.assert_awaited_once()
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_with_no_expired_entries(self):
|
||||
"""cleanup_expired returns 0 when no expired entries exist."""
|
||||
db = AsyncMock()
|
||||
|
||||
exec_result = MagicMock()
|
||||
exec_result.rowcount = 0
|
||||
db.execute = AsyncMock(return_value=exec_result)
|
||||
db.commit = AsyncMock()
|
||||
|
||||
count = await cleanup_expired(db)
|
||||
assert count == 0
|
||||
@@ -0,0 +1,386 @@
|
||||
"""Tests for decision service: advance_decision, create_vote_session_for_step.
|
||||
|
||||
These are pure unit tests that mock the database layer to test
|
||||
the service logic in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for decision service tests")
|
||||
|
||||
from app.services.decision_service import ( # noqa: E402
|
||||
advance_decision,
|
||||
create_vote_session_for_step,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers: mock objects that behave like SQLAlchemy models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_step(
|
||||
step_id: uuid.UUID | None = None,
|
||||
decision_id: uuid.UUID | None = None,
|
||||
step_order: int = 0,
|
||||
step_type: str = "qualification",
|
||||
status: str = "pending",
|
||||
vote_session_id: uuid.UUID | None = None,
|
||||
) -> MagicMock:
|
||||
"""Create a mock DecisionStep."""
|
||||
step = MagicMock()
|
||||
step.id = step_id or uuid.uuid4()
|
||||
step.decision_id = decision_id or uuid.uuid4()
|
||||
step.step_order = step_order
|
||||
step.step_type = step_type
|
||||
step.title = None
|
||||
step.description = None
|
||||
step.status = status
|
||||
step.vote_session_id = vote_session_id
|
||||
step.outcome = None
|
||||
step.created_at = datetime.now(timezone.utc)
|
||||
return step
|
||||
|
||||
|
||||
def _make_decision(
|
||||
decision_id: uuid.UUID | None = None,
|
||||
title: str = "Decision de test",
|
||||
decision_type: str = "document_change",
|
||||
status: str = "draft",
|
||||
voting_protocol_id: uuid.UUID | None = None,
|
||||
steps: list | None = None,
|
||||
) -> MagicMock:
|
||||
"""Create a mock Decision."""
|
||||
decision = MagicMock()
|
||||
decision.id = decision_id or uuid.uuid4()
|
||||
decision.title = title
|
||||
decision.description = None
|
||||
decision.context = None
|
||||
decision.decision_type = decision_type
|
||||
decision.status = status
|
||||
decision.voting_protocol_id = voting_protocol_id
|
||||
decision.created_by_id = None
|
||||
decision.created_at = datetime.now(timezone.utc)
|
||||
decision.updated_at = datetime.now(timezone.utc)
|
||||
decision.steps = steps or []
|
||||
return decision
|
||||
|
||||
|
||||
def _make_protocol(
|
||||
protocol_id: uuid.UUID | None = None,
|
||||
duration_days: int = 30,
|
||||
) -> MagicMock:
|
||||
"""Create a mock VotingProtocol with formula_config."""
|
||||
protocol = MagicMock()
|
||||
protocol.id = protocol_id or uuid.uuid4()
|
||||
protocol.name = "Protocole standard"
|
||||
protocol.vote_type = "binary"
|
||||
protocol.formula_config = MagicMock()
|
||||
protocol.formula_config.duration_days = duration_days
|
||||
return protocol
|
||||
|
||||
|
||||
def _make_db_for_advance(decision: MagicMock | None) -> AsyncMock:
|
||||
"""Create a mock async database session for advance_decision."""
|
||||
db = AsyncMock()
|
||||
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = decision
|
||||
|
||||
db.execute = AsyncMock(return_value=result)
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
|
||||
return db
|
||||
|
||||
|
||||
def _make_db_for_vote_session(
|
||||
decision: MagicMock | None,
|
||||
protocol: MagicMock | None = None,
|
||||
) -> AsyncMock:
|
||||
"""Create a mock async database session for create_vote_session_for_step.
|
||||
|
||||
Sequential execute calls:
|
||||
1st -> decision lookup
|
||||
2nd -> protocol lookup
|
||||
"""
|
||||
db = AsyncMock()
|
||||
|
||||
call_results = []
|
||||
|
||||
# Decision result
|
||||
decision_result = MagicMock()
|
||||
decision_result.scalar_one_or_none.return_value = decision
|
||||
call_results.append(decision_result)
|
||||
|
||||
# Protocol result
|
||||
if protocol is not None:
|
||||
proto_result = MagicMock()
|
||||
proto_result.scalar_one_or_none.return_value = protocol
|
||||
call_results.append(proto_result)
|
||||
|
||||
db.execute = AsyncMock(side_effect=call_results)
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
db.flush = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
|
||||
return db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: advance_decision
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdvanceDecision:
|
||||
"""Test decision_service.advance_decision."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_from_draft_activates_first_step(self):
|
||||
"""Advancing a draft decision with pending steps activates the first one
|
||||
and moves decision to qualification."""
|
||||
decision_id = uuid.uuid4()
|
||||
step1 = _make_step(decision_id=decision_id, step_order=0, status="pending")
|
||||
step2 = _make_step(decision_id=decision_id, step_order=1, status="pending")
|
||||
|
||||
decision = _make_decision(
|
||||
decision_id=decision_id,
|
||||
status="draft",
|
||||
steps=[step1, step2],
|
||||
)
|
||||
|
||||
db = _make_db_for_advance(decision)
|
||||
|
||||
result = await advance_decision(decision_id, db)
|
||||
|
||||
assert step1.status == "active"
|
||||
assert step2.status == "pending"
|
||||
assert decision.status == "qualification"
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_completes_active_and_activates_next(self):
|
||||
"""Advancing completes the current active step and activates the next pending one."""
|
||||
decision_id = uuid.uuid4()
|
||||
step1 = _make_step(decision_id=decision_id, step_order=0, status="active")
|
||||
step2 = _make_step(decision_id=decision_id, step_order=1, status="pending")
|
||||
|
||||
decision = _make_decision(
|
||||
decision_id=decision_id,
|
||||
status="qualification",
|
||||
steps=[step1, step2],
|
||||
)
|
||||
|
||||
db = _make_db_for_advance(decision)
|
||||
|
||||
result = await advance_decision(decision_id, db)
|
||||
|
||||
assert step1.status == "completed"
|
||||
assert step2.status == "active"
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_last_step_advances_status(self):
|
||||
"""When the last active step is completed with no more pending steps,
|
||||
the decision status advances."""
|
||||
decision_id = uuid.uuid4()
|
||||
step1 = _make_step(decision_id=decision_id, step_order=0, status="completed")
|
||||
step2 = _make_step(decision_id=decision_id, step_order=1, status="active")
|
||||
|
||||
decision = _make_decision(
|
||||
decision_id=decision_id,
|
||||
status="qualification",
|
||||
steps=[step1, step2],
|
||||
)
|
||||
|
||||
db = _make_db_for_advance(decision)
|
||||
|
||||
result = await advance_decision(decision_id, db)
|
||||
|
||||
assert step2.status == "completed"
|
||||
assert decision.status == "review"
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_no_steps_advances_status(self):
|
||||
"""A decision with no steps advances its status directly."""
|
||||
decision_id = uuid.uuid4()
|
||||
decision = _make_decision(
|
||||
decision_id=decision_id,
|
||||
status="draft",
|
||||
steps=[],
|
||||
)
|
||||
|
||||
db = _make_db_for_advance(decision)
|
||||
|
||||
result = await advance_decision(decision_id, db)
|
||||
|
||||
assert decision.status == "qualification"
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_full_progression(self):
|
||||
"""A decision with no steps can progress through all statuses."""
|
||||
decision_id = uuid.uuid4()
|
||||
decision = _make_decision(
|
||||
decision_id=decision_id,
|
||||
status="draft",
|
||||
steps=[],
|
||||
)
|
||||
|
||||
statuses_seen = [decision.status]
|
||||
db = _make_db_for_advance(decision)
|
||||
|
||||
# Advance through all states until closed
|
||||
for _ in range(10): # safety limit
|
||||
# Recreate db mock for each call (side_effect is consumed)
|
||||
db = _make_db_for_advance(decision)
|
||||
await advance_decision(decision_id, db)
|
||||
statuses_seen.append(decision.status)
|
||||
if decision.status == "closed":
|
||||
break
|
||||
|
||||
assert statuses_seen == [
|
||||
"draft",
|
||||
"qualification",
|
||||
"review",
|
||||
"voting",
|
||||
"executed",
|
||||
"closed",
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_closed_raises_error(self):
|
||||
"""Advancing a closed decision raises ValueError."""
|
||||
decision_id = uuid.uuid4()
|
||||
decision = _make_decision(
|
||||
decision_id=decision_id,
|
||||
status="closed",
|
||||
steps=[],
|
||||
)
|
||||
|
||||
db = _make_db_for_advance(decision)
|
||||
|
||||
with pytest.raises(ValueError, match="deja cloturee"):
|
||||
await advance_decision(decision_id, db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_not_found_raises_error(self):
|
||||
"""Advancing a non-existent decision raises ValueError."""
|
||||
db = _make_db_for_advance(None)
|
||||
|
||||
with pytest.raises(ValueError, match="Decision introuvable"):
|
||||
await advance_decision(uuid.uuid4(), db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: create_vote_session_for_step
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateVoteSessionForStep:
|
||||
"""Test decision_service.create_vote_session_for_step."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vote_session_success(self):
|
||||
"""A vote session is created and linked to the step."""
|
||||
decision_id = uuid.uuid4()
|
||||
step_id = uuid.uuid4()
|
||||
protocol_id = uuid.uuid4()
|
||||
|
||||
step = _make_step(
|
||||
step_id=step_id,
|
||||
decision_id=decision_id,
|
||||
step_type="vote",
|
||||
status="active",
|
||||
vote_session_id=None,
|
||||
)
|
||||
|
||||
decision = _make_decision(
|
||||
decision_id=decision_id,
|
||||
status="voting",
|
||||
voting_protocol_id=protocol_id,
|
||||
steps=[step],
|
||||
)
|
||||
|
||||
protocol = _make_protocol(protocol_id=protocol_id, duration_days=14)
|
||||
db = _make_db_for_vote_session(decision, protocol)
|
||||
|
||||
result = await create_vote_session_for_step(decision_id, step_id, db)
|
||||
|
||||
# The step should now have a vote_session_id set
|
||||
assert step.vote_session_id is not None
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vote_session_decision_not_found(self):
|
||||
"""ValueError when decision does not exist."""
|
||||
db = _make_db_for_vote_session(None)
|
||||
|
||||
with pytest.raises(ValueError, match="Decision introuvable"):
|
||||
await create_vote_session_for_step(uuid.uuid4(), uuid.uuid4(), db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vote_session_step_not_found(self):
|
||||
"""ValueError when step does not belong to the decision."""
|
||||
decision_id = uuid.uuid4()
|
||||
decision = _make_decision(decision_id=decision_id, steps=[])
|
||||
|
||||
db = _make_db_for_vote_session(decision)
|
||||
|
||||
with pytest.raises(ValueError, match="Etape introuvable"):
|
||||
await create_vote_session_for_step(decision_id, uuid.uuid4(), db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vote_session_step_already_has_session(self):
|
||||
"""ValueError when step already has a vote session."""
|
||||
decision_id = uuid.uuid4()
|
||||
step_id = uuid.uuid4()
|
||||
existing_session_id = uuid.uuid4()
|
||||
|
||||
step = _make_step(
|
||||
step_id=step_id,
|
||||
decision_id=decision_id,
|
||||
vote_session_id=existing_session_id,
|
||||
)
|
||||
|
||||
decision = _make_decision(
|
||||
decision_id=decision_id,
|
||||
voting_protocol_id=uuid.uuid4(),
|
||||
steps=[step],
|
||||
)
|
||||
|
||||
db = _make_db_for_vote_session(decision)
|
||||
|
||||
with pytest.raises(ValueError, match="deja une session de vote"):
|
||||
await create_vote_session_for_step(decision_id, step_id, db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vote_session_no_protocol(self):
|
||||
"""ValueError when decision has no voting protocol configured."""
|
||||
decision_id = uuid.uuid4()
|
||||
step_id = uuid.uuid4()
|
||||
|
||||
step = _make_step(
|
||||
step_id=step_id,
|
||||
decision_id=decision_id,
|
||||
vote_session_id=None,
|
||||
)
|
||||
|
||||
decision = _make_decision(
|
||||
decision_id=decision_id,
|
||||
voting_protocol_id=None,
|
||||
steps=[step],
|
||||
)
|
||||
|
||||
db = _make_db_for_vote_session(decision)
|
||||
|
||||
with pytest.raises(ValueError, match="Aucun protocole de vote"):
|
||||
await create_vote_session_for_step(decision_id, step_id, db)
|
||||
@@ -0,0 +1,856 @@
|
||||
"""TDD: Document ↔ Protocol ↔ Vote integration tests.
|
||||
|
||||
Tests the interrelation between:
|
||||
- DocumentItem ←→ VotingProtocol (via voting_protocol_id)
|
||||
- VotingProtocol ←→ FormulaConfig (formula parameters)
|
||||
- VoteSession creation from DocumentItem context
|
||||
- Threshold computation using item's protocol (inertia presets)
|
||||
- Smith vs WoT standard protocol behavior
|
||||
- ItemVersion lifecycle: propose → vote → accept/reject
|
||||
- Multi-criteria adoption (WoT + Smith + TechComm)
|
||||
|
||||
All tests are pure unit tests exercising engine functions + service logic
|
||||
without a real database (mocks only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.mode_params import parse_mode_params
|
||||
from app.engine.nuanced_vote import evaluate_nuanced
|
||||
from app.engine.smith_threshold import smith_threshold
|
||||
from app.engine.techcomm_threshold import techcomm_threshold
|
||||
from app.engine.threshold import wot_threshold
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. DOCUMENT-PROTOCOL INTERRELATION
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestInertiaPresetsThresholds:
|
||||
"""Verify that different inertia presets produce different thresholds.
|
||||
|
||||
Inertia presets map to gradient_exponent values:
|
||||
low → G=0.1 (easy replacement)
|
||||
standard → G=0.2 (balanced)
|
||||
high → G=0.4 (hard replacement)
|
||||
very_high → G=0.6 (very hard replacement)
|
||||
"""
|
||||
|
||||
WOT_SIZE = 7224
|
||||
TOTAL_VOTES = 120 # ~1.66% participation
|
||||
|
||||
INERTIA_MAP = {
|
||||
"low": {"gradient_exponent": 0.1, "majority_pct": 50},
|
||||
"standard": {"gradient_exponent": 0.2, "majority_pct": 50},
|
||||
"high": {"gradient_exponent": 0.4, "majority_pct": 60},
|
||||
"very_high": {"gradient_exponent": 0.6, "majority_pct": 66},
|
||||
}
|
||||
|
||||
def _threshold_for_preset(self, preset: str) -> int:
|
||||
params = self.INERTIA_MAP[preset]
|
||||
return wot_threshold(
|
||||
wot_size=self.WOT_SIZE,
|
||||
total_votes=self.TOTAL_VOTES,
|
||||
majority_pct=params["majority_pct"],
|
||||
gradient_exponent=params["gradient_exponent"],
|
||||
)
|
||||
|
||||
def test_low_inertia_easiest(self):
|
||||
"""Low inertia should produce the lowest threshold."""
|
||||
t_low = self._threshold_for_preset("low")
|
||||
t_std = self._threshold_for_preset("standard")
|
||||
assert t_low < t_std, f"Low ({t_low}) should be < standard ({t_std})"
|
||||
|
||||
def test_standard_below_high(self):
|
||||
"""Standard inertia should be below high."""
|
||||
t_std = self._threshold_for_preset("standard")
|
||||
t_high = self._threshold_for_preset("high")
|
||||
assert t_std < t_high, f"Standard ({t_std}) should be < high ({t_high})"
|
||||
|
||||
def test_high_below_very_high(self):
|
||||
"""High inertia should be below very_high."""
|
||||
t_high = self._threshold_for_preset("high")
|
||||
t_vh = self._threshold_for_preset("very_high")
|
||||
assert t_high < t_vh, f"High ({t_high}) should be < very_high ({t_vh})"
|
||||
|
||||
def test_monotonic_ordering(self):
|
||||
"""All 4 presets must be strictly ordered: low < standard < high < very_high."""
|
||||
thresholds = {p: self._threshold_for_preset(p) for p in self.INERTIA_MAP}
|
||||
assert thresholds["low"] < thresholds["standard"]
|
||||
assert thresholds["standard"] < thresholds["high"]
|
||||
assert thresholds["high"] < thresholds["very_high"]
|
||||
|
||||
def test_low_inertia_near_majority(self):
|
||||
"""With low inertia (G=0.1), even at 1.66% participation,
|
||||
threshold shouldn't be too far from total votes."""
|
||||
t = self._threshold_for_preset("low")
|
||||
# With G=0.1, inertia is mild even at low participation
|
||||
assert t <= self.TOTAL_VOTES, f"Low inertia threshold ({t}) should be <= total ({self.TOTAL_VOTES})"
|
||||
|
||||
def test_very_high_inertia_near_unanimity(self):
|
||||
"""With very_high inertia (G=0.6, M=66%), at 1.66% participation,
|
||||
threshold should be very close to total votes (near unanimity)."""
|
||||
t = self._threshold_for_preset("very_high")
|
||||
# At very low participation with high G and high M, threshold ≈ total
|
||||
ratio = t / self.TOTAL_VOTES
|
||||
assert ratio > 0.85, f"Very high inertia ratio ({ratio:.2f}) should demand near-unanimity"
|
||||
|
||||
|
||||
class TestSmithProtocolVsStandard:
|
||||
"""Compare the behavior of Smith protocol (D30M50B.1G.2S.1) vs
|
||||
WoT standard (D30M50B.1G.2) — the only difference is the Smith criterion."""
|
||||
|
||||
WOT_SIZE = 7224
|
||||
SMITH_SIZE = 20
|
||||
TOTAL = 120
|
||||
VOTES_FOR = 97
|
||||
|
||||
def test_same_wot_threshold(self):
|
||||
"""Both protocols have M50B.1G.2 → same WoT threshold."""
|
||||
smith_params = parse_mode_params("D30M50B.1G.2S.1")
|
||||
std_params = parse_mode_params("D30M50B.1G.2")
|
||||
|
||||
t_smith = wot_threshold(
|
||||
wot_size=self.WOT_SIZE, total_votes=self.TOTAL,
|
||||
majority_pct=smith_params["majority_pct"],
|
||||
gradient_exponent=smith_params["gradient_exponent"],
|
||||
)
|
||||
t_std = wot_threshold(
|
||||
wot_size=self.WOT_SIZE, total_votes=self.TOTAL,
|
||||
majority_pct=std_params["majority_pct"],
|
||||
gradient_exponent=std_params["gradient_exponent"],
|
||||
)
|
||||
|
||||
assert t_smith == t_std
|
||||
|
||||
def test_smith_criterion_present_vs_absent(self):
|
||||
"""Smith protocol has smith_exponent=0.1, standard has None."""
|
||||
smith_params = parse_mode_params("D30M50B.1G.2S.1")
|
||||
std_params = parse_mode_params("D30M50B.1G.2")
|
||||
|
||||
assert smith_params["smith_exponent"] == 0.1
|
||||
assert std_params["smith_exponent"] is None
|
||||
|
||||
def test_smith_protocol_can_be_blocked_by_smiths(self):
|
||||
"""Smith protocol adoption requires both WoT AND Smith criteria."""
|
||||
wot_thresh = wot_threshold(
|
||||
wot_size=self.WOT_SIZE, total_votes=self.TOTAL,
|
||||
majority_pct=50, gradient_exponent=0.2,
|
||||
)
|
||||
wot_pass = self.VOTES_FOR >= wot_thresh
|
||||
|
||||
smith_thresh = smith_threshold(self.SMITH_SIZE, 0.1) # = 2
|
||||
smith_votes_for = 1 # Only 1 smith voted → fails
|
||||
|
||||
adopted = wot_pass and (smith_votes_for >= smith_thresh)
|
||||
assert wot_pass is True
|
||||
assert smith_votes_for < smith_thresh
|
||||
assert adopted is False
|
||||
|
||||
def test_standard_protocol_ignores_smith(self):
|
||||
"""Standard protocol with no smith_exponent always passes smith criterion."""
|
||||
params = parse_mode_params("D30M50B.1G.2")
|
||||
smith_ok = True # Default when smith_exponent is None
|
||||
if params["smith_exponent"] is not None:
|
||||
smith_ok = False # Would need smith votes
|
||||
|
||||
assert smith_ok is True
|
||||
|
||||
def test_smith_threshold_scales_with_smith_size(self):
|
||||
"""Smith threshold ceil(N^0.1) grows slowly with smith WoT size."""
|
||||
sizes_and_expected = [
|
||||
(1, 1), # ceil(1^0.1) = 1
|
||||
(5, 2), # ceil(5^0.1) = ceil(1.175) = 2
|
||||
(20, 2), # ceil(20^0.1) = ceil(1.35) = 2
|
||||
(100, 2), # ceil(100^0.1) = ceil(1.585) = 2
|
||||
(1000, 2), # ceil(1000^0.1) = ceil(1.995) = 2
|
||||
(1024, 2), # ceil(1024^0.1) = ceil(2.0) = 2
|
||||
(1025, 3), # ceil(1025^0.1) > 2
|
||||
]
|
||||
for size, expected in sizes_and_expected:
|
||||
result = smith_threshold(size, 0.1)
|
||||
assert result == expected, f"smith_threshold({size}, 0.1) = {result}, expected {expected}"
|
||||
|
||||
|
||||
class TestModeParamsRoundtrip:
|
||||
"""Verify mode_params parsing produces correct formula parameters."""
|
||||
|
||||
def test_smith_protocol_params(self):
|
||||
"""D30M50B.1G.2S.1 — the Forgeron Smith protocol."""
|
||||
params = parse_mode_params("D30M50B.1G.2S.1")
|
||||
assert params["duration_days"] == 30
|
||||
assert params["majority_pct"] == 50
|
||||
assert params["base_exponent"] == 0.1
|
||||
assert params["gradient_exponent"] == 0.2
|
||||
assert params["smith_exponent"] == 0.1
|
||||
assert params["techcomm_exponent"] is None
|
||||
|
||||
def test_techcomm_protocol_params(self):
|
||||
"""D30M50B.1G.2T.1 — a TechComm protocol."""
|
||||
params = parse_mode_params("D30M50B.1G.2T.1")
|
||||
assert params["techcomm_exponent"] == 0.1
|
||||
assert params["smith_exponent"] is None
|
||||
|
||||
def test_full_protocol_params(self):
|
||||
"""D30M50B.1G.2S.1T.1 — both Smith AND TechComm."""
|
||||
params = parse_mode_params("D30M50B.1G.2S.1T.1")
|
||||
assert params["smith_exponent"] == 0.1
|
||||
assert params["techcomm_exponent"] == 0.1
|
||||
|
||||
def test_high_inertia_params(self):
|
||||
"""D30M60B.1G.4 — high inertia preset."""
|
||||
params = parse_mode_params("D30M60B.1G.4")
|
||||
assert params["majority_pct"] == 60
|
||||
assert params["gradient_exponent"] == 0.4
|
||||
|
||||
def test_very_high_inertia_params(self):
|
||||
"""D30M66B.1G.6 — very high inertia preset."""
|
||||
params = parse_mode_params("D30M66B.1G.6")
|
||||
assert params["majority_pct"] == 66
|
||||
assert params["gradient_exponent"] == 0.6
|
||||
|
||||
def test_params_used_in_threshold_match(self):
|
||||
"""Threshold computed from parsed params must match direct computation."""
|
||||
params = parse_mode_params("D30M50B.1G.2S.1")
|
||||
computed = wot_threshold(
|
||||
wot_size=7224, total_votes=120,
|
||||
majority_pct=params["majority_pct"],
|
||||
base_exponent=params["base_exponent"],
|
||||
gradient_exponent=params["gradient_exponent"],
|
||||
)
|
||||
direct = wot_threshold(
|
||||
wot_size=7224, total_votes=120,
|
||||
majority_pct=50, base_exponent=0.1, gradient_exponent=0.2,
|
||||
)
|
||||
assert computed == direct
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. VOTE BEHAVIOR — ADVANCED SCENARIOS
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestInertiaFormulaBehavior:
|
||||
"""Deep tests on the inertia formula behavior across participation levels."""
|
||||
|
||||
def test_participation_curve_is_monotonically_decreasing(self):
|
||||
"""As participation increases, required threshold ratio decreases.
|
||||
This is the fundamental property of inertia-based democracy."""
|
||||
W = 7224
|
||||
M = 0.5
|
||||
G = 0.2
|
||||
|
||||
prev_ratio = float("inf")
|
||||
for t in range(10, W + 1, 100):
|
||||
participation = t / W
|
||||
inertia = 1.0 - participation ** G
|
||||
ratio = M + (1.0 - M) * inertia
|
||||
assert ratio <= prev_ratio, (
|
||||
f"Ratio must decrease: at T={t}, ratio={ratio:.4f} > prev={prev_ratio:.4f}"
|
||||
)
|
||||
prev_ratio = ratio
|
||||
|
||||
def test_at_full_participation_ratio_equals_majority(self):
|
||||
"""At T=W (100% participation), ratio should equal M exactly."""
|
||||
W = 7224
|
||||
M_pct = 50
|
||||
M = M_pct / 100
|
||||
|
||||
threshold = wot_threshold(wot_size=W, total_votes=W, majority_pct=M_pct)
|
||||
expected = math.ceil(0.1 ** W + M * W)
|
||||
assert threshold == expected
|
||||
|
||||
def test_at_1_percent_participation_near_unanimity(self):
|
||||
"""At ~1% participation, threshold should be near total votes."""
|
||||
W = 7224
|
||||
T = 72 # ~1%
|
||||
|
||||
threshold = wot_threshold(wot_size=W, total_votes=T, majority_pct=50)
|
||||
ratio = threshold / T
|
||||
assert ratio > 0.75, f"At 1% participation, ratio={ratio:.2f} should be > 0.75"
|
||||
|
||||
def test_at_50_percent_participation(self):
|
||||
"""At 50% participation with G=0.2, threshold is well above simple majority."""
|
||||
W = 1000
|
||||
T = 500
|
||||
|
||||
threshold = wot_threshold(wot_size=W, total_votes=T, majority_pct=50,
|
||||
gradient_exponent=0.2)
|
||||
# (500/1000)^0.2 ≈ 0.87, inertia ≈ 0.13, ratio ≈ 0.565
|
||||
assert threshold > 250, "Should be above simple majority"
|
||||
assert threshold < 400, "Should not be near unanimity"
|
||||
|
||||
def test_gradient_zero_means_always_majority(self):
|
||||
"""With G=0, (T/W)^0 = 1, inertia = 0, ratio = M always.
|
||||
This effectively disables inertia."""
|
||||
W = 7224
|
||||
M_pct = 50
|
||||
|
||||
for T in [10, 100, 1000, 7224]:
|
||||
threshold = wot_threshold(wot_size=W, total_votes=T,
|
||||
majority_pct=M_pct, gradient_exponent=0.0001)
|
||||
expected_approx = M_pct / 100 * T
|
||||
# With very small G, threshold should be close to M*T
|
||||
assert abs(threshold - math.ceil(expected_approx)) <= 2, (
|
||||
f"At T={T}, threshold={threshold}, expected≈{expected_approx:.0f}"
|
||||
)
|
||||
|
||||
|
||||
class TestMultiCriteriaAdoption:
|
||||
"""Test that adoption requires ALL applicable criteria to pass."""
|
||||
|
||||
WOT = 7224
|
||||
TOTAL = 120
|
||||
VOTES_FOR = 97
|
||||
SMITH_SIZE = 20
|
||||
TECHCOMM_SIZE = 5
|
||||
|
||||
def _wot_threshold(self):
|
||||
return wot_threshold(wot_size=self.WOT, total_votes=self.TOTAL,
|
||||
majority_pct=50, gradient_exponent=0.2)
|
||||
|
||||
def test_all_pass(self):
|
||||
"""WoT pass + Smith pass + TechComm pass → adopted."""
|
||||
wot_ok = self.VOTES_FOR >= self._wot_threshold()
|
||||
smith_ok = 3 >= smith_threshold(self.SMITH_SIZE, 0.1) # 3 >= 2
|
||||
tc_ok = 2 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1) # 2 >= 2
|
||||
|
||||
assert wot_ok and smith_ok and tc_ok
|
||||
|
||||
def test_wot_fails(self):
|
||||
"""WoT fail + Smith pass + TechComm pass → rejected."""
|
||||
wot_ok = 50 >= self._wot_threshold() # 50 < 94
|
||||
smith_ok = 3 >= smith_threshold(self.SMITH_SIZE, 0.1)
|
||||
tc_ok = 2 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1)
|
||||
|
||||
assert not wot_ok
|
||||
assert not (wot_ok and smith_ok and tc_ok)
|
||||
|
||||
def test_smith_fails(self):
|
||||
"""WoT pass + Smith fail + TechComm pass → rejected."""
|
||||
wot_ok = self.VOTES_FOR >= self._wot_threshold()
|
||||
smith_ok = 1 >= smith_threshold(self.SMITH_SIZE, 0.1) # 1 < 2
|
||||
tc_ok = 2 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1)
|
||||
|
||||
assert wot_ok and tc_ok
|
||||
assert not smith_ok
|
||||
assert not (wot_ok and smith_ok and tc_ok)
|
||||
|
||||
def test_techcomm_fails(self):
|
||||
"""WoT pass + Smith pass + TechComm fail → rejected."""
|
||||
wot_ok = self.VOTES_FOR >= self._wot_threshold()
|
||||
smith_ok = 3 >= smith_threshold(self.SMITH_SIZE, 0.1)
|
||||
tc_ok = 1 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1) # 1 < 2
|
||||
|
||||
assert wot_ok and smith_ok
|
||||
assert not tc_ok
|
||||
assert not (wot_ok and smith_ok and tc_ok)
|
||||
|
||||
def test_all_fail(self):
|
||||
"""All three fail → rejected."""
|
||||
wot_ok = 10 >= self._wot_threshold()
|
||||
smith_ok = 0 >= smith_threshold(self.SMITH_SIZE, 0.1)
|
||||
tc_ok = 0 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1)
|
||||
|
||||
assert not (wot_ok or smith_ok or tc_ok)
|
||||
|
||||
def test_no_smith_no_techcomm(self):
|
||||
"""When protocol has no Smith/TechComm, only WoT matters."""
|
||||
params = parse_mode_params("D30M50B.1G.2")
|
||||
wot_ok = self.VOTES_FOR >= self._wot_threshold()
|
||||
|
||||
# smith_exponent and techcomm_exponent are None
|
||||
smith_ok = True # default when not configured
|
||||
tc_ok = True
|
||||
if params["smith_exponent"] is not None:
|
||||
smith_ok = False
|
||||
if params["techcomm_exponent"] is not None:
|
||||
tc_ok = False
|
||||
|
||||
assert wot_ok and smith_ok and tc_ok
|
||||
|
||||
|
||||
class TestEdgeCasesVotes:
|
||||
"""Edge cases in vote behavior."""
|
||||
|
||||
def test_single_vote_small_wot(self):
|
||||
"""1 vote out of 5 WoT members → threshold near 1 (almost unanimity)."""
|
||||
threshold = wot_threshold(wot_size=5, total_votes=1, majority_pct=50)
|
||||
# With 1/5 = 20% participation, inertia is high → threshold ≈ 1
|
||||
assert threshold == 1
|
||||
|
||||
def test_single_vote_large_wot(self):
|
||||
"""1 vote out of 7224 WoT → threshold = 1 (need that 1 vote to be for)."""
|
||||
threshold = wot_threshold(wot_size=7224, total_votes=1, majority_pct=50)
|
||||
assert threshold == 1
|
||||
|
||||
def test_two_votes_disagree(self):
|
||||
"""2 votes: 1 for + 1 against. At low participation → need near-unanimity."""
|
||||
threshold = wot_threshold(wot_size=7224, total_votes=2, majority_pct=50)
|
||||
# Threshold should be close to 2 (near unanimity)
|
||||
assert threshold == 2
|
||||
|
||||
def test_exact_threshold_boundary(self):
|
||||
"""votes_for == threshold exactly → adopted (>= comparison)."""
|
||||
threshold = wot_threshold(wot_size=7224, total_votes=120, majority_pct=50)
|
||||
assert threshold >= threshold # votes_for == threshold → adopted
|
||||
|
||||
def test_one_below_threshold_boundary(self):
|
||||
"""votes_for == threshold - 1 → rejected."""
|
||||
threshold = wot_threshold(wot_size=7224, total_votes=120, majority_pct=50)
|
||||
assert (threshold - 1) < threshold
|
||||
|
||||
def test_constant_base_raises_minimum(self):
|
||||
"""With C=5, threshold is at least 5 even with no participation effect."""
|
||||
threshold = wot_threshold(wot_size=100, total_votes=0,
|
||||
majority_pct=50, constant_base=5.0)
|
||||
assert threshold >= 5
|
||||
|
||||
def test_wot_size_1_minimal(self):
|
||||
"""WoT of 1 member, 1 vote → threshold = 1."""
|
||||
threshold = wot_threshold(wot_size=1, total_votes=1, majority_pct=50)
|
||||
assert threshold == 1
|
||||
|
||||
|
||||
class TestNuancedVoteAdvanced:
|
||||
"""Advanced nuanced vote scenarios."""
|
||||
|
||||
def test_all_level_5_adopted(self):
|
||||
"""60 voters all at level 5 (TOUT A FAIT) → adopted."""
|
||||
result = evaluate_nuanced([5] * 60)
|
||||
assert result["adopted"] is True
|
||||
assert result["positive_pct"] == 100.0
|
||||
|
||||
def test_all_level_0_rejected(self):
|
||||
"""60 voters all at level 0 (CONTRE) → rejected."""
|
||||
result = evaluate_nuanced([0] * 60)
|
||||
assert result["adopted"] is False
|
||||
assert result["positive_pct"] == 0.0
|
||||
|
||||
def test_exactly_80_pct_positive(self):
|
||||
"""Exactly 80% positive (48/60) → threshold_met = True."""
|
||||
# 48 positive (levels 3-5) + 12 negative (levels 0-2) = 60
|
||||
votes = [4] * 48 + [1] * 12
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
assert result["positive_pct"] == 80.0
|
||||
assert result["threshold_met"] is True
|
||||
assert result["adopted"] is True
|
||||
|
||||
def test_just_below_80_pct(self):
|
||||
"""79.67% positive (47.8/60 ≈ 47/59) → threshold_met = False."""
|
||||
# 47 positive + 13 negative = 60
|
||||
votes = [4] * 47 + [1] * 13
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
assert result["positive_pct"] < 80.0
|
||||
assert result["threshold_met"] is False
|
||||
assert result["adopted"] is False
|
||||
|
||||
def test_min_participants_exactly_met(self):
|
||||
"""59 participants exactly → min_participants_met = True."""
|
||||
votes = [5] * 59
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
assert result["min_participants_met"] is True
|
||||
assert result["adopted"] is True
|
||||
|
||||
def test_min_participants_not_met(self):
|
||||
"""58 participants → min_participants_met = False, even if 100% positive."""
|
||||
votes = [5] * 58
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
assert result["min_participants_met"] is False
|
||||
assert result["threshold_met"] is True # 100% > 80%
|
||||
assert result["adopted"] is False # but quorum not met
|
||||
|
||||
def test_neutre_counts_as_positive(self):
|
||||
"""Level 3 (NEUTRE) counts as positive in the formula."""
|
||||
votes = [3] * 60
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
assert result["positive_count"] == 60
|
||||
assert result["positive_pct"] == 100.0
|
||||
assert result["adopted"] is True
|
||||
|
||||
def test_invalid_level_raises(self):
|
||||
"""Vote level 6 should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="invalide"):
|
||||
evaluate_nuanced([6])
|
||||
|
||||
def test_negative_level_raises(self):
|
||||
"""Vote level -1 should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="invalide"):
|
||||
evaluate_nuanced([-1])
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. ITEM MODIFICATION / DELETION / ADDITION WORKFLOW
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestItemVersionWorkflow:
|
||||
"""Test the ItemVersion status lifecycle and apply/reject logic
|
||||
using the document_service functions with mock database."""
|
||||
|
||||
def _make_item(self, item_id=None, text="Original text"):
|
||||
item = MagicMock()
|
||||
item.id = item_id or uuid.uuid4()
|
||||
item.current_text = text
|
||||
return item
|
||||
|
||||
def _make_version(self, version_id=None, item_id=None, proposed_text="New text",
|
||||
status="proposed"):
|
||||
version = MagicMock()
|
||||
version.id = version_id or uuid.uuid4()
|
||||
version.item_id = item_id or uuid.uuid4()
|
||||
version.proposed_text = proposed_text
|
||||
version.status = status
|
||||
version.rationale = "Test rationale"
|
||||
return version
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_version_updates_current_text(self):
|
||||
"""When a version is accepted, item.current_text is updated."""
|
||||
from app.services.document_service import apply_version
|
||||
|
||||
item_id = uuid.uuid4()
|
||||
version_id = uuid.uuid4()
|
||||
|
||||
item = self._make_item(item_id, "Old text")
|
||||
version = self._make_version(version_id, item_id, "New improved text")
|
||||
|
||||
db = AsyncMock()
|
||||
# Mock item query
|
||||
item_result = MagicMock()
|
||||
item_result.scalar_one_or_none.return_value = item
|
||||
# Mock version query
|
||||
version_result = MagicMock()
|
||||
version_result.scalar_one_or_none.return_value = version
|
||||
# Mock other versions query (no other pending versions)
|
||||
other_result = MagicMock()
|
||||
other_result.scalars.return_value = iter([])
|
||||
|
||||
db.execute = AsyncMock(side_effect=[item_result, version_result, other_result])
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
|
||||
result = await apply_version(item_id, version_id, db)
|
||||
|
||||
assert item.current_text == "New improved text"
|
||||
assert version.status == "accepted"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_version_rejects_competing_versions(self):
|
||||
"""When a version is accepted, all other pending versions are rejected."""
|
||||
from app.services.document_service import apply_version
|
||||
|
||||
item_id = uuid.uuid4()
|
||||
version_id = uuid.uuid4()
|
||||
|
||||
item = self._make_item(item_id)
|
||||
version = self._make_version(version_id, item_id, "Winning text")
|
||||
other1 = self._make_version(status="proposed")
|
||||
other2 = self._make_version(status="voting")
|
||||
|
||||
db = AsyncMock()
|
||||
item_result = MagicMock()
|
||||
item_result.scalar_one_or_none.return_value = item
|
||||
version_result = MagicMock()
|
||||
version_result.scalar_one_or_none.return_value = version
|
||||
other_result = MagicMock()
|
||||
other_result.scalars.return_value = iter([other1, other2])
|
||||
|
||||
db.execute = AsyncMock(side_effect=[item_result, version_result, other_result])
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
|
||||
await apply_version(item_id, version_id, db)
|
||||
|
||||
assert other1.status == "rejected"
|
||||
assert other2.status == "rejected"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_version_wrong_item_raises(self):
|
||||
"""Applying a version that belongs to a different item should raise."""
|
||||
from app.services.document_service import apply_version
|
||||
|
||||
item_id = uuid.uuid4()
|
||||
version_id = uuid.uuid4()
|
||||
|
||||
item = self._make_item(item_id)
|
||||
version = self._make_version(version_id, uuid.uuid4(), "Text") # different item_id
|
||||
|
||||
db = AsyncMock()
|
||||
item_result = MagicMock()
|
||||
item_result.scalar_one_or_none.return_value = item
|
||||
version_result = MagicMock()
|
||||
version_result.scalar_one_or_none.return_value = version
|
||||
|
||||
db.execute = AsyncMock(side_effect=[item_result, version_result])
|
||||
|
||||
with pytest.raises(ValueError, match="n'appartient pas"):
|
||||
await apply_version(item_id, version_id, db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_version_sets_status(self):
|
||||
"""Rejecting a version sets its status to 'rejected'."""
|
||||
from app.services.document_service import reject_version
|
||||
|
||||
item_id = uuid.uuid4()
|
||||
version_id = uuid.uuid4()
|
||||
|
||||
item = self._make_item(item_id)
|
||||
version = self._make_version(version_id, item_id, status="proposed")
|
||||
|
||||
db = AsyncMock()
|
||||
item_result = MagicMock()
|
||||
item_result.scalar_one_or_none.return_value = item
|
||||
version_result = MagicMock()
|
||||
version_result.scalar_one_or_none.return_value = version
|
||||
|
||||
db.execute = AsyncMock(side_effect=[item_result, version_result])
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
|
||||
result = await reject_version(item_id, version_id, db)
|
||||
|
||||
assert version.status == "rejected"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_nonexistent_item_raises(self):
|
||||
"""Applying a version to a nonexistent item should raise."""
|
||||
from app.services.document_service import apply_version
|
||||
|
||||
db = AsyncMock()
|
||||
item_result = MagicMock()
|
||||
item_result.scalar_one_or_none.return_value = None
|
||||
|
||||
db.execute = AsyncMock(return_value=item_result)
|
||||
|
||||
with pytest.raises(ValueError, match="introuvable"):
|
||||
await apply_version(uuid.uuid4(), uuid.uuid4(), db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_nonexistent_version_raises(self):
|
||||
"""Applying a nonexistent version should raise."""
|
||||
from app.services.document_service import apply_version
|
||||
|
||||
item_id = uuid.uuid4()
|
||||
item = self._make_item(item_id)
|
||||
|
||||
db = AsyncMock()
|
||||
item_result = MagicMock()
|
||||
item_result.scalar_one_or_none.return_value = item
|
||||
version_result = MagicMock()
|
||||
version_result.scalar_one_or_none.return_value = None
|
||||
|
||||
db.execute = AsyncMock(side_effect=[item_result, version_result])
|
||||
|
||||
with pytest.raises(ValueError, match="introuvable"):
|
||||
await apply_version(item_id, uuid.uuid4(), db)
|
||||
|
||||
|
||||
class TestDocumentSerialization:
|
||||
"""Test document serialization for IPFS archival with new fields."""
|
||||
|
||||
def _make_doc(self, items=None):
|
||||
from app.services.document_service import serialize_document_to_text
|
||||
|
||||
doc = MagicMock()
|
||||
doc.title = "Acte d'engagement Certification"
|
||||
doc.version = "1.0.0"
|
||||
doc.doc_type = "engagement"
|
||||
doc.status = "active"
|
||||
doc.description = "Test document"
|
||||
doc.items = items or []
|
||||
return doc
|
||||
|
||||
def _make_item(self, position, sort_order, title, text, section_tag=None, item_type="clause"):
|
||||
item = MagicMock()
|
||||
item.position = position
|
||||
item.sort_order = sort_order
|
||||
item.title = title
|
||||
item.current_text = text
|
||||
item.item_type = item_type
|
||||
item.section_tag = section_tag
|
||||
return item
|
||||
|
||||
def test_serialization_includes_section_items(self):
|
||||
"""Serialization should include items from all sections."""
|
||||
from app.services.document_service import serialize_document_to_text
|
||||
|
||||
items = [
|
||||
self._make_item("I1", 0, "Preambule", "Texte preambule", "introduction"),
|
||||
self._make_item("E1", 1, "Clause 1", "Texte clause", "fondamental"),
|
||||
self._make_item("X1", 2, "Annexe 1", "Texte annexe", "annexe"),
|
||||
]
|
||||
doc = self._make_doc(items)
|
||||
result = serialize_document_to_text(doc)
|
||||
|
||||
assert "Preambule" in result
|
||||
assert "Clause 1" in result
|
||||
assert "Annexe 1" in result
|
||||
assert "Texte preambule" in result
|
||||
|
||||
def test_serialization_preserves_order(self):
|
||||
"""Items should appear in sort_order, not insertion order."""
|
||||
from app.services.document_service import serialize_document_to_text
|
||||
|
||||
items = [
|
||||
self._make_item("X1", 2, "Third", "Text 3"),
|
||||
self._make_item("I1", 0, "First", "Text 1"),
|
||||
self._make_item("E1", 1, "Second", "Text 2"),
|
||||
]
|
||||
doc = self._make_doc(items)
|
||||
result = serialize_document_to_text(doc)
|
||||
|
||||
first_pos = result.index("First")
|
||||
second_pos = result.index("Second")
|
||||
third_pos = result.index("Third")
|
||||
assert first_pos < second_pos < third_pos
|
||||
|
||||
def test_serialization_deterministic(self):
|
||||
"""Same document serialized twice must produce identical output."""
|
||||
from app.services.document_service import serialize_document_to_text
|
||||
|
||||
items = [self._make_item("E1", 0, "Clause 1", "Text 1")]
|
||||
doc = self._make_doc(items)
|
||||
|
||||
result1 = serialize_document_to_text(doc)
|
||||
result2 = serialize_document_to_text(doc)
|
||||
assert result1 == result2
|
||||
|
||||
|
||||
class TestVoteSessionCreationFromItem:
|
||||
"""Test the logic for creating a VoteSession from a DocumentItem's protocol context.
|
||||
This simulates what the votes router does when creating a session."""
|
||||
|
||||
def test_session_inherits_protocol_params(self):
|
||||
"""A vote session created for an item should use the item's protocol."""
|
||||
# Simulate: DocumentItem has voting_protocol_id → VotingProtocol has mode_params
|
||||
protocol_params = parse_mode_params("D30M50B.1G.2S.1")
|
||||
|
||||
# These params should be used in threshold computation
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=protocol_params["majority_pct"],
|
||||
gradient_exponent=protocol_params["gradient_exponent"],
|
||||
base_exponent=protocol_params["base_exponent"],
|
||||
)
|
||||
|
||||
assert threshold == 94
|
||||
|
||||
def test_different_protocols_different_thresholds(self):
|
||||
"""Items with different protocols should produce different thresholds."""
|
||||
# Certification item (standard WoT)
|
||||
std_params = parse_mode_params("D30M50B.1G.2")
|
||||
# Forgeron item (Smith protocol)
|
||||
smith_params = parse_mode_params("D30M50B.1G.2S.1")
|
||||
# TechComm item
|
||||
tc_params = parse_mode_params("D30M50B.1G.2T.1")
|
||||
|
||||
# WoT thresholds are the same (same M/B/G)
|
||||
t_std = wot_threshold(wot_size=7224, total_votes=120,
|
||||
majority_pct=std_params["majority_pct"],
|
||||
gradient_exponent=std_params["gradient_exponent"])
|
||||
t_smith = wot_threshold(wot_size=7224, total_votes=120,
|
||||
majority_pct=smith_params["majority_pct"],
|
||||
gradient_exponent=smith_params["gradient_exponent"])
|
||||
assert t_std == t_smith
|
||||
|
||||
# But Smith protocol requires additional smith votes
|
||||
smith_req = smith_threshold(20, smith_params["smith_exponent"])
|
||||
assert smith_req == 2
|
||||
|
||||
# TechComm protocol requires additional techcomm votes
|
||||
tc_req = techcomm_threshold(5, tc_params["techcomm_exponent"])
|
||||
assert tc_req == 2
|
||||
|
||||
def test_session_duration_from_protocol(self):
|
||||
"""Session duration should come from protocol's duration_days."""
|
||||
params = parse_mode_params("D30M50B.1G.2")
|
||||
assert params["duration_days"] == 30
|
||||
|
||||
params_short = parse_mode_params("D7M50B.1G.2")
|
||||
assert params_short["duration_days"] == 7
|
||||
|
||||
|
||||
class TestRealWorldScenarios:
|
||||
"""Real-world voting scenarios from Duniter community."""
|
||||
|
||||
def test_forgeron_vote_feb_2026(self):
|
||||
"""Engagement Forgeron v2.0.0 — Feb 2026.
|
||||
97 pour / 23 contre, WoT 7224, Smith 20.
|
||||
Mode: D30M50B.1G.2S.1 → threshold=94 → adopted."""
|
||||
params = parse_mode_params("D30M50B.1G.2S.1")
|
||||
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224, total_votes=120,
|
||||
majority_pct=params["majority_pct"],
|
||||
gradient_exponent=params["gradient_exponent"],
|
||||
)
|
||||
assert threshold == 94
|
||||
|
||||
adopted = 97 >= threshold
|
||||
assert adopted is True
|
||||
|
||||
# Smith criterion
|
||||
smith_req = smith_threshold(20, params["smith_exponent"])
|
||||
assert smith_req == 2
|
||||
# Assume at least 2 smiths voted for → passes
|
||||
smith_ok = 5 >= smith_req
|
||||
assert smith_ok is True
|
||||
|
||||
def test_forgeron_vote_barely_passes(self):
|
||||
"""Same scenario but with exactly 94 votes for → still passes."""
|
||||
threshold = wot_threshold(wot_size=7224, total_votes=117,
|
||||
majority_pct=50, gradient_exponent=0.2)
|
||||
# Slightly different total (94+23=117)
|
||||
assert 94 >= threshold
|
||||
|
||||
def test_forgeron_vote_would_fail_at_93(self):
|
||||
"""93 votes for out of 116 → fails (threshold likely ~93-94)."""
|
||||
threshold = wot_threshold(wot_size=7224, total_votes=116,
|
||||
majority_pct=50, gradient_exponent=0.2)
|
||||
# With 116 total, threshold is still high
|
||||
# 93 may or may not pass depending on exact computation
|
||||
if threshold > 93:
|
||||
assert 93 < threshold
|
||||
|
||||
def test_certification_item_low_inertia(self):
|
||||
"""A certification document item with low inertia (G=0.1) is easier to replace."""
|
||||
threshold_low = wot_threshold(wot_size=7224, total_votes=120,
|
||||
majority_pct=50, gradient_exponent=0.1)
|
||||
threshold_std = wot_threshold(wot_size=7224, total_votes=120,
|
||||
majority_pct=50, gradient_exponent=0.2)
|
||||
assert threshold_low < threshold_std
|
||||
|
||||
def test_very_high_inertia_item(self):
|
||||
"""A formule/ordonnancement item with very_high inertia (G=0.6, M=66%)."""
|
||||
threshold = wot_threshold(wot_size=7224, total_votes=120,
|
||||
majority_pct=66, gradient_exponent=0.6)
|
||||
# Should be very close to 120 (near unanimity at such low participation)
|
||||
assert threshold >= 110, f"Very high inertia should demand near-unanimity, got {threshold}"
|
||||
|
||||
def test_full_wot_participation_simple_majority_wins(self):
|
||||
"""If entire WoT of 7224 votes, simple majority (3613) suffices with standard params."""
|
||||
threshold = wot_threshold(wot_size=7224, total_votes=7224,
|
||||
majority_pct=50, gradient_exponent=0.2)
|
||||
# At full participation: threshold ≈ 0.5 * 7224 = 3612
|
||||
assert threshold == math.ceil(0.1 ** 7224 + 0.5 * 7224)
|
||||
assert threshold == 3612
|
||||
|
||||
def test_techcomm_vote_cotec_5_members(self):
|
||||
"""TechComm criterion with 5 members, exponent 0.1 → need 2 votes."""
|
||||
tc_threshold = techcomm_threshold(5, 0.1)
|
||||
assert tc_threshold == 2
|
||||
|
||||
# 1 TC vote → fails
|
||||
assert 1 < tc_threshold
|
||||
# 2 TC votes → passes
|
||||
assert 2 >= tc_threshold
|
||||
@@ -0,0 +1,418 @@
|
||||
"""Tests for document service: apply_version, reject_version, and serialization.
|
||||
|
||||
These are pure unit tests that mock the database layer to test
|
||||
the service logic in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for document service tests")
|
||||
|
||||
from app.services.document_service import ( # noqa: E402
|
||||
apply_version,
|
||||
reject_version,
|
||||
serialize_document_to_text,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers: mock objects that behave like SQLAlchemy models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_item(
|
||||
item_id: uuid.UUID | None = None,
|
||||
document_id: uuid.UUID | None = None,
|
||||
current_text: str = "Texte original",
|
||||
position: str = "1",
|
||||
item_type: str = "clause",
|
||||
title: str | None = None,
|
||||
sort_order: int = 0,
|
||||
) -> MagicMock:
|
||||
"""Create a mock DocumentItem."""
|
||||
item = MagicMock()
|
||||
item.id = item_id or uuid.uuid4()
|
||||
item.document_id = document_id or uuid.uuid4()
|
||||
item.current_text = current_text
|
||||
item.position = position
|
||||
item.item_type = item_type
|
||||
item.title = title
|
||||
item.sort_order = sort_order
|
||||
item.created_at = datetime.now(timezone.utc)
|
||||
item.updated_at = datetime.now(timezone.utc)
|
||||
return item
|
||||
|
||||
|
||||
def _make_version(
|
||||
version_id: uuid.UUID | None = None,
|
||||
item_id: uuid.UUID | None = None,
|
||||
proposed_text: str = "Texte propose",
|
||||
status: str = "proposed",
|
||||
) -> MagicMock:
|
||||
"""Create a mock ItemVersion."""
|
||||
version = MagicMock()
|
||||
version.id = version_id or uuid.uuid4()
|
||||
version.item_id = item_id or uuid.uuid4()
|
||||
version.proposed_text = proposed_text
|
||||
version.status = status
|
||||
version.diff_text = None
|
||||
version.rationale = None
|
||||
version.decision_id = None
|
||||
version.proposed_by_id = None
|
||||
version.created_at = datetime.now(timezone.utc)
|
||||
return version
|
||||
|
||||
|
||||
def _make_document(
|
||||
doc_id: uuid.UUID | None = None,
|
||||
slug: str = "test-doc",
|
||||
title: str = "Document de test",
|
||||
doc_type: str = "licence",
|
||||
version: str = "1.0.0",
|
||||
status: str = "active",
|
||||
description: str | None = "Description de test",
|
||||
items: list | None = None,
|
||||
) -> MagicMock:
|
||||
"""Create a mock Document."""
|
||||
doc = MagicMock()
|
||||
doc.id = doc_id or uuid.uuid4()
|
||||
doc.slug = slug
|
||||
doc.title = title
|
||||
doc.doc_type = doc_type
|
||||
doc.version = version
|
||||
doc.status = status
|
||||
doc.description = description
|
||||
doc.ipfs_cid = None
|
||||
doc.chain_anchor = None
|
||||
doc.items = items or []
|
||||
doc.created_at = datetime.now(timezone.utc)
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
return doc
|
||||
|
||||
|
||||
def _make_async_db(
|
||||
item: MagicMock | None = None,
|
||||
version: MagicMock | None = None,
|
||||
other_versions: list[MagicMock] | None = None,
|
||||
) -> AsyncMock:
|
||||
"""Create a mock async database session.
|
||||
|
||||
The mock session's execute() returns appropriate results based on
|
||||
the query being run. It supports multiple sequential calls:
|
||||
1st call -> item lookup
|
||||
2nd call -> version lookup
|
||||
3rd call (optional) -> other versions lookup (for apply_version)
|
||||
"""
|
||||
db = AsyncMock()
|
||||
|
||||
call_results = []
|
||||
|
||||
# Item result
|
||||
item_result = MagicMock()
|
||||
item_result.scalar_one_or_none.return_value = item
|
||||
call_results.append(item_result)
|
||||
|
||||
# Version result
|
||||
version_result = MagicMock()
|
||||
version_result.scalar_one_or_none.return_value = version
|
||||
call_results.append(version_result)
|
||||
|
||||
# Other versions result (for apply_version)
|
||||
if other_versions is not None:
|
||||
other_result = MagicMock()
|
||||
other_scalars = MagicMock()
|
||||
other_scalars.__iter__ = MagicMock(return_value=iter(other_versions))
|
||||
other_result.scalars.return_value = other_scalars
|
||||
call_results.append(other_result)
|
||||
|
||||
db.execute = AsyncMock(side_effect=call_results)
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
|
||||
return db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: apply_version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApplyVersion:
|
||||
"""Test document_service.apply_version."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_version_updates_text(self):
|
||||
"""Applying a version replaces item's current_text with proposed_text."""
|
||||
item_id = uuid.uuid4()
|
||||
version_id = uuid.uuid4()
|
||||
|
||||
item = _make_item(item_id=item_id, current_text="Ancien texte")
|
||||
version = _make_version(
|
||||
version_id=version_id,
|
||||
item_id=item_id,
|
||||
proposed_text="Nouveau texte",
|
||||
)
|
||||
|
||||
db = _make_async_db(item=item, version=version, other_versions=[])
|
||||
|
||||
result = await apply_version(item_id, version_id, db)
|
||||
|
||||
assert result.current_text == "Nouveau texte"
|
||||
assert version.status == "accepted"
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_version_rejects_other_pending(self):
|
||||
"""Applying a version rejects other pending/voting versions."""
|
||||
item_id = uuid.uuid4()
|
||||
version_id = uuid.uuid4()
|
||||
|
||||
item = _make_item(item_id=item_id)
|
||||
version = _make_version(version_id=version_id, item_id=item_id)
|
||||
|
||||
other_v1 = _make_version(item_id=item_id, status="proposed")
|
||||
other_v2 = _make_version(item_id=item_id, status="voting")
|
||||
|
||||
db = _make_async_db(
|
||||
item=item,
|
||||
version=version,
|
||||
other_versions=[other_v1, other_v2],
|
||||
)
|
||||
|
||||
await apply_version(item_id, version_id, db)
|
||||
|
||||
assert other_v1.status == "rejected"
|
||||
assert other_v2.status == "rejected"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_version_item_not_found(self):
|
||||
"""ValueError is raised when item does not exist."""
|
||||
db = _make_async_db(item=None, version=None)
|
||||
|
||||
with pytest.raises(ValueError, match="Element de document introuvable"):
|
||||
await apply_version(uuid.uuid4(), uuid.uuid4(), db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_version_version_not_found(self):
|
||||
"""ValueError is raised when version does not exist."""
|
||||
item = _make_item()
|
||||
db = _make_async_db(item=item, version=None)
|
||||
|
||||
with pytest.raises(ValueError, match="Version introuvable"):
|
||||
await apply_version(item.id, uuid.uuid4(), db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_version_wrong_item(self):
|
||||
"""ValueError is raised when version belongs to a different item."""
|
||||
item_id = uuid.uuid4()
|
||||
other_item_id = uuid.uuid4()
|
||||
version_id = uuid.uuid4()
|
||||
|
||||
item = _make_item(item_id=item_id)
|
||||
version = _make_version(version_id=version_id, item_id=other_item_id)
|
||||
|
||||
db = _make_async_db(item=item, version=version)
|
||||
|
||||
with pytest.raises(ValueError, match="n'appartient pas"):
|
||||
await apply_version(item_id, version_id, db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: reject_version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRejectVersion:
|
||||
"""Test document_service.reject_version."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_version_sets_status(self):
|
||||
"""Rejecting a version sets its status to 'rejected'."""
|
||||
item_id = uuid.uuid4()
|
||||
version_id = uuid.uuid4()
|
||||
|
||||
item = _make_item(item_id=item_id)
|
||||
version = _make_version(
|
||||
version_id=version_id,
|
||||
item_id=item_id,
|
||||
status="proposed",
|
||||
)
|
||||
|
||||
db = _make_async_db(item=item, version=version)
|
||||
|
||||
result = await reject_version(item_id, version_id, db)
|
||||
|
||||
assert result.status == "rejected"
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_version_item_not_found(self):
|
||||
"""ValueError is raised when item does not exist."""
|
||||
db = _make_async_db(item=None, version=None)
|
||||
|
||||
with pytest.raises(ValueError, match="Element de document introuvable"):
|
||||
await reject_version(uuid.uuid4(), uuid.uuid4(), db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_version_version_not_found(self):
|
||||
"""ValueError is raised when version does not exist."""
|
||||
item = _make_item()
|
||||
db = _make_async_db(item=item, version=None)
|
||||
|
||||
with pytest.raises(ValueError, match="Version introuvable"):
|
||||
await reject_version(item.id, uuid.uuid4(), db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_version_wrong_item(self):
|
||||
"""ValueError is raised when version belongs to a different item."""
|
||||
item_id = uuid.uuid4()
|
||||
other_item_id = uuid.uuid4()
|
||||
version_id = uuid.uuid4()
|
||||
|
||||
item = _make_item(item_id=item_id)
|
||||
version = _make_version(version_id=version_id, item_id=other_item_id)
|
||||
|
||||
db = _make_async_db(item=item, version=version)
|
||||
|
||||
with pytest.raises(ValueError, match="n'appartient pas"):
|
||||
await reject_version(item_id, version_id, db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: serialize_document_to_text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSerializeDocumentToText:
|
||||
"""Test document serialization for archival."""
|
||||
|
||||
def test_basic_serialization(self):
|
||||
"""A document with items serializes to the expected text format."""
|
||||
doc_id = uuid.uuid4()
|
||||
|
||||
item1 = _make_item(
|
||||
document_id=doc_id,
|
||||
position="1",
|
||||
title="Preambule",
|
||||
item_type="preamble",
|
||||
current_text="Le texte du preambule.",
|
||||
sort_order=0,
|
||||
)
|
||||
item2 = _make_item(
|
||||
document_id=doc_id,
|
||||
position="2",
|
||||
title="Article premier",
|
||||
item_type="clause",
|
||||
current_text="Le texte de l'article premier.",
|
||||
sort_order=1,
|
||||
)
|
||||
item3 = _make_item(
|
||||
document_id=doc_id,
|
||||
position="2.1",
|
||||
title=None,
|
||||
item_type="rule",
|
||||
current_text="Sous-article sans titre.",
|
||||
sort_order=2,
|
||||
)
|
||||
|
||||
doc = _make_document(
|
||||
doc_id=doc_id,
|
||||
title="Licence G1",
|
||||
version="2.0.0",
|
||||
doc_type="licence",
|
||||
status="active",
|
||||
description="La licence monetaire de la G1",
|
||||
items=[item1, item2, item3],
|
||||
)
|
||||
|
||||
text = serialize_document_to_text(doc)
|
||||
|
||||
assert "# Licence G1" in text
|
||||
assert "Version: 2.0.0" in text
|
||||
assert "Type: licence" in text
|
||||
assert "Statut: active" in text
|
||||
assert "Description: La licence monetaire de la G1" in text
|
||||
|
||||
# Items
|
||||
assert "## 1 - Preambule [preamble]" in text
|
||||
assert "Le texte du preambule." in text
|
||||
assert "## 2 - Article premier [clause]" in text
|
||||
assert "Le texte de l'article premier." in text
|
||||
assert "## 2.1 [rule]" in text
|
||||
assert "Sous-article sans titre." in text
|
||||
|
||||
def test_serialization_ordering(self):
|
||||
"""Items are serialized in sort_order, not insertion order."""
|
||||
doc_id = uuid.uuid4()
|
||||
|
||||
item_b = _make_item(
|
||||
document_id=doc_id,
|
||||
position="2",
|
||||
title="Second",
|
||||
current_text="Texte B",
|
||||
sort_order=1,
|
||||
)
|
||||
item_a = _make_item(
|
||||
document_id=doc_id,
|
||||
position="1",
|
||||
title="Premier",
|
||||
current_text="Texte A",
|
||||
sort_order=0,
|
||||
)
|
||||
|
||||
# Insert in reverse order
|
||||
doc = _make_document(doc_id=doc_id, items=[item_b, item_a])
|
||||
|
||||
text = serialize_document_to_text(doc)
|
||||
|
||||
# "Premier" should appear before "Second"
|
||||
idx_a = text.index("Texte A")
|
||||
idx_b = text.index("Texte B")
|
||||
assert idx_a < idx_b, "Items should be ordered by sort_order"
|
||||
|
||||
def test_serialization_without_description(self):
|
||||
"""Document without description omits that line."""
|
||||
doc = _make_document(description=None, items=[])
|
||||
text = serialize_document_to_text(doc)
|
||||
|
||||
assert "Description:" not in text
|
||||
|
||||
def test_serialization_hash_is_deterministic(self):
|
||||
"""Same document content produces the same SHA-256 hash."""
|
||||
doc_id = uuid.uuid4()
|
||||
item = _make_item(
|
||||
document_id=doc_id,
|
||||
position="1",
|
||||
title="Test",
|
||||
current_text="Contenu identique",
|
||||
sort_order=0,
|
||||
)
|
||||
|
||||
doc1 = _make_document(doc_id=doc_id, title="Doc", version="1.0", items=[item])
|
||||
doc2 = _make_document(doc_id=doc_id, title="Doc", version="1.0", items=[item])
|
||||
|
||||
text1 = serialize_document_to_text(doc1)
|
||||
text2 = serialize_document_to_text(doc2)
|
||||
|
||||
hash1 = hashlib.sha256(text1.encode("utf-8")).hexdigest()
|
||||
hash2 = hashlib.sha256(text2.encode("utf-8")).hexdigest()
|
||||
|
||||
assert hash1 == hash2
|
||||
|
||||
def test_empty_document(self):
|
||||
"""A document with no items serializes header only."""
|
||||
doc = _make_document(items=[])
|
||||
text = serialize_document_to_text(doc)
|
||||
|
||||
assert "# Document de test" in text
|
||||
assert "Version: 1.0.0" in text
|
||||
# Should end with just a newline after the header block
|
||||
lines = text.strip().split("\n")
|
||||
assert len(lines) >= 4 # title, version, type, status
|
||||
@@ -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,655 @@
|
||||
"""Tests for mandate service: advance_mandate, assign_mandatee, revoke_mandate, create_vote_session_for_step.
|
||||
|
||||
These are pure unit tests that mock the database layer to test
|
||||
the service logic in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for mandate service tests")
|
||||
|
||||
from app.services.mandate_service import ( # noqa: E402
|
||||
advance_mandate,
|
||||
assign_mandatee,
|
||||
create_vote_session_for_step,
|
||||
revoke_mandate,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers: mock objects that behave like SQLAlchemy models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_step(
|
||||
step_id: uuid.UUID | None = None,
|
||||
mandate_id: uuid.UUID | None = None,
|
||||
step_order: int = 0,
|
||||
step_type: str = "formulation",
|
||||
status: str = "pending",
|
||||
vote_session_id: uuid.UUID | None = None,
|
||||
) -> MagicMock:
|
||||
"""Create a mock MandateStep."""
|
||||
step = MagicMock()
|
||||
step.id = step_id or uuid.uuid4()
|
||||
step.mandate_id = mandate_id or uuid.uuid4()
|
||||
step.step_order = step_order
|
||||
step.step_type = step_type
|
||||
step.title = None
|
||||
step.description = None
|
||||
step.status = status
|
||||
step.vote_session_id = vote_session_id
|
||||
step.outcome = None
|
||||
step.created_at = datetime.now(timezone.utc)
|
||||
return step
|
||||
|
||||
|
||||
def _make_mandate(
|
||||
mandate_id: uuid.UUID | None = None,
|
||||
title: str = "Mandat de test",
|
||||
mandate_type: str = "techcomm",
|
||||
status: str = "draft",
|
||||
mandatee_id: uuid.UUID | None = None,
|
||||
decision_id: uuid.UUID | None = None,
|
||||
steps: list | None = None,
|
||||
) -> MagicMock:
|
||||
"""Create a mock Mandate."""
|
||||
mandate = MagicMock()
|
||||
mandate.id = mandate_id or uuid.uuid4()
|
||||
mandate.title = title
|
||||
mandate.description = None
|
||||
mandate.mandate_type = mandate_type
|
||||
mandate.status = status
|
||||
mandate.mandatee_id = mandatee_id
|
||||
mandate.decision_id = decision_id
|
||||
mandate.starts_at = None
|
||||
mandate.ends_at = None
|
||||
mandate.created_at = datetime.now(timezone.utc)
|
||||
mandate.updated_at = datetime.now(timezone.utc)
|
||||
mandate.steps = steps or []
|
||||
return mandate
|
||||
|
||||
|
||||
def _make_identity(identity_id: uuid.UUID | None = None) -> MagicMock:
|
||||
"""Create a mock DuniterIdentity."""
|
||||
identity = MagicMock()
|
||||
identity.id = identity_id or uuid.uuid4()
|
||||
identity.address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
|
||||
identity.username = "Alice"
|
||||
return identity
|
||||
|
||||
|
||||
def _make_protocol(
|
||||
protocol_id: uuid.UUID | None = None,
|
||||
duration_days: int = 30,
|
||||
) -> MagicMock:
|
||||
"""Create a mock VotingProtocol with formula_config."""
|
||||
protocol = MagicMock()
|
||||
protocol.id = protocol_id or uuid.uuid4()
|
||||
protocol.name = "Protocole standard"
|
||||
protocol.vote_type = "binary"
|
||||
protocol.formula_config = MagicMock()
|
||||
protocol.formula_config.duration_days = duration_days
|
||||
return protocol
|
||||
|
||||
|
||||
def _make_decision_mock(
|
||||
decision_id: uuid.UUID | None = None,
|
||||
voting_protocol_id: uuid.UUID | None = None,
|
||||
) -> MagicMock:
|
||||
"""Create a mock Decision for the vote session lookup."""
|
||||
decision = MagicMock()
|
||||
decision.id = decision_id or uuid.uuid4()
|
||||
decision.voting_protocol_id = voting_protocol_id
|
||||
return decision
|
||||
|
||||
|
||||
def _make_db_for_advance(mandate: MagicMock | None) -> AsyncMock:
|
||||
"""Create a mock async database session for advance_mandate."""
|
||||
db = AsyncMock()
|
||||
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = mandate
|
||||
|
||||
db.execute = AsyncMock(return_value=result)
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
|
||||
return db
|
||||
|
||||
|
||||
def _make_db_for_assign(
|
||||
mandate: MagicMock | None,
|
||||
identity: MagicMock | None = None,
|
||||
) -> AsyncMock:
|
||||
"""Create a mock async database session for assign_mandatee.
|
||||
|
||||
Sequential execute calls:
|
||||
1st -> mandate lookup
|
||||
2nd -> identity lookup
|
||||
"""
|
||||
db = AsyncMock()
|
||||
|
||||
call_results = []
|
||||
|
||||
# Mandate result
|
||||
mandate_result = MagicMock()
|
||||
mandate_result.scalar_one_or_none.return_value = mandate
|
||||
call_results.append(mandate_result)
|
||||
|
||||
# Identity result
|
||||
if identity is not None or mandate is not None:
|
||||
identity_result = MagicMock()
|
||||
identity_result.scalar_one_or_none.return_value = identity
|
||||
call_results.append(identity_result)
|
||||
|
||||
db.execute = AsyncMock(side_effect=call_results)
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
|
||||
return db
|
||||
|
||||
|
||||
def _make_db_for_revoke(mandate: MagicMock | None) -> AsyncMock:
|
||||
"""Create a mock async database session for revoke_mandate."""
|
||||
db = AsyncMock()
|
||||
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = mandate
|
||||
|
||||
db.execute = AsyncMock(return_value=result)
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
|
||||
return db
|
||||
|
||||
|
||||
def _make_db_for_vote_session(
|
||||
mandate: MagicMock | None,
|
||||
decision: MagicMock | None = None,
|
||||
protocol: MagicMock | None = None,
|
||||
) -> AsyncMock:
|
||||
"""Create a mock async database session for create_vote_session_for_step.
|
||||
|
||||
Sequential execute calls:
|
||||
1st -> mandate lookup
|
||||
2nd -> decision lookup
|
||||
3rd -> protocol lookup
|
||||
"""
|
||||
db = AsyncMock()
|
||||
|
||||
call_results = []
|
||||
|
||||
# Mandate result
|
||||
mandate_result = MagicMock()
|
||||
mandate_result.scalar_one_or_none.return_value = mandate
|
||||
call_results.append(mandate_result)
|
||||
|
||||
# Decision result
|
||||
if decision is not None:
|
||||
decision_result = MagicMock()
|
||||
decision_result.scalar_one_or_none.return_value = decision
|
||||
call_results.append(decision_result)
|
||||
|
||||
# Protocol result
|
||||
if protocol is not None:
|
||||
proto_result = MagicMock()
|
||||
proto_result.scalar_one_or_none.return_value = protocol
|
||||
call_results.append(proto_result)
|
||||
|
||||
db.execute = AsyncMock(side_effect=call_results)
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
db.flush = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
|
||||
return db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: advance_mandate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdvanceMandate:
|
||||
"""Test mandate_service.advance_mandate."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_from_draft_activates_first_step(self):
|
||||
"""Advancing a draft mandate with pending steps activates the first one
|
||||
and moves mandate to candidacy."""
|
||||
mandate_id = uuid.uuid4()
|
||||
step1 = _make_step(mandate_id=mandate_id, step_order=0, status="pending")
|
||||
step2 = _make_step(mandate_id=mandate_id, step_order=1, status="pending")
|
||||
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="draft",
|
||||
steps=[step1, step2],
|
||||
)
|
||||
|
||||
db = _make_db_for_advance(mandate)
|
||||
|
||||
result = await advance_mandate(mandate_id, db)
|
||||
|
||||
assert step1.status == "active"
|
||||
assert step2.status == "pending"
|
||||
assert mandate.status == "candidacy"
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_completes_active_and_activates_next(self):
|
||||
"""Advancing completes the current active step and activates the next pending one."""
|
||||
mandate_id = uuid.uuid4()
|
||||
step1 = _make_step(mandate_id=mandate_id, step_order=0, status="active")
|
||||
step2 = _make_step(mandate_id=mandate_id, step_order=1, status="pending")
|
||||
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="candidacy",
|
||||
steps=[step1, step2],
|
||||
)
|
||||
|
||||
db = _make_db_for_advance(mandate)
|
||||
|
||||
result = await advance_mandate(mandate_id, db)
|
||||
|
||||
assert step1.status == "completed"
|
||||
assert step2.status == "active"
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_last_step_advances_status(self):
|
||||
"""When the last active step is completed with no more pending steps,
|
||||
the mandate status advances."""
|
||||
mandate_id = uuid.uuid4()
|
||||
step1 = _make_step(mandate_id=mandate_id, step_order=0, status="completed")
|
||||
step2 = _make_step(mandate_id=mandate_id, step_order=1, status="active")
|
||||
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="candidacy",
|
||||
steps=[step1, step2],
|
||||
)
|
||||
|
||||
db = _make_db_for_advance(mandate)
|
||||
|
||||
result = await advance_mandate(mandate_id, db)
|
||||
|
||||
assert step2.status == "completed"
|
||||
assert mandate.status == "voting"
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_full_progression(self):
|
||||
"""A mandate with no steps can progress through all statuses."""
|
||||
mandate_id = uuid.uuid4()
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="draft",
|
||||
steps=[],
|
||||
)
|
||||
|
||||
statuses_seen = [mandate.status]
|
||||
|
||||
# Advance through all states until completed
|
||||
for _ in range(10): # safety limit
|
||||
db = _make_db_for_advance(mandate)
|
||||
await advance_mandate(mandate_id, db)
|
||||
statuses_seen.append(mandate.status)
|
||||
if mandate.status == "completed":
|
||||
break
|
||||
|
||||
assert statuses_seen == [
|
||||
"draft",
|
||||
"candidacy",
|
||||
"voting",
|
||||
"active",
|
||||
"reporting",
|
||||
"completed",
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_completed_raises_error(self):
|
||||
"""Advancing a completed mandate raises ValueError."""
|
||||
mandate_id = uuid.uuid4()
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="completed",
|
||||
steps=[],
|
||||
)
|
||||
|
||||
db = _make_db_for_advance(mandate)
|
||||
|
||||
with pytest.raises(ValueError, match="statut terminal"):
|
||||
await advance_mandate(mandate_id, db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_revoked_raises_error(self):
|
||||
"""Advancing a revoked mandate raises ValueError."""
|
||||
mandate_id = uuid.uuid4()
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="revoked",
|
||||
steps=[],
|
||||
)
|
||||
|
||||
db = _make_db_for_advance(mandate)
|
||||
|
||||
with pytest.raises(ValueError, match="statut terminal"):
|
||||
await advance_mandate(mandate_id, db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advance_not_found_raises_error(self):
|
||||
"""Advancing a non-existent mandate raises ValueError."""
|
||||
db = _make_db_for_advance(None)
|
||||
|
||||
with pytest.raises(ValueError, match="Mandat introuvable"):
|
||||
await advance_mandate(uuid.uuid4(), db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: assign_mandatee
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAssignMandatee:
|
||||
"""Test mandate_service.assign_mandatee."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_success(self):
|
||||
"""Assigning a valid identity to an active mandate succeeds."""
|
||||
mandate_id = uuid.uuid4()
|
||||
mandatee_id = uuid.uuid4()
|
||||
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="active",
|
||||
)
|
||||
|
||||
identity = _make_identity(identity_id=mandatee_id)
|
||||
db = _make_db_for_assign(mandate, identity)
|
||||
|
||||
result = await assign_mandatee(mandate_id, mandatee_id, db)
|
||||
|
||||
assert mandate.mandatee_id == mandatee_id
|
||||
assert mandate.starts_at is not None
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_draft_mandate(self):
|
||||
"""Assigning to a draft mandate is allowed."""
|
||||
mandate_id = uuid.uuid4()
|
||||
mandatee_id = uuid.uuid4()
|
||||
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="draft",
|
||||
)
|
||||
|
||||
identity = _make_identity(identity_id=mandatee_id)
|
||||
db = _make_db_for_assign(mandate, identity)
|
||||
|
||||
result = await assign_mandatee(mandate_id, mandatee_id, db)
|
||||
|
||||
assert mandate.mandatee_id == mandatee_id
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_not_found_raises_error(self):
|
||||
"""Assigning to a non-existent mandate raises ValueError."""
|
||||
db = _make_db_for_assign(None)
|
||||
|
||||
with pytest.raises(ValueError, match="Mandat introuvable"):
|
||||
await assign_mandatee(uuid.uuid4(), uuid.uuid4(), db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_completed_raises_error(self):
|
||||
"""Assigning to a completed mandate raises ValueError."""
|
||||
mandate_id = uuid.uuid4()
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="completed",
|
||||
)
|
||||
|
||||
db = _make_db_for_assign(mandate)
|
||||
|
||||
with pytest.raises(ValueError, match="statut terminal"):
|
||||
await assign_mandatee(mandate_id, uuid.uuid4(), db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_revoked_raises_error(self):
|
||||
"""Assigning to a revoked mandate raises ValueError."""
|
||||
mandate_id = uuid.uuid4()
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="revoked",
|
||||
)
|
||||
|
||||
db = _make_db_for_assign(mandate)
|
||||
|
||||
with pytest.raises(ValueError, match="statut terminal"):
|
||||
await assign_mandatee(mandate_id, uuid.uuid4(), db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_identity_not_found_raises_error(self):
|
||||
"""Assigning a non-existent identity raises ValueError."""
|
||||
mandate_id = uuid.uuid4()
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="active",
|
||||
)
|
||||
|
||||
db = _make_db_for_assign(mandate, identity=None)
|
||||
|
||||
with pytest.raises(ValueError, match="Identite Duniter introuvable"):
|
||||
await assign_mandatee(mandate_id, uuid.uuid4(), db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: revoke_mandate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRevokeMandate:
|
||||
"""Test mandate_service.revoke_mandate."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_active_mandate(self):
|
||||
"""Revoking an active mandate sets status to revoked and cancels steps."""
|
||||
mandate_id = uuid.uuid4()
|
||||
step1 = _make_step(mandate_id=mandate_id, step_order=0, status="completed")
|
||||
step2 = _make_step(mandate_id=mandate_id, step_order=1, status="active")
|
||||
step3 = _make_step(mandate_id=mandate_id, step_order=2, status="pending")
|
||||
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="active",
|
||||
steps=[step1, step2, step3],
|
||||
)
|
||||
|
||||
db = _make_db_for_revoke(mandate)
|
||||
|
||||
result = await revoke_mandate(mandate_id, db)
|
||||
|
||||
assert mandate.status == "revoked"
|
||||
assert mandate.ends_at is not None
|
||||
# Completed steps stay completed
|
||||
assert step1.status == "completed"
|
||||
# Active and pending steps are cancelled
|
||||
assert step2.status == "cancelled"
|
||||
assert step3.status == "cancelled"
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_draft_mandate(self):
|
||||
"""Revoking a draft mandate is allowed."""
|
||||
mandate_id = uuid.uuid4()
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="draft",
|
||||
steps=[],
|
||||
)
|
||||
|
||||
db = _make_db_for_revoke(mandate)
|
||||
|
||||
result = await revoke_mandate(mandate_id, db)
|
||||
|
||||
assert mandate.status == "revoked"
|
||||
assert mandate.ends_at is not None
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_not_found_raises_error(self):
|
||||
"""Revoking a non-existent mandate raises ValueError."""
|
||||
db = _make_db_for_revoke(None)
|
||||
|
||||
with pytest.raises(ValueError, match="Mandat introuvable"):
|
||||
await revoke_mandate(uuid.uuid4(), db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_completed_raises_error(self):
|
||||
"""Revoking an already completed mandate raises ValueError."""
|
||||
mandate_id = uuid.uuid4()
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="completed",
|
||||
)
|
||||
|
||||
db = _make_db_for_revoke(mandate)
|
||||
|
||||
with pytest.raises(ValueError, match="statut terminal"):
|
||||
await revoke_mandate(mandate_id, db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_already_revoked_raises_error(self):
|
||||
"""Revoking an already revoked mandate raises ValueError."""
|
||||
mandate_id = uuid.uuid4()
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="revoked",
|
||||
)
|
||||
|
||||
db = _make_db_for_revoke(mandate)
|
||||
|
||||
with pytest.raises(ValueError, match="statut terminal"):
|
||||
await revoke_mandate(mandate_id, db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: create_vote_session_for_step
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateVoteSessionForStep:
|
||||
"""Test mandate_service.create_vote_session_for_step."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vote_session_success(self):
|
||||
"""A vote session is created and linked to the mandate step."""
|
||||
mandate_id = uuid.uuid4()
|
||||
step_id = uuid.uuid4()
|
||||
decision_id = uuid.uuid4()
|
||||
protocol_id = uuid.uuid4()
|
||||
|
||||
step = _make_step(
|
||||
step_id=step_id,
|
||||
mandate_id=mandate_id,
|
||||
step_type="vote",
|
||||
status="active",
|
||||
vote_session_id=None,
|
||||
)
|
||||
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
status="voting",
|
||||
decision_id=decision_id,
|
||||
steps=[step],
|
||||
)
|
||||
|
||||
decision = _make_decision_mock(
|
||||
decision_id=decision_id,
|
||||
voting_protocol_id=protocol_id,
|
||||
)
|
||||
|
||||
protocol = _make_protocol(protocol_id=protocol_id, duration_days=14)
|
||||
|
||||
db = _make_db_for_vote_session(mandate, decision, protocol)
|
||||
|
||||
result = await create_vote_session_for_step(mandate_id, step_id, db)
|
||||
|
||||
# The step should now have a vote_session_id set
|
||||
assert step.vote_session_id is not None
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vote_session_mandate_not_found(self):
|
||||
"""ValueError when mandate does not exist."""
|
||||
db = _make_db_for_vote_session(None)
|
||||
|
||||
with pytest.raises(ValueError, match="Mandat introuvable"):
|
||||
await create_vote_session_for_step(uuid.uuid4(), uuid.uuid4(), db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vote_session_step_not_found(self):
|
||||
"""ValueError when step does not belong to the mandate."""
|
||||
mandate_id = uuid.uuid4()
|
||||
mandate = _make_mandate(mandate_id=mandate_id, decision_id=uuid.uuid4(), steps=[])
|
||||
|
||||
db = _make_db_for_vote_session(mandate)
|
||||
|
||||
with pytest.raises(ValueError, match="Etape introuvable"):
|
||||
await create_vote_session_for_step(mandate_id, uuid.uuid4(), db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vote_session_step_already_has_session(self):
|
||||
"""ValueError when step already has a vote session."""
|
||||
mandate_id = uuid.uuid4()
|
||||
step_id = uuid.uuid4()
|
||||
existing_session_id = uuid.uuid4()
|
||||
|
||||
step = _make_step(
|
||||
step_id=step_id,
|
||||
mandate_id=mandate_id,
|
||||
vote_session_id=existing_session_id,
|
||||
)
|
||||
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
decision_id=uuid.uuid4(),
|
||||
steps=[step],
|
||||
)
|
||||
|
||||
db = _make_db_for_vote_session(mandate)
|
||||
|
||||
with pytest.raises(ValueError, match="deja une session de vote"):
|
||||
await create_vote_session_for_step(mandate_id, step_id, db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vote_session_no_decision(self):
|
||||
"""ValueError when mandate has no linked decision."""
|
||||
mandate_id = uuid.uuid4()
|
||||
step_id = uuid.uuid4()
|
||||
|
||||
step = _make_step(
|
||||
step_id=step_id,
|
||||
mandate_id=mandate_id,
|
||||
vote_session_id=None,
|
||||
)
|
||||
|
||||
mandate = _make_mandate(
|
||||
mandate_id=mandate_id,
|
||||
decision_id=None,
|
||||
steps=[step],
|
||||
)
|
||||
|
||||
db = _make_db_for_vote_session(mandate)
|
||||
|
||||
with pytest.raises(ValueError, match="Aucune decision liee"):
|
||||
await create_vote_session_for_step(mandate_id, step_id, db)
|
||||
@@ -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,75 @@
|
||||
"""Tests for mode-params string parser."""
|
||||
|
||||
from app.engine.mode_params import parse_mode_params
|
||||
|
||||
|
||||
class TestParseModeParams:
|
||||
"""Parse compact parameter strings into structured dicts."""
|
||||
|
||||
def test_standard_params(self):
|
||||
"""D30M50B.1G.2 => standard Licence G1 params."""
|
||||
result = parse_mode_params("D30M50B.1G.2")
|
||||
assert result["duration_days"] == 30
|
||||
assert result["majority_pct"] == 50
|
||||
assert result["base_exponent"] == 0.1
|
||||
assert result["gradient_exponent"] == 0.2
|
||||
# Optional criteria absent
|
||||
assert result["smith_exponent"] is None
|
||||
assert result["techcomm_exponent"] is None
|
||||
|
||||
def test_with_smith_exponent(self):
|
||||
"""D30M50B.1G.2S.1 => standard + smith_exponent=0.1."""
|
||||
result = parse_mode_params("D30M50B.1G.2S.1")
|
||||
assert result["duration_days"] == 30
|
||||
assert result["majority_pct"] == 50
|
||||
assert result["base_exponent"] == 0.1
|
||||
assert result["gradient_exponent"] == 0.2
|
||||
assert result["smith_exponent"] == 0.1
|
||||
assert result["techcomm_exponent"] is None
|
||||
|
||||
def test_with_techcomm_exponent(self):
|
||||
"""D30M50B.1G.2T.1 => standard + techcomm_exponent=0.1."""
|
||||
result = parse_mode_params("D30M50B.1G.2T.1")
|
||||
assert result["duration_days"] == 30
|
||||
assert result["majority_pct"] == 50
|
||||
assert result["base_exponent"] == 0.1
|
||||
assert result["gradient_exponent"] == 0.2
|
||||
assert result["smith_exponent"] is None
|
||||
assert result["techcomm_exponent"] == 0.1
|
||||
|
||||
def test_full_params_with_constant(self):
|
||||
"""D30M50B1G.5C10 => integer base, gradient=0.5, constant=10."""
|
||||
result = parse_mode_params("D30M50B1G.5C10")
|
||||
assert result["duration_days"] == 30
|
||||
assert result["majority_pct"] == 50
|
||||
assert result["base_exponent"] == 1.0
|
||||
assert result["gradient_exponent"] == 0.5
|
||||
assert result["constant_base"] == 10.0
|
||||
|
||||
def test_empty_string_defaults(self):
|
||||
"""Empty string returns all defaults."""
|
||||
result = parse_mode_params("")
|
||||
assert result["duration_days"] == 30
|
||||
assert result["majority_pct"] == 50
|
||||
assert result["base_exponent"] == 0.1
|
||||
assert result["gradient_exponent"] == 0.2
|
||||
assert result["constant_base"] == 0.0
|
||||
assert result["smith_exponent"] is None
|
||||
assert result["techcomm_exponent"] is None
|
||||
assert result["ratio_multiplier"] is None
|
||||
assert result["is_ratio_mode"] is False
|
||||
|
||||
def test_whitespace_only_returns_defaults(self):
|
||||
"""Whitespace-only string treated as empty."""
|
||||
result = parse_mode_params(" ")
|
||||
assert result["duration_days"] == 30
|
||||
|
||||
def test_roundtrip_consistency(self):
|
||||
"""Parsing a standard string then re-checking all keys."""
|
||||
result = parse_mode_params("D30M50B.1G.2")
|
||||
expected_keys = {
|
||||
"duration_days", "majority_pct", "base_exponent",
|
||||
"gradient_exponent", "constant_base", "smith_exponent",
|
||||
"techcomm_exponent", "ratio_multiplier", "is_ratio_mode",
|
||||
}
|
||||
assert set(result.keys()) == expected_keys
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for six-level nuanced vote evaluation.
|
||||
|
||||
Levels: 0-CONTRE, 1-PAS DU TOUT, 2-PAS D'ACCORD, 3-NEUTRE, 4-D'ACCORD, 5-TOUT A FAIT
|
||||
Positive = levels 3 + 4 + 5
|
||||
Adoption requires: positive_pct >= threshold (80%) AND total >= min_participants (59).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.nuanced_vote import evaluate_nuanced
|
||||
|
||||
|
||||
class TestNuancedVoteAdoption:
|
||||
"""Cases where the vote should be adopted."""
|
||||
|
||||
def test_59_positive_10_negative_adopted(self):
|
||||
"""59 positive (levels 3-5) + 10 negative = 69 total.
|
||||
positive_pct = 59/69 ~ 85.5% >= 80% and 69 >= 59 => adopted.
|
||||
"""
|
||||
votes = [5] * 20 + [4] * 20 + [3] * 19 + [2] * 5 + [1] * 3 + [0] * 2
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
assert result["total"] == 69
|
||||
assert result["positive_count"] == 59
|
||||
assert result["positive_pct"] == pytest.approx(85.51, abs=0.1)
|
||||
assert result["threshold_met"] is True
|
||||
assert result["min_participants_met"] is True
|
||||
assert result["adopted"] is True
|
||||
|
||||
def test_all_tout_a_fait_adopted(self):
|
||||
"""All 59 voters at level 5 => 100% positive, adopted."""
|
||||
votes = [5] * 59
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
assert result["total"] == 59
|
||||
assert result["positive_count"] == 59
|
||||
assert result["positive_pct"] == 100.0
|
||||
assert result["adopted"] is True
|
||||
|
||||
|
||||
class TestNuancedVoteRejection:
|
||||
"""Cases where the vote should be rejected."""
|
||||
|
||||
def test_40_positive_30_negative_rejected(self):
|
||||
"""40 positive + 30 negative = 70 total.
|
||||
positive_pct = 40/70 ~ 57.14% < 80% => threshold not met.
|
||||
"""
|
||||
votes = [5] * 15 + [4] * 15 + [3] * 10 + [2] * 10 + [1] * 10 + [0] * 10
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
assert result["total"] == 70
|
||||
assert result["positive_count"] == 40
|
||||
assert result["positive_pct"] == pytest.approx(57.14, abs=0.1)
|
||||
assert result["threshold_met"] is False
|
||||
assert result["min_participants_met"] is True # 70 >= 59
|
||||
assert result["adopted"] is False
|
||||
|
||||
def test_min_participants_not_met(self):
|
||||
"""50 positive + 5 negative = 55 total < 59 min_participants.
|
||||
Even though 50/55 ~ 90.9% > 80%, adoption fails on min_participants.
|
||||
"""
|
||||
votes = [5] * 30 + [4] * 10 + [3] * 10 + [1] * 3 + [0] * 2
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
assert result["total"] == 55
|
||||
assert result["positive_count"] == 50
|
||||
assert result["positive_pct"] > 80
|
||||
assert result["threshold_met"] is True
|
||||
assert result["min_participants_met"] is False
|
||||
assert result["adopted"] is False
|
||||
|
||||
|
||||
class TestNuancedVoteEdgeCases:
|
||||
"""Edge cases and exact boundary conditions."""
|
||||
|
||||
def test_exact_threshold_80_percent(self):
|
||||
"""Exactly 80% positive votes should pass the threshold."""
|
||||
# 80 positive out of 100 = exactly 80%
|
||||
votes = [5] * 40 + [4] * 20 + [3] * 20 + [2] * 10 + [1] * 5 + [0] * 5
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
assert result["total"] == 100
|
||||
assert result["positive_count"] == 80
|
||||
assert result["positive_pct"] == 80.0
|
||||
assert result["threshold_met"] is True
|
||||
assert result["min_participants_met"] is True
|
||||
assert result["adopted"] is True
|
||||
|
||||
def test_just_below_threshold(self):
|
||||
"""79 positive out of 100 = 79% < 80% => rejected."""
|
||||
votes = [5] * 39 + [4] * 20 + [3] * 20 + [2] * 11 + [1] * 5 + [0] * 5
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
assert result["total"] == 100
|
||||
assert result["positive_count"] == 79
|
||||
assert result["positive_pct"] == 79.0
|
||||
assert result["threshold_met"] is False
|
||||
assert result["adopted"] is False
|
||||
|
||||
def test_empty_votes(self):
|
||||
"""Zero votes => not adopted."""
|
||||
result = evaluate_nuanced([], threshold_pct=80, min_participants=59)
|
||||
assert result["total"] == 0
|
||||
assert result["positive_count"] == 0
|
||||
assert result["positive_pct"] == 0.0
|
||||
assert result["adopted"] is False
|
||||
|
||||
def test_invalid_vote_level(self):
|
||||
"""Vote level outside 0-5 raises ValueError."""
|
||||
with pytest.raises(ValueError, match="invalide"):
|
||||
evaluate_nuanced([5, 3, 6])
|
||||
|
||||
def test_per_level_counts(self):
|
||||
"""Verify per-level breakdown is correct."""
|
||||
votes = [0, 1, 2, 3, 4, 5, 5, 4, 3]
|
||||
result = evaluate_nuanced(votes, threshold_pct=50, min_participants=1)
|
||||
|
||||
assert result["per_level_counts"] == {0: 1, 1: 1, 2: 1, 3: 2, 4: 2, 5: 2}
|
||||
assert result["positive_count"] == 6 # 2+2+2
|
||||
assert result["total"] == 9
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Tests for public API: basic schema validation and serialization.
|
||||
|
||||
Uses mock database sessions to test the public router logic
|
||||
without requiring a real PostgreSQL connection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for public API tests")
|
||||
|
||||
from app.schemas.document import DocumentFullOut, DocumentOut # noqa: E402
|
||||
from app.schemas.sanctuary import SanctuaryEntryOut # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers: mock objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_document_mock(
|
||||
doc_id: uuid.UUID | None = None,
|
||||
slug: str = "licence-g1",
|
||||
title: str = "Licence G1",
|
||||
doc_type: str = "licence",
|
||||
version: str = "2.0.0",
|
||||
doc_status: str = "active",
|
||||
description: str | None = "La licence monetaire",
|
||||
) -> MagicMock:
|
||||
"""Create a mock Document for schema validation."""
|
||||
doc = MagicMock()
|
||||
doc.id = doc_id or uuid.uuid4()
|
||||
doc.slug = slug
|
||||
doc.title = title
|
||||
doc.doc_type = doc_type
|
||||
doc.version = version
|
||||
doc.status = doc_status
|
||||
doc.description = description
|
||||
doc.ipfs_cid = None
|
||||
doc.chain_anchor = None
|
||||
doc.genesis_json = None
|
||||
doc.created_at = datetime.now(timezone.utc)
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
doc.items = []
|
||||
return doc
|
||||
|
||||
|
||||
def _make_item_mock(
|
||||
item_id: uuid.UUID | None = None,
|
||||
document_id: uuid.UUID | None = None,
|
||||
position: str = "1",
|
||||
item_type: str = "clause",
|
||||
title: str | None = "Article 1",
|
||||
current_text: str = "Texte de l'article",
|
||||
sort_order: int = 0,
|
||||
) -> MagicMock:
|
||||
"""Create a mock DocumentItem for schema validation."""
|
||||
item = MagicMock()
|
||||
item.id = item_id or uuid.uuid4()
|
||||
item.document_id = document_id or uuid.uuid4()
|
||||
item.position = position
|
||||
item.item_type = item_type
|
||||
item.title = title
|
||||
item.current_text = current_text
|
||||
item.voting_protocol_id = None
|
||||
item.sort_order = sort_order
|
||||
item.section_tag = None
|
||||
item.inertia_preset = "standard"
|
||||
item.is_permanent_vote = True
|
||||
item.created_at = datetime.now(timezone.utc)
|
||||
item.updated_at = datetime.now(timezone.utc)
|
||||
return item
|
||||
|
||||
|
||||
def _make_sanctuary_entry_mock(
|
||||
entry_id: uuid.UUID | None = None,
|
||||
entry_type: str = "document",
|
||||
reference_id: uuid.UUID | None = None,
|
||||
title: str | None = "Licence G1 v2.0.0",
|
||||
content_hash: str = "abc123def456",
|
||||
ipfs_cid: str | None = "QmTestCid123",
|
||||
chain_tx_hash: str | None = "0xdeadbeef",
|
||||
) -> MagicMock:
|
||||
"""Create a mock SanctuaryEntry for schema validation."""
|
||||
entry = MagicMock()
|
||||
entry.id = entry_id or uuid.uuid4()
|
||||
entry.entry_type = entry_type
|
||||
entry.reference_id = reference_id or uuid.uuid4()
|
||||
entry.title = title
|
||||
entry.content_hash = content_hash
|
||||
entry.ipfs_cid = ipfs_cid
|
||||
entry.chain_tx_hash = chain_tx_hash
|
||||
entry.chain_block = 12345 if chain_tx_hash else None
|
||||
entry.metadata_json = None
|
||||
entry.created_at = datetime.now(timezone.utc)
|
||||
return entry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: DocumentOut schema serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDocumentOutSchema:
|
||||
"""Test DocumentOut schema validation from mock objects."""
|
||||
|
||||
def test_document_out_basic(self):
|
||||
"""DocumentOut validates from a mock document object."""
|
||||
doc = _make_document_mock()
|
||||
out = DocumentOut.model_validate(doc)
|
||||
|
||||
assert out.slug == "licence-g1"
|
||||
assert out.title == "Licence G1"
|
||||
assert out.doc_type == "licence"
|
||||
assert out.version == "2.0.0"
|
||||
assert out.status == "active"
|
||||
# items_count is set explicitly after validation (not from model)
|
||||
out.items_count = 0
|
||||
assert out.items_count == 0
|
||||
|
||||
def test_document_out_with_items_count(self):
|
||||
"""DocumentOut can have items_count set after validation."""
|
||||
doc = _make_document_mock()
|
||||
out = DocumentOut.model_validate(doc)
|
||||
out.items_count = 42
|
||||
|
||||
assert out.items_count == 42
|
||||
|
||||
def test_document_out_all_fields_present(self):
|
||||
"""All expected fields are present in the DocumentOut serialization."""
|
||||
doc = _make_document_mock()
|
||||
out = DocumentOut.model_validate(doc)
|
||||
data = out.model_dump()
|
||||
|
||||
expected_fields = {
|
||||
"id", "slug", "title", "doc_type", "version", "status",
|
||||
"description", "ipfs_cid", "chain_anchor", "genesis_json",
|
||||
"created_at", "updated_at", "items_count",
|
||||
}
|
||||
assert expected_fields.issubset(set(data.keys()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: DocumentFullOut schema serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDocumentFullOutSchema:
|
||||
"""Test DocumentFullOut schema validation (document with items)."""
|
||||
|
||||
def test_document_full_out_empty_items(self):
|
||||
"""DocumentFullOut works with an empty items list."""
|
||||
doc = _make_document_mock()
|
||||
doc.items = []
|
||||
out = DocumentFullOut.model_validate(doc)
|
||||
|
||||
assert out.slug == "licence-g1"
|
||||
assert out.items == []
|
||||
|
||||
def test_document_full_out_with_items(self):
|
||||
"""DocumentFullOut includes items when present."""
|
||||
doc_id = uuid.uuid4()
|
||||
doc = _make_document_mock(doc_id=doc_id)
|
||||
item1 = _make_item_mock(document_id=doc_id, position="1", sort_order=0)
|
||||
item2 = _make_item_mock(document_id=doc_id, position="2", sort_order=1)
|
||||
doc.items = [item1, item2]
|
||||
|
||||
out = DocumentFullOut.model_validate(doc)
|
||||
|
||||
assert len(out.items) == 2
|
||||
assert out.items[0].position == "1"
|
||||
assert out.items[1].position == "2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: SanctuaryEntryOut schema serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSanctuaryEntryOutSchema:
|
||||
"""Test SanctuaryEntryOut schema validation."""
|
||||
|
||||
def test_sanctuary_entry_out_basic(self):
|
||||
"""SanctuaryEntryOut validates from a mock entry."""
|
||||
entry = _make_sanctuary_entry_mock()
|
||||
out = SanctuaryEntryOut.model_validate(entry)
|
||||
|
||||
assert out.entry_type == "document"
|
||||
assert out.content_hash == "abc123def456"
|
||||
assert out.ipfs_cid == "QmTestCid123"
|
||||
assert out.chain_tx_hash == "0xdeadbeef"
|
||||
assert out.chain_block == 12345
|
||||
|
||||
def test_sanctuary_entry_out_without_ipfs(self):
|
||||
"""SanctuaryEntryOut works when IPFS CID is None."""
|
||||
entry = _make_sanctuary_entry_mock(ipfs_cid=None, chain_tx_hash=None)
|
||||
out = SanctuaryEntryOut.model_validate(entry)
|
||||
|
||||
assert out.ipfs_cid is None
|
||||
assert out.chain_tx_hash is None
|
||||
assert out.chain_block is None
|
||||
|
||||
def test_sanctuary_entry_out_all_fields(self):
|
||||
"""All expected fields are present in SanctuaryEntryOut."""
|
||||
entry = _make_sanctuary_entry_mock()
|
||||
out = SanctuaryEntryOut.model_validate(entry)
|
||||
data = out.model_dump()
|
||||
|
||||
expected_fields = {
|
||||
"id", "entry_type", "reference_id", "title",
|
||||
"content_hash", "ipfs_cid", "chain_tx_hash",
|
||||
"chain_block", "metadata_json", "created_at",
|
||||
}
|
||||
assert expected_fields.issubset(set(data.keys()))
|
||||
|
||||
def test_sanctuary_entry_types(self):
|
||||
"""Different entry_type values are accepted."""
|
||||
for entry_type in ("document", "decision", "vote_result"):
|
||||
entry = _make_sanctuary_entry_mock(entry_type=entry_type)
|
||||
out = SanctuaryEntryOut.model_validate(entry)
|
||||
assert out.entry_type == entry_type
|
||||
@@ -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
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Tests for WoT threshold formula, Smith threshold, and TechComm threshold.
|
||||
|
||||
Real-world reference case:
|
||||
Vote Engagement Forgeron v2.0.0 (Feb 2026)
|
||||
wot_size=7224, votes_for=97, votes_against=23, total=120
|
||||
params M=50, B=0.1, G=0.2 => threshold=94 => adopted (97 >= 94)
|
||||
"""
|
||||
|
||||
import math
|
||||
import pytest
|
||||
|
||||
from app.engine.threshold import wot_threshold
|
||||
from app.engine.smith_threshold import smith_threshold
|
||||
from app.engine.techcomm_threshold import techcomm_threshold
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WoT threshold: real-world vote Forgeron
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWotThresholdForgeron:
|
||||
"""Test with the actual Engagement Forgeron v2.0.0 vote numbers."""
|
||||
|
||||
def test_forgeron_vote_passes(self):
|
||||
"""97 votes_for out of 120 total (wot=7224) must pass."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# With low participation (120/7224 ~ 1.66%), near-unanimity is required.
|
||||
# The historical threshold was 94, and 97 >= 94.
|
||||
assert 97 >= threshold
|
||||
# The threshold should be high relative to total votes (inertia effect)
|
||||
assert threshold > 60, f"Threshold {threshold} should be well above simple majority"
|
||||
|
||||
def test_forgeron_vote_threshold_value(self):
|
||||
"""Verify the computed threshold is in a reasonable range."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# At ~1.66% participation, inertia should push threshold close to 78-95%
|
||||
# of total votes. The exact value depends on the formula.
|
||||
assert 80 <= threshold <= 120
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WoT threshold: low participation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWotThresholdLowParticipation:
|
||||
"""With very low participation, near-unanimity should be required."""
|
||||
|
||||
def test_ten_votes_out_of_7224(self):
|
||||
"""10 voters out of 7224 => nearly all must vote 'for'."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=10,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# With participation ratio 10/7224 ~ 0.14%, threshold should be
|
||||
# very close to total_votes (near-unanimity).
|
||||
assert threshold >= 9, f"Expected near-unanimity but got threshold={threshold}"
|
||||
assert threshold <= 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WoT threshold: high participation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWotThresholdHighParticipation:
|
||||
"""With high participation, threshold should approach simple majority M."""
|
||||
|
||||
def test_3000_votes_out_of_7224(self):
|
||||
"""3000/7224 ~ 41.5% participation => threshold closer to 50%."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=3000,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# With ~42% participation, the inertia factor diminishes.
|
||||
# threshold should be well below 90% of votes but above simple majority.
|
||||
simple_majority = math.ceil(3000 * 0.5)
|
||||
assert threshold >= simple_majority, (
|
||||
f"Threshold {threshold} should be at least simple majority {simple_majority}"
|
||||
)
|
||||
# Should be noticeably less than near-unanimity
|
||||
assert threshold < 2700, (
|
||||
f"Threshold {threshold} should be much less than near-unanimity at high participation"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WoT threshold: edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWotThresholdEdgeCases:
|
||||
"""Edge-case behaviour."""
|
||||
|
||||
def test_zero_total_votes(self):
|
||||
"""With zero votes, threshold is ceil(C + B^W)."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=0,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# B^W = 0.1^7224 is effectively 0
|
||||
expected = math.ceil(0.0 + 0.1 ** 7224)
|
||||
assert threshold == expected
|
||||
|
||||
def test_invalid_wot_size_zero(self):
|
||||
with pytest.raises(ValueError, match="wot_size"):
|
||||
wot_threshold(wot_size=0, total_votes=10)
|
||||
|
||||
def test_invalid_negative_votes(self):
|
||||
with pytest.raises(ValueError, match="total_votes"):
|
||||
wot_threshold(wot_size=100, total_votes=-1)
|
||||
|
||||
def test_invalid_majority_pct(self):
|
||||
with pytest.raises(ValueError, match="majority_pct"):
|
||||
wot_threshold(wot_size=100, total_votes=10, majority_pct=150)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Smith threshold
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSmithThreshold:
|
||||
"""Test Smith sub-WoT threshold: ceil(smith_size ^ S)."""
|
||||
|
||||
def test_smith_size_20_exponent_01(self):
|
||||
"""smith_size=20, exponent=0.1 => ceil(20^0.1)."""
|
||||
result = smith_threshold(smith_wot_size=20, exponent=0.1)
|
||||
expected = math.ceil(20 ** 0.1)
|
||||
assert result == expected
|
||||
# 20^0.1 ~ 1.35, ceil => 2
|
||||
assert result == 2
|
||||
|
||||
def test_smith_size_1(self):
|
||||
"""smith_size=1 => ceil(1^0.1) = 1."""
|
||||
assert smith_threshold(smith_wot_size=1, exponent=0.1) == 1
|
||||
|
||||
def test_smith_invalid(self):
|
||||
with pytest.raises(ValueError):
|
||||
smith_threshold(smith_wot_size=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TechComm threshold
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTechcommThreshold:
|
||||
"""Test TechComm threshold: ceil(cotec_size ^ T)."""
|
||||
|
||||
def test_cotec_size_5_exponent_01(self):
|
||||
"""cotec_size=5, exponent=0.1 => ceil(5^0.1)."""
|
||||
result = techcomm_threshold(cotec_size=5, exponent=0.1)
|
||||
expected = math.ceil(5 ** 0.1)
|
||||
assert result == expected
|
||||
# 5^0.1 ~ 1.175, ceil => 2
|
||||
assert result == 2
|
||||
|
||||
def test_cotec_size_1(self):
|
||||
assert techcomm_threshold(cotec_size=1, exponent=0.1) == 1
|
||||
|
||||
def test_cotec_invalid(self):
|
||||
with pytest.raises(ValueError):
|
||||
techcomm_threshold(cotec_size=0)
|
||||
@@ -0,0 +1,478 @@
|
||||
"""Tests for vote service engine functions: threshold details, session logic, nuanced evaluation, and simulation.
|
||||
|
||||
All tests are pure unit tests that exercise the engine functions directly
|
||||
without any database dependency.
|
||||
|
||||
Real-world reference case:
|
||||
Vote Engagement Forgeron v2.0.0 (Feb 2026)
|
||||
wot_size=7224, votes_for=97, votes_against=23, total=120
|
||||
params M=50, B=0.1, G=0.2 => threshold=94 => adopted (97 >= 94)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.nuanced_vote import evaluate_nuanced
|
||||
from app.engine.smith_threshold import smith_threshold
|
||||
from app.engine.techcomm_threshold import techcomm_threshold
|
||||
from app.engine.threshold import wot_threshold
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Threshold details computation: real Forgeron case (97/23/7224)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestThresholdDetailsForgeron:
|
||||
"""Simulate the threshold details computation that the service would return
|
||||
for the real Engagement Forgeron v2.0.0 vote."""
|
||||
|
||||
WOT_SIZE = 7224
|
||||
VOTES_FOR = 97
|
||||
VOTES_AGAINST = 23
|
||||
TOTAL = 120
|
||||
SMITH_SIZE = 20
|
||||
SMITH_EXPONENT = 0.1
|
||||
TECHCOMM_SIZE = 5
|
||||
TECHCOMM_EXPONENT = 0.1
|
||||
|
||||
def _compute_details(self) -> dict:
|
||||
"""Reproduce the threshold details logic from vote_service.get_threshold_details."""
|
||||
wot_thresh = wot_threshold(
|
||||
wot_size=self.WOT_SIZE,
|
||||
total_votes=self.TOTAL,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
constant_base=0.0,
|
||||
)
|
||||
wot_passed = self.VOTES_FOR >= wot_thresh
|
||||
|
||||
smith_thresh = smith_threshold(self.SMITH_SIZE, self.SMITH_EXPONENT)
|
||||
# Assume all smith members voted for
|
||||
smith_votes_for = 5
|
||||
smith_passed = smith_votes_for >= smith_thresh
|
||||
|
||||
techcomm_thresh = techcomm_threshold(self.TECHCOMM_SIZE, self.TECHCOMM_EXPONENT)
|
||||
# Assume 2 techcomm members voted for
|
||||
techcomm_votes_for = 2
|
||||
techcomm_passed = techcomm_votes_for >= techcomm_thresh
|
||||
|
||||
overall_passed = wot_passed and smith_passed and techcomm_passed
|
||||
participation_rate = self.TOTAL / self.WOT_SIZE
|
||||
|
||||
return {
|
||||
"wot_threshold": wot_thresh,
|
||||
"smith_threshold": smith_thresh,
|
||||
"techcomm_threshold": techcomm_thresh,
|
||||
"votes_for": self.VOTES_FOR,
|
||||
"votes_against": self.VOTES_AGAINST,
|
||||
"votes_total": self.TOTAL,
|
||||
"wot_passed": wot_passed,
|
||||
"smith_passed": smith_passed,
|
||||
"techcomm_passed": techcomm_passed,
|
||||
"overall_passed": overall_passed,
|
||||
"participation_rate": round(participation_rate, 6),
|
||||
"formula_params": {
|
||||
"M": 50, "B": 0.1, "G": 0.2, "C": 0.0,
|
||||
"S": self.SMITH_EXPONENT, "T": self.TECHCOMM_EXPONENT,
|
||||
},
|
||||
}
|
||||
|
||||
def test_wot_threshold_value(self):
|
||||
"""WoT threshold for Forgeron vote should be in the expected range."""
|
||||
details = self._compute_details()
|
||||
assert 80 <= details["wot_threshold"] <= 120
|
||||
# 97 votes_for must pass
|
||||
assert details["wot_passed"] is True
|
||||
|
||||
def test_smith_threshold_value(self):
|
||||
"""Smith threshold: ceil(20^0.1) = ceil(1.35) = 2."""
|
||||
details = self._compute_details()
|
||||
assert details["smith_threshold"] == 2
|
||||
assert details["smith_passed"] is True
|
||||
|
||||
def test_techcomm_threshold_value(self):
|
||||
"""TechComm threshold: ceil(5^0.1) = ceil(1.175) = 2."""
|
||||
details = self._compute_details()
|
||||
assert details["techcomm_threshold"] == 2
|
||||
assert details["techcomm_passed"] is True
|
||||
|
||||
def test_overall_pass(self):
|
||||
"""All three criteria pass => overall adopted."""
|
||||
details = self._compute_details()
|
||||
assert details["overall_passed"] is True
|
||||
|
||||
def test_participation_rate(self):
|
||||
"""Participation rate for 120/7224 ~ 1.66%."""
|
||||
details = self._compute_details()
|
||||
expected = round(120 / 7224, 6)
|
||||
assert details["participation_rate"] == expected
|
||||
assert details["participation_rate"] < 0.02 # less than 2%
|
||||
|
||||
def test_formula_params_present(self):
|
||||
"""All formula params must be present and correct."""
|
||||
details = self._compute_details()
|
||||
params = details["formula_params"]
|
||||
assert params["M"] == 50
|
||||
assert params["B"] == 0.1
|
||||
assert params["G"] == 0.2
|
||||
assert params["C"] == 0.0
|
||||
assert params["S"] == 0.1
|
||||
assert params["T"] == 0.1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Close session behavior (engine-level logic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCloseSessionLogic:
|
||||
"""Test the logic that would execute when a session is closed:
|
||||
computing the final tally and determining adopted/rejected."""
|
||||
|
||||
def test_binary_vote_adopted(self):
|
||||
"""97 for / 23 against out of 7224 WoT => adopted."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
adopted = 97 >= threshold
|
||||
assert adopted is True
|
||||
result = "adopted" if adopted else "rejected"
|
||||
assert result == "adopted"
|
||||
|
||||
def test_binary_vote_rejected(self):
|
||||
"""50 for / 70 against out of 7224 WoT => rejected."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
adopted = 50 >= threshold
|
||||
assert adopted is False
|
||||
result = "adopted" if adopted else "rejected"
|
||||
assert result == "rejected"
|
||||
|
||||
def test_close_with_zero_votes(self):
|
||||
"""Session with 0 votes => threshold is ~0 (B^W), effectively rejected
|
||||
because 0 votes_for cannot meet even a tiny threshold."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=0,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# B^W = 0.1^7224 -> effectively 0, ceil(0) = 0
|
||||
# But with 0 votes_for, 0 >= 0 is True
|
||||
# This is a degenerate case; in practice sessions with 0 votes
|
||||
# would be marked invalid
|
||||
assert threshold == 0 or threshold == math.ceil(0.1 ** 7224)
|
||||
|
||||
def test_close_high_participation(self):
|
||||
"""3000/7224 participating, 2500 for / 500 against => should pass.
|
||||
At ~41.5% participation with G=0.2, the inertia factor is low,
|
||||
so a strong supermajority should pass.
|
||||
"""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=3000,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# At ~41.5% participation, threshold is lower than near-unanimity
|
||||
# but still above simple majority (1500)
|
||||
assert threshold > 1500, f"Threshold {threshold} should be above simple majority"
|
||||
assert threshold < 3000, f"Threshold {threshold} should be below total votes"
|
||||
adopted = 2500 >= threshold
|
||||
assert adopted is True
|
||||
|
||||
def test_close_barely_fails(self):
|
||||
"""Use exact threshold to verify borderline rejection."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# votes_for = threshold - 1 => should fail
|
||||
barely_fail = (threshold - 1) >= threshold
|
||||
assert barely_fail is False
|
||||
|
||||
def test_close_smith_criterion_blocks(self):
|
||||
"""Even if WoT passes, failing Smith criterion blocks adoption."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
wot_pass = 97 >= threshold
|
||||
|
||||
smith_required = smith_threshold(20, 0.1) # ceil(20^0.1) = 2
|
||||
smith_ok = 1 >= smith_required # Only 1 smith voted -> fails
|
||||
|
||||
adopted = wot_pass and smith_ok
|
||||
assert wot_pass is True
|
||||
assert smith_ok is False
|
||||
assert adopted is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nuanced vote result evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNuancedVoteResult:
|
||||
"""Test nuanced vote evaluation with per-level breakdown,
|
||||
as would be returned by compute_result for nuanced votes."""
|
||||
|
||||
def test_nuanced_adopted_with_breakdown(self):
|
||||
"""Standard nuanced vote that passes threshold and min_participants."""
|
||||
votes = [5] * 30 + [4] * 20 + [3] * 15 + [2] * 5 + [1] * 3 + [0] * 2
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
# Total = 75, positive = 30+20+15 = 65
|
||||
assert result["total"] == 75
|
||||
assert result["positive_count"] == 65
|
||||
assert result["positive_pct"] == pytest.approx(86.67, abs=0.1)
|
||||
assert result["adopted"] is True
|
||||
|
||||
# Verify per-level breakdown
|
||||
assert result["per_level_counts"][5] == 30
|
||||
assert result["per_level_counts"][4] == 20
|
||||
assert result["per_level_counts"][3] == 15
|
||||
assert result["per_level_counts"][2] == 5
|
||||
assert result["per_level_counts"][1] == 3
|
||||
assert result["per_level_counts"][0] == 2
|
||||
|
||||
def test_nuanced_rejected_threshold_not_met(self):
|
||||
"""Nuanced vote where positive percentage is below threshold."""
|
||||
votes = [5] * 10 + [4] * 10 + [3] * 10 + [2] * 15 + [1] * 15 + [0] * 10
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
# Total = 70, positive = 10+10+10 = 30
|
||||
assert result["total"] == 70
|
||||
assert result["positive_count"] == 30
|
||||
assert result["positive_pct"] == pytest.approx(42.86, abs=0.1)
|
||||
assert result["threshold_met"] is False
|
||||
assert result["min_participants_met"] is True
|
||||
assert result["adopted"] is False
|
||||
|
||||
def test_nuanced_all_neutre(self):
|
||||
"""All voters at level 3 (NEUTRE) still counts as positive."""
|
||||
votes = [3] * 60
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
assert result["total"] == 60
|
||||
assert result["positive_count"] == 60
|
||||
assert result["positive_pct"] == 100.0
|
||||
assert result["adopted"] is True
|
||||
|
||||
def test_nuanced_mixed_heavy_negative(self):
|
||||
"""Majority at levels 0-2 => rejected."""
|
||||
votes = [0] * 20 + [1] * 20 + [2] * 15 + [3] * 2 + [4] * 1 + [5] * 1
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
# Total = 59, positive = 2+1+1 = 4
|
||||
assert result["total"] == 59
|
||||
assert result["positive_count"] == 4
|
||||
assert result["positive_pct"] < 10
|
||||
assert result["adopted"] is False
|
||||
|
||||
def test_nuanced_breakdown_dict_structure(self):
|
||||
"""Verify the breakdown structure matches what the service builds."""
|
||||
votes = [5] * 20 + [4] * 20 + [3] * 19 + [2] * 5 + [1] * 3 + [0] * 2
|
||||
evaluation = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
# Simulate the nuanced_breakdown dict the service builds
|
||||
nuanced_breakdown = {
|
||||
"per_level_counts": evaluation["per_level_counts"],
|
||||
"positive_count": evaluation["positive_count"],
|
||||
"positive_pct": evaluation["positive_pct"],
|
||||
"threshold_met": evaluation["threshold_met"],
|
||||
"min_participants_met": evaluation["min_participants_met"],
|
||||
"threshold_pct": 80,
|
||||
"min_participants": 59,
|
||||
}
|
||||
|
||||
assert "per_level_counts" in nuanced_breakdown
|
||||
assert nuanced_breakdown["threshold_pct"] == 80
|
||||
assert nuanced_breakdown["min_participants"] == 59
|
||||
assert nuanced_breakdown["positive_count"] == 59
|
||||
assert nuanced_breakdown["threshold_met"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simulation endpoint logic (pure engine functions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSimulationLogic:
|
||||
"""Test the formula simulation that the /simulate endpoint performs,
|
||||
exercising all three threshold engine functions together."""
|
||||
|
||||
def test_simulation_forgeron_case(self):
|
||||
"""Simulate the Forgeron vote: wot=7224, total=120, M50 B.1 G.2."""
|
||||
wot_thresh = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
|
||||
# Derived values (matching the simulate endpoint logic)
|
||||
w = 7224
|
||||
t = 120
|
||||
m = 0.5
|
||||
g = 0.2
|
||||
participation_rate = t / w
|
||||
turnout_ratio = min(t / w, 1.0)
|
||||
inertia_factor = 1.0 - turnout_ratio ** g
|
||||
required_ratio = m + (1.0 - m) * inertia_factor
|
||||
|
||||
assert 80 <= wot_thresh <= 120
|
||||
assert participation_rate == pytest.approx(0.016611, abs=0.001)
|
||||
assert 0 < inertia_factor < 1
|
||||
assert required_ratio > m # Inertia pushes above simple majority
|
||||
|
||||
def test_simulation_full_participation(self):
|
||||
"""At 100% participation, threshold approaches simple majority."""
|
||||
wot_size = 100
|
||||
total = 100
|
||||
|
||||
wot_thresh = wot_threshold(
|
||||
wot_size=wot_size,
|
||||
total_votes=total,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
|
||||
# At full participation, turnout_ratio = 1.0
|
||||
# inertia_factor = 1.0 - 1.0^0.2 = 0
|
||||
# required_ratio = 0.5 + 0.5*0 = 0.5
|
||||
# threshold = 0 + 0.1^100 + 0.5 * 100 = ~50
|
||||
assert wot_thresh == math.ceil(0.1 ** 100 + 0.5 * 100)
|
||||
assert wot_thresh == 50
|
||||
|
||||
def test_simulation_with_smith_and_techcomm(self):
|
||||
"""Simulate with all three criteria."""
|
||||
wot_thresh = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
smith_thresh = smith_threshold(smith_wot_size=20, exponent=0.1)
|
||||
techcomm_thresh = techcomm_threshold(cotec_size=5, exponent=0.1)
|
||||
|
||||
assert wot_thresh > 0
|
||||
assert smith_thresh == 2 # ceil(20^0.1) = ceil(1.35) = 2
|
||||
assert techcomm_thresh == 2 # ceil(5^0.1) = ceil(1.175) = 2
|
||||
|
||||
def test_simulation_varying_majority(self):
|
||||
"""Higher majority_pct increases the threshold at full participation."""
|
||||
thresh_50 = wot_threshold(wot_size=100, total_votes=100, majority_pct=50)
|
||||
thresh_66 = wot_threshold(wot_size=100, total_votes=100, majority_pct=66)
|
||||
thresh_80 = wot_threshold(wot_size=100, total_votes=100, majority_pct=80)
|
||||
|
||||
assert thresh_50 < thresh_66 < thresh_80
|
||||
|
||||
def test_simulation_varying_gradient(self):
|
||||
"""Higher gradient exponent means more inertia at partial participation.
|
||||
|
||||
With T/W < 1, a higher G makes (T/W)^G smaller, so
|
||||
inertia_factor = 1 - (T/W)^G becomes larger, raising the threshold.
|
||||
At 50% participation: (0.5)^0.2 ~ 0.87, (0.5)^1.0 = 0.5.
|
||||
"""
|
||||
thresh_g02 = wot_threshold(
|
||||
wot_size=200, total_votes=100,
|
||||
majority_pct=50, gradient_exponent=0.2,
|
||||
)
|
||||
thresh_g10 = wot_threshold(
|
||||
wot_size=200, total_votes=100,
|
||||
majority_pct=50, gradient_exponent=1.0,
|
||||
)
|
||||
|
||||
# Higher G = more inertia at partial participation = higher threshold
|
||||
assert thresh_g10 > thresh_g02
|
||||
|
||||
def test_simulation_zero_votes(self):
|
||||
"""Zero votes produces a minimal threshold."""
|
||||
thresh = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=0,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# B^W = 0.1^7224 ~ 0, ceil(0) = 0
|
||||
assert thresh == 0
|
||||
|
||||
def test_simulation_small_wot(self):
|
||||
"""Small WoT size (e.g. 5 members)."""
|
||||
thresh = wot_threshold(
|
||||
wot_size=5,
|
||||
total_votes=3,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# With 3/5 participation, should require more than simple majority of 3
|
||||
assert thresh >= 2
|
||||
assert thresh <= 3
|
||||
|
||||
def test_simulation_result_structure(self):
|
||||
"""Verify the simulation produces all expected values for the API response."""
|
||||
w = 7224
|
||||
t = 120
|
||||
m_pct = 50
|
||||
m = m_pct / 100.0
|
||||
b = 0.1
|
||||
g = 0.2
|
||||
c = 0.0
|
||||
|
||||
wot_thresh = wot_threshold(
|
||||
wot_size=w, total_votes=t,
|
||||
majority_pct=m_pct, base_exponent=b,
|
||||
gradient_exponent=g, constant_base=c,
|
||||
)
|
||||
smith_thresh = smith_threshold(smith_wot_size=20, exponent=0.1)
|
||||
techcomm_thresh = techcomm_threshold(cotec_size=5, exponent=0.1)
|
||||
|
||||
participation_rate = t / w
|
||||
turnout_ratio = min(t / w, 1.0)
|
||||
inertia_factor = 1.0 - turnout_ratio ** g
|
||||
required_ratio = m + (1.0 - m) * inertia_factor
|
||||
|
||||
# Build the simulation result dict (matching FormulaSimulationResult schema)
|
||||
result = {
|
||||
"wot_threshold": wot_thresh,
|
||||
"smith_threshold": smith_thresh,
|
||||
"techcomm_threshold": techcomm_thresh,
|
||||
"participation_rate": round(participation_rate, 6),
|
||||
"required_ratio": round(required_ratio, 6),
|
||||
"inertia_factor": round(inertia_factor, 6),
|
||||
}
|
||||
|
||||
assert isinstance(result["wot_threshold"], int)
|
||||
assert isinstance(result["smith_threshold"], int)
|
||||
assert isinstance(result["techcomm_threshold"], int)
|
||||
assert 0 < result["participation_rate"] < 1
|
||||
assert 0 < result["required_ratio"] <= 1
|
||||
assert 0 < result["inertia_factor"] < 1
|
||||
@@ -0,0 +1,6 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
pythonpath = .
|
||||
@@ -0,0 +1,17 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
alembic==1.14.0
|
||||
asyncpg==0.30.0
|
||||
aiosqlite==0.22.1
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
python-multipart==0.0.18
|
||||
websockets==14.1
|
||||
substrate-interface==1.7.10
|
||||
py-sr25519-bindings==0.2.1
|
||||
base58==2.1.1
|
||||
httpx==0.28.1
|
||||
aioipfs==0.7.1
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.24.0
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user