Ajouter les sauvegardes restaurables par groupe

This commit is contained in:
Daniel Allaire 2026-06-01 07:40:30 -04:00
parent bcc2552c44
commit 670cae8604
10 changed files with 419 additions and 7 deletions

View file

@ -6,3 +6,4 @@ INVITATION_EXPIRE_HOURS={{ groupe_meditation_invitation_expire_hours }}
VAPID_PUBLIC_KEY={{ groupe_meditation_vapid_public_key }}
VAPID_PRIVATE_KEY_B64={{ groupe_meditation_vapid_private_key_b64 }}
VAPID_SUBJECT={{ groupe_meditation_vapid_subject }}
GROUP_BACKUP_DIR={{ groupe_meditation_backup_dir }}

View file

@ -4,8 +4,8 @@
path: "{{ groupe_meditation_backup_dir }}"
state: directory
owner: root
group: root
mode: "0750"
group: "{{ groupe_meditation_service_group }}"
mode: "2770"
- name: Installer la sauvegarde quotidienne
ansible.builtin.template:
@ -15,4 +15,3 @@
group: root
mode: "0750"
no_log: true

View file

@ -1,5 +1,6 @@
#!/bin/bash
set -euo pipefail
umask 007
BACKUP_DIR="{{ groupe_meditation_backup_dir }}"
RETENTION_DAYS="{{ groupe_meditation_backup_retention_days }}"
@ -11,8 +12,11 @@ DATE="$(date +%Y-%m-%d_%H%M)"
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 }}/group_backup.py" all
pg_dump -U "$DB_USER" -h "$DB_HOST" -Fc "$DB_NAME" > "$BACKUP_DIR/grpmed_${DATE}.dump"
find "$BACKUP_DIR" -name "grpmed_*.dump" -mtime +"$RETENTION_DAYS" -delete
find "$BACKUP_DIR" -name "group_*.json" -mtime +"$RETENTION_DAYS" -delete
echo "[$(date)] Sauvegarde terminee: grpmed_${DATE}.dump"

View file

@ -20,6 +20,7 @@ class Settings(BaseSettings):
VAPID_PUBLIC_KEY: str | None = None
VAPID_PRIVATE_KEY_B64: str | None = None
VAPID_SUBJECT: str = "mailto:admin@app.87-16.org"
GROUP_BACKUP_DIR: str = "/var/backups/groupe-meditation"
class Config:
env_file = str(_ENV_FILE)

View file

