diff --git a/backend/app/config.py b/backend/app/config.py index 731de26..942956b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -10,8 +10,8 @@ class Settings(BaseSettings): ENVIRONMENT: str = "development" # development, staging, production LOG_LEVEL: str = "INFO" - # Database - DATABASE_URL: str = "postgresql+asyncpg://glibredecision:change-me-in-production@localhost:5432/glibredecision" + # Database — SQLite by default for local dev, PostgreSQL for Docker/prod + DATABASE_URL: str = "sqlite+aiosqlite:///./glibredecision.db" DATABASE_POOL_SIZE: int = 20 DATABASE_MAX_OVERFLOW: int = 10 diff --git a/backend/app/database.py b/backend/app/database.py index e83e36a..c38787a 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -3,14 +3,25 @@ from sqlalchemy.orm import DeclarativeBase from app.config import settings -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, -) +_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) diff --git a/backend/app/models/cache.py b/backend/app/models/cache.py index 99f21ef..95c9f4e 100644 --- a/backend/app/models/cache.py +++ b/backend/app/models/cache.py @@ -1,8 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import String, DateTime, func -from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy import String, DateTime, JSON, Uuid, func from sqlalchemy.orm import Mapped, mapped_column from app.database import Base @@ -11,8 +10,8 @@ from app.database import Base class BlockchainCache(Base): __tablename__ = "blockchain_cache" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + 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(JSONB, nullable=False) + 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) diff --git a/backend/app/models/decision.py b/backend/app/models/decision.py index 619f41d..282a68d 100644 --- a/backend/app/models/decision.py +++ b/backend/app/models/decision.py @@ -1,8 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, func -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base @@ -11,7 +10,7 @@ from app.database import Base class Decision(Base): __tablename__ = "decisions" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) title: Mapped[str] = mapped_column(String(256), nullable=False) description: Mapped[str | None] = mapped_column(Text) context: Mapped[str | None] = mapped_column(Text) @@ -28,7 +27,7 @@ class Decision(Base): class DecisionStep(Base): __tablename__ = "decision_steps" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + 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 diff --git a/backend/app/models/document.py b/backend/app/models/document.py index 20709ab..adf7280 100644 --- a/backend/app/models/document.py +++ b/backend/app/models/document.py @@ -1,8 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, func -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base @@ -11,7 +10,7 @@ from app.database import Base class Document(Base): __tablename__ = "documents" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + 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 @@ -29,7 +28,7 @@ class Document(Base): class DocumentItem(Base): __tablename__ = "document_items" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + 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 @@ -47,7 +46,7 @@ class DocumentItem(Base): class ItemVersion(Base): __tablename__ = "item_versions" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + 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) diff --git a/backend/app/models/mandate.py b/backend/app/models/mandate.py index 71a9712..d231fd3 100644 --- a/backend/app/models/mandate.py +++ b/backend/app/models/mandate.py @@ -1,8 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, func -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base @@ -11,7 +10,7 @@ from app.database import Base class Mandate(Base): __tablename__ = "mandates" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) title: Mapped[str] = mapped_column(String(256), nullable=False) description: Mapped[str | None] = mapped_column(Text) mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom @@ -29,7 +28,7 @@ class Mandate(Base): class MandateStep(Base): __tablename__ = "mandate_steps" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False) step_order: Mapped[int] = mapped_column(Integer, nullable=False) step_type: Mapped[str] = mapped_column(String(32), nullable=False) # formulation, candidacy, vote, assignment, reporting, completion, revocation diff --git a/backend/app/models/protocol.py b/backend/app/models/protocol.py index 8757ef8..8bb5474 100644 --- a/backend/app/models/protocol.py +++ b/backend/app/models/protocol.py @@ -1,8 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import String, Integer, Float, Boolean, DateTime, ForeignKey, Text, func -from sqlalchemy.dialects.postgresql import UUID +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 @@ -11,7 +10,7 @@ from app.database import Base class FormulaConfig(Base): __tablename__ = "formula_configs" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + 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) @@ -40,7 +39,7 @@ class FormulaConfig(Base): class VotingProtocol(Base): __tablename__ = "voting_protocols" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + 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 diff --git a/backend/app/models/sanctuary.py b/backend/app/models/sanctuary.py index cd4809c..0baadd9 100644 --- a/backend/app/models/sanctuary.py +++ b/backend/app/models/sanctuary.py @@ -1,8 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import String, Integer, Text, DateTime, func -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy import String, Integer, Text, DateTime, Uuid, func from sqlalchemy.orm import Mapped, mapped_column from app.database import Base @@ -11,9 +10,9 @@ from app.database import Base class SanctuaryEntry(Base): __tablename__ = "sanctuary_entries" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + 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(as_uuid=True), nullable=False) + 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)) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 0dfb802..e19a7c0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,8 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import String, Boolean, DateTime, ForeignKey, func -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy import String, Boolean, DateTime, ForeignKey, Uuid, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base @@ -11,7 +10,7 @@ from app.database import Base class DuniterIdentity(Base): __tablename__ = "duniter_identities" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + 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 @@ -26,7 +25,7 @@ class DuniterIdentity(Base): class Session(Base): __tablename__ = "sessions" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + 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()) diff --git a/backend/app/models/vote.py b/backend/app/models/vote.py index b2950b8..bef610d 100644 --- a/backend/app/models/vote.py +++ b/backend/app/models/vote.py @@ -1,8 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import String, Integer, Float, Boolean, Text, DateTime, ForeignKey, func -from sqlalchemy.dialects.postgresql import UUID +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 @@ -11,7 +10,7 @@ from app.database import Base class VoteSession(Base): __tablename__ = "vote_sessions" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + 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) @@ -49,7 +48,7 @@ class VoteSession(Base): class Vote(Base): __tablename__ = "votes" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + 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 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 56b822d..3549eec 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -5,7 +5,7 @@ from __future__ import annotations import secrets from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -133,11 +133,11 @@ async def get_me( return IdentityOut.model_validate(identity) -@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +@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), -) -> None: +): """Invalidate the current session token. Note: get_current_identity already validated the token, so we know it exists. diff --git a/backend/app/routers/decisions.py b/backend/app/routers/decisions.py index a1b5ace..e4ece47 100644 --- a/backend/app/routers/decisions.py +++ b/backend/app/routers/decisions.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -188,7 +188,7 @@ async def create_vote_session_for_step_endpoint( return VoteSessionOut.model_validate(session) -@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) +@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), diff --git a/backend/app/routers/documents.py b/backend/app/routers/documents.py index c6d068c..a4c516b 100644 --- a/backend/app/routers/documents.py +++ b/backend/app/routers/documents.py @@ -6,7 +6,7 @@ import difflib import logging import uuid -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -316,7 +316,7 @@ async def update_item( return DocumentItemOut.model_validate(item) -@router.delete("/{slug}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +@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, diff --git a/backend/app/routers/mandates.py b/backend/app/routers/mandates.py index 6a705de..32d7191 100644 --- a/backend/app/routers/mandates.py +++ b/backend/app/routers/mandates.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -124,7 +124,7 @@ async def update_mandate( return MandateOut.model_validate(mandate) -@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) +@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), diff --git a/backend/requirements.txt b/backend/requirements.txt index 8b1a9ca..8401843 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,6 +3,7 @@ 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 diff --git a/backend/seed.py b/backend/seed.py index d79e0b4..cfe7712 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -101,39 +101,45 @@ async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig # --------------------------------------------------------------------------- -# Seed: VotingProtocols +# Seed: VotingProtocols (premier pack de modalites) # --------------------------------------------------------------------------- async def seed_voting_protocols( session: AsyncSession, formulas: dict[str, FormulaConfig], ) -> dict[str, VotingProtocol]: - """Create the 4 base voting protocols.""" + """Create the first pack of voting modalities (3 protocols).""" protocols: dict[str, dict] = { - "Standard G1": { - "description": "Protocole binaire standard pour la Licence G1.", + "Vote majoritaire": { + "description": ( + "Vote binaire a majorite simple. Le seuil d'adoption " + "s'adapte dynamiquement au taux de participation via " + "la formule d'inertie WoT." + ), "vote_type": "binary", "formula_config_id": formulas["Standard Licence G1"].id, "mode_params": "D30M50B.1G.2", }, - "Forgeron Smith": { - "description": "Protocole binaire avec critere Smith pour les forgerons.", + "Vote quadratique": { + "description": ( + "Vote pondere par la racine carree des certifications. " + "Reduit l'influence des gros certificateurs et favorise " + "une participation large et diversifiee." + ), "vote_type": "binary", "formula_config_id": formulas["Forgeron avec Smith"].id, "mode_params": "D30M50B.1G.2S.1", }, - "Comite Tech": { - "description": "Protocole binaire avec critere Comite Technique.", + "Vote permanent": { + "description": ( + "Vote continu sans date de fin. Le resultat evolue en " + "temps reel avec chaque nouveau vote. Adapte aux documents " + "de reference sous revision permanente." + ), "vote_type": "binary", "formula_config_id": formulas["Comite Tech"].id, "mode_params": "D30M50B.1G.2T.1", }, - "Vote Nuance 6 niveaux": { - "description": "Protocole de vote nuance a 6 niveaux.", - "vote_type": "nuanced", - "formula_config_id": formulas["Vote Nuance"].id, - "mode_params": None, - }, } result: dict[str, VotingProtocol] = {} @@ -149,143 +155,134 @@ async def seed_voting_protocols( # --------------------------------------------------------------------------- -# Seed: Document - Licence G1 +# Seed: Document - Acte d'engagement certification # --------------------------------------------------------------------------- -LICENCE_G1_ITEMS: list[dict] = [ +ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [ { "position": "1", "item_type": "preamble", - "title": "Preambule", + "title": "Objet", "sort_order": 1, "current_text": ( - "Licence de la monnaie libre et engagement de responsabilite. " - "La monnaie libre G1 (June) est co-produite par ses membres." + "Le present acte definit les engagements de tout membre de la " + "toile de confiance qui certifie l'identite d'une autre personne " + "dans le reseau Duniter." ), }, { "position": "2", - "item_type": "section", - "title": "Avertissement TdC", + "item_type": "clause", + "title": "Connaissance personnelle", "sort_order": 2, "current_text": ( - "Certifier n'est pas uniquement s'assurer de l'identite unique " - "de la personne (son unicite). C'est aussi affirmer que vous la " - "connaissez bien et que vous saurez la joindre facilement." + "Je certifie connaitre personnellement la personne que je " + "certifie, l'avoir rencontree physiquement a plusieurs reprises, " + "et pouvoir la contacter par au moins deux moyens de communication " + "differents." ), }, { "position": "3", "item_type": "clause", - "title": "Conseils", + "title": "Verification d'identite", "sort_order": 3, "current_text": ( - "Connaitre la personne par plusieurs moyens de communication differents " - "(physique, electronique, etc.). Connaitre son lieu de vie principal. " - "Avoir echange avec elle en utilisant des moyens de communication " - "susceptibles d'identifier un humain vivant." + "Je certifie avoir verifie que la personne n'a qu'un seul compte " + "membre dans la toile de confiance, et que l'identite declaree " + "correspond a une personne humaine vivante." ), }, { "position": "4", - "item_type": "verification", - "title": "Verifications", + "item_type": "clause", + "title": "Engagement de suivi", "sort_order": 4, "current_text": ( - "De suffisamment bien connaitre la personne pour pouvoir la contacter, " - "echanger avec elle. De s'assurer que la personne a bien le controle " - "de son compte Duniter." + "Je m'engage a surveiller l'activite de mes certifies et a " + "signaler tout comportement suspect (comptes multiples, " + "usurpation d'identite, comptes abandonnes)." ), }, { "position": "5", - "item_type": "rule", - "title": "Regles TdC", + "item_type": "verification", + "title": "Delai entre certifications", "sort_order": 5, "current_text": ( - "Chaque membre dispose de 100 certifications possibles. " - "Il est possible de certifier 1 nouveau membre tous les 5 jours. " - "Un membre doit avoir au moins 5 certifications pour devenir membre. " - "Un membre doit renouveler son adhesion tous les 2 ans." + "Je respecte un delai minimum de reflexion de 5 jours entre " + "chaque nouvelle certification emise." ), }, { "position": "6", "item_type": "rule", - "title": "Production DU", + "title": "Renouvellement", "sort_order": 6, "current_text": ( - "1 DU (Dividende Universel) est produit par personne et par jour. " - "Le DU est la monnaie de base co-produite par chaque membre." + "Je renouvelle mes certifications avant leur expiration pour " + "maintenir la cohesion de la toile de confiance." ), }, { "position": "7", - "item_type": "rule", - "title": "Code monetaire", + "item_type": "clause", + "title": "Responsabilite", "sort_order": 7, "current_text": ( - "DU formule : DU(t+1) = DU(t) + c^2 * M/N. " - "c = 4.88% / an. Le DU est re-evalue chaque equinoxe." + "Je suis conscient que la certification engage ma responsabilite " + "vis-a-vis de la communaute. Une certification abusive peut " + "entrainer la perte de confiance des autres membres." ), }, { "position": "8", - "item_type": "clause", - "title": "Logiciels", + "item_type": "rule", + "title": "Revocation", "sort_order": 8, "current_text": ( - "Les logiciels G1 doivent transmettre cette licence integralement " - "aux utilisateurs et developper un acces libre au code source." - ), - }, - { - "position": "9", - "item_type": "clause", - "title": "Modification", - "sort_order": 9, - "current_text": ( - "Proposants, soutiens et votants doivent etre membres de la TdC. " - "Toute modification de cette licence doit etre soumise au vote " - "des membres selon le protocole en vigueur." + "Une certification peut etre revoquee si les conditions de " + "l'engagement ne sont plus remplies. La revocation est soumise " + "au protocole de vote en vigueur." ), }, ] -async def seed_document_licence_g1(session: AsyncSession) -> Document: - """Create the Licence G1 document with its items.""" +async def seed_document_engagement_certification(session: AsyncSession) -> Document: + """Create the Acte d'engagement certification document with its items.""" doc, created = await get_or_create( session, Document, "slug", - "licence-g1", - title="Licence G1", - doc_type="licence", - version="0.3.0", + "engagement-certification", + title="Acte d'engagement certification", + doc_type="engagement", + version="1.0.0", status="active", description=( - "Licence de la monnaie libre G1 (June). " - "Definit les regles de la toile de confiance et du Dividende Universel." + "Acte d'engagement pour les certificateurs de la toile de confiance " + "Duniter. Definit les obligations et responsabilites liees a la " + "certification de nouveaux membres." ), ) - print(f" Document 'Licence G1': {'created' if created else 'exists'}") + print(f" Document 'Acte d'engagement certification': {'created' if created else 'exists'}") if created: - for item_data in LICENCE_G1_ITEMS: + for item_data in ENGAGEMENT_CERTIFICATION_ITEMS: item = DocumentItem(document_id=doc.id, **item_data) session.add(item) await session.flush() - print(f" -> {len(LICENCE_G1_ITEMS)} items created") + print(f" -> {len(ENGAGEMENT_CERTIFICATION_ITEMS)} items created") return doc # --------------------------------------------------------------------------- -# Seed: Document - Engagement Forgeron v2.0.0 +# Seed: Document - Acte d'engagement forgeron v2.0.0 # --------------------------------------------------------------------------- -FORGERON_ITEMS: list[dict] = [ +ENGAGEMENT_FORGERON_ITEMS: list[dict] = [ { "position": "1", "item_type": "preamble", @@ -387,36 +384,36 @@ FORGERON_ITEMS: list[dict] = [ ] -async def seed_document_forgeron(session: AsyncSession) -> Document: - """Create the Engagement Forgeron v2.0.0 document with its items.""" +async def seed_document_engagement_forgeron(session: AsyncSession) -> Document: + """Create the Acte d'engagement forgeron v2.0.0 document with its items.""" doc, created = await get_or_create( session, Document, "slug", "engagement-forgeron", - title="Engagement Forgeron v2.0.0", + title="Acte d'engagement forgeron", doc_type="engagement", version="2.0.0", status="active", description=( - "Engagement des forgerons (validateurs) pour Duniter V2. " - "Adopte en fevrier 2026 (97 pour / 23 contre)." + "Acte d'engagement des forgerons (validateurs de blocs) pour " + "Duniter V2. Adopte en fevrier 2026 (97 pour / 23 contre)." ), ) - print(f" Document 'Engagement Forgeron v2.0.0': {'created' if created else 'exists'}") + print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}") if created: - for item_data in FORGERON_ITEMS: + for item_data in ENGAGEMENT_FORGERON_ITEMS: item = DocumentItem(document_id=doc.id, **item_data) session.add(item) await session.flush() - print(f" -> {len(FORGERON_ITEMS)} items created") + print(f" -> {len(ENGAGEMENT_FORGERON_ITEMS)} items created") return doc # --------------------------------------------------------------------------- -# Seed: Decision template - Processus Runtime Upgrade +# Seed: Decision template - Runtime Upgrade # --------------------------------------------------------------------------- RUNTIME_UPGRADE_STEPS: list[dict] = [ @@ -474,15 +471,16 @@ async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision: session, Decision, "title", - "Processus Runtime Upgrade", + "Runtime Upgrade", description=( - "Template de decision pour les mises a jour du runtime Duniter V2. " - "5 etapes : qualification, revue, vote, execution, suivi." + "Processus de mise a jour du runtime Duniter V2 on-chain. " + "Chaque upgrade suit un protocole strict en 5 etapes. " + "(Decision on-chain)" ), decision_type="runtime_upgrade", status="draft", ) - print(f" Decision 'Processus Runtime Upgrade': {'created' if created else 'exists'}") + print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}") if created: for step_data in RUNTIME_UPGRADE_STEPS: @@ -509,16 +507,16 @@ async def run_seed(): print("\n[1/5] Formula Configs...") formulas = await seed_formula_configs(session) - print("\n[2/5] Voting Protocols...") + print("\n[2/5] Voting Protocols (premier pack de modalites)...") await seed_voting_protocols(session, formulas) - print("\n[3/5] Document: Licence G1...") - await seed_document_licence_g1(session) + print("\n[3/5] Document: Acte d'engagement certification...") + await seed_document_engagement_certification(session) - print("\n[4/5] Document: Engagement Forgeron v2.0.0...") - await seed_document_forgeron(session) + print("\n[4/5] Document: Acte d'engagement forgeron...") + await seed_document_engagement_forgeron(session) - print("\n[5/5] Decision: Processus Runtime Upgrade...") + print("\n[5/5] Decision: Runtime Upgrade...") await seed_decision_runtime_upgrade(session) print("\n" + "=" * 60) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index a4a3afa..ed90d9b 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -1,37 +1,73 @@ -version: "3.9" - -# Dev overrides -- usage: -# docker compose -f docker/docker-compose.yml -f docker/docker-compose.dev.yml up +# Development stack -- standalone (no Traefik needed) +# Usage: docker compose -f docker/docker-compose.dev.yml up +# Ports: frontend 3002, backend 8002, postgres 5432, IPFS API 5001, IPFS GW 8080 services: postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-glibredecision} + POSTGRES_USER: ${POSTGRES_USER:-glibredecision} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-glibredecision-dev} ports: - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-glibredecision}"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s backend: build: + context: ../ + dockerfile: docker/backend.Dockerfile target: development + depends_on: + postgres: + condition: service_healthy volumes: - ../backend:/app ports: - "8002:8002" environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-glibredecision}:${POSTGRES_PASSWORD:-glibredecision-dev}@postgres:5432/${POSTGRES_DB:-glibredecision} + SECRET_KEY: dev-secret-key-not-for-production DEBUG: "true" + ENVIRONMENT: development CORS_ORIGINS: '["http://localhost:3002"]' - labels: [] + DUNITER_RPC_URL: ${DUNITER_RPC_URL:-wss://gdev.p2p.legal/ws} + IPFS_API_URL: http://ipfs:5001 + IPFS_GATEWAY_URL: http://ipfs:8080 frontend: build: + context: ../ + dockerfile: docker/frontend.Dockerfile target: development + depends_on: + - backend volumes: - ../frontend:/app + - frontend-node-modules:/app/node_modules ports: - "3002:3002" environment: NUXT_PUBLIC_API_BASE: http://localhost:8002/api/v1 - labels: [] ipfs: + image: ipfs/kubo:latest + restart: unless-stopped ports: - "5001:5001" - "8080:8080" + volumes: + - ipfs-data:/data/ipfs + +volumes: + postgres-data: + ipfs-data: + frontend-node-modules: diff --git a/docker/frontend.Dockerfile b/docker/frontend.Dockerfile index bcc9227..4f42238 100644 --- a/docker/frontend.Dockerfile +++ b/docker/frontend.Dockerfile @@ -42,4 +42,10 @@ FROM base AS development ENV NODE_ENV=development WORKDIR /app -ENTRYPOINT ["npm", "run", "dev"] + +COPY frontend/package.json ./ +RUN npm install + +EXPOSE 3002 + +CMD ["npm", "run", "dev"] diff --git a/frontend/app/app.vue b/frontend/app/app.vue index a7a6e20..f574f7d 100644 --- a/frontend/app/app.vue +++ b/frontend/app/app.vue @@ -1,6 +1,7 @@ + + + + diff --git a/frontend/app/components/common/SectionLayout.vue b/frontend/app/components/common/SectionLayout.vue new file mode 100644 index 0000000..47d41a9 --- /dev/null +++ b/frontend/app/components/common/SectionLayout.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/frontend/app/components/common/StatusBadge.vue b/frontend/app/components/common/StatusBadge.vue index 7a0f4ce..6de1b67 100644 --- a/frontend/app/components/common/StatusBadge.vue +++ b/frontend/app/components/common/StatusBadge.vue @@ -1,61 +1,150 @@ + + diff --git a/frontend/app/components/common/ToolboxVignette.vue b/frontend/app/components/common/ToolboxVignette.vue new file mode 100644 index 0000000..0b1b35f --- /dev/null +++ b/frontend/app/components/common/ToolboxVignette.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/frontend/app/components/decisions/DecisionCadrage.vue b/frontend/app/components/decisions/DecisionCadrage.vue index 7bf113b..933391f 100644 --- a/frontend/app/components/decisions/DecisionCadrage.vue +++ b/frontend/app/components/decisions/DecisionCadrage.vue @@ -99,7 +99,7 @@ function onSubmit() { - diff --git a/frontend/app/components/decisions/DecisionCard.vue b/frontend/app/components/decisions/DecisionCard.vue index 7e65caf..37edcab 100644 --- a/frontend/app/components/decisions/DecisionCard.vue +++ b/frontend/app/components/decisions/DecisionCard.vue @@ -48,7 +48,7 @@ function navigate() { {{ decision.title }} - +

diff --git a/frontend/app/components/decisions/DecisionWorkflow.vue b/frontend/app/components/decisions/DecisionWorkflow.vue index fc778fc..66ddbc1 100644 --- a/frontend/app/components/decisions/DecisionWorkflow.vue +++ b/frontend/app/components/decisions/DecisionWorkflow.vue @@ -96,7 +96,7 @@ function formatDate(dateStr: string): string { {{ stepTypeLabel(step.step_type) }} - +

diff --git a/frontend/app/components/documents/DocumentList.vue b/frontend/app/components/documents/DocumentList.vue index 40d6ded..2202d1e 100644 --- a/frontend/app/components/documents/DocumentList.vue +++ b/frontend/app/components/documents/DocumentList.vue @@ -55,7 +55,7 @@ function formatDate(dateStr: string): string {

{{ doc.title }}

- + diff --git a/frontend/app/components/documents/ItemCard.vue b/frontend/app/components/documents/ItemCard.vue index 9a8831d..6a0515a 100644 --- a/frontend/app/components/documents/ItemCard.vue +++ b/frontend/app/components/documents/ItemCard.vue @@ -70,7 +70,7 @@ function navigateToItem() {
- +
diff --git a/frontend/app/components/documents/ItemVersionDiff.vue b/frontend/app/components/documents/ItemVersionDiff.vue index 722b562..06ea5eb 100644 --- a/frontend/app/components/documents/ItemVersionDiff.vue +++ b/frontend/app/components/documents/ItemVersionDiff.vue @@ -35,7 +35,7 @@ function truncateAddress(address: string | null): string {
- + Propose par @@ -57,14 +57,14 @@ function truncateAddress(address: string | null): string {

Modifications

- +

Texte propose

- +
diff --git a/frontend/app/components/mandates/MandateCard.vue b/frontend/app/components/mandates/MandateCard.vue index ed83deb..b3c45a0 100644 --- a/frontend/app/components/mandates/MandateCard.vue +++ b/frontend/app/components/mandates/MandateCard.vue @@ -47,7 +47,7 @@ function navigate() { {{ mandate.title }}
- +

diff --git a/frontend/app/components/mandates/MandateTimeline.vue b/frontend/app/components/mandates/MandateTimeline.vue index 9229b6f..94f78a7 100644 --- a/frontend/app/components/mandates/MandateTimeline.vue +++ b/frontend/app/components/mandates/MandateTimeline.vue @@ -98,7 +98,7 @@ function formatDate(dateStr: string): string { {{ stepTypeLabel(step.step_type) }} - +

diff --git a/frontend/app/components/sanctuary/SanctuaryEntry.vue b/frontend/app/components/sanctuary/SanctuaryEntry.vue index 51c0f28..beb3b40 100644 --- a/frontend/app/components/sanctuary/SanctuaryEntry.vue +++ b/frontend/app/components/sanctuary/SanctuaryEntry.vue @@ -121,7 +121,7 @@ async function copyHash() { IPFS CID
- +
@@ -131,7 +131,7 @@ async function copyHash() { On-chain - + diff --git a/frontend/app/composables/useMood.ts b/frontend/app/composables/useMood.ts new file mode 100644 index 0000000..60e4b42 --- /dev/null +++ b/frontend/app/composables/useMood.ts @@ -0,0 +1,65 @@ +import type { Ref } from 'vue' + +export interface Mood { + id: string + label: string + description: string + icon: string + isDark: boolean +} + +const STORAGE_KEY = 'glibredecision_mood' + +const moods: Mood[] = [ + { id: 'peps', label: 'Peps', description: 'Energique et chaleureux', icon: 'i-lucide-sun', isDark: false }, + { id: 'zen', label: 'Zen', description: 'Calme et serein', icon: 'i-lucide-leaf', isDark: false }, + { id: 'chagrine', label: 'Chagrine', description: 'Profond et subtil', icon: 'i-lucide-moon', isDark: true }, + { id: 'grave', label: 'Grave', description: 'Serieux et solennel', icon: 'i-lucide-shield', isDark: true }, +] + +const currentMood: Ref = ref('peps') + +function applyMood(moodId: string) { + if (import.meta.server) return + + const mood = moods.find(m => m.id === moodId) + if (!mood) return + + const html = document.documentElement + + // Remove all existing mood classes + moods.forEach(m => html.classList.remove(`mood-${m.id}`)) + + // Add the new mood class + html.classList.add(`mood-${moodId}`) + + // Sync color-mode (light/dark) via Nuxt's useColorMode + const colorMode = useColorMode() + colorMode.preference = mood.isDark ? 'dark' : 'light' + + // Persist choice + localStorage.setItem(STORAGE_KEY, moodId) + + currentMood.value = moodId +} + +function setMood(moodId: string) { + applyMood(moodId) +} + +function initMood() { + if (import.meta.server) return + + const saved = localStorage.getItem(STORAGE_KEY) + const moodId = saved && moods.some(m => m.id === saved) ? saved : 'peps' + applyMood(moodId) +} + +export function useMood() { + return { + currentMood: readonly(currentMood), + moods, + setMood, + initMood, + } +} diff --git a/frontend/app/pages/decisions/[id].vue b/frontend/app/pages/decisions/[id].vue index 2bec7c2..b4ddff8 100644 --- a/frontend/app/pages/decisions/[id].vue +++ b/frontend/app/pages/decisions/[id].vue @@ -334,7 +334,7 @@ async function handleAddStep() { /> - +/** + * Decisions — page index. + * + * Utilise SectionLayout avec status filters, recherche, tri, + * et sidebar "Boite a outils" affichant les protocoles de vote. + */ const decisions = useDecisionsStore() +const protocols = useProtocolsStore() +const auth = useAuthStore() -const filterType = ref(undefined) -const filterStatus = ref(undefined) +const activeStatus = ref(null) +const searchQuery = ref('') +const sortBy = ref<'date' | 'title' | 'status'>('date') -const typeOptions = [ - { label: 'Tous les types', value: undefined }, - { label: 'Runtime upgrade', value: 'runtime_upgrade' }, - { label: 'Modification de document', value: 'document_change' }, - { label: 'Vote de mandat', value: 'mandate_vote' }, - { label: 'Changement de parametre', value: 'parameter_change' }, - { label: 'Autre', value: 'other' }, +const sortOptions = [ + { label: 'Date', value: 'date' }, + { label: 'Titre', value: 'title' }, + { label: 'Statut', value: 'status' }, ] -const statusOptions = [ - { label: 'Tous les statuts', value: undefined }, - { label: 'Brouillon', value: 'draft' }, - { label: 'Qualification', value: 'qualification' }, - { label: 'Revue', value: 'review' }, - { label: 'En vote', value: 'voting' }, - { label: 'Execute', value: 'executed' }, - { label: 'Clos', value: 'closed' }, -] - -async function loadDecisions() { - await decisions.fetchAll({ - decision_type: filterType.value, - status: filterStatus.value, - }) -} - -onMounted(() => { - loadDecisions() +onMounted(async () => { + await Promise.all([ + decisions.fetchAll(), + protocols.fetchProtocols(), + ]) }) -watch([filterType, filterStatus], () => { - loadDecisions() +/** Status filter pills with counts. */ +const statuses = computed(() => [ + { id: 'draft', label: 'En prepa', count: decisions.list.filter(d => d.status === 'draft').length }, + { id: 'voting', label: 'En vote', count: decisions.list.filter(d => d.status === 'voting' || d.status === 'qualification' || d.status === 'review').length }, + { id: 'executed', label: 'En vigueur', count: decisions.list.filter(d => d.status === 'executed').length }, + { id: 'closed', label: 'Clos', count: decisions.list.filter(d => d.status === 'closed').length }, +]) + +/** Map for the voting pill — include qualification/review under "En vote". */ +const statusGroupMap: Record = { + draft: ['draft'], + voting: ['qualification', 'review', 'voting'], + executed: ['executed'], + closed: ['closed'], +} + +/** Filtered and sorted decisions. */ +const filteredDecisions = computed(() => { + let list = [...decisions.list] + + // Filter by status group + if (activeStatus.value && statusGroupMap[activeStatus.value]) { + const statuses = statusGroupMap[activeStatus.value] + list = list.filter(d => statuses.includes(d.status)) + } + + // Filter by search query (client-side) + if (searchQuery.value.trim()) { + const q = searchQuery.value.toLowerCase() + list = list.filter(d => d.title.toLowerCase().includes(q)) + } + + // Sort + switch (sortBy.value) { + case 'title': + list.sort((a, b) => a.title.localeCompare(b.title, 'fr')) + break + case 'status': + list.sort((a, b) => a.status.localeCompare(b.status)) + break + case 'date': + default: + list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) + break + } + + return list }) -const statusColor = (status: string) => { - switch (status) { - case 'draft': return 'warning' - case 'qualification': return 'info' - case 'review': return 'info' - case 'voting': return 'primary' - case 'executed': return 'success' - case 'closed': return 'neutral' - default: return 'neutral' - } -} - -const statusLabel = (status: string) => { - switch (status) { - case 'draft': return 'Brouillon' - case 'qualification': return 'Qualification' - case 'review': return 'Revue' - case 'voting': return 'En vote' - case 'executed': return 'Execute' - case 'closed': return 'Clos' - default: return status - } -} - const typeLabel = (decisionType: string) => { switch (decisionType) { case 'runtime_upgrade': return 'Runtime upgrade' @@ -83,121 +96,204 @@ function formatDate(dateStr: string): string {