groupe-meditation/backend/app/services/group_backups.py

273 lines
9.7 KiB
Python

"""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]