Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/controllers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def register_blueprints(app):
from app.controllers.rules import rules_bp
from app.controllers.password import password_bp
from app.controllers.password_reset import password_reset_bp
from app.controllers.account_deletion import account_deletion_bp
from app.controllers.static import static_bp
from app.controllers.error import error_bp

Expand All @@ -38,5 +39,6 @@ def register_blueprints(app):
app.register_blueprint(rules_bp)
app.register_blueprint(password_bp)
app.register_blueprint(password_reset_bp)
app.register_blueprint(account_deletion_bp)
app.register_blueprint(static_bp)
app.register_blueprint(error_bp)
91 changes: 91 additions & 0 deletions app/controllers/account_deletion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""
Account deletion controller - let a logged-in user request deletion of their
own account. The request lands in a reviewer queue; an admin approves it, which
runs the actual deletion and emails the user.
"""

from flask import Blueprint, render_template, request, session
from app.models.user import user_by_name
from app.models.account_deletion_request import (
account_deletion_request_create,
pending_account_deletion_request_by_user,
)
from app.services.passhash import match_string
from app.services.limiter import limit
from app.controllers.decorators import login_required

account_deletion_bp = Blueprint('account_deletion', __name__)


@account_deletion_bp.route('/delete-account', methods=['GET'])
@login_required
def delete_account_get():
"""Display the account deletion request form."""
username = session.get('name')

already_pending = False
try:
already_pending = bool(
pending_account_deletion_request_by_user(username)
)
except Exception as e:
print(f"Error checking pending deletion request: {e}")

return render_template('user/delete-account.html',
already_pending=already_pending,
message=None,
submitted=False)


@account_deletion_bp.route('/delete-account', methods=['POST'])
@login_required
@limit("5 per hour", key_func=lambda: session.get('name'))
def delete_account_post():
"""Create a pending account deletion request for the current user."""
username = session.get('name')
if not username:
return 'User not logged in or session invalid', 401

password = request.form.get('password', '')
note = request.form.get('note', '').strip()

try:
user = user_by_name(username)
except Exception as e:
print(f"Error: User not found: {e}")
return render_template('user/delete-account.html',
already_pending=False,
message="User not found.",
submitted=False)

# Require the current password to confirm the request. This prevents an
# unattended/hijacked session from deleting the account.
if not password or not match_string(user['password'], password):
return render_template('user/delete-account.html',
already_pending=False,
message="Password is incorrect.",
submitted=False)

# Don't stack duplicate pending requests for the same user.
try:
if pending_account_deletion_request_by_user(username):
return render_template('user/delete-account.html',
already_pending=True,
message=None,
submitted=False)
except Exception as e:
print(f"Error checking pending deletion request: {e}")

try:
account_deletion_request_create(username, user.get('email', ''), note)
except Exception as e:
print(f"Error creating deletion request: {e}")
return render_template('user/delete-account.html',
already_pending=False,
message="Could not submit your request. Please try again later.",
submitted=False)

return render_template('user/delete-account.html',
already_pending=True,
message=None,
submitted=True)
121 changes: 121 additions & 0 deletions app/models/account_deletion_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""
Account deletion request model.

A logged-in user can request that their own account be deleted. Requests land
in a pending queue that reviewers approve or reject; approving a request runs the
full account deletion and emails the (now former) user. The requester's email is
denormalized onto the request so the confirmation email can still be sent after
the user document is gone.
"""

from datetime import datetime
from bson import ObjectId
from pymongo import DESCENDING, ReturnDocument
from app.services.database import get_collection, check_connection
from app.models.errors import ErrNoResult, ErrUnavailable

# Request lifecycle states.
STATUS_PENDING = 'pending'
STATUS_APPROVED = 'approved'
STATUS_REJECTED = 'rejected'


def account_deletion_request_create(username, email, note=''):
"""Create a pending account deletion request.

Args:
username: Username of the requesting user
email: The user's email (denormalized for the confirmation email)
note: Optional free-text reason from the requester

Returns:
The inserted request document.
"""
if not check_connection():
raise ErrUnavailable("Database is unavailable")

collection = get_collection('account_deletion_request')
obj_id = ObjectId()

request = {
'_id': obj_id,
'hexid': str(obj_id),
'requester': username,
'email': email,
'note': note or '',
'status': STATUS_PENDING,
'created_at': datetime.utcnow(),
'reviewed_by': None,
'reviewed_at': None,
}

collection.insert_one(request)
return request


def account_deletion_requests_pending():
"""Return all pending account deletion requests, newest first."""
if not check_connection():
raise ErrUnavailable("Database is unavailable")

collection = get_collection('account_deletion_request')
return list(collection.find({'status': STATUS_PENDING})
.sort('created_at', DESCENDING))


def count_pending_account_deletion_requests():
"""Count pending account deletion requests."""
if not check_connection():
raise ErrUnavailable("Database is unavailable")

collection = get_collection('account_deletion_request')
return collection.count_documents({'status': STATUS_PENDING})


def account_deletion_request_by_hexid(hexid):
"""Get an account deletion request by its hex ID.

Raises:
ErrNoResult: If no request matches.
"""
if not check_connection():
raise ErrUnavailable("Database is unavailable")

collection = get_collection('account_deletion_request')
request = collection.find_one({'hexid': hexid})
if not request:
raise ErrNoResult("Account deletion request not found")
return request


def account_deletion_request_set_status(hexid, status, reviewer):
"""Mark a request approved/rejected and stamp the reviewer.

Returns:
The updated request document, or None if not found.
"""
if not check_connection():
raise ErrUnavailable("Database is unavailable")

collection = get_collection('account_deletion_request')
return collection.find_one_and_update(
{'hexid': hexid},
{'$set': {
'status': status,
'reviewed_by': reviewer,
'reviewed_at': datetime.utcnow(),
}},
return_document=ReturnDocument.AFTER
)


def pending_account_deletion_request_by_user(username):
"""Return a user's existing pending deletion request, if any (anti-dup)."""
if not check_connection():
raise ErrUnavailable("Database is unavailable")

collection = get_collection('account_deletion_request')
return collection.find_one({
'requester': username,
'status': STATUS_PENDING,
})
Loading
Loading