Add project deletion script with cascading cleanup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
116
scripts/delete-projects.py
Executable 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()
|
||||
Reference in New Issue
Block a user