diff --git a/ansible/roles/app/templates/env.j2 b/ansible/roles/app/templates/env.j2 index 5bf8f63..7c7b50a 100644 --- a/ansible/roles/app/templates/env.j2 +++ b/ansible/roles/app/templates/env.j2 @@ -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 }} diff --git a/ansible/roles/backup/tasks/main.yml b/ansible/roles/backup/tasks/main.yml index 6c4c326..940237a 100644 --- a/ansible/roles/backup/tasks/main.yml +++ b/ansible/roles/backup/tasks/main.yml @@ -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 - diff --git a/ansible/roles/backup/templates/groupe-meditation-backup.j2 b/ansible/roles/backup/templates/groupe-meditation-backup.j2 index 57bb711..e8046c8 100644 --- a/ansible/roles/backup/templates/groupe-meditation-backup.j2 +++ b/ansible/roles/backup/templates/groupe-meditation-backup.j2 @@ -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" - diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 592199d..326c292 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index d0aad02..4981a0e 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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), diff --git a/backend/app/services/group_backups.py b/backend/app/services/group_backups.py new file mode 100644 index 0000000..0e486c9 --- /dev/null +++ b/backend/app/services/group_backups.py @@ -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] diff --git a/deploy/group_backup.py b/deploy/group_backup.py new file mode 100644 index 0000000..452df92 --- /dev/null +++ b/deploy/group_backup.py @@ -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())) diff --git a/docs/90_EXPLOITATION.md b/docs/90_EXPLOITATION.md index 9726b2c..9ca257d 100644 --- a/docs/90_EXPLOITATION.md +++ b/docs/90_EXPLOITATION.md @@ -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. - diff --git a/docs/91_DEPLOIEMENT_ANSIBLE.md b/docs/91_DEPLOIEMENT_ANSIBLE.md index 4fa3cd3..a69d903 100644 --- a/docs/91_DEPLOIEMENT_ANSIBLE.md +++ b/docs/91_DEPLOIEMENT_ANSIBLE.md @@ -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. - diff --git a/frontend/src/pages/GestionGroupes.jsx b/frontend/src/pages/GestionGroupes.jsx index 8e4d8e4..30b9bad 100644 --- a/frontend/src/pages/GestionGroupes.jsx +++ b/frontend/src/pages/GestionGroupes.jsx @@ -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 (