Preparer les schemas SQL par groupe
This commit is contained in:
parent
670cae8604
commit
4a64061706
8 changed files with 226 additions and 1 deletions
|
|
@ -13,10 +13,15 @@ mkdir -p "$BACKUP_DIR"
|
|||
export PGPASSWORD="{{ groupe_meditation_db_password }}"
|
||||
|
||||
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
|
||||
|
||||
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 "group_*.json" -mtime +"$RETENTION_DAYS" -delete
|
||||
find "$BACKUP_DIR" -name "grp_*.dump" -mtime +"$RETENTION_DAYS" -delete
|
||||
|
||||
echo "[$(date)] Sauvegarde terminee: grpmed_${DATE}.dump"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from sqlalchemy import text
|
|||
from app.core.config import settings
|
||||
from app.core.audit import AuditMiddleware
|
||||
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
|
||||
|
||||
# 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 with engine.begin() as conn:
|
||||
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(
|
||||
"ALTER TABLE groupes "
|
||||
"ADD COLUMN IF NOT EXISTS assemblee_affaires_ordre VARCHAR(20) DEFAULT 'premiere'"
|
||||
|
|
@ -118,6 +124,7 @@ async def lifespan(app: FastAPI):
|
|||
"ventes_litterature",
|
||||
):
|
||||
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
|
||||
await engine.dispose()
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ class Groupe(Base):
|
|||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
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")
|
||||
region: Mapped[str] = mapped_column(String(20), default="87")
|
||||
adresse_local: Mapped[str | None] = mapped_column(Text)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from app.services.group_backups import (
|
|||
lister_sauvegardes_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.membre import Membre
|
||||
from app.models.reunion import Reunion
|
||||
|
|
@ -114,6 +115,26 @@ async def backup_groupe_admin(
|
|||
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")
|
||||
async def backups_groupe_admin(
|
||||
groupe_id: str,
|
||||
|
|
@ -164,6 +185,7 @@ async def liste_groupes_admin(
|
|||
"assemblee_affaires_ordre": groupe.assemblee_affaires_ordre,
|
||||
"format_reunion": groupe.format_reunion,
|
||||
"lien_aa87": groupe.lien_aa87,
|
||||
"tenant_schema": groupe.tenant_schema,
|
||||
"cree_le": groupe.cree_le,
|
||||
"membres": nb_membres,
|
||||
"reunions": nb_reunions,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ from app.models.rotation import Rotation
|
|||
from app.models.transaction_comptable import TransactionComptable
|
||||
from app.models.vente_litterature import VenteLitterature
|
||||
from app.models.vente_jeton import VenteJeton
|
||||
from app.services.tenant_schemas import ensure_group_tenant_schema
|
||||
|
||||
DEMO_GROUP_NAME = "Groupe Démonstration"
|
||||
OLD_DEMO_GROUP_NAMES = ("Groupe Résilience",)
|
||||
|
|
@ -228,6 +229,7 @@ async def creer_groupe(db: AsyncSession, data: dict) -> Groupe:
|
|||
)
|
||||
db.add(groupe)
|
||||
await db.flush()
|
||||
await ensure_group_tenant_schema(db, groupe)
|
||||
await _peupler_groupe_base(db, groupe)
|
||||
await db.flush()
|
||||
return groupe
|
||||
|
|
@ -286,6 +288,7 @@ async def _creer_base(
|
|||
)
|
||||
db.add(groupe)
|
||||
await db.flush()
|
||||
await ensure_group_tenant_schema(db, groupe)
|
||||
sysadmin, postes, litterature = await _peupler_groupe_base(db, groupe)
|
||||
return groupe, sysadmin, postes, litterature
|
||||
|
||||
|
|
|
|||
153
backend/app/services/tenant_schemas.py
Normal file
153
backend/app/services/tenant_schemas.py
Normal 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
24
deploy/tenant_schemas.py
Normal 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()))
|
||||
|
|
@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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 les rencontres, assemblées, finances, rapports et journal par groupe.
|
||||
- Vérifier que l'impersonification reste dans le groupe du membre ciblé.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue