Multi-tenancy : espaces de travail + fix auth reload (rate limiter OPTIONS)

- Modèles Organization + OrgMember, migration Alembic (SQLite compatible)
- organization_id nullable sur Document, Decision, Mandate, VotingProtocol
- Service, schéma, router /organizations + dependency get_active_org_id
- Seed : Duniter G1 + Axiom Team ; tout le contenu seed attaché à Duniter G1
- Backend : list/create filtrés par header X-Organization
- Frontend : store organizations, WorkspaceSelector réel, useApi injecte l'org
- Fix critique : rate_limiter exclut les requêtes OPTIONS (CORS preflight)
  → résout le bug "Failed to fetch /auth/me" au reload (429 sur preflight)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-04-23 15:17:14 +02:00
parent 224e5b0f5e
commit 79e468b40f
31 changed files with 1296 additions and 159 deletions

View File

@@ -26,6 +26,8 @@ from app.database import Base
from app.models import ( # noqa: F401
DuniterIdentity,
Session,
Organization,
OrgMember,
Document,
DocumentItem,
ItemVersion,

View File

@@ -0,0 +1,41 @@
"""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] = None
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)
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')

View File

View File

@@ -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

View File

@@ -13,6 +13,7 @@ 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
# ── Structured logging setup ───────────────────────────────────────────────
@@ -117,6 +118,7 @@ app.include_router(protocols.router, prefix="/api/v1/protocols", tags=["protocol
app.include_router(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"])
app.include_router(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"])
# ── Health check ─────────────────────────────────────────────────────────

View File

@@ -64,14 +64,6 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
self._last_cleanup: float = time.time()
self._lock = asyncio.Lock()
def _get_limit_for_path(self, path: str) -> int:
"""Return the rate limit applicable to the given request path."""
if "/auth" in path:
return self.rate_limit_auth
if "/vote" in path:
return self.rate_limit_vote
return self.rate_limit_default
def _get_client_ip(self, request: Request) -> str:
"""Extract the client IP from the request, respecting X-Forwarded-For."""
forwarded = request.headers.get("x-forwarded-for")
@@ -101,6 +93,22 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
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
@@ -111,8 +119,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
await self._cleanup_old_entries()
client_ip = self._get_client_ip(request)
path = request.url.path
limit = self._get_limit_for_path(path)
limit = self._get_limit_for_request(request)
now = time.time()
window_start = now - 60
@@ -133,7 +140,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
logger.warning(
"Rate limit depasse pour %s sur %s (%d/%d)",
client_ip, path, request_count, limit,
client_ip, request.url.path, request_count, limit,
)
return JSONResponse(

View File

@@ -1,4 +1,5 @@
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
@@ -9,6 +10,7 @@ from app.models.cache import BlockchainCache
__all__ = [
"DuniterIdentity", "Session",
"Organization", "OrgMember",
"Document", "DocumentItem", "ItemVersion",
"Decision", "DecisionStep",
"VoteSession", "Vote",

View File

@@ -16,6 +16,7 @@ class Decision(Base):
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())

View File

@@ -17,6 +17,7 @@ class Document(Base):
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

View File

@@ -15,6 +15,7 @@ class Mandate(Base):
description: Mapped[str | None] = mapped_column(Text)
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, candidacy, voting, active, reporting, completed, revoked
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))

View File

@@ -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")

View File

@@ -44,6 +44,7 @@ class VotingProtocol(Base):
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())

View File

@@ -132,9 +132,9 @@ async def verify_challenge(
detail="Challenge invalide",
)
# 4. Verify signature (bypass for demo profiles in DEMO_MODE)
# 4. Verify signature (bypass for demo profiles in dev/demo mode)
_demo_addresses = {p["address"] for p in DEV_PROFILES}
is_demo_bypass = settings.DEMO_MODE and payload.address in _demo_addresses
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses
if not is_demo_bypass:
# polkadot.js / Cesium2 signRaw(type='bytes') wraps: <Bytes>{challenge}</Bytes>

View File

@@ -21,6 +21,7 @@ from app.schemas.decision import (
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
@@ -49,6 +50,7 @@ async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
@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),
@@ -57,6 +59,8 @@ async def list_decisions(
"""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)
if decision_type is not None:
stmt = stmt.where(Decision.decision_type == decision_type)
if status_filter is not None:
@@ -74,11 +78,13 @@ 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()

View File

