fix: Prevent admin user-delete 500 via soft-delete fallback

This commit is contained in:
2025-12-21 17:18:31 +01:00
parent f1cb360e4f
commit bf579b93e6
2 changed files with 54 additions and 7 deletions

View File

@ -662,15 +662,29 @@ async def delete_user(
db: Database,
admin: User = Depends(require_admin),
):
"""Delete a user and all their data."""
"""
Delete a user and all their data.
Production-hardening:
- Prefer hard-delete (keeps DB tidy).
- If hard-delete is blocked by FK constraints, fall back to a safe deactivation
(soft-delete) so the admin UI never hits a 500.
"""
from app.models.blog import BlogPost
from app.models.admin_log import AdminActivityLog
from app.services.auth import AuthService
from sqlalchemy.exc import IntegrityError
import secrets
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Safety rails
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete your own admin account")
if user.is_admin:
raise HTTPException(status_code=400, detail="Cannot delete admin user")
@ -687,17 +701,47 @@ async def delete_user(
AdminActivityLog.__table__.delete().where(AdminActivityLog.admin_id == user_id)
)
# Now delete the user (cascades to domains, subscriptions, portfolio, price_alerts)
await db.delete(user)
await db.commit()
# Now delete the user (cascades to domains, subscriptions, portfolio, listings, alerts, etc.)
# If FK constraints block the delete (e.g., some rows reference users.id without cascade),
# we fall back to a safe soft-delete.
try:
await db.delete(user)
await db.commit()
deleted_mode = "hard"
except IntegrityError:
await db.rollback()
# Soft delete: disable account + remove auth factors so the user can never log in again.
# (We keep the row to satisfy FK constraints elsewhere.)
user.is_active = False
user.is_verified = False
user.hashed_password = AuthService.hash_password(secrets.token_urlsafe(32))
user.stripe_customer_id = None
user.password_reset_token = None
user.password_reset_expires = None
user.email_verification_token = None
user.email_verification_expires = None
user.oauth_provider = None
user.oauth_id = None
user.oauth_avatar = None
user.last_login = None
await db.commit()
deleted_mode = "soft"
# Log this action
await log_admin_activity(
db, admin.id, "user_delete",
f"Deleted user {user_email} and all their data"
f"Deleted user {user_email} (mode={deleted_mode})"
)
return {"message": f"User {user_email} and all their data have been deleted"}
if deleted_mode == "hard":
return {"message": f"User {user_email} and all their data have been deleted"}
return {
"message": f"User {user_email} has been deactivated (soft delete) due to existing references",
"mode": "soft",
}
@router.post("/users/{user_id}/upgrade")

View File

@ -64,7 +64,10 @@ class User(Base):
"PortfolioDomain", back_populates="user", cascade="all, delete-orphan"
)
price_alerts: Mapped[List["PriceAlert"]] = relationship(
"PriceAlert", cascade="all, delete-orphan", passive_deletes=True
# NOTE:
# We do not rely on DB-level ON DELETE CASCADE for this FK (it is not declared in the model),
# so we must not set passive_deletes=True. Otherwise deleting a user can fail with FK violations.
"PriceAlert", cascade="all, delete-orphan"
)
# For Sale Marketplace
listings: Mapped[List["DomainListing"]] = relationship(