"""Administration locale d'une instance OpenMind. Volontairement une CLI et non une interface web : il n'existe aucune page /admin exposee sur Internet, et ces commandes ne s'utilisent qu'avec un acces au conteneur. docker compose exec openmind python -m app.cli stats docker compose exec openmind python -m app.cli shares list --limit 20 docker compose exec openmind python -m app.cli shares delete docker compose exec openmind python -m app.cli shares purge-expired --dry-run docker compose exec openmind python -m app.cli backup /app/data/backups/openmind.db Aucune commande n'affiche ni le jeton de gestion, ni son hash, ni le contenu des cartes. """ from __future__ import annotations import argparse import sqlite3 import sys from datetime import datetime from pathlib import Path from sqlalchemy import func, select from app.config import get_settings from app.db.session import SessionLocal from app.models.share import SharedMap, utcnow _SQLITE_PREFIX = "sqlite:///" def database_path() -> Path | None: """Chemin du fichier SQLite, ou None si la base n'est pas un fichier.""" url = get_settings().database_url if not url.startswith(_SQLITE_PREFIX): return None raw = url[len(_SQLITE_PREFIX) :] if not raw or raw == ":memory:": return None return Path(raw) def _format_size(num_bytes: int) -> str: value = float(num_bytes) for unit in ("o", "Ko", "Mo", "Go"): if value < 1024 or unit == "Go": return f"{value:.1f} {unit}" if unit != "o" else f"{int(value)} {unit}" value /= 1024 return f"{value:.1f} Go" def _format_date(moment: datetime | None) -> str: if moment is None: return "jamais" return moment.strftime("%Y-%m-%d %H:%M") def _mask(token: str) -> str: """N'affiche jamais un jeton en entier : il autorise la lecture.""" if len(token) <= 8: return f"{token[:2]}...{token[-2:]}" return f"{token[:4]}...{token[-4:]}" def _confirm(question: str, assume_yes: bool) -> bool: if assume_yes: return True if not sys.stdin.isatty(): print("Confirmation impossible (entree non interactive). Utilisez --yes.") return False answer = input(f"{question} [o/N] ").strip().lower() return answer in {"o", "oui", "y", "yes"} def cmd_stats(_args: argparse.Namespace) -> int: now = utcnow() with SessionLocal() as session: total = session.execute(select(func.count()).select_from(SharedMap)).scalar_one() expired = session.execute( select(func.count()) .select_from(SharedMap) .where(SharedMap.expires_at.is_not(None), SharedMap.expires_at <= now) ).scalar_one() oldest = session.execute( select(SharedMap.created_at).order_by(SharedMap.created_at.asc()).limit(1) ).scalar_one_or_none() newest = session.execute( select(SharedMap.created_at).order_by(SharedMap.created_at.desc()).limit(1) ).scalar_one_or_none() settings = get_settings() path = database_path() size = 0 if path and path.exists(): size = path.stat().st_size for suffix in ("-wal", "-shm"): side = path.with_name(path.name + suffix) if side.exists(): size += side.stat().st_size limit = settings.max_shared_maps print(f"Partages : {total}") print(f" actifs : {total - expired}") print(f" expires : {expired}") print(f"Capacite : {'illimitee' if limit <= 0 else limit}") print(f"Partage active : {'oui' if settings.sharing_enabled else 'non'}") print(f"Base SQLite : {path or 'non applicable'}") print(f"Taille (WAL comprise) : {_format_size(size)}") print(f"Plus ancien : {_format_date(oldest)}") print(f"Plus recent : {_format_date(newest)}") return 0 def cmd_shares_list(args: argparse.Namespace) -> int: now = utcnow() stmt = select(SharedMap).order_by(SharedMap.created_at.desc()) if args.expired: stmt = stmt.where(SharedMap.expires_at.is_not(None), SharedMap.expires_at <= now) elif args.active: stmt = stmt.where((SharedMap.expires_at.is_(None)) | (SharedMap.expires_at > now)) stmt = stmt.limit(args.limit) with SessionLocal() as session: rows = list(session.execute(stmt).scalars()) if not rows: print("Aucun partage.") return 0 header = f"{'JETON':<14} {'TITRE':<28} {'CREE':<17} {'MAJ':<17} {'EXPIRE':<17} {'NOEUDS':>7}" print(header) print("-" * len(header)) for share in rows: # `parentId` apparait exactement une fois par noeud, contrairement a # `id` que portent aussi les liens et les relations. nodes = share.map_data.count('"parentId"') title = (share.title or "(sans titre)")[:27] marker = " (expire)" if share.is_expired(now=now) else "" print( f"{_mask(share.share_token):<14} {title:<28} " f"{_format_date(share.created_at):<17} {_format_date(share.updated_at):<17} " f"{_format_date(share.expires_at) + marker:<17} {nodes:>7}" ) return 0 def cmd_shares_delete(args: argparse.Namespace) -> int: """Suppression administrative : n'utilise pas le jeton de gestion. Elle est reservee a qui a deja un acces au conteneur, donc a la base. """ with SessionLocal() as session: share = session.execute( select(SharedMap).where(SharedMap.share_token == args.share_token) ).scalar_one_or_none() if share is None: print("Aucun partage ne correspond a ce jeton.") return 1 print(f"Partage : {share.title or '(sans titre)'} ({_mask(share.share_token)})") print(f" cree le : {_format_date(share.created_at)}") print(f" expire le : {_format_date(share.expires_at)}") if not _confirm("Supprimer definitivement ce partage ?", args.yes): print("Annule.") return 1 session.delete(share) session.commit() print("1 partage supprime.") return 0 def cmd_shares_purge(args: argparse.Namespace) -> int: now = utcnow() with SessionLocal() as session: rows = list( session.execute( select(SharedMap).where( SharedMap.expires_at.is_not(None), SharedMap.expires_at <= now ) ).scalars() ) if not rows: print("0 partage(s) supprime(s) : aucun partage expire.") return 0 print(f"{len(rows)} partage(s) expire(s) :") for share in rows: print( f" {_mask(share.share_token)} {(share.title or '(sans titre)')[:40]}" f" expire le {_format_date(share.expires_at)}" ) if args.dry_run: print("Mode simulation : rien n'a ete supprime.") return 0 if not _confirm(f"Supprimer ces {len(rows)} partage(s) ?", args.yes): print("Annule.") return 1 for share in rows: session.delete(share) session.commit() print(f"{len(rows)} partage(s) supprime(s).") return 0 def _sqlite_backup(source: Path, destination: Path) -> None: """Copie coherente via l'API de sauvegarde SQLite. Un simple `cp` du fichier n'est pas sur en mode WAL : les ecritures recentes vivent dans le fichier -wal, et la copie obtenue peut etre incoherente. L'API backup, elle, produit une base complete et utilisable pendant que l'application continue de tourner. """ destination.parent.mkdir(parents=True, exist_ok=True) with ( sqlite3.connect(f"file:{source}?mode=ro", uri=True) as origin, sqlite3.connect(destination) as copy, ): origin.backup(copy) def _apply_retention(destination: Path, keep: int) -> list[Path]: """Ne garde que les `keep` sauvegardes les plus recentes du meme dossier. Le filtre est volontairement etroit : meme dossier, meme extension, et le fichier qui vient d'etre ecrit est toujours conserve. Une commande de sauvegarde ne doit pas devenir un effaceur de fichiers surprise. """ if keep <= 0: return [] others = sorted( ( path for path in destination.parent.glob(f"*{destination.suffix}") if path.is_file() and path != destination ), key=lambda path: path.stat().st_mtime, reverse=True, ) # La sauvegarde qui vient d'etre ecrite compte comme la plus recente, et # comme l'une des `keep` conservees : on ne peut donc pas finir avec keep+1. ordered = [destination, *others] removed = [] for path in ordered[keep:]: path.unlink() removed.append(path) return removed def cmd_backup(args: argparse.Namespace) -> int: source = database_path() if source is None or not source.exists(): print("Aucun fichier SQLite a sauvegarder.") return 1 destination = Path(args.destination) if destination.exists() and not args.yes: print(f"{destination} existe deja. Utilisez --yes pour l'ecraser.") return 1 _sqlite_backup(source, destination) print(f"Sauvegarde ecrite : {destination} ({_format_size(destination.stat().st_size)})") for removed in _apply_retention(destination, args.keep): print(f"Ancienne sauvegarde supprimee : {removed.name}") return 0 def cmd_restore(args: argparse.Namespace) -> int: """Restauration prudente : verification, copie de securite, puis remplacement. Le remplacement passe lui aussi par l'API backup de SQLite, qui ecrit dans la base cible sous son propre verrou plutot que d'echanger le fichier sous les pieds d'un processus qui l'a ouvert. Arreter le conteneur avant reste la facon la plus sure de proceder. """ source = Path(args.source) target = database_path() if target is None: print("La base configuree n'est pas un fichier SQLite.") return 1 if not source.exists(): print(f"Fichier introuvable : {source}") return 1 try: with sqlite3.connect(f"file:{source}?mode=ro", uri=True) as check: tables = {row[0] for row in check.execute("SELECT name FROM sqlite_master")} except sqlite3.DatabaseError: print("Ce fichier n'est pas une base SQLite lisible.") return 1 if "shared_maps" not in tables: print("Cette base ne contient pas de table shared_maps : restauration refusee.") return 1 print(f"Source : {source}") print(f"Destination : {target}") if not _confirm("Remplacer le contenu de la base actuelle ?", args.yes): print("Annule.") return 1 if target.exists(): safety = target.parent / "backups" / f"pre-restore-{utcnow():%Y%m%d-%H%M%S}.db" _sqlite_backup(target, safety) print(f"Sauvegarde de securite : {safety}") _sqlite_backup(source, target) print("Restauration terminee. Redemarrez le conteneur pour repartir proprement.") return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="python -m app.cli", description="Administration locale d'OpenMind (partages, sauvegarde).", ) sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("stats", help="Compteurs et taille de la base").set_defaults(func=cmd_stats) shares = sub.add_parser("shares", help="Gestion des partages") shares_sub = shares.add_subparsers(dest="shares_command", required=True) listing = shares_sub.add_parser("list", help="Lister les partages") listing.add_argument("--limit", type=int, default=50, help="Nombre maximum de lignes") group = listing.add_mutually_exclusive_group() group.add_argument("--expired", action="store_true", help="Uniquement les partages expires") group.add_argument("--active", action="store_true", help="Uniquement les partages actifs") listing.set_defaults(func=cmd_shares_list) delete = shares_sub.add_parser("delete", help="Supprimer un partage") delete.add_argument("share_token", help="Jeton public du partage") delete.add_argument("--yes", action="store_true", help="Ne pas demander confirmation") delete.set_defaults(func=cmd_shares_delete) purge = shares_sub.add_parser("purge-expired", help="Supprimer les partages expires") purge.add_argument("--dry-run", action="store_true", help="Afficher sans supprimer") purge.add_argument("--yes", action="store_true", help="Ne pas demander confirmation") purge.set_defaults(func=cmd_shares_purge) backup = sub.add_parser("backup", help="Sauvegarder la base pendant que l'app tourne") backup.add_argument("destination", help="Chemin du fichier de sauvegarde") backup.add_argument("--yes", action="store_true", help="Ecraser un fichier existant") backup.add_argument( "--keep", type=int, default=0, help="Ne garder que les N sauvegardes les plus recentes du dossier (0 = tout garder)", ) backup.set_defaults(func=cmd_backup) restore = sub.add_parser("restore", help="Restaurer une sauvegarde (prudent)") restore.add_argument("source", help="Fichier de sauvegarde a restaurer") restore.add_argument("--yes", action="store_true", help="Ne pas demander confirmation") restore.set_defaults(func=cmd_restore) return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) return int(args.func(args)) if __name__ == "__main__": # pragma: no cover - point d'entree raise SystemExit(main())