@ -15,6 +15,11 @@ from app.services.admin_database import (
reinitialiser_demo,
supprimer_groupe,
)
from app.services.group_backups import (
creer_sauvegarde_groupe,
lister_sauvegardes_groupe,
restaurer_sauvegarde_groupe,
)
from app.models.groupe import Groupe
from app.models.membre import Membre
from app.models.reunion import Reunion
@ -38,6 +43,10 @@ class GroupeAdminRequest(BaseModel):
lien_aa87: str = "https://aa87.org"
class RestoreGroupeRequest(BaseModel):
fichier: str
async def require_sysadmin(membre=Depends(get_current_membre)):
if not is_sysadmin(membre):
raise HTTPException(status_code=403, detail="Réservé au sysadmin")
@ -93,6 +102,39 @@ async def reset_groupe_admin(
raise HTTPException(status_code=400, detail=str(exc))
@router.post("/groupes/{groupe_id}/backup")
async def backup_groupe_admin(
groupe_id: str,
_sysadmin=Depends(require_sysadmin),
db: AsyncSession = Depends(get_db),
):
try:
return await creer_sauvegarde_groupe(db, groupe_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.get("/groupes/{groupe_id}/backups")
async def backups_groupe_admin(
groupe_id: str,
_sysadmin=Depends(require_sysadmin),
):
return lister_sauvegardes_groupe(groupe_id)
@router.post("/groupes/{groupe_id}/restore")
async def restore_groupe_admin(
groupe_id: str,
req: RestoreGroupeRequest,
_sysadmin=Depends(require_sysadmin),
db: AsyncSession = Depends(get_db),
):
try:
return await restaurer_sauvegarde_groupe(db, groupe_id, req.fichier)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.get("/groupes")
async def liste_groupes_admin(
_sysadmin=Depends(require_sysadmin),

View file

@ -0,0 +1,273 @@
"""Sauvegardes et restaurations applicatives par groupe."""
from __future__ import annotations
import json
import re
import uuid
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal
from pathlib import Path
from sqlalchemy import insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.schema import Table
from app.core.config import settings
from app.core.database import Base
from app.models.affectation import Affectation # noqa: F401
from app.models.candidature import Candidature # noqa: F401
from app.models.collecte import Collecte # noqa: F401
from app.models.config_repartition import ConfigRepartition # noqa: F401
from app.models.depense import Depense # noqa: F401
from app.models.destinataire_contribution import DestinataireContribution # noqa: F401
from app.models.envoi_contribution import EnvoiContribution # noqa: F401
from app.models.evenement import Evenement # noqa: F401
from app.models.groupe import Groupe # noqa: F401
from app.models.historique_modification import HistoriqueModification # noqa: F401
from app.models.instance_admin import InstanceAdmin # noqa: F401
from app.models.invitation import Invitation # noqa: F401
from app.models.jeton import Jeton # noqa: F401
from app.models.journal_action import JournalAction # noqa: F401
from app.models.litterature import Litterature # noqa: F401
from app.models.membre import Membre # noqa: F401
from app.models.mouvement_reserve import MouvementReserve # noqa: F401
from app.models.operation_bancaire import OperationBancaire # noqa: F401
from app.models.poste import Poste # noqa: F401
from app.models.poste_module import PosteModule # noqa: F401
from app.models.presence import Presence # noqa: F401
from app.models.proposition import Proposition # noqa: F401
from app.models.push_subscription import PushSubscription # noqa: F401
from app.models.pv_reunion import PvReunion # noqa: F401
from app.models.rapport_adopte import RapportAdopte # noqa: F401
from app.models.rapport_rsg import RapportRsg # noqa: F401
from app.models.reserve import Reserve # noqa: F401
from app.models.reunion import Reunion # noqa: F401
from app.models.rotation import Rotation # noqa: F401
from app.models.sync_log import SyncLog # noqa: F401
from app.models.transaction_comptable import TransactionComptable # noqa: F401
from app.models.vente_jeton import VenteJeton # noqa: F401
from app.models.vente_litterature import VenteLitterature # noqa: F401
from app.services.admin_database import _vider_groupe
BACKUP_VERSION = 1
GROUP_BACKUP_PREFIX = "group"
CHILD_TABLES = {
"postes_modules": ("poste_id", "postes"),
"presences": ("reunion_id", "reunions"),
"pv_reunions": ("reunion_id", "reunions"),
}
EXCLUDED_TABLES = {"instance_admins"}
RESTORE_NULL_FIRST = {
"candidatures": ("proposition_id",),
"propositions": ("candidature_id",),
}
RESTORE_TABLE_ORDER = [
"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",
]
def _backup_dir() -> Path:
path = Path(settings.GROUP_BACKUP_DIR)
path.mkdir(parents=True, exist_ok=True)
return path
def _table(name: str) -> Table:
return Base.metadata.tables[name]
def _safe_backup_name(name: str) -> str:
if not re.fullmatch(r"group_[0-9a-f-]+_[0-9]{8}_[0-9]{6}\.json", name):
raise ValueError("Nom de sauvegarde invalide")
return name
def _serialize(value):
if isinstance(value, (datetime, date, time)):
return value.isoformat()
if isinstance(value, uuid.UUID):
return str(value)
if isinstance(value, Decimal):
return str(value)
if isinstance(value, timedelta):
return value.total_seconds()
return value
def _deserialize(column, value):
if value is None:
return None
try:
py_type = column.type.python_type
except NotImplementedError:
return value
if py_type is uuid.UUID:
return uuid.UUID(str(value))
if py_type is datetime:
return datetime.fromisoformat(value)
if py_type is date:
return date.fromisoformat(value)
if py_type is time:
return time.fromisoformat(value)
if py_type is Decimal:
return Decimal(str(value))
if py_type is timedelta:
return timedelta(seconds=float(value))
return value
async def _rows_for_table(db: AsyncSession, table: Table, groupe_id: str) -> list[dict]:
if table.name == "groupes":
stmt = select(table).where(table.c.id == groupe_id)
elif table.name in EXCLUDED_TABLES:
return []
elif "groupe_id" in table.c:
stmt = select(table).where(table.c.groupe_id == groupe_id)
elif table.name in CHILD_TABLES:
column_name, parent_name = CHILD_TABLES[table.name]
parent = _table(parent_name)
stmt = select(table).where(table.c[column_name].in_(select(parent.c.id).where(parent.c.groupe_id == groupe_id)))
else:
return []
result = await db.execute(stmt)
return [
{key: _serialize(value) for key, value in row._mapping.items()}
for row in result.fetchall()
]
def _restore_order() -> list[Table]:
order = []
for table_name in RESTORE_TABLE_ORDER:
table = _table(table_name)
if table.name in EXCLUDED_TABLES:
continue
if table.name == "groupes" or "groupe_id" in table.c or table.name in CHILD_TABLES:
order.append(table)
return order
async def creer_sauvegarde_groupe(db: AsyncSession, groupe_id: str) -> dict:
groupe_table = _table("groupes")
groupe = (await db.execute(select(groupe_table).where(groupe_table.c.id == groupe_id))).first()
if not groupe:
raise ValueError("Groupe introuvable")
payload = {
"version": BACKUP_VERSION,
"created_at": datetime.now(timezone.utc).isoformat(),
"group_id": str(groupe_id),
"tables": {},
}
for table in _restore_order():
rows = await _rows_for_table(db, table, groupe_id)
if rows:
payload["tables"][table.name] = rows
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
filename = f"{GROUP_BACKUP_PREFIX}_{groupe_id}_{stamp}.json"
path = _backup_dir() / filename
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return {"message": "Sauvegarde créée", "fichier": filename, "created_at": payload["created_at"]}
def lister_sauvegardes_groupe(groupe_id: str) -> list[dict]:
backups = []
for path in _backup_dir().glob(f"{GROUP_BACKUP_PREFIX}_{groupe_id}_*.json"):
stat = path.stat()
backups.append({
"fichier": path.name,
"taille": stat.st_size,
"cree_le": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
})
return sorted(backups, key=lambda item: item["fichier"], reverse=True)
async def restaurer_sauvegarde_groupe(db: AsyncSession, groupe_id: str, fichier: str) -> dict:
filename = _safe_backup_name(fichier)
path = _backup_dir() / filename
if not path.exists():
raise ValueError("Sauvegarde introuvable")
payload = json.loads(path.read_text(encoding="utf-8"))
if payload.get("version") != BACKUP_VERSION:
raise ValueError("Version de sauvegarde incompatible")
if str(payload.get("group_id")) != str(groupe_id):
raise ValueError("Cette sauvegarde appartient à un autre groupe")
await creer_sauvegarde_groupe(db, groupe_id)
await _vider_groupe(db, groupe_id)
tables = payload.get("tables") or {}
group_rows = tables.get("groupes") or []
if group_rows:
group_row = group_rows[0]
group_table = _table("groupes")
values = {
key: _deserialize(group_table.c[key], value)
for key, value in group_row.items()
if key in group_table.c and key != "id"
}
await db.execute(update(group_table).where(group_table.c.id == groupe_id).values(**values))
deferred_updates = []
for table in _restore_order():
if table.name == "groupes":
continue
rows = tables.get(table.name) or []
if not rows:
continue
insert_rows = []
for row in rows:
values = {
key: _deserialize(table.c[key], value)
for key, value in row.items()
if key in table.c
}
for column_name in RESTORE_NULL_FIRST.get(table.name, ()):
if values.get(column_name) is not None:
deferred_updates.append((table, values["id"], column_name, values[column_name]))
values[column_name] = None
insert_rows.append(values)
await db.execute(insert(table), insert_rows)
for table, row_id, column_name, value in deferred_updates:
await db.execute(update(table).where(table.c.id == row_id).values({column_name: value}))
return {"message": "Groupe restauré", "fichier": filename}
async def creer_sauvegardes_tous_groupes(db: AsyncSession) -> list[dict]:
groupes = (await db.execute(select(_table("groupes").c.id))).scalars().all()
return [await creer_sauvegarde_groupe(db, str(groupe_id)) for groupe_id in groupes]

32
deploy/group_backup.py Normal file
View file

@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Sauvegardes applicatives par groupe.
Usage:
cd /opt/groupe-meditation/backend
venv/bin/python ../deploy/group_backup.py all
"""
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.group_backups import creer_sauvegardes_tous_groupes # noqa: E402
async def main():
if len(sys.argv) < 2 or sys.argv[1] != "all":
print("Usage: group_backup.py all", file=sys.stderr)
return 2
async with async_session() as db:
backups = await creer_sauvegardes_tous_groupes(db)
await db.commit()
for backup in backups:
print(f"{backup['fichier']}")
await engine.dispose()
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View file

@ -53,7 +53,12 @@ Depuis le poste de contrôle:
ansible-playbook ansible/backup-now.yml
```
Les sauvegardes contiennent des données personnelles et doivent être protégées.
Le système produit deux niveaux de sauvegarde:
- une archive PostgreSQL complète de la base;
- une archive applicative par groupe, restaurable depuis le module sysadmin.
Le bouton `Sauvegarder` d'une tuile de groupe crée une archive immédiate de ce groupe. Le bouton `Restaurer` restaure seulement ce groupe et crée d'abord une sauvegarde de sécurité. Les sauvegardes contiennent des données personnelles et doivent être protégées.
## Redéployer
@ -139,4 +144,3 @@ Si l'application est indisponible:
3. Faire adopter comme d'habitude si requis.
4. Ressaisir les données lorsque l'application revient.
5. Conserver les notes papier jusqu'à vérification.

View file

@ -135,6 +135,8 @@ Le playbook est conçu pour être relancé. Il synchronise le code, reconstruit
ansible-playbook ansible/backup-now.yml --ask-vault-pass
```
La sauvegarde quotidienne génère une archive globale PostgreSQL et une archive JSON par groupe. Les archives par groupe alimentent le bouton `Restaurer` du module sysadmin.
## NGINX
L'application installe un NGINX local sur la VM. Il sert:
@ -143,4 +145,3 @@ L'application installe un NGINX local sur la VM. Il sert:
- le reverse proxy vers l'API FastAPI locale.
Un proxy externe peut gérer TLS et relayer vers cette VM en HTTP.

View file

@ -122,6 +122,52 @@ export default function GestionGroupes() {
}
};
const sauvegarder = async (groupe) => {
setBusy(true);
setError('');
setMsg('');
try {
const res = await api.post(`/admin/groupes/${groupe.id}/backup`);
setMsg(`Sauvegarde créée : ${res.fichier}`);
} catch (e) {
setError(e.message);
} finally {
setBusy(false);
}
};
const restaurer = async (groupe) => {
setBusy(true);
setError('');
setMsg('');
try {
const backups = await api.get(`/admin/groupes/${groupe.id}/backups`);
if (!backups.length) {
setError('Aucune sauvegarde disponible pour ce groupe');
return;
}
const choix = backups.map((b, index) => `${index + 1}. ${b.fichier}`).join('\n');
const reponse = window.prompt(`Restaurer "${groupe.nom}" depuis quelle sauvegarde?\n\n${choix}\n\nTapez le numéro.`);
if (!reponse) return;
const index = Number.parseInt(reponse, 10) - 1;
const backup = backups[index];
if (!backup) {
setError('Choix invalide');
return;
}
const confirmation = window.prompt(`Restaurer "${groupe.nom}" depuis "${backup.fichier}"? Tapez RESTAURER pour continuer.`);
if (confirmation !== 'RESTAURER') return;
const res = await api.post(`/admin/groupes/${groupe.id}/restore`, { fichier: backup.fichier });
setMsg(res.message || 'Groupe restauré');
await charger();
await loadGroupes();
} catch (e) {
setError(e.message);
} finally {
setBusy(false);
}
};
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Gestion des groupes" sousTitre="Groupes, démo et opérations sysadmin" />
@ -191,6 +237,12 @@ export default function GestionGroupes() {
>
Réinit
</span>
<span
onClick={(e) => { e.stopPropagation(); restaurer(groupe); }}
className="shrink-0 rounded-xl bg-amber-50 px-3 py-2 text-xs font-bold text-amber-700"
>
Restaurer
</span>
<span className={`shrink-0 text-2xl text-aa-blue transition ${ouvert ? 'rotate-180' : ''}`}>⌄</span>
</button>
@ -208,6 +260,9 @@ export default function GestionGroupes() {
</div>
<div className="grid grid-cols-2 gap-2">
<button onClick={() => modifier(groupe)} className="p-3 bg-gray-100 text-gray-700 rounded-xl text-sm font-bold">Modifier</button>
<button onClick={() => sauvegarder(groupe)} disabled={busy} className="p-3 bg-blue-50 text-blue-700 rounded-xl text-sm font-bold disabled:opacity-40">
Sauvegarder
</button>
<button onClick={() => supprimer(groupe)} disabled={groupe.protege || busy} className="p-3 bg-red-50 text-red-700 rounded-xl text-sm font-bold disabled:opacity-40">
Supprimer
</button>