273 lines
9.7 KiB
Python
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]
|