@@ -25,6 +25,7 @@ from app.schemas.document import (
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
@@ -65,6 +66,7 @@ async def _get_item(db: AsyncSession, document_id: uuid.UUID, item_id: uuid.UUID
@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),
@@ -73,6 +75,8 @@ async def list_documents(
"""List all reference documents, with optional filters."""
stmt = select(Document)
if org_id is not None:
stmt = stmt.where(Document.organization_id == org_id)
if doc_type is not None:
stmt = stmt.where(Document.doc_type == doc_type)
if status_filter is not None:
@@ -101,6 +105,7 @@ 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
@@ -111,7 +116,7 @@ async def create_document(
detail="Un document avec ce slug existe deja",
)
doc = Document(**payload.model_dump())
doc = Document(**payload.model_dump(), organization_id=org_id)
db.add(doc)
await db.commit()
await db.refresh(doc)

View File

@@ -22,6 +22,7 @@ from app.schemas.mandate import (
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,
@@ -55,6 +56,7 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
@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, description="Filtrer par type de mandat"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0),
@@ -63,6 +65,8 @@ async def list_mandates(
"""List all mandates with optional filters."""
stmt = select(Mandate).options(selectinload(Mandate.steps))
if org_id is not None:
stmt = stmt.where(Mandate.organization_id == org_id)
if mandate_type is not None:
stmt = stmt.where(Mandate.mandate_type == mandate_type)
if status_filter is not None:
@@ -80,9 +84,10 @@ 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:
"""Create a new mandate."""
mandate = Mandate(**payload.model_dump())
mandate = Mandate(**payload.model_dump(), organization_id=org_id)
db.add(mandate)
await db.commit()
await db.refresh(mandate)

View File

@@ -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)

View File

@@ -25,6 +25,7 @@ from app.schemas.protocol import (
VotingProtocolOut,
VotingProtocolUpdate,
)
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
router = APIRouter()
@@ -63,6 +64,7 @@ async def _get_formula(db: AsyncSession, formula_id: uuid.UUID) -> FormulaConfig
@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),
@@ -70,6 +72,8 @@ async def list_protocols(
"""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)
if vote_type is not None:
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
@@ -85,6 +89,7 @@ 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.
@@ -100,7 +105,7 @@ async def create_protocol(
detail="Configuration de formule introuvable",
)
protocol = VotingProtocol(**payload.model_dump())
protocol = VotingProtocol(**payload.model_dump(), organization_id=org_id)
db.add(protocol)
await db.commit()
await db.refresh(protocol)

View File

@@ -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

View File

@@ -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()

View File

@@ -32,6 +32,7 @@ from app.models.protocol import FormulaConfig, VotingProtocol
from app.models.document import Document, DocumentItem
from app.models.decision import Decision, DecisionStep
from app.models.mandate import Mandate, MandateStep
from app.models.organization import Organization
from app.models.user import DuniterIdentity
from app.models.vote import VoteSession, Vote
@@ -161,6 +162,7 @@ async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig
async def seed_voting_protocols(
session: AsyncSession,
formulas: dict[str, FormulaConfig],
org_id: uuid.UUID | None = None,
) -> dict[str, VotingProtocol]:
protocols: dict[str, dict] = {
"Vote WoT standard": {
@@ -206,6 +208,7 @@ async def seed_voting_protocols(
instance, created = await get_or_create(
session, VotingProtocol, "name", name, **params,
)
instance.organization_id = org_id
status = "created" if created else "exists"
print(f" VotingProtocol '{name}': {status}")
result[name] = instance
@@ -829,6 +832,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
async def seed_document_engagement_certification(
session: AsyncSession,
protocols: dict[str, VotingProtocol],
org_id: uuid.UUID | None = None,
) -> Document:
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
@@ -850,6 +854,7 @@ async def seed_document_engagement_certification(
),
genesis_json=genesis,
)
doc.organization_id = org_id
print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
if created:
@@ -1893,6 +1898,7 @@ ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
async def seed_document_engagement_forgeron(
session: AsyncSession,
protocols: dict[str, VotingProtocol],
org_id: uuid.UUID | None = None,
) -> Document:
genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
@@ -1916,6 +1922,7 @@ async def seed_document_engagement_forgeron(
),
genesis_json=genesis,
)
doc.organization_id = org_id
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
if created:
@@ -1988,7 +1995,7 @@ RUNTIME_UPGRADE_STEPS: list[dict] = [
]
async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
async def seed_decision_runtime_upgrade(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision:
decision, created = await get_or_create(
session,
Decision,
@@ -2009,6 +2016,7 @@ async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
decision_type="runtime_upgrade",
status="draft",
)
decision.organization_id = org_id
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
if created:
@@ -2148,7 +2156,7 @@ async def seed_votes_on_items(
# Seed: Additional decisions (demo content)
# ---------------------------------------------------------------------------
async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
async def seed_decision_licence_evolution(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision:
"""Seed a community decision: evolution of the G1 monetary license."""
decision, created = await get_or_create(
session,
@@ -2170,6 +2178,7 @@ async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
decision_type="community",
status="draft",
)
decision.organization_id = org_id
print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}")
if created:
@@ -2225,7 +2234,7 @@ async def seed_decision_licence_evolution(session: AsyncSession) -> Decision:
# Seed: Mandates (Comité Technique + Admin Forgerons)
# ---------------------------------------------------------------------------
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) -> None:
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity], org_id: uuid.UUID | None = None) -> None:
"""Seed example mandates: TechComm and Smith Admin."""
now = datetime.now(timezone.utc)
@@ -2397,6 +2406,7 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) ->
m_data["title"],
**{k: v for k, v in m_data.items() if k != "title"},
)
mandate.organization_id = org_id
status_str = "created" if created else "exists"
print(f" Mandate '{mandate.title[:50]}': {status_str}")
@@ -2408,6 +2418,43 @@ async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity]) ->
print(f" -> {len(steps_data)} steps created")
# ---------------------------------------------------------------------------
# Seed: Organizations
# ---------------------------------------------------------------------------
async def seed_organizations(session: AsyncSession) -> dict[str, Organization]:
"""Create the two base transparent organizations (idempotent)."""
orgs_data = [
{
"slug": "duniter-g1",
"name": "Duniter G1",
"org_type": "community",
"is_transparent": True,
"color": "#22c55e",
"icon": "i-lucide-globe",
"description": "Communauté Duniter — monnaie libre G1. Accessible à tous les membres authentifiés.",
},
{
"slug": "axiom-team",
"name": "Axiom Team",
"org_type": "collective",
"is_transparent": True,
"color": "#3b82f6",
"icon": "i-lucide-users",
"description": "Équipe Axiom — développement et gouvernance des outils communs.",
},
]
orgs: dict[str, Organization] = {}
for data in orgs_data:
org, created = await get_or_create(session, Organization, "slug", data["slug"], **{k: v for k, v in data.items() if k != "slug"})
status_str = "created" if created else "exists"
print(f" Organisation '{org.name}': {status_str}")
orgs[org.slug] = org
return orgs
# ---------------------------------------------------------------------------
# Main seed runner
# ---------------------------------------------------------------------------
@@ -2423,23 +2470,27 @@ async def run_seed():
async with async_session() as session:
async with session.begin():
print("\n[0/10] Organizations...")
orgs = await seed_organizations(session)
duniter_g1_id = orgs["duniter-g1"].id
print("\n[1/10] Formula Configs...")
formulas = await seed_formula_configs(session)
print("\n[2/10] Voting Protocols...")
protocols = await seed_voting_protocols(session, formulas)
protocols = await seed_voting_protocols(session, formulas, org_id=duniter_g1_id)
print("\n[3/10] Document: Acte d'engagement Certification...")
await seed_document_engagement_certification(session, protocols)
await seed_document_engagement_certification(session, protocols, org_id=duniter_g1_id)
print("\n[4/10] Document: Acte d'engagement forgeron v2.0.0...")
doc_forgeron = await seed_document_engagement_forgeron(session, protocols)
doc_forgeron = await seed_document_engagement_forgeron(session, protocols, org_id=duniter_g1_id)
print("\n[5/10] Decision: Runtime Upgrade...")
await seed_decision_runtime_upgrade(session)
await seed_decision_runtime_upgrade(session, org_id=duniter_g1_id)
print("\n[6/10] Decision: Évolution Licence G1 v0.4.0...")
await seed_decision_licence_evolution(session)
await seed_decision_licence_evolution(session, org_id=duniter_g1_id)
print("\n[7/10] Simulated voters...")
voters = await seed_voters(session)
@@ -2453,7 +2504,7 @@ async def run_seed():
)
print("\n[9/10] Mandates...")
await seed_mandates(session, voters)
await seed_mandates(session, voters, org_id=duniter_g1_id)
print("\n[10/10] Done.")