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