refactor(admin): move the owned-apps delete guard behind the service door

The user-delete guard 79a78c3c added queried Application straight from
the controller, tripping the API controller boundary ratchet. The count
now lives in application_restore.applications_owned_by — the module
that owns Application's recycle-bin semantics and documents why
tombstoned rows block too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Fable 5 committed 2026-08-31 21:04:21 -04:00
1 parent 27e6c0ba9a
commit cc0597bf28
2 files changed
+15 -6

No files matched your search

+4 -6
View File
@@ -216,12 +216,10 @@ def delete_user(user_id):
if admin_count <= 1:
return jsonify({'error': 'Cannot delete the last admin'}), 400
# Applications never ride a user delete: their user_id is NOT NULL, but the
# rows own containers and volumes that must go through app delete + Recycle
# Bin purge — never a silent cascade. Unfiltered on purpose: a tombstoned
# app still holds the FK, so it blocks too.
from app.models import Application
owned = Application.query.filter_by(user_id=user.id).count()
# Applications never ride a user delete — see applications_owned_by for
# why tombstoned rows block too.
from app.services.application_restore import applications_owned_by
owned = applications_owned_by(user.id)
if owned:
return jsonify({'error': f'User still owns {owned} application(s), '
'including any in the Recycle Bin — delete or '
@@ -33,6 +33,17 @@ from app.services.cron_service import CronService
logger = logging.getLogger(__name__)
def applications_owned_by(user_id):
"""Count of Application rows a user owns — live AND tombstoned.
Applications never ride a user delete: their user_id is NOT NULL, but the
rows own containers and volumes that must go through app delete + Recycle
Bin purge — never a silent cascade. Unfiltered on purpose: a tombstoned
app still holds the FK, so it blocks too.
"""
return Application.query.filter_by(user_id=user_id).count()
def _live_name_clash(app):
"""Another LIVE app already using this one's name, if any."""
return (Application.query_active()