Add project deletion script with cascading cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 10:00:50 -07:00
parent b5f74e5ef5
commit c5d6d76d3a
2 changed files with 124 additions and 1 deletions

View File

@@ -49,7 +49,7 @@ frontend/ React + TypeScript
lib/ API client (axios), utils, constants
chart/mgmt-suite/ Helm chart for Kubernetes deployment
scripts/ Keycloak seeding (seed-keycloak.py — users, groups, memberships, sponsors)
scripts/ Keycloak seeding (seed-keycloak.py) and project deletion (delete-projects.py)
```
## Running Locally
@@ -77,6 +77,11 @@ cd backend && source .venv/bin/activate && python run.py
# Frontend
cd frontend && npm run dev # Dev server
cd frontend && npm run build # Production build (also runs tsc)
# Project deletion
python scripts/delete-projects.py KEY1 KEY2 --dry-run # Preview
python scripts/delete-projects.py KEY1 KEY2 # Delete (DB + Keycloak)
python scripts/delete-projects.py KEY1 --skip-keycloak # DB only
```
## Architecture Conventions
@@ -159,6 +164,8 @@ cd frontend && npm run build # Production build (also runs tsc)
- **Cert encryption** — Fernet key persists to `.fernet_key` file in dev, env var in production. Changing the key makes existing encrypted data unrecoverable.
- **Cert passphrase storage** — PKCS12 import passphrases optionally stored encrypted (Fernet) in `passphrase_encrypted` column. Retrieved via separate endpoint for security. PKCS12 export still without passphrase (explicit product decision).
- **Downtime Tracker** — Logs application outage events with start/end times, cause, resolution, and lessons learned. Enclave field supports multi-select (IL5, IL6) stored as comma-separated string, serialized as array in API. Scope (disabled/limited) and planned (boolean) classify the nature of outages. Work-hours classification computed client-side using `date-fns-tz` against configurable work hours settings.
- **Project deletion** — No delete API or UI (intentional). Use `scripts/delete-projects.py` for admin-only cleanup. Cascades to: user permissions, feedback, licenses, certs, billing data, KC queue items, KC sync errors, sponsored-user references, and the KC top-level group (which cascades child groups). Supports `--dry-run` and `--skip-keycloak`.
- **Project key format** — 1-10 characters: first character must be an uppercase letter, followed by uppercase letters or digits. Regex: `^[A-Z][A-Z0-9]{0,9}$`. Validated in routes, import service, and frontend form.
- **Backend replicas** — scale freely with Postgres.
## Stubs (Not Yet Implemented)

116
scripts/delete-projects.py Executable file
View File

@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Delete projects by key from the local database and enqueue KC group cleanup.
Usage:
python scripts/delete-projects.py SEED1 SEED2 SEED3
python scripts/delete-projects.py SEED1 --dry-run
python scripts/delete-projects.py SEED1 --skip-keycloak
"""
import argparse
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
from app import create_app
from app.extensions import db
from app.projects.models import Project
from app.users.models import User, UserPermission
from app.feedback.models import Feedback
from app.billing.models import BillingData
from app.licenses.models import License
from app.certs.models import Cert
from app.audit import AuditLog
from app.kc_queue_models import KcQueueItem
from app.settings.models import KcSyncError
def delete_project(key, dry_run=False, skip_kc=False):
project = Project.query.filter_by(key=key).first()
if not project:
print(f" [{key}] NOT FOUND — skipping")
return False
print(f" [{key}] \"{project.name}\" (id={project.id})")
# Gather counts for summary
perm_count = UserPermission.query.filter_by(project_key=key).count()
feedback_count = Feedback.query.filter_by(project_key=key).count()
license_count = project.licenses.count()
cert_count = project.certs.count()
billing = BillingData.query.filter_by(project_id=project.id).first()
sponsor_count = User.query.filter_by(sponsor_project_key=key).count()
queue_count = KcQueueItem.query.filter_by(project_key=key).count()
sync_err_count = KcSyncError.query.filter_by(project_key=key).count()
print(f" permissions: {perm_count}, feedback: {feedback_count}, "
f"licenses: {license_count}, certs: {cert_count}")
print(f" billing: {'yes' if billing else 'no'}, "
f"sponsored users: {sponsor_count}, "
f"kc queue items: {queue_count}, sync errors: {sync_err_count}")
if dry_run:
print(f" DRY RUN — no changes made")
return True
UserPermission.query.filter_by(project_key=key).delete()
Feedback.query.filter_by(project_key=key).delete()
License.query.filter(License.project_id == project.id).delete()
Cert.query.filter(Cert.project_id == project.id).delete()
if billing:
db.session.delete(billing)
User.query.filter_by(sponsor_project_key=key).update(
{"sponsor_project_key": None, "unsponsored_since": None}
)
KcQueueItem.query.filter_by(project_key=key).delete()
KcSyncError.query.filter_by(project_key=key).delete()
db.session.delete(project)
db.session.commit()
print(f" DELETED from database")
if not skip_kc:
try:
from app.keycloak import KeycloakAdmin
kc = KeycloakAdmin()
group_id = kc.find_group_by_name(key)
if group_id:
kc.delete_group(group_id)
print(f" DELETED KC group \"{key}\" ({group_id})")
else:
print(f" KC group \"{key}\" not found — nothing to delete")
except Exception as e:
print(f" WARNING: KC group cleanup failed: {e}")
print(f" You may need to manually remove the \"{key}\" group from Keycloak")
return True
def main():
parser = argparse.ArgumentParser(description="Delete projects by key")
parser.add_argument("keys", nargs="+", help="Project keys to delete")
parser.add_argument("--dry-run", action="store_true",
help="Show what would be deleted without making changes")
parser.add_argument("--skip-keycloak", action="store_true",
help="Skip Keycloak group deletion (DB only)")
args = parser.parse_args()
app = create_app()
with app.app_context():
print(f"{'DRY RUN — ' if args.dry_run else ''}Deleting {len(args.keys)} project(s):\n")
deleted = 0
for key in args.keys:
key = key.strip().upper()
if delete_project(key, dry_run=args.dry_run, skip_kc=args.skip_keycloak):
deleted += 1
print()
label = "would be deleted" if args.dry_run else "deleted"
print(f"Done. {deleted}/{len(args.keys)} project(s) {label}.")
if __name__ == "__main__":
main()