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 }}"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
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.
|
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é.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue