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_PRIVATE_KEY_B64={{ groupe_meditation_vapid_private_key_b64 }}
|
||||
VAPID_SUBJECT={{ groupe_meditation_vapid_subject }}
|
||||
GROUP_BACKUP_DIR={{ groupe_meditation_backup_dir }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Gestion des groupes" sousTitre="Groupes, démo et opérations sysadmin" />
|
||||
|
|
@ -191,6 +237,12 @@ export default function GestionGroupes() {
|
|||
>
|
||||
Réinit
|
||||
</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>
|
||||
</button>
|
||||
|
||||
|
|
@ -208,6 +260,9 @@ export default function GestionGroupes() {
|
|||
</div>
|
||||
<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={() => 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">
|
||||
Supprimer
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Reference in a new issue