From 2d2ac79cd5a3cc9e33efc9df310f2cb2e7b5bf91 Mon Sep 17 00:00:00 2001 From: Yvv Date: Sat, 25 Apr 2026 02:47:20 +0200 Subject: [PATCH] =?UTF-8?q?Alembic=20:=20migration=20initiale=20+=20cha?= =?UTF-8?q?=C3=AEne=20idempotente=20IF=20NOT=20EXISTS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 0b9c1d2e3f4a : migration initiale (CREATE TABLE IF NOT EXISTS) — safe sur une DB déjà bootstrappée via create_all() - 70914b334cfb : ADD COLUMN IF NOT EXISTS (organization_id) — was down_revision=None - b78571ae9e00 : CREATE TABLE IF NOT EXISTS qualification_protocols - c4e812fb3a01 : CREATE TABLE IF NOT EXISTS groups + group_members - d91a3c7f8b02 : ADD COLUMN IF NOT EXISTS origin (mandates) - Dockerfile prod : restaure alembic upgrade head au démarrage Sur le serveur prod, exécuter une fois : docker exec -backend alembic upgrade head Co-Authored-By: Claude Sonnet 4.6 --- ..._04_23_0000-0b9c1d2e3f4a_initial_schema.py | 292 ++++++++++++++++++ ..._23_1227-70914b334cfb_add_organizations.py | 21 +- ...b78571ae9e00_add_qualification_protocol.py | 27 +- ...2026_04_23_1900-c4e812fb3a01_add_groups.py | 41 ++- ...24_1000-d91a3c7f8b02_add_mandate_origin.py | 2 +- docker/backend.Dockerfile | 2 +- 6 files changed, 334 insertions(+), 51 deletions(-) create mode 100644 backend/alembic/versions/2026_04_23_0000-0b9c1d2e3f4a_initial_schema.py diff --git a/backend/alembic/versions/2026_04_23_0000-0b9c1d2e3f4a_initial_schema.py b/backend/alembic/versions/2026_04_23_0000-0b9c1d2e3f4a_initial_schema.py new file mode 100644 index 0000000..4b47683 --- /dev/null +++ b/backend/alembic/versions/2026_04_23_0000-0b9c1d2e3f4a_initial_schema.py @@ -0,0 +1,292 @@ +"""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(""" + ALTER TABLE item_versions + ADD CONSTRAINT IF NOT EXISTS item_versions_item_id_fkey + FOREIGN KEY (item_id) REFERENCES document_items(id) + """) + + 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 diff --git a/backend/alembic/versions/2026_04_23_1227-70914b334cfb_add_organizations.py b/backend/alembic/versions/2026_04_23_1227-70914b334cfb_add_organizations.py index a49a2b2..cf83924 100644 --- a/backend/alembic/versions/2026_04_23_1227-70914b334cfb_add_organizations.py +++ b/backend/alembic/versions/2026_04_23_1227-70914b334cfb_add_organizations.py @@ -12,22 +12,21 @@ import sqlalchemy as sa revision: str = '70914b334cfb' -down_revision: Union[str, None] = None +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: - # SQLite does not support ADD CONSTRAINT via ALTER TABLE — FK constraints - # are declared in models only; integrity is enforced at app layer. - op.add_column('decisions', sa.Column('organization_id', sa.Uuid(), nullable=True)) - op.create_index(op.f('ix_decisions_organization_id'), 'decisions', ['organization_id'], unique=False) - op.add_column('documents', sa.Column('organization_id', sa.Uuid(), nullable=True)) - op.create_index(op.f('ix_documents_organization_id'), 'documents', ['organization_id'], unique=False) - op.add_column('mandates', sa.Column('organization_id', sa.Uuid(), nullable=True)) - op.create_index(op.f('ix_mandates_organization_id'), 'mandates', ['organization_id'], unique=False) - op.add_column('voting_protocols', sa.Column('organization_id', sa.Uuid(), nullable=True)) - op.create_index(op.f('ix_voting_protocols_organization_id'), 'voting_protocols', ['organization_id'], unique=False) + # 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: diff --git a/backend/alembic/versions/2026_04_23_1708-b78571ae9e00_add_qualification_protocol.py b/backend/alembic/versions/2026_04_23_1708-b78571ae9e00_add_qualification_protocol.py index 44725fd..f32a749 100644 --- a/backend/alembic/versions/2026_04_23_1708-b78571ae9e00_add_qualification_protocol.py +++ b/backend/alembic/versions/2026_04_23_1708-b78571ae9e00_add_qualification_protocol.py @@ -16,23 +16,18 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - op.create_table( - 'qualification_protocols', - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('name', sa.String(128), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('small_group_max', sa.Integer(), nullable=False, server_default='5'), - sa.Column('collective_wot_min', sa.Integer(), nullable=False, server_default='50'), - sa.Column( - 'default_modalities_json', - sa.Text(), - nullable=False, - server_default='["vote_wot","vote_smith","consultation_avis","election"]', - ), - sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), - sa.PrimaryKeyConstraint('id'), + 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: diff --git a/backend/alembic/versions/2026_04_23_1900-c4e812fb3a01_add_groups.py b/backend/alembic/versions/2026_04_23_1900-c4e812fb3a01_add_groups.py index 55897d0..733d618 100644 --- a/backend/alembic/versions/2026_04_23_1900-c4e812fb3a01_add_groups.py +++ b/backend/alembic/versions/2026_04_23_1900-c4e812fb3a01_add_groups.py @@ -17,31 +17,28 @@ depends_on = None def upgrade() -> None: - op.create_table( - "groups", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("name", sa.String(128), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("organization_id", sa.Uuid(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), - sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]), - sa.PrimaryKeyConstraint("id"), + op.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.create_index("ix_groups_organization_id", "groups", ["organization_id"]) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_groups_organization_id ON groups (organization_id)") - op.create_table( - "group_members", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("group_id", sa.Uuid(), nullable=False), - sa.Column("identity_id", sa.Uuid(), nullable=True), - sa.Column("display_name", sa.String(128), nullable=False), - sa.Column("added_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), - sa.ForeignKeyConstraint(["group_id"], ["groups.id"]), - sa.ForeignKeyConstraint(["identity_id"], ["duniter_identities.id"]), - sa.PrimaryKeyConstraint("id"), + op.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.create_index("ix_group_members_group_id", "group_members", ["group_id"]) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_group_members_group_id ON group_members (group_id)") def downgrade() -> None: diff --git a/backend/alembic/versions/2026_04_24_1000-d91a3c7f8b02_add_mandate_origin.py b/backend/alembic/versions/2026_04_24_1000-d91a3c7f8b02_add_mandate_origin.py index fde9331..3d54a20 100644 --- a/backend/alembic/versions/2026_04_24_1000-d91a3c7f8b02_add_mandate_origin.py +++ b/backend/alembic/versions/2026_04_24_1000-d91a3c7f8b02_add_mandate_origin.py @@ -17,7 +17,7 @@ depends_on = None def upgrade() -> None: - op.add_column("mandates", sa.Column("origin", sa.Text(), nullable=True)) + op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin TEXT") def downgrade() -> None: diff --git a/docker/backend.Dockerfile b/docker/backend.Dockerfile index c911cfa..71e513d 100644 --- a/docker/backend.Dockerfile +++ b/docker/backend.Dockerfile @@ -32,7 +32,7 @@ EXPOSE 8002 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD curl -f http://localhost:8002/api/health || exit 1 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002"] +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8002"] # ── Development ─────────────────────────────────────────────────────────────── FROM base AS development