diff --git a/src/gened/profile.py b/src/gened/profile.py
index bdef131..173f8f0 100644
--- a/src/gened/profile.py
+++ b/src/gened/profile.py
@@ -2,19 +2,32 @@
#
# SPDX-License-Identifier: AGPL-3.0-only
-from flask import Blueprint, flash, redirect, render_template, request, session, url_for
+from flask import (
+ Blueprint,
+ current_app,
+ flash,
+ redirect,
+ render_template,
+ request,
+ session,
+ url_for,
+)
from werkzeug.wrappers.response import Response
-from .auth import get_auth, login_required
+from .auth import generate_anon_username, get_auth, login_required
from .data_deletion import delete_user_data
from .db import get_db
from .redir import safe_redirect
bp = Blueprint('profile', __name__, url_prefix="/profile", template_folder='templates')
+@bp.before_request
+@login_required
+def before_request() -> None:
+ """ Apply decorator to protect all profile blueprint endpoints. """
+
@bp.route("/")
-@login_required
def main() -> str:
db = get_db()
auth = get_auth()
@@ -78,7 +91,6 @@ def main() -> str:
@bp.route("/delete_data", methods=['POST'])
-@login_required
def delete_data() -> Response:
# Require explicit confirmation
if request.form.get('confirm_delete') != 'DELETE':
@@ -126,7 +138,54 @@ def delete_data() -> Response:
db.commit()
- # Clear their session and log them out
+ # Clear their session to log them out
session.clear()
+
+ current_app.logger.info(f"Account deleted: ID {user_id}")
flash("Your data has been deleted and your account has been deactivated.", "success")
return redirect(url_for("auth.login"))
+
+
+@bp.route("/anonymize", methods=['POST'])
+def anonymize() -> Response:
+ auth = get_auth()
+ user_id = auth.user_id
+ assert user_id is not None # due to @login_required
+ db = get_db()
+
+ # Check if this is an external, non-LTI account
+ auth_row = db.execute("""
+ SELECT auth_external.user_id, auth_providers.name as provider
+ FROM auth_external
+ JOIN auth_providers ON auth_providers.id = auth_external.auth_provider
+ WHERE user_id=?
+ """, [user_id]).fetchone()
+
+ if not auth_row or auth_row['provider'] == 'lti':
+ flash("Account anonymization is only available to external, non-LTI accounts.", "warning")
+ return safe_redirect(request.referrer, default_endpoint="profile.main")
+
+ # Generate new anonymous display name
+ new_name = generate_anon_username()
+
+ # Update user record to remove personal info
+ db.execute("""
+ UPDATE users
+ SET full_name = ?,
+ email = NULL,
+ auth_name = NULL
+ WHERE id = ?
+ """, [new_name, user_id])
+
+ # Mark external auth entry as anonymous
+ db.execute("""
+ UPDATE auth_external
+ SET is_anon = 1
+ WHERE user_id = ?
+ """, [user_id])
+
+ db.commit()
+
+ current_app.logger.info(f"Account anonymized: ID {user_id}")
+ flash("Your account has been anonymized.", "success")
+ return redirect(url_for("profile.main"))
diff --git a/src/gened/templates/profile_view.html b/src/gened/templates/profile_view.html
index 0a55298..8e9f959 100644
--- a/src/gened/templates/profile_view.html
+++ b/src/gened/templates/profile_view.html
@@ -108,18 +108,28 @@
Your Data
-
+
+ {% if user.provider_name not in ['lti', 'demo', 'local'] %}
+
+ {% endif %}
+
+
-
{% endif %}
+
{% if not auth.cur_class %}
{% include "free_query_dialog.html" %}
{% endif %}
+
+ {% if user.provider_name not in ['lti', 'demo', 'local'] %}
+
+
+
Anonymize Your Account
+
+
This will:
+
+
Remove your name, email, and username from your account
+
Give your account a new, anonymous name
+
Keep your account active and your usage data intact
+
+
+
Warning: Account anonymization cannot be undone.
+
+
+
+
+ {% endif %}
+
{% endblock %}
diff --git a/tests/test_privacy.py b/tests/test_privacy.py
index abfc32a..3b746db 100644
--- a/tests/test_privacy.py
+++ b/tests/test_privacy.py
@@ -2,7 +2,9 @@
#
# SPDX-License-Identifier: AGPL-3.0-only
-import pytest
+import re
+
+from flask import url_for
from gened.db import get_db
@@ -263,3 +265,88 @@ def test_delete_class_unauthorized(app, client, auth):
with app.app_context():
verify_row_count("classes", "WHERE id = ? AND name != '[deleted]'", [class_id], 1, "Class should still exist")
+
+def test_anonymize_user_unauthorized(app, client):
+ """Test unauthorized access to user anonymization"""
+ # Test without login
+ response = client.post('/profile/anonymize')
+ assert response.status_code == 302
+ assert response.location.startswith('/auth/login?')
+
+
+def test_anonymize_user_provider_restrictions(app, client, auth):
+ """Test provider restrictions for anonymization"""
+ auth.login('testuser', 'testpassword')
+
+ with app.app_context():
+ db = get_db()
+ # Get original user data
+ init_user = db.execute("SELECT * FROM users WHERE auth_name='testuser'").fetchone()
+
+ # Force-set current user as LTI user
+ db.execute("""
+ UPDATE auth_external
+ SET auth_provider = (SELECT id FROM auth_providers WHERE name = 'lti')
+ WHERE user_id = (SELECT id FROM users WHERE auth_name = 'testuser')
+ """)
+ db.commit()
+
+ response = client.post('/profile/anonymize', follow_redirects=True)
+ assert response.status_code == 200
+ assert b"Account anonymization is only available to external, non-LTI accounts" in response.data
+
+ # Verify nothing was anonymized
+ user = db.execute("SELECT * FROM users WHERE auth_name='testuser'").fetchone()
+ assert user['full_name'] == init_user['full_name']
+ assert user['email'] == init_user['email']
+
+
+def test_anonymize_user_full_process(app, client, mock_oauth_patch):
+ """Test complete user anonymization process"""
+ # Set up mock OAuth login
+ with app.test_request_context():
+ auth_url = url_for('oauth.auth', provider_name='google')
+
+ # Perform OAuth login
+ with client:
+ response = client.get(auth_url)
+ assert response.status_code == 302
+
+ # Get initial state
+ with app.app_context():
+ db = get_db()
+ initial_user = db.execute("SELECT * FROM users WHERE full_name=?", [mock_oauth_patch.test_user['name']]).fetchone()
+ user_id = initial_user['id']
+ initial_auth = db.execute("SELECT * FROM auth_external WHERE user_id = ?", [user_id]).fetchone()
+
+ # Verify initial state
+ assert not initial_auth['is_anon'] # This is the key state we're testing
+
+ # Perform anonymization
+ response = client.post('/profile/anonymize')
+ assert response.status_code == 302
+ assert response.location == "/profile/"
+
+ # Verify final state
+ with app.app_context():
+ db = get_db()
+ user = db.execute("SELECT * FROM users WHERE id = ?", [user_id]).fetchone()
+ auth = db.execute("SELECT * FROM auth_external WHERE user_id = ?", [user_id]).fetchone()
+
+ # Check user record
+ assert user['full_name'] != initial_user['full_name']
+ # Anonymous usernames are three capitalized words concatenated
+ assert re.match(r"^(?:[A-Z][a-z]+){3}$", user['full_name'])
+ assert user['email'] is None
+ assert user['auth_name'] is None
+
+ # Check external auth set to anonymous
+ assert auth['is_anon'] == 1
+
+ # Verify other data remains intact
+ assert user['id'] == initial_user['id']
+ assert user['auth_provider'] == initial_user['auth_provider']
+ assert user['is_admin'] == initial_user['is_admin']
+ assert user['is_tester'] == initial_user['is_tester']
+ assert user['query_tokens'] == initial_user['query_tokens']
+ assert user['last_class_id'] == initial_user['last_class_id']