Preparer les schemas SQL par groupe

This commit is contained in:
Daniel Allaire 2026-06-01 07:46:29 -04:00
parent 670cae8604
commit 4a64061706
8 changed files with 226 additions and 1 deletions

View file

@ -13,10 +13,15 @@ mkdir -p "$BACKUP_DIR"
export PGPASSWORD="{{ groupe_meditation_db_password }}" export PGPASSWORD="{{ groupe_meditation_db_password }}"
cd "{{ groupe_meditation_backend_dir }}" cd "{{ groupe_meditation_backend_dir }}"
"{{ groupe_meditation_backend_dir }}/venv/bin/python" "{{ groupe_meditation_deploy_dir }}/tenant_schemas.py"
"{{ groupe_meditation_backend_dir }}/venv/bin/python" "{{ groupe_meditation_deploy_dir }}/group_backup.py" all "{{ groupe_meditation_backend_dir }}/venv/bin/python" "{{ groupe_meditation_deploy_dir }}/group_backup.py" all
pg_dump -U "$DB_USER" -h "$DB_HOST" -Fc "$DB_NAME" > "$BACKUP_DIR/grpmed_${DATE}.dump" pg_dump -U "$DB_USER" -h "$DB_HOST" -Fc "$DB_NAME" > "$BACKUP_DIR/grpmed_${DATE}.dump"
for schema in $(PGPASSWORD="$PGPASSWORD" psql -U "$DB_USER" -h "$DB_HOST" -d "$DB_NAME" -Atc "SELECT tenant_schema FROM groupes WHERE tenant_schema IS NOT NULL ORDER BY tenant_schema"); do
pg_dump -U "$DB_USER" -h "$DB_HOST" -n "$schema" -Fc "$DB_NAME" > "$BACKUP_DIR/${schema}_${DATE}.dump"
done
find "$BACKUP_DIR" -name "grpmed_*.dump" -mtime +"$RETENTION_DAYS" -delete find "$BACKUP_DIR" -name "grpmed_*.dump" -mtime +"$RETENTION_DAYS" -delete
find "$BACKUP_DIR" -name "group_*.json" -mtime +"$RETENTION_DAYS" -delete find "$BACKUP_DIR" -name "group_*.json" -mtime +"$RETENTION_DAYS" -delete
find "$BACKUP_DIR" -name "grp_*.dump" -mtime +"$RETENTION_DAYS" -delete
echo "[$(date)] Sauvegarde terminee: grpmed_${DATE}.dump" echo "[$(date)] Sauvegarde terminee: grpmed_${DATE}.dump"

View file

@ -11,6 +11,7 @@ from sqlalchemy import text
from app.core.config import settings from app.core.config import settings
from app.core.audit import AuditMiddleware from app.core.audit import AuditMiddleware
from app.core.database import engine, Base from app.core.database import engine, Base
from app.services.tenant_schemas import ensure_all_tenant_schemas
from app.routers import admin, auth, membres, postes, accueil, invitations, collectes, reunions, depenses, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, propositions, operations_bancaires, evenements, notifications, journal from app.routers import admin, auth, membres, postes, accueil, invitations, collectes, reunions, depenses, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, propositions, operations_bancaires, evenements, notifications, journal
# Import models pour que create_all crée toutes les tables # Import models pour que create_all crée toutes les tables
@ -36,6 +37,11 @@ from app.models.instance_admin import InstanceAdmin # noqa: F401
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
await conn.execute(text("ALTER TABLE groupes ADD COLUMN IF NOT EXISTS tenant_schema VARCHAR(80) UNIQUE"))
await conn.execute(text(
"UPDATE groupes SET tenant_schema = 'grp_' || replace(id::text, '-', '') "
"WHERE tenant_schema IS NULL"
))
await conn.execute(text( await conn.execute(text(
"ALTER TABLE groupes " "ALTER TABLE groupes "
"ADD COLUMN IF NOT EXISTS assemblee_affaires_ordre VARCHAR(20) DEFAULT 'premiere'" "ADD COLUMN IF NOT EXISTS assemblee_affaires_ordre VARCHAR(20) DEFAULT 'premiere'"
@ -118,6 +124,7 @@ async def lifespan(app: FastAPI):
"ventes_litterature", "ventes_litterature",
): ):
await conn.execute(text(f"CREATE INDEX IF NOT EXISTS idx_{table}_groupe_id ON {table} (groupe_id)")) await conn.execute(text(f"CREATE INDEX IF NOT EXISTS idx_{table}_groupe_id ON {table} (groupe_id)"))
await ensure_all_tenant_schemas(conn)
yield yield
await engine.dispose() await engine.dispose()

View file

@ -11,6 +11,7 @@ class Groupe(Base):
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
nom: Mapped[str] = mapped_column(String(100), nullable=False) nom: Mapped[str] = mapped_column(String(100), nullable=False)
tenant_schema: Mapped[str | None] = mapped_column(String(80), unique=True)
district: Mapped[str] = mapped_column(String(20), default="87-16") district: Mapped[str] = mapped_column(String(20), default="87-16")
region: Mapped[str] = mapped_column(String(20), default="87") region: Mapped[str] = mapped_column(String(20), default="87")
adresse_local: Mapped[str | None] = mapped_column(Text) adresse_local: Mapped[str | None] = mapped_column(Text)

View file

@ -20,6 +20,7 @@ from app.services.group_backups import (
lister_sauvegardes_groupe, lister_sauvegardes_groupe,
restaurer_sauvegarde_groupe, restaurer_sauvegarde_groupe,
) )
from app.services.tenant_schemas import refresh_all_tenant_schemas, refresh_group_tenant_schema
from app.models.groupe import Groupe from app.models.groupe import Groupe
from app.models.membre import Membre from app.models.membre import Membre
from app.models.reunion import Reunion from app.models.reunion import Reunion
@ -114,6 +115,26 @@ async def backup_groupe_admin(
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
@router.post("/groupes/{groupe_id}/tenant-schema/refresh")
async def refresh_tenant_schema_groupe_admin(
groupe_id: str,
_sysadmin=Depends(require_sysadmin),
db: AsyncSession = Depends(get_db),
):
try:
return await refresh_group_tenant_schema(db, groupe_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.post("/tenant-schemas/refresh")
async def refresh_tenant_schemas_admin(
_sysadmin=Depends(require_sysadmin),
db: AsyncSession = Depends(get_db),
):
return await refresh_all_tenant_schemas(db)
@router.get("/groupes/{groupe_id}/backups") @router.get("/groupes/{groupe_id}/backups")
async def backups_groupe_admin( async def backups_groupe_admin(
groupe_id: str, groupe_id: str,
@ -164,6 +185,7 @@ async def liste_groupes_admin(
"assemblee_affaires_ordre": groupe.assemblee_affaires_ordre, "assemblee_affaires_ordre": groupe.assemblee_affaires_ordre,
"format_reunion": groupe.format_reunion, "format_reunion": groupe.format_reunion,
"lien_aa87": groupe.lien_aa87, "lien_aa87": groupe.lien_aa87,
"tenant_schema": groupe.tenant_schema,
"cree_le": groupe.cree_le, "cree_le": groupe.cree_le,
"membres": nb_membres, "membres": nb_membres,
"reunions": nb_reunions, "reunions": nb_reunions,

View file

@ -40,6 +40,7 @@ from app.models.rotation import Rotation
from app.models.transaction_comptable import TransactionComptable from app.models.transaction_comptable import TransactionComptable
from app.models.vente_litterature import VenteLitterature from app.models.vente_litterature import VenteLitterature
from app.models.vente_jeton import VenteJeton from app.models.vente_jeton import VenteJeton
from app.services.tenant_schemas import ensure_group_tenant_schema
DEMO_GROUP_NAME = "Groupe Démonstration" DEMO_GROUP_NAME = "Groupe Démonstration"
OLD_DEMO_GROUP_NAMES = ("Groupe Résilience",) OLD_DEMO_GROUP_NAMES = ("Groupe Résilience",)
@ -228,6 +229,7 @@ async def creer_groupe(db: AsyncSession, data: dict) -> Groupe:
) )
db.add(groupe) db.add(groupe)
await db.flush() await db.flush()
await ensure_group_tenant_schema(db, groupe)
await _peupler_groupe_base(db, groupe) await _peupler_groupe_base(db, groupe)
await db.flush() await db.flush()
return groupe return groupe
@ -286,6 +288,7 @@ async def _creer_base(
) )
db.add(groupe) db.add(groupe)
await db.flush() await db.flush()
await ensure_group_tenant_schema(db, groupe)
sysadmin, postes, litterature = await _peupler_groupe_base(db, groupe) sysadmin, postes, litterature = await _peupler_groupe_base(db, groupe)
return groupe, sysadmin, postes, litterature return groupe, sysadmin, postes, litterature

View file

@ -0,0 +1,153 @@
"""Préparation des schémas PostgreSQL par groupe.
Cette couche pose l'isolation physique sans encore changer le chemin de lecture
principal de l'application. Les schémas tenant sont remplis depuis les tables
publiques et servent de base de migration vers l'exécution par search_path.
"""
from __future__ import annotations
import re
import uuid
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
TENANT_TABLES = [
"groupes",
"membres",
"postes",
"postes_modules",
"config_repartition",
"destinataires_contributions",
"litterature",
"reserves",
"reunions",
"presences",
"pv_reunions",
"rapports_rsg",
"candidatures",
"propositions",
"affectations",
"rotations",
"collectes",
"depenses",
"envois_contributions",
"evenements",
"invitations",
"jetons",
"ventes_jetons",
"ventes_litterature",
"operations_bancaires",
"mouvements_reserves",
"transactions_comptables",
"rapports_adoptes",
"push_subscriptions",
"sync_log",
"historique_modifications",
"journal_actions",
]
CHILD_TABLE_FILTERS = {
"postes_modules": "poste_id IN (SELECT id FROM public.postes WHERE groupe_id = :gid)",
"presences": "reunion_id IN (SELECT id FROM public.reunions WHERE groupe_id = :gid)",
"pv_reunions": "reunion_id IN (SELECT id FROM public.reunions WHERE groupe_id = :gid)",
}
def tenant_schema_name(groupe_id) -> str:
valeur = str(groupe_id)
try:
valeur = uuid.UUID(valeur).hex
except ValueError:
valeur = re.sub(r"[^a-zA-Z0-9_]", "_", valeur).lower()
return f"grp_{valeur}"
def _quote_ident(name: str) -> str:
if not re.fullmatch(r"[a-z_][a-z0-9_]*", name):
raise ValueError("Nom de schéma invalide")
return f'"{name}"'
async def ensure_tenant_schema_column(conn: AsyncConnection) -> None:
await conn.execute(text("ALTER TABLE groupes ADD COLUMN IF NOT EXISTS tenant_schema VARCHAR(80) UNIQUE"))
await conn.execute(text(
"UPDATE groupes SET tenant_schema = 'grp_' || replace(id::text, '-', '') "
"WHERE tenant_schema IS NULL"
))
async def ensure_tenant_schema(conn: AsyncConnection, schema: str) -> None:
schema_ident = _quote_ident(schema)
await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema_ident}"))
for table in TENANT_TABLES:
await conn.execute(text(
f"CREATE TABLE IF NOT EXISTS {schema_ident}.{_quote_ident(table)} "
f"(LIKE public.{_quote_ident(table)} INCLUDING ALL)"
))
async def recreate_tenant_tables(conn: AsyncConnection, schema: str) -> None:
schema_ident = _quote_ident(schema)
await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema_ident}"))
for table in reversed(TENANT_TABLES):
await conn.execute(text(f"DROP TABLE IF EXISTS {schema_ident}.{_quote_ident(table)} CASCADE"))
for table in TENANT_TABLES:
await conn.execute(text(
f"CREATE TABLE {schema_ident}.{_quote_ident(table)} "
f"(LIKE public.{_quote_ident(table)} INCLUDING ALL)"
))
async def ensure_all_tenant_schemas(conn: AsyncConnection) -> None:
await ensure_tenant_schema_column(conn)
result = await conn.execute(text("SELECT tenant_schema FROM groupes WHERE tenant_schema IS NOT NULL"))
for schema in result.scalars().all():
await ensure_tenant_schema(conn, schema)
async def ensure_group_tenant_schema(db: AsyncSession, groupe) -> str:
schema = groupe.tenant_schema or tenant_schema_name(groupe.id)
groupe.tenant_schema = schema
await db.flush()
conn = await db.connection()
await ensure_tenant_schema(conn, schema)
return schema
async def refresh_group_tenant_schema(db: AsyncSession, groupe_id) -> dict:
conn = await db.connection()
await ensure_tenant_schema_column(conn)
result = await conn.execute(
text("SELECT tenant_schema FROM groupes WHERE id = :gid"),
{"gid": str(groupe_id)},
)
schema = result.scalar_one_or_none()
if not schema:
schema = tenant_schema_name(groupe_id)
await conn.execute(
text("UPDATE groupes SET tenant_schema = :schema WHERE id = :gid"),
{"schema": schema, "gid": str(groupe_id)},
)
await recreate_tenant_tables(conn, schema)
schema_ident = _quote_ident(schema)
params = {"gid": str(groupe_id)}
for table in TENANT_TABLES:
table_ident = _quote_ident(table)
if table == "groupes":
where_clause = "id = :gid"
elif table in CHILD_TABLE_FILTERS:
where_clause = CHILD_TABLE_FILTERS[table]
else:
where_clause = "groupe_id = :gid"
await conn.execute(text(
f"INSERT INTO {schema_ident}.{table_ident} "
f"SELECT * FROM public.{table_ident} WHERE {where_clause}"
), params)
return {"schema": schema, "tables": len(TENANT_TABLES)}
async def refresh_all_tenant_schemas(db: AsyncSession) -> list[dict]:
result = await db.execute(text("SELECT id FROM groupes ORDER BY nom"))
return [await refresh_group_tenant_schema(db, groupe_id) for groupe_id in result.scalars().all()]

24
deploy/tenant_schemas.py Normal file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Prépare et rafraîchit les schémas PostgreSQL par groupe."""
import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
from app.core.database import async_session, engine # noqa: E402
from app.services.tenant_schemas import refresh_all_tenant_schemas # noqa: E402
async def main():
async with async_session() as db:
results = await refresh_all_tenant_schemas(db)
await db.commit()
for result in results:
print(f"{result['schema']} ({result['tables']} tables)")
await engine.dispose()
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View file

@ -10,6 +10,17 @@ L'application peut servir plusieurs groupes sous une même instance. Les groupes
Le groupe courant est déterminé par le membre authentifié et le `groupe_id` dans le jeton. Une requête authentifiée ne doit lire ou modifier que les données de ce groupe. Le groupe courant est déterminé par le membre authentifié et le `groupe_id` dans le jeton. Une requête authentifiée ne doit lire ou modifier que les données de ce groupe.
## Isolation physique
Chaque groupe reçoit aussi un schéma PostgreSQL dédié, nommé à partir de son identifiant (`grp_<uuid>`). Les tables du groupe peuvent être rafraîchies dans ce schéma avec le script:
```bash
cd /opt/groupe-meditation/backend
venv/bin/python ../deploy/tenant_schemas.py
```
La sauvegarde quotidienne rafraîchit ces schémas et produit aussi un `pg_dump` par schéma. La voie applicative principale continue de filtrer par `groupe_id`; les schémas dédiés constituent la base de migration vers une exécution par `search_path` tenant.
## Page publique ## Page publique
La page de connexion liste les groupes sous forme de tuiles. Chaque tuile affiche l'horaire et les coordonnées du groupe, puis le formulaire de connexion. La page de connexion liste les groupes sous forme de tuiles. Chaque tuile affiche l'horaire et les coordonnées du groupe, puis le formulaire de connexion.
@ -84,4 +95,3 @@ Sysadmin est transversal pour l'administration technique. Ses actions doivent ê
- Vérifier que les postes et permissions ne se croisent pas. - Vérifier que les postes et permissions ne se croisent pas.
- Vérifier les rencontres, assemblées, finances, rapports et journal par groupe. - Vérifier les rencontres, assemblées, finances, rapports et journal par groupe.
- Vérifier que l'impersonification reste dans le groupe du membre ciblé. - Vérifier que l'impersonification reste dans le groupe du membre ciblé.