From 2302b2fd0cae8b5f723862e26b25acc13d27e7c7 Mon Sep 17 00:00:00 2001 From: bilalesi Date: Tue, 16 Jun 2026 16:47:53 +0200 Subject: [PATCH] add vlab manager and delete projects scripts --- .gitignore | 1 + pyproject.toml | 2 + scripts/delete_projects.py | 978 +++++++++++++++++++++++++++ scripts/seed_credit_package_rates.py | 3 +- scripts/vlab_manager.py | 711 +++++++++++++++++++ 5 files changed, 1693 insertions(+), 2 deletions(-) create mode 100644 scripts/delete_projects.py create mode 100644 scripts/vlab_manager.py diff --git a/.gitignore b/.gitignore index d31ca95b..ff741e70 100644 --- a/.gitignore +++ b/.gitignore @@ -321,3 +321,4 @@ notes.md scripts/subscription_manager/ .personal migration-out/ +deletion-out/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 35c49c18..bef6d072 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,8 @@ send_emails = "scripts.send_emails:run_async" manage-coupons = "scripts.manage_stripe_coupons:run_async" bulk-invite = "scripts.bulk_invite_to_project:run_async" migrate-tax-billing = "scripts.migrate_to_tax_billing:run" +delete-projects = "scripts.delete_projects:run" +vlab-manager = "scripts.vlab_manager:run" [dependency-groups] dev = [ diff --git a/scripts/delete_projects.py b/scripts/delete_projects.py new file mode 100644 index 00000000..1f160b94 --- /dev/null +++ b/scripts/delete_projects.py @@ -0,0 +1,978 @@ +#!/usr/bin/env python3 +""" +Bulk project deletion script (interactive, phased, dry-run by default). + +Supports two modes: + - soft (default): marks projects as deleted (reversible) + - hard: permanently removes projects AND all dependent rows (irreversible) + +Supports two selection strategies: + - delete list: provide the project IDs to delete (--project-ids) + - keep list: provide the project IDs to KEEP; everything else is deleted (--keep-ids) + +Every action is logged to ./deletion-out/ for audit trail. + +Usage +----- + # Dry-run: audit projects, show what would happen + uv run python scripts/delete_projects.py \ + --virtual-lab-id 00000000-0000-0000-0000-000000000001 \ + --project-ids "id1,id2,id3" + + # Apply soft-delete + uv run python scripts/delete_projects.py \ + --virtual-lab-id 00000000-0000-0000-0000-000000000001 \ + --project-ids "id1,id2,id3" \ + --apply + + # Keep only specific projects, delete the rest + uv run python scripts/delete_projects.py \ + --virtual-lab-id 00000000-0000-0000-0000-000000000001 \ + --keep-ids "id_to_keep_1,id_to_keep_2" \ + --apply + + # Apply hard-delete (IRREVERSIBLE — removes rows from DB entirely) + uv run python scripts/delete_projects.py \ + --virtual-lab-id 00000000-0000-0000-0000-000000000001 \ + --project-ids "id1,id2,id3" \ + --hard \ + --apply + + # Read project IDs from a file (one UUID per line) + uv run python scripts/delete_projects.py \ + --virtual-lab-id 00000000-0000-0000-0000-000000000001 \ + --project-ids-file projects_to_delete.txt \ + --hard --apply + + # Keep list from a file + uv run python scripts/delete_projects.py \ + --virtual-lab-id 00000000-0000-0000-0000-000000000001 \ + --keep-ids-file projects_to_keep.txt \ + --apply + +Environment +----------- + DATABASE_URL — async PostgreSQL URL (reads from .env.local by default) +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Literal, cast + +from dotenv import load_dotenv +from InquirerPy import inquirer +from loguru import logger +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from sqlalchemy import and_, delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + +# Ensure the project root is importable +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from virtual_labs.infrastructure.db.models import ( # noqa: E402 + Bookmark, + Project, + ProjectInvite, + ProjectStar, + UserPreference, + VirtualLab, +) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +console = Console() +logger.configure( + handlers=[{"sink": sys.stdout, "format": "[{time:HH:mm:ss}] {message}"}] +) + +OUT_DIR = Path("deletion-out") +OUT_DIR.mkdir(exist_ok=True) +RUN_TS = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + +# A fixed "system" user ID used as deleted_by when running from script. +# In production this would be the admin's KC user ID. +SCRIPT_USER_ID = uuid.UUID("481f8535-bfa4-40f2-87e2-705c299fb2ed") + + +# --------------------------------------------------------------------------- +# Config & state +# --------------------------------------------------------------------------- + + +@dataclass +class RunConfig: + database_url: str + virtual_lab_id: uuid.UUID + project_ids: list[uuid.UUID] + deleted_by: uuid.UUID + hard: bool + apply: bool + keep_mode: bool # True = project_ids are IDs to KEEP (delete the rest) + + @property + def dry_run(self) -> bool: + return not self.apply + + @property + def mode_label(self) -> str: + return "HARD-DELETE" if self.hard else "SOFT-DELETE" + + @property + def selection_label(self) -> str: + return "KEEP list (delete everything else)" if self.keep_mode else "DELETE list" + + +@dataclass +class ProjectInfo: + id: uuid.UUID + name: str + description: str | None + virtual_lab_name: str + deleted: bool + stars_count: int + bookmarks_count: int + invites_count: int + created_at: datetime + + +@dataclass +class DeletionState: + audited: list[ProjectInfo] = field(default_factory=list) + skipped: list[dict[str, Any]] = field(default_factory=list) + deleted: list[dict[str, Any]] = field(default_factory=list) + errors: list[dict[str, Any]] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# UI helpers +# --------------------------------------------------------------------------- + + +def _banner(title: str, body: str = "", style: str = "bright_blue") -> None: + console.print( + Panel(f"[bold]{title}[/]\n{body}".strip(), border_style=style, padding=(1, 2)) + ) + + +def _dump(name: str, payload: Any) -> Path: + path = OUT_DIR / f"{name}-{RUN_TS}.json" + path.write_text(json.dumps(payload, default=str, indent=2)) + logger.info(f" ↳ wrote {path}") + return path + + +async def _confirm_phase(name: str, dry_run: bool) -> Literal["yes", "skip", "abort"]: + suffix = "[DRY-RUN]" if dry_run else "[WILL WRITE]" + return cast( + Literal["yes", "skip", "abort"], + await inquirer.select( + message=f"{suffix} Proceed with {name}?", + choices=[ + {"name": "yes — execute this phase", "value": "yes"}, + {"name": "skip — move to the next phase", "value": "skip"}, + {"name": "abort — stop here", "value": "abort"}, + ], + default="yes", + ).execute_async(), + ) + + +# --------------------------------------------------------------------------- +# Phase 0 — Preflight +# --------------------------------------------------------------------------- + + +async def phase0_preflight(cfg: RunConfig) -> None: + _banner("Phase 0 — Preflight", "Validate environment + connectivity. No writes.") + + table = Table(show_header=False, padding=(0, 1)) + table.add_column("Setting", style="bold") + table.add_column("Value") + table.add_row( + "Mode", "[red]APPLY (will write)[/]" if cfg.apply else "[green]DRY-RUN[/]" + ) + table.add_row( + "Delete type", + "[red bold]HARD (irreversible, rows removed)[/]" + if cfg.hard + else "[yellow]SOFT (reversible, marked deleted)[/]", + ) + table.add_row( + "Selection", + f"[cyan]{cfg.selection_label}[/] ({len(cfg.project_ids)} IDs provided)", + ) + table.add_row("Database URL", cfg.database_url[:60] + "…") + table.add_row("Virtual Lab ID", str(cfg.virtual_lab_id)) + table.add_row("Projects to delete", str(len(cfg.project_ids))) + table.add_row("Deleted-by user", str(cfg.deleted_by)) + console.print(table) + + # DB reachability + engine = create_async_engine(cfg.database_url, echo=False) + try: + async with engine.connect() as conn: + await conn.execute(select(1)) + logger.info("✅ DB reachable") + except Exception as e: + logger.error(f"❌ DB unreachable: {e}") + raise SystemExit(2) + finally: + await engine.dispose() + + # Validate virtual lab exists + engine = create_async_engine(cfg.database_url, echo=False) + Session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + try: + async with Session() as session: + vl = ( + await session.execute( + select(VirtualLab).where(VirtualLab.id == cfg.virtual_lab_id) + ) + ).scalar_one_or_none() + if vl is None: + logger.error( + f"❌ Virtual lab {cfg.virtual_lab_id} not found in database." + ) + raise SystemExit(2) + if vl.deleted: + logger.warning( + f"⚠️ Virtual lab '{vl.name}' is already marked as deleted." + ) + else: + logger.info(f"✅ Virtual lab found: '{vl.name}'") + finally: + await engine.dispose() + + +# --------------------------------------------------------------------------- +# Phase 1 — Audit +# --------------------------------------------------------------------------- + + +async def phase1_audit(cfg: RunConfig, state: DeletionState) -> None: + _banner( + "Phase 1 — Audit", + "Read-only inventory of projects to be deleted.", + ) + + engine = create_async_engine(cfg.database_url, echo=False) + Session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + try: + async with Session() as session: + audit_table = Table( + title="Projects targeted for deletion", + header_style="bold cyan", + padding=(0, 1), + ) + for col in ( + "Project ID", + "Name", + "Already deleted?", + "Stars", + "Bookmarks", + "Invites", + "Created", + ): + audit_table.add_column(col) + + for pid in cfg.project_ids: + row = ( + await session.execute( + select(Project, VirtualLab) + .join(VirtualLab) + .where( + and_( + Project.id == pid, + Project.virtual_lab_id == cfg.virtual_lab_id, + ) + ) + ) + ).one_or_none() + + if row is None: + state.skipped.append( + {"project_id": str(pid), "reason": "not_found"} + ) + audit_table.add_row( + str(pid), "[red]NOT FOUND[/]", "—", "—", "—", "—", "—" + ) + continue + + project, vl = row.tuple() + + stars = ( + await session.execute( + select(func.count(ProjectStar.id)).where( + ProjectStar.project_id == pid + ) + ) + ).scalar() or 0 + + bookmarks = ( + await session.execute( + select(func.count(Bookmark.id)).where( + Bookmark.project_id == pid + ) + ) + ).scalar() or 0 + + invites = ( + await session.execute( + select(func.count(ProjectInvite.id)).where( + ProjectInvite.project_id == pid + ) + ) + ).scalar() or 0 + + info = ProjectInfo( + id=project.id, + name=project.name, + description=project.description, + virtual_lab_name=vl.name, + deleted=project.deleted, + stars_count=stars, + bookmarks_count=bookmarks, + invites_count=invites, + created_at=project.created_at, + ) + state.audited.append(info) + + deleted_str = "[yellow]YES[/]" if project.deleted else "no" + audit_table.add_row( + str(project.id), + project.name, + deleted_str, + str(stars), + str(bookmarks), + str(invites), + project.created_at.strftime("%Y-%m-%d"), + ) + + console.print(audit_table) + + # Summary + already_deleted = sum(1 for p in state.audited if p.deleted) + to_delete = sum(1 for p in state.audited if not p.deleted) + + summary = Table(show_header=False, padding=(0, 1)) + summary.add_column("Metric", style="bold") + summary.add_column("Value") + summary.add_row("Total requested", str(len(cfg.project_ids))) + summary.add_row("Found & active", str(to_delete)) + summary.add_row("Already deleted", str(already_deleted)) + summary.add_row("Not found / skipped", str(len(state.skipped))) + console.print(summary) + + finally: + await engine.dispose() + + _dump( + "deletion-audit", + { + "virtual_lab_id": str(cfg.virtual_lab_id), + "audited": [ + { + "id": str(p.id), + "name": p.name, + "deleted": p.deleted, + "stars": p.stars_count, + "bookmarks": p.bookmarks_count, + "invites": p.invites_count, + } + for p in state.audited + ], + "skipped": state.skipped, + }, + ) + + +# --------------------------------------------------------------------------- +# Phase 2 — Confirmation & Deletion +# --------------------------------------------------------------------------- + + +async def _soft_delete_project( + session: AsyncSession, + project: ProjectInfo, + cfg: RunConfig, + state: DeletionState, +) -> None: + """Soft-delete: set deleted=True, preserve all data.""" + result = await session.execute( + update(Project) + .where( + and_( + Project.id == project.id, + Project.virtual_lab_id == cfg.virtual_lab_id, + ~Project.deleted, + ) + ) + .values( + deleted=True, + deleted_at=func.now(), + deleted_by=cfg.deleted_by, + ) + .returning(Project.id, Project.deleted, Project.deleted_at) + ) + + if cfg.dry_run: + await session.rollback() + logger.info(f"[DRY-RUN] Would soft-delete: {project.name} ({project.id})") + state.deleted.append( + { + "project_id": str(project.id), + "name": project.name, + "mode": "soft", + "dry_run": True, + } + ) + else: + row = result.one_or_none() + if row is None: + logger.warning( + f"⚠️ {project.name} ({project.id}) — " + "no rows affected (race condition or already deleted)" + ) + state.skipped.append( + {"project_id": str(project.id), "reason": "no_rows_affected"} + ) + else: + await session.commit() + logger.info( + f"✅ Soft-deleted: {project.name} ({project.id}) at {row.deleted_at}" + ) + state.deleted.append( + { + "project_id": str(project.id), + "name": project.name, + "mode": "soft", + "deleted_at": str(row.deleted_at), + } + ) + + +async def _hard_delete_project( + session: AsyncSession, + project: ProjectInfo, + cfg: RunConfig, + state: DeletionState, +) -> None: + """Hard-delete: remove dependent rows first, then the project row. + + Deletion order (respects FK constraints): + 1. UserPreference — NULL-ify the optional project_id FK + 2. ProjectStar — delete (non-nullable FK) + 3. ProjectInvite — delete (non-nullable FK) + 4. Bookmark — delete (non-nullable FK) + 5. Project — delete the project itself + """ + pid = project.id + removed_counts: dict[str, int] = {} + + # 1. Nullify UserPreference.project_id where it points to this project + res = await session.execute( + update(UserPreference) + .where(UserPreference.project_id == pid) + .values(project_id=None) + ) + removed_counts["user_preferences_nullified"] = res.rowcount # type: ignore[assignment] + + # 2. Delete project stars + res = await session.execute( + delete(ProjectStar).where(ProjectStar.project_id == pid) + ) + removed_counts["project_stars"] = res.rowcount # type: ignore[assignment] + + # 3. Delete project invites + res = await session.execute( + delete(ProjectInvite).where(ProjectInvite.project_id == pid) + ) + removed_counts["project_invites"] = res.rowcount # type: ignore[assignment] + + # 4. Delete bookmarks + res = await session.execute(delete(Bookmark).where(Bookmark.project_id == pid)) + removed_counts["bookmarks"] = res.rowcount # type: ignore[assignment] + + # 5. Delete the project itself + res = await session.execute( + delete(Project).where( + and_( + Project.id == pid, + Project.virtual_lab_id == cfg.virtual_lab_id, + ) + ) + ) + removed_counts["project"] = res.rowcount # type: ignore[assignment] + + if cfg.dry_run: + await session.rollback() + logger.info( + f"[DRY-RUN] Would hard-delete: {project.name} ({project.id}) " + f"— cascaded: {removed_counts}" + ) + state.deleted.append( + { + "project_id": str(project.id), + "name": project.name, + "mode": "hard", + "dry_run": True, + "would_remove": removed_counts, + } + ) + else: + if removed_counts["project"] == 0: + await session.rollback() + logger.warning( + f"⚠️ {project.name} ({project.id}) — " + "project row not found during hard-delete (race condition?)" + ) + state.skipped.append( + {"project_id": str(project.id), "reason": "hard_delete_no_rows"} + ) + else: + await session.commit() + logger.info( + f"✅ Hard-deleted: {project.name} ({project.id}) " + f"— removed: {removed_counts}" + ) + state.deleted.append( + { + "project_id": str(project.id), + "name": project.name, + "mode": "hard", + "removed": removed_counts, + } + ) + + +async def phase2_delete(cfg: RunConfig, state: DeletionState) -> None: + if cfg.hard: + _banner( + "Phase 2 — HARD-DELETE projects", + "Permanently removes project rows AND all dependent data.\n" + "This is IRREVERSIBLE. Related stars, bookmarks, and invites will be gone.", + ) + else: + _banner( + "Phase 2 — Soft-delete projects", + "Sets deleted=True on each project. Data is NOT removed from disk.", + ) + + candidates = [p for p in state.audited if not p.deleted] + if not candidates: + logger.info("No active projects to delete. Nothing to do.") + return + + danger_color = "red bold" if cfg.hard else "red" + action_word = "HARD-DELETE" if cfg.hard else "soft-delete" + + console.print( + Panel( + f"[{danger_color}]About to {action_word} " + f"{len(candidates)} project(s).[/]\n" + + ( + "Rows will be PERMANENTLY REMOVED from the database. " + "This cannot be undone without a backup restore." + if cfg.hard + else "This sets deleted=True on each project. Data is NOT removed." + ), + border_style="red", + title="⚠️ Danger zone", + ) + ) + + # List them one more time for the operator + for p in candidates: + console.print(f" • {p.name} ({p.id})") + + # Extra confirmation gate for hard-delete + if cfg.hard and not cfg.dry_run: + console.print() + typed = await inquirer.text( + message=( + 'Type "HARD DELETE" (exactly) to confirm irreversible deletion, ' + "or anything else to abort:" + ), + ).execute_async() + if typed != "HARD DELETE": + logger.warning("Confirmation text did not match. Aborting.") + raise SystemExit(0) + + decision = await _confirm_phase(f"Phase 2 — {action_word}", cfg.dry_run) + if decision == "abort": + logger.warning("Aborted by operator.") + raise SystemExit(0) + if decision == "skip": + logger.info("Phase 2 skipped.") + return + + engine = create_async_engine(cfg.database_url, echo=False) + Session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + try: + async with Session() as session: + for project in candidates: + try: + if cfg.hard: + await _hard_delete_project(session, project, cfg, state) + else: + await _soft_delete_project(session, project, cfg, state) + except Exception as e: + await session.rollback() + logger.error( + f"❌ Failed to delete {project.name} ({project.id}): {e}" + ) + state.errors.append( + { + "project_id": str(project.id), + "name": project.name, + "error": str(e), + } + ) + finally: + await engine.dispose() + + +# --------------------------------------------------------------------------- +# Phase 3 — Summary +# --------------------------------------------------------------------------- + + +async def phase3_summary(cfg: RunConfig, state: DeletionState) -> None: + _banner("Phase 3 — Summary") + + summary = Table(show_header=False, padding=(0, 1)) + summary.add_column("Bucket", style="bold") + summary.add_column("Count") + summary.add_row("Requested", str(len(cfg.project_ids))) + summary.add_row("Deleted", str(len(state.deleted))) + summary.add_row("Skipped (already deleted / not found)", str(len(state.skipped))) + summary.add_row("Errors", str(len(state.errors))) + console.print(summary) + + payload = { + "config": { + "apply": cfg.apply, + "mode": cfg.mode_label, + "virtual_lab_id": str(cfg.virtual_lab_id), + "deleted_by": str(cfg.deleted_by), + "project_ids": [str(p) for p in cfg.project_ids], + }, + "deleted": state.deleted, + "skipped": state.skipped, + "errors": state.errors, + } + _dump("deletion-summary", payload) + + if state.errors: + console.print( + Panel( + f"[bold red]{len(state.errors)} project(s) failed to delete.[/]\n" + "Check the deletion-summary JSON for details.", + border_style="red", + ) + ) + + console.print( + f"[dim]All artefacts written under {OUT_DIR}/. Keep these as the audit log.[/]" + ) + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- + + +def _parse_project_ids(raw: str) -> list[uuid.UUID]: + """Parse comma-separated or newline-separated UUIDs.""" + ids: list[uuid.UUID] = [] + for token in raw.replace(",", "\n").splitlines(): + token = token.strip() + if not token or token.startswith("#"): + continue + try: + ids.append(uuid.UUID(token)) + except ValueError: + logger.error(f"Invalid UUID: '{token}'") + raise SystemExit(2) + return ids + + +def _parse_args() -> RunConfig: + load_dotenv(".env.local") + + parser = argparse.ArgumentParser( + description="Bulk delete projects from a virtual lab (soft or hard)." + ) + parser.add_argument( + "--virtual-lab-id", + required=True, + help="UUID of the virtual lab containing the projects.", + ) + parser.add_argument( + "--project-ids", + default=None, + help="Comma-separated list of project UUIDs to delete.", + ) + parser.add_argument( + "--project-ids-file", + default=None, + help="Path to a file with one project UUID per line (projects to delete).", + ) + parser.add_argument( + "--keep-ids", + default=None, + help="Comma-separated list of project UUIDs to KEEP. " + "All other projects in the VL will be deleted.", + ) + parser.add_argument( + "--keep-ids-file", + default=None, + help="Path to a file with one project UUID per line (projects to keep).", + ) + parser.add_argument( + "--deleted-by", + default=None, + help="UUID of the user performing the deletion (default: system zero-UUID).", + ) + parser.add_argument( + "--hard", + action="store_true", + help="IRREVERSIBLE hard-delete: removes project rows and all dependent data " + "from the database entirely. Without this flag, soft-delete is used.", + ) + parser.add_argument( + "--apply", + action="store_true", + help="Actually write to the database. Without this flag the script is read-only.", + ) + args = parser.parse_args() + + # Parse virtual lab ID + try: + virtual_lab_id = uuid.UUID(args.virtual_lab_id) + except ValueError: + logger.error(f"Invalid virtual-lab-id: '{args.virtual_lab_id}'") + raise SystemExit(2) + + # Parse project IDs — either "delete" list or "keep" list + has_delete = args.project_ids or args.project_ids_file + has_keep = args.keep_ids or args.keep_ids_file + + if has_delete and has_keep: + logger.error( + "Provide either delete IDs (--project-ids/--project-ids-file) " + "OR keep IDs (--keep-ids/--keep-ids-file), not both." + ) + raise SystemExit(2) + if not has_delete and not has_keep: + logger.error( + "Provide --project-ids, --project-ids-file, --keep-ids, or --keep-ids-file." + ) + raise SystemExit(2) + + keep_mode = bool(has_keep) + + if has_keep: + # Parse keep IDs + if args.keep_ids and args.keep_ids_file: + logger.error("Provide either --keep-ids or --keep-ids-file, not both.") + raise SystemExit(2) + if args.keep_ids_file: + file_path = Path(args.keep_ids_file) + if not file_path.exists(): + logger.error(f"File not found: {file_path}") + raise SystemExit(2) + project_ids = _parse_project_ids(file_path.read_text()) + else: + project_ids = _parse_project_ids(args.keep_ids) + else: + # Parse delete IDs + if args.project_ids and args.project_ids_file: + logger.error( + "Provide either --project-ids or --project-ids-file, not both." + ) + raise SystemExit(2) + if args.project_ids_file: + file_path = Path(args.project_ids_file) + if not file_path.exists(): + logger.error(f"File not found: {file_path}") + raise SystemExit(2) + project_ids = _parse_project_ids(file_path.read_text()) + else: + project_ids = _parse_project_ids(args.project_ids) + + if not project_ids: + logger.error("No project IDs provided.") + raise SystemExit(2) + + # Deleted-by user + deleted_by = SCRIPT_USER_ID + if args.deleted_by: + try: + deleted_by = uuid.UUID(args.deleted_by) + except ValueError: + logger.error(f"Invalid deleted-by UUID: '{args.deleted_by}'") + raise SystemExit(2) + + database_url = os.getenv("DATABASE_URL") or os.getenv( + "DATABASE_URI", + "postgresql+asyncpg://user:pass@host:port/db_name", + ) + + return RunConfig( + database_url=database_url, + virtual_lab_id=virtual_lab_id, + project_ids=project_ids, + deleted_by=deleted_by, + hard=bool(args.hard), + apply=bool(args.apply), + keep_mode=keep_mode, + ) + + +async def _resolve_keep_mode(cfg: RunConfig) -> RunConfig: + """Convert a keep-list into a delete-list by querying all active projects in the VL.""" + engine = create_async_engine(cfg.database_url, echo=False) + Session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + try: + async with Session() as session: + all_projects = ( + await session.execute( + select(Project.id, Project.name).where( + and_( + Project.virtual_lab_id == cfg.virtual_lab_id, + ~Project.deleted, + ) + ) + ) + ).all() + + all_ids = {row.id for row in all_projects} + keep_ids = set(cfg.project_ids) + + # Warn about keep IDs that don't exist in this VL + unknown_keep = keep_ids - all_ids + if unknown_keep: + logger.warning( + f"⚠️ {len(unknown_keep)} keep-ID(s) not found in this VL " + f"(will be ignored): {[str(u) for u in unknown_keep]}" + ) + + delete_ids = sorted(all_ids - keep_ids) + + if not delete_ids: + logger.info("All projects are in the keep list. Nothing to delete.") + raise SystemExit(0) + + # Show the resolution + console.print( + Panel( + f"[bold]Keep-mode resolution:[/]\n" + f" Total active projects in VL: {len(all_ids)}\n" + f" Projects to KEEP: {len(keep_ids & all_ids)}\n" + f" Projects to DELETE: [red]{len(delete_ids)}[/]", + border_style="yellow", + title="Selection strategy", + ) + ) + + # Show which projects will be kept + kept_names = [ + f" ✓ {row.name} ({row.id})" + for row in all_projects + if row.id in keep_ids + ] + if kept_names: + console.print("\n [green]Projects that will be KEPT:[/]") + for line in kept_names: + console.print(line) + + # Show which projects will be deleted + delete_names = [ + f" ✗ {row.name} ({row.id})" + for row in all_projects + if row.id in set(delete_ids) + ] + if delete_names: + console.print("\n [red]Projects that will be DELETED:[/]") + for line in delete_names: + console.print(line) + + console.print() + + finally: + await engine.dispose() + + # Return a new config with resolved delete list and keep_mode=False + return RunConfig( + database_url=cfg.database_url, + virtual_lab_id=cfg.virtual_lab_id, + project_ids=list(delete_ids), + deleted_by=cfg.deleted_by, + hard=cfg.hard, + apply=cfg.apply, + keep_mode=False, + ) + + +async def _amain(cfg: RunConfig) -> int: + state = DeletionState() + + # Phase 0 + await phase0_preflight(cfg) + + # If keep_mode, resolve the keep list into a delete list + if cfg.keep_mode: + cfg = await _resolve_keep_mode(cfg) + + _banner( + "Ready", + f"Preflight passed. {len(cfg.project_ids)} project(s) targeted.\n" + "Press Ctrl-C to abort at any time.", + style="green", + ) + if not await inquirer.confirm( + message="Continue to audit?", default=True + ).execute_async(): + return 0 + + # Phase 1 + await phase1_audit(cfg, state) + candidates = [p for p in state.audited if not p.deleted] + if not candidates: + logger.info("Nothing to delete. Exiting.") + return 0 + + # Phase 2 + await phase2_delete(cfg, state) + + # Phase 3 + await phase3_summary(cfg, state) + + return 1 if state.errors else 0 + + +def run() -> int: + cfg = _parse_args() + return asyncio.run(_amain(cfg)) + + +if __name__ == "__main__": + sys.exit(run()) diff --git a/scripts/seed_credit_package_rates.py b/scripts/seed_credit_package_rates.py index 7ff04916..77abab46 100644 --- a/scripts/seed_credit_package_rates.py +++ b/scripts/seed_credit_package_rates.py @@ -172,9 +172,8 @@ def main() -> None: database_url = os.getenv("DATABASE_URL") or os.getenv( "DATABASE_URI", - "postgresql+asyncpg://vlm:vlm@localhost:15432/vlm", + "postgresql+asyncpg://user:pass@host:port/db_name", ) - tiers = CHF_FLAT_TIER if args.flat else CHF_VOLUME_TIERS asyncio.run( diff --git a/scripts/vlab_manager.py b/scripts/vlab_manager.py new file mode 100644 index 00000000..90a0b793 --- /dev/null +++ b/scripts/vlab_manager.py @@ -0,0 +1,711 @@ +#!/usr/bin/env python3 +""" +Virtual Lab manager — interactive read-only inspector. + +Displays detailed information about a virtual lab including its +projects, users (from DB relationships), subscriptions, payments, +invites, and promotion code usage. + +Usage +----- + # Interactive menu (select what to view) + uv run python scripts/vlab_manager.py \ + --virtual-lab-id 00000000-0000-0000-0000-000000000001 + + # Show everything at once + uv run python scripts/vlab_manager.py \ + --virtual-lab-id 00000000-0000-0000-0000-000000000001 \ + --all + + # Show specific sections + uv run python scripts/vlab_manager.py \ + --virtual-lab-id ... --projects --subscriptions + +Environment +----------- + DATABASE_URL — async PostgreSQL URL (reads from .env.local by default) +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +import uuid +from dataclasses import dataclass +from datetime import datetime + +from dotenv import load_dotenv +from InquirerPy import inquirer +from loguru import logger +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + +# Ensure the project root is importable +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from virtual_labs.infrastructure.db.models import ( # noqa: E402 + PaymentMethod, + Project, + PromotionCode, + PromotionCodeUsage, + Subscription, + SubscriptionPayment, + VirtualLab, + VirtualLabInvite, +) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +console = Console() +logger.configure( + handlers=[{"sink": sys.stdout, "format": "[{time:HH:mm:ss}] {message}"}] +) + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + + +@dataclass +class RunConfig: + database_url: str + virtual_lab_id: uuid.UUID + show_projects: bool + show_users: bool + show_subscriptions: bool + show_payments: bool + show_invites: bool + show_promotions: bool + + @property + def show_all(self) -> bool: + return all( + [ + self.show_projects, + self.show_users, + self.show_subscriptions, + self.show_payments, + self.show_invites, + self.show_promotions, + ] + ) + + +# --------------------------------------------------------------------------- +# UI helpers +# --------------------------------------------------------------------------- + + +def _banner(title: str, body: str = "", style: str = "bright_blue") -> None: + console.print( + Panel(f"[bold]{title}[/]\n{body}".strip(), border_style=style, padding=(1, 2)) + ) + + +def _fmt_dt(dt: datetime | None) -> str: + if dt is None: + return "—" + return dt.strftime("%Y-%m-%d %H:%M") + + +def _fmt_bool(val: bool | None) -> str: + if val is None: + return "—" + return "✓" if val else "✗" + + +def _fmt_cents(cents: int | None, currency: str = "CHF") -> str: + if cents is None: + return "—" + return f"{cents / 100:.2f} {currency.upper()}" + + +# --------------------------------------------------------------------------- +# Display: Virtual Lab Overview +# --------------------------------------------------------------------------- + + +async def show_overview(session: AsyncSession, vl: VirtualLab) -> None: + _banner("Virtual Lab Overview") + + table = Table(show_header=False, padding=(0, 1)) + table.add_column("Field", style="bold") + table.add_column("Value") + table.add_row("ID", str(vl.id)) + table.add_row("Name", vl.name) + table.add_row("Description", vl.description or "—") + table.add_row("Entity", vl.entity) + table.add_row("Email", vl.reference_email or "—") + table.add_row("Email verified", _fmt_bool(vl.email_verified)) + table.add_row( + "Compute cell", str(vl.compute_cell.value) if vl.compute_cell else "—" + ) + table.add_row("Owner ID", str(vl.owner_id)) + table.add_row("Admin group", vl.admin_group_id) + table.add_row("Member group", vl.member_group_id) + table.add_row("Deleted", _fmt_bool(vl.deleted)) + table.add_row("Created", _fmt_dt(vl.created_at)) + table.add_row("Updated", _fmt_dt(vl.updated_at)) + console.print(table) + + +# --------------------------------------------------------------------------- +# Display: Projects +# --------------------------------------------------------------------------- + + +async def show_projects(session: AsyncSession, vl_id: uuid.UUID) -> None: + _banner("Projects") + + projects = ( + ( + await session.execute( + select(Project) + .where(Project.virtual_lab_id == vl_id) + .order_by(Project.created_at) + ) + ) + .scalars() + .all() + ) + + if not projects: + console.print(" [dim]No projects found.[/]") + return + + table = Table(header_style="bold cyan", padding=(0, 1)) + for col in ("ID", "Name", "Deleted", "Owner", "Created"): + table.add_column(col) + + for p in projects: + table.add_row( + str(p.id), + p.name, + _fmt_bool(p.deleted), + str(p.owner_id), + _fmt_dt(p.created_at), + ) + + console.print(table) + console.print(f" [dim]Total: {len(projects)}[/]") + + +# --------------------------------------------------------------------------- +# Display: Users (from DB relationships — subscriptions, project owners) +# --------------------------------------------------------------------------- + + +async def show_users(session: AsyncSession, vl_id: uuid.UUID) -> None: + _banner( + "Users", + "Users derived from DB relationships (project owners, subscribers, invitees).\n" + "Full membership is managed in Keycloak groups.", + ) + + # Collect user IDs from multiple sources + user_sources: dict[uuid.UUID, set[str]] = {} + + def _add(uid: uuid.UUID, source: str) -> None: + user_sources.setdefault(uid, set()).add(source) + + # VL owner + vl = ( + await session.execute(select(VirtualLab).where(VirtualLab.id == vl_id)) + ).scalar_one() + _add(vl.owner_id, "vl_owner") + + # Project owners + project_owners = ( + ( + await session.execute( + select(Project.owner_id) + .where(and_(Project.virtual_lab_id == vl_id, ~Project.deleted)) + .distinct() + ) + ) + .scalars() + .all() + ) + for uid in project_owners: + _add(uid, "project_owner") + + # Subscribers + subscribers = ( + ( + await session.execute( + select(Subscription.user_id) + .where(Subscription.virtual_lab_id == vl_id) + .distinct() + ) + ) + .scalars() + .all() + ) + for uid in subscribers: + _add(uid, "subscriber") + + # Invitees (accepted) + accepted_invites = ( + ( + await session.execute( + select(VirtualLabInvite.user_id) + .where( + and_( + VirtualLabInvite.virtual_lab_id == vl_id, + VirtualLabInvite.accepted.is_(True), + VirtualLabInvite.user_id.isnot(None), + ) + ) + .distinct() + ) + ) + .scalars() + .all() + ) + for uid in accepted_invites: + if uid is not None: + _add(uid, "invite_accepted") + + if not user_sources: + console.print(" [dim]No users found.[/]") + return + + table = Table(header_style="bold cyan", padding=(0, 1)) + table.add_column("User ID") + table.add_column("Roles / Sources") + + for uid, sources in sorted(user_sources.items(), key=lambda x: str(x[0])): + table.add_row(str(uid), ", ".join(sorted(sources))) + + console.print(table) + console.print(f" [dim]Total unique users: {len(user_sources)}[/]") + + +# --------------------------------------------------------------------------- +# Display: Subscriptions +# --------------------------------------------------------------------------- + + +async def show_subscriptions(session: AsyncSession, vl_id: uuid.UUID) -> None: + _banner("Subscriptions") + + subs = ( + ( + await session.execute( + select(Subscription) + .where(Subscription.virtual_lab_id == vl_id) + .order_by(Subscription.created_at.desc()) + ) + ) + .scalars() + .all() + ) + + if not subs: + console.print(" [dim]No subscriptions found.[/]") + return + + table = Table(header_style="bold cyan", padding=(0, 1)) + for col in ("ID", "Type", "Status", "User", "Period start", "Period end", "Source"): + table.add_column(col) + + for s in subs: + table.add_row( + str(s.id)[:8] + "…", + s.subscription_type or s.type or "—", + str(s.status.value) if s.status else "—", + str(s.user_id)[:8] + "…", + _fmt_dt(s.current_period_start), + _fmt_dt(s.current_period_end), + str(s.source.value) if s.source else "—", + ) + + console.print(table) + console.print(f" [dim]Total: {len(subs)}[/]") + + +# --------------------------------------------------------------------------- +# Display: Payments +# --------------------------------------------------------------------------- + + +async def show_payments(session: AsyncSession, vl_id: uuid.UUID) -> None: + _banner("Payments") + + payments = ( + ( + await session.execute( + select(SubscriptionPayment) + .where(SubscriptionPayment.virtual_lab_id == vl_id) + .order_by(SubscriptionPayment.payment_date.desc()) + ) + ) + .scalars() + .all() + ) + + # Also show payment methods + methods = ( + ( + await session.execute( + select(PaymentMethod).where(PaymentMethod.virtual_lab_id == vl_id) + ) + ) + .scalars() + .all() + ) + + if methods: + console.print("\n [bold]Payment Methods:[/]") + pm_table = Table(header_style="bold cyan", padding=(0, 1)) + for col in ("ID", "Brand", "Last 4", "Cardholder", "Default", "Expires"): + pm_table.add_column(col) + + for m in methods: + pm_table.add_row( + str(m.id)[:8] + "…", + m.brand, + m.card_number, + m.cardholder_name, + _fmt_bool(m.default), + m.expire_at or "—", + ) + console.print(pm_table) + + if not payments: + console.print(" [dim]No payments found.[/]") + return + + console.print("\n [bold]Payment History:[/]") + table = Table(header_style="bold cyan", padding=(0, 1)) + for col in ("Date", "Amount", "Status", "Card", "Invoice", "Standalone"): + table.add_column(col) + + for p in payments: + table.add_row( + _fmt_dt(p.payment_date), + _fmt_cents(p.amount_paid, p.currency), + str(p.status.value) if p.status else "—", + f"{p.card_brand} •{p.card_last4}", + p.stripe_invoice_id or "—", + _fmt_bool(p.standalone), + ) + + console.print(table) + console.print(f" [dim]Total payments: {len(payments)}[/]") + + +# --------------------------------------------------------------------------- +# Display: Invites +# --------------------------------------------------------------------------- + + +async def show_invites(session: AsyncSession, vl_id: uuid.UUID) -> None: + _banner("Invites") + + invites = ( + ( + await session.execute( + select(VirtualLabInvite) + .where(VirtualLabInvite.virtual_lab_id == vl_id) + .order_by(VirtualLabInvite.created_at.desc()) + ) + ) + .scalars() + .all() + ) + + if not invites: + console.print(" [dim]No invites found.[/]") + return + + table = Table(header_style="bold cyan", padding=(0, 1)) + for col in ("Email", "Role", "Accepted", "Inviter", "User ID", "Created"): + table.add_column(col) + + for inv in invites: + table.add_row( + inv.user_email, + inv.role, + _fmt_bool(inv.accepted), + str(inv.inviter_id)[:8] + "…", + str(inv.user_id)[:8] + "…" if inv.user_id else "—", + _fmt_dt(inv.created_at), + ) + + console.print(table) + + # Summary counts + total = len(invites) + accepted = sum(1 for i in invites if i.accepted is True) + pending = sum(1 for i in invites if i.accepted is None or i.accepted is False) + console.print( + f" [dim]Total: {total} | Accepted: {accepted} | Pending: {pending}[/]" + ) + + +# --------------------------------------------------------------------------- +# Display: Promotion Codes +# --------------------------------------------------------------------------- + + +async def show_promotions(session: AsyncSession, vl_id: uuid.UUID) -> None: + _banner("Promotion Codes & Usage") + + # Show promotion code usages for this VL + usages = ( + await session.execute( + select(PromotionCodeUsage, PromotionCode) + .join( + PromotionCode, PromotionCodeUsage.promotion_code_id == PromotionCode.id + ) + .where(PromotionCodeUsage.virtual_lab_id == vl_id) + .order_by(PromotionCodeUsage.redeemed_at.desc()) + ) + ).all() + + if not usages: + console.print(" [dim]No promotion code usage found for this virtual lab.[/]") + else: + table = Table(header_style="bold cyan", padding=(0, 1)) + for col in ("Code", "Credits", "User", "Status", "Redeemed at"): + table.add_column(col) + + for usage_row in usages: + usage, promo = usage_row.tuple() + table.add_row( + promo.code, + str(usage.credits_granted), + str(usage.user_id)[:8] + "…", + str(usage.status.value) if usage.status else "—", + _fmt_dt(usage.redeemed_at), + ) + + console.print(table) + console.print(f" [dim]Total usages for this VL: {len(usages)}[/]") + + # Also show all available active promo codes (global, not VL-specific) + console.print("\n [bold]All active promotion codes (global):[/]") + active_promos = ( + ( + await session.execute( + select(PromotionCode) + .where(PromotionCode.active.is_(True)) + .order_by(PromotionCode.valid_from.desc()) + ) + ) + .scalars() + .all() + ) + + if not active_promos: + console.print(" [dim]No active promotion codes.[/]") + return + + promo_table = Table(header_style="bold cyan", padding=(0, 1)) + for col in ("Code", "Credits", "Max uses", "Used", "Valid from", "Valid until"): + promo_table.add_column(col) + + for pc in active_promos: + promo_table.add_row( + pc.code, + str(int(pc.credits_amount)), + str(pc.max_total_uses) if pc.max_total_uses else "∞", + str(pc.current_total_uses), + _fmt_dt(pc.valid_from), + _fmt_dt(pc.valid_until), + ) + + console.print(promo_table) + + +# --------------------------------------------------------------------------- +# Interactive menu +# --------------------------------------------------------------------------- + +SECTION_CHOICES = [ + {"name": "Projects", "value": "projects"}, + {"name": "Users", "value": "users"}, + {"name": "Subscriptions", "value": "subscriptions"}, + {"name": "Payments & Payment Methods", "value": "payments"}, + {"name": "Invites", "value": "invites"}, + {"name": "Promotion Codes", "value": "promotions"}, + {"name": "— Show all —", "value": "all"}, + {"name": "— Exit —", "value": "exit"}, +] + + +async def _run_section(section: str, session: AsyncSession, vl_id: uuid.UUID) -> None: + match section: + case "projects": + await show_projects(session, vl_id) + case "users": + await show_users(session, vl_id) + case "subscriptions": + await show_subscriptions(session, vl_id) + case "payments": + await show_payments(session, vl_id) + case "invites": + await show_invites(session, vl_id) + case "promotions": + await show_promotions(session, vl_id) + + +async def _run_all(session: AsyncSession, vl_id: uuid.UUID) -> None: + for section in ( + "projects", + "users", + "subscriptions", + "payments", + "invites", + "promotions", + ): + await _run_section(section, session, vl_id) + console.print() + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- + + +def _parse_args() -> RunConfig: + load_dotenv(".env.local") + + parser = argparse.ArgumentParser( + description="Interactive virtual lab manager — inspect lab data." + ) + parser.add_argument( + "--virtual-lab-id", + required=True, + help="UUID of the virtual lab to inspect.", + ) + parser.add_argument("--all", action="store_true", help="Show all sections at once.") + parser.add_argument("--projects", action="store_true", help="Show projects.") + parser.add_argument("--users", action="store_true", help="Show users.") + parser.add_argument( + "--subscriptions", action="store_true", help="Show subscriptions." + ) + parser.add_argument("--payments", action="store_true", help="Show payments.") + parser.add_argument("--invites", action="store_true", help="Show invites.") + parser.add_argument( + "--promotions", action="store_true", help="Show promotion codes." + ) + args = parser.parse_args() + + try: + virtual_lab_id = uuid.UUID(args.virtual_lab_id) + except ValueError: + logger.error(f"Invalid virtual-lab-id: '{args.virtual_lab_id}'") + raise SystemExit(2) + + # If --all or no specific section flags, we'll use interactive mode + if args.all: + pass # all flags already set above via `args.all or args.X` + + database_url = os.getenv("DATABASE_URL") or os.getenv( + "DATABASE_URI", + "postgresql+asyncpg://user:pass@host:port/db_name", + ) + + return RunConfig( + database_url=database_url, + virtual_lab_id=virtual_lab_id, + show_projects=args.all or args.projects, + show_users=args.all or args.users, + show_subscriptions=args.all or args.subscriptions, + show_payments=args.all or args.payments, + show_invites=args.all or args.invites, + show_promotions=args.all or args.promotions, + ) + + +async def _amain(cfg: RunConfig) -> int: + engine = create_async_engine(cfg.database_url, echo=False) + Session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + try: + async with Session() as session: + # Validate VL exists + vl = ( + await session.execute( + select(VirtualLab).where(VirtualLab.id == cfg.virtual_lab_id) + ) + ).scalar_one_or_none() + + if vl is None: + logger.error(f"❌ Virtual lab {cfg.virtual_lab_id} not found.") + return 2 + + await show_overview(session, vl) + + # If specific sections requested via CLI flags, show them + if cfg.show_all: + await _run_all(session, cfg.virtual_lab_id) + return 0 + + any_flag = any( + [ + cfg.show_projects, + cfg.show_users, + cfg.show_subscriptions, + cfg.show_payments, + cfg.show_invites, + cfg.show_promotions, + ] + ) + + if any_flag: + if cfg.show_projects: + await show_projects(session, cfg.virtual_lab_id) + if cfg.show_users: + await show_users(session, cfg.virtual_lab_id) + if cfg.show_subscriptions: + await show_subscriptions(session, cfg.virtual_lab_id) + if cfg.show_payments: + await show_payments(session, cfg.virtual_lab_id) + if cfg.show_invites: + await show_invites(session, cfg.virtual_lab_id) + if cfg.show_promotions: + await show_promotions(session, cfg.virtual_lab_id) + return 0 + + # Interactive mode — loop until user exits + while True: + choice = await inquirer.select( + message="What would you like to view?", + choices=SECTION_CHOICES, + default="projects", + ).execute_async() + + if choice == "exit": + break + elif choice == "all": + await _run_all(session, cfg.virtual_lab_id) + else: + await _run_section(choice, session, cfg.virtual_lab_id) + + console.print() + + finally: + await engine.dispose() + + return 0 + + +def run() -> int: + cfg = _parse_args() + return asyncio.run(_amain(cfg)) + + +if __name__ == "__main__": + sys.exit(run())