From 79e468b40f5b4c97689f078167ab510f961ab06d Mon Sep 17 00:00:00 2001 From: Yvv Date: Thu, 23 Apr 2026 15:17:14 +0200 Subject: [PATCH] Multi-tenancy : espaces de travail + fix auth reload (rate limiter OPTIONS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/alembic/env.py | 2 + ..._23_1227-70914b334cfb_add_organizations.py | 41 ++ backend/app/dependencies/__init__.py | 0 backend/app/dependencies/org.py | 26 + backend/app/main.py | 2 + backend/app/middleware/rate_limiter.py | 29 +- backend/app/models/__init__.py | 2 + backend/app/models/decision.py | 1 + backend/app/models/document.py | 1 + backend/app/models/mandate.py | 1 + backend/app/models/organization.py | 42 ++ backend/app/models/protocol.py | 1 + backend/app/routers/auth.py | 4 +- backend/app/routers/decisions.py | 6 + backend/app/routers/documents.py | 7 +- backend/app/routers/mandates.py | 7 +- backend/app/routers/organizations.py | 96 +++ backend/app/routers/protocols.py | 7 +- backend/app/schemas/organization.py | 42 ++ backend/app/services/org_service.py | 60 ++ backend/seed.py | 69 ++- frontend/app/app.vue | 27 +- frontend/app/components/WorkspaceSelector.vue | 100 ++-- frontend/app/composables/useApi.ts | 4 + frontend/app/pages/decisions/new.vue | 554 ++++++++++++++++-- frontend/app/pages/index.vue | 99 +++- frontend/app/pages/login.vue | 3 +- frontend/app/pages/protocols/index.vue | 18 - frontend/app/pages/tools.vue | 120 ++++ frontend/app/stores/auth.ts | 13 +- frontend/app/stores/organizations.ts | 71 +++ 31 files changed, 1296 insertions(+), 159 deletions(-) create mode 100644 backend/alembic/versions/2026_04_23_1227-70914b334cfb_add_organizations.py create mode 100644 backend/app/dependencies/__init__.py create mode 100644 backend/app/dependencies/org.py create mode 100644 backend/app/models/organization.py create mode 100644 backend/app/routers/organizations.py create mode 100644 backend/app/schemas/organization.py create mode 100644 backend/app/services/org_service.py create mode 100644 frontend/app/stores/organizations.ts diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 1693651..4b26dbc 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -26,6 +26,8 @@ from app.database import Base from app.models import ( # noqa: F401 DuniterIdentity, Session, + Organization, + OrgMember, Document, DocumentItem, ItemVersion, 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 new file mode 100644 index 0000000..a49a2b2 --- /dev/null +++ b/backend/alembic/versions/2026_04_23_1227-70914b334cfb_add_organizations.py @@ -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') diff --git a/backend/app/dependencies/__init__.py b/backend/app/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/dependencies/org.py b/backend/app/dependencies/org.py new file mode 100644 index 0000000..e4db16c --- /dev/null +++ b/backend/app/dependencies/org.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 677bacd..5853934 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 ───────────────────────────────────────────────────────── diff --git a/backend/app/middleware/rate_limiter.py b/backend/app/middleware/rate_limiter.py index 46f33f3..165dc5f 100644 --- a/backend/app/middleware/rate_limiter.py +++ b/backend/app/middleware/rate_limiter.py @@ -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( diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index cdc2e64..9fe097e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/decision.py b/backend/app/models/decision.py index 282a68d..72096d6 100644 --- a/backend/app/models/decision.py +++ b/backend/app/models/decision.py @@ -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()) diff --git a/backend/app/models/document.py b/backend/app/models/document.py index 6a86bb6..27c83fc 100644 --- a/backend/app/models/document.py +++ b/backend/app/models/document.py @@ -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 diff --git a/backend/app/models/mandate.py b/backend/app/models/mandate.py index d231fd3..3afbf88 100644 --- a/backend/app/models/mandate.py +++ b/backend/app/models/mandate.py @@ -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)) diff --git a/backend/app/models/organization.py b/backend/app/models/organization.py new file mode 100644 index 0000000..14e3a95 --- /dev/null +++ b/backend/app/models/organization.py @@ -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") diff --git a/backend/app/models/protocol.py b/backend/app/models/protocol.py index 8bb5474..5016c22 100644 --- a/backend/app/models/protocol.py +++ b/backend/app/models/protocol.py @@ -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()) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 48d6cf1..2175626 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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: {challenge} diff --git a/backend/app/routers/decisions.py b/backend/app/routers/decisions.py index e4ece47..d3eaa2c 100644 --- a/backend/app/routers/decisions.py +++ b/backend/app/routers/decisions.py @@ -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() diff --git a/backend/app/routers/documents.py b/backend/app/routers/documents.py index a4c516b..7d64b9f 100644 --- a/backend/app/routers/documents.py +++ b/backend/app/routers/documents.py @@ -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) diff --git a/backend/app/routers/mandates.py b/backend/app/routers/mandates.py index 32d7191..d8f5af5 100644 --- a/backend/app/routers/mandates.py +++ b/backend/app/routers/mandates.py @@ -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) diff --git a/backend/app/routers/organizations.py b/backend/app/routers/organizations.py new file mode 100644 index 0000000..a198bde --- /dev/null +++ b/backend/app/routers/organizations.py @@ -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) diff --git a/backend/app/routers/protocols.py b/backend/app/routers/protocols.py index 4bac473..ace6d2f 100644 --- a/backend/app/routers/protocols.py +++ b/backend/app/routers/protocols.py @@ -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) diff --git a/backend/app/schemas/organization.py b/backend/app/schemas/organization.py new file mode 100644 index 0000000..19ace46 --- /dev/null +++ b/backend/app/schemas/organization.py @@ -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 diff --git a/backend/app/services/org_service.py b/backend/app/services/org_service.py new file mode 100644 index 0000000..f2574a5 --- /dev/null +++ b/backend/app/services/org_service.py @@ -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() diff --git a/backend/seed.py b/backend/seed.py index 2c2e810..feb6d2a 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -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.") diff --git a/frontend/app/app.vue b/frontend/app/app.vue index 17aa5e7..021aa06 100644 --- a/frontend/app/app.vue +++ b/frontend/app/app.vue @@ -1,24 +1,20 @@