Ajouter les sauvegardes restaurables par groupe
This commit is contained in:
parent
bcc2552c44
commit
670cae8604
10 changed files with 419 additions and 7 deletions
|
|
@ -6,3 +6,4 @@ INVITATION_EXPIRE_HOURS={{ groupe_meditation_invitation_expire_hours }}
|
||||||
VAPID_PUBLIC_KEY={{ groupe_meditation_vapid_public_key }}
|
VAPID_PUBLIC_KEY={{ groupe_meditation_vapid_public_key }}
|
||||||
VAPID_PRIVATE_KEY_B64={{ groupe_meditation_vapid_private_key_b64 }}
|
VAPID_PRIVATE_KEY_B64={{ groupe_meditation_vapid_private_key_b64 }}
|
||||||
VAPID_SUBJECT={{ groupe_meditation_vapid_subject }}
|
VAPID_SUBJECT={{ groupe_meditation_vapid_subject }}
|
||||||
|
GROUP_BACKUP_DIR={{ groupe_meditation_backup_dir }}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
path: "{{ groupe_meditation_backup_dir }}"
|
path: "{{ groupe_meditation_backup_dir }}"
|
||||||
state: directory
|
state: directory
|
||||||
owner: root
|
owner: root
|
||||||
group: root
|
group: "{{ groupe_meditation_service_group }}"
|
||||||
mode: "0750"
|
mode: "2770"
|
||||||
|
|
||||||
- name: Installer la sauvegarde quotidienne
|
- name: Installer la sauvegarde quotidienne
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
|
|
@ -15,4 +15,3 @@
|
||||||
group: root
|
group: root
|
||||||
mode: "0750"
|
mode: "0750"
|
||||||
no_log: true
|
no_log: true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
umask 007
|
||||||
|
|
||||||
BACKUP_DIR="{{ groupe_meditation_backup_dir }}"
|
BACKUP_DIR="{{ groupe_meditation_backup_dir }}"
|
||||||
RETENTION_DAYS="{{ groupe_meditation_backup_retention_days }}"
|
RETENTION_DAYS="{{ groupe_meditation_backup_retention_days }}"
|
||||||
|
|
@ -11,8 +12,11 @@ DATE="$(date +%Y-%m-%d_%H%M)"
|
||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
export PGPASSWORD="{{ groupe_meditation_db_password }}"
|
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"
|
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 "grpmed_*.dump" -mtime +"$RETENTION_DAYS" -delete
|
||||||
|
find "$BACKUP_DIR" -name "group_*.json" -mtime +"$RETENTION_DAYS" -delete
|
||||||
|
|
||||||
echo "[$(date)] Sauvegarde terminee: grpmed_${DATE}.dump"
|
echo "[$(date)] Sauvegarde terminee: grpmed_${DATE}.dump"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class Settings(BaseSettings):
|
||||||
VAPID_PUBLIC_KEY: str | None = None
|
VAPID_PUBLIC_KEY: str | None = None
|
||||||
VAPID_PRIVATE_KEY_B64: str | None = None
|
VAPID_PRIVATE_KEY_B64: str | None = None
|
||||||
VAPID_SUBJECT: str = "mailto:admin@app.87-16.org"
|
VAPID_SUBJECT: str = "mailto:admin@app.87-16.org"
|
||||||
|
GROUP_BACKUP_DIR: str = "/var/backups/groupe-meditation"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = str(_ENV_FILE)
|
env_file = str(_ENV_FILE)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ from app.services.admin_database import (
|
||||||
reinitialiser_demo,
|
reinitialiser_demo,
|
||||||
supprimer_groupe,
|
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.groupe import Groupe
|
||||||
from app.models.membre import Membre
|
from app.models.membre import Membre
|
||||||
from app.models.reunion import Reunion
|
from app.models.reunion import Reunion
|
||||||
|
|
@ -38,6 +43,10 @@ class GroupeAdminRequest(BaseModel):
|
||||||
lien_aa87: str = "https://aa87.org"
|
lien_aa87: str = "https://aa87.org"
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreGroupeRequest(BaseModel):
|
||||||
|
fichier: str
|
||||||
|
|
||||||
|
|
||||||
async def require_sysadmin(membre=Depends(get_current_membre)):
|
async def require_sysadmin(membre=Depends(get_current_membre)):
|
||||||
if not is_sysadmin(membre):
|
if not is_sysadmin(membre):
|
||||||
raise HTTPException(status_code=403, detail="Réservé au sysadmin")
|
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))
|
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")
|
@router.get("/groupes")
|
||||||
async def liste_groupes_admin(
|
async def liste_groupes_admin(
|
||||||
_sysadmin=Depends(require_sysadmin),
|
_sysadmin=Depends(require_sysadmin),
|
||||||
|
|
|
||||||
273
backend/app/services/group_backups.py
Normal file
273
backend/app/services/group_backups.py
Normal 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
32
deploy/group_backup.py
Normal 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()))
|
||||||
|
|
@ -53,7 +53,12 @@ Depuis le poste de contrôle:
|
||||||
ansible-playbook ansible/backup-now.yml
|
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
|
## Redéployer
|
||||||
|
|
||||||
|
|
@ -139,4 +144,3 @@ Si l'application est indisponible:
|
||||||
3. Faire adopter comme d'habitude si requis.
|
3. Faire adopter comme d'habitude si requis.
|
||||||
4. Ressaisir les données lorsque l'application revient.
|
4. Ressaisir les données lorsque l'application revient.
|
||||||
5. Conserver les notes papier jusqu'à vérification.
|
5. Conserver les notes papier jusqu'à vérification.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
## NGINX
|
||||||
|
|
||||||
L'application installe un NGINX local sur la VM. Il sert:
|
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.
|
- le reverse proxy vers l'API FastAPI locale.
|
||||||
|
|
||||||
Un proxy externe peut gérer TLS et relayer vers cette VM en HTTP.
|
Un proxy externe peut gérer TLS et relayer vers cette VM en HTTP.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100">
|
<div className="min-h-screen bg-gray-100">
|
||||||
<PageHeader titre="Gestion des groupes" sousTitre="Groupes, démo et opérations sysadmin" />
|
<PageHeader titre="Gestion des groupes" sousTitre="Groupes, démo et opérations sysadmin" />
|
||||||
|
|
@ -191,6 +237,12 @@ export default function GestionGroupes() {
|
||||||
>
|
>
|
||||||
Réinit
|
Réinit
|
||||||
</span>
|
</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>
|
<span className={`shrink-0 text-2xl text-aa-blue transition ${ouvert ? 'rotate-180' : ''}`}>⌄</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -208,6 +260,9 @@ export default function GestionGroupes() {
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<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={() => 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">
|
<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
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue