From 496cc0ea9fa75b0dffb128bb88ebd50cd53086c2 Mon Sep 17 00:00:00 2001 From: David Llewellyn-Jones Date: Wed, 26 Jul 2023 18:35:20 +0100 Subject: [PATCH] Add an account deletion option Adds an option to delete the user's account to the profile page. Fixes #536. Co-authored-by: Bastian Greshake Tzovaras --- server/apps/users/forms.py | 4 + server/apps/users/helpers.py | 21 +++ server/apps/users/templates/users/delete.html | 44 ++++++ .../apps/users/templates/users/goodbye.html | 31 ++++ .../apps/users/templates/users/profile.html | 2 + .../users/tests/fixtures/delete_user.yaml | 92 +++++++++++ server/apps/users/tests/test_views.py | 149 ++++++++++++++++++ server/apps/users/urls.py | 1 + server/apps/users/views.py | 37 ++++- static/css/main.css | 2 +- 10 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 server/apps/users/templates/users/delete.html create mode 100644 server/apps/users/templates/users/goodbye.html create mode 100644 server/apps/users/tests/fixtures/delete_user.yaml diff --git a/server/apps/users/forms.py b/server/apps/users/forms.py index d965b9a9..275ad71e 100644 --- a/server/apps/users/forms.py +++ b/server/apps/users/forms.py @@ -114,3 +114,7 @@ class UserProfileForm(forms.Form): widget=forms.CheckboxInput(attrs={"class": "custom-control-input"})) profile_submitted.group = "hidden" +class UserProfileDeleteForm(forms.Form): + delete_oh_data = forms.BooleanField(label = "Delete your stories from OpenHumans", + required=False, + widget=forms.CheckboxInput(attrs={"class": "custom-control-input"})) diff --git a/server/apps/users/helpers.py b/server/apps/users/helpers.py index fbd96ef0..c9b3f1be 100644 --- a/server/apps/users/helpers.py +++ b/server/apps/users/helpers.py @@ -1,5 +1,6 @@ from django.contrib.auth.models import User from .models import UserProfile +from server.apps.main.models import PublicExperience def user_profile_exists(user): """ @@ -53,3 +54,23 @@ def get_user_profile(user): except UserProfile.DoesNotExist: uo = None return uo + +def delete_user(user, delete_oh_data): + """ + Deletes the user and all data associated with it. + + Args: + delete_oh_data: True if stories on OpenHumans should also be deleted + + Returns: + None + """ + ohmember = user.openhumansmember + + # Delete the stories from the OpenHumans database + if delete_oh_data: + ohmember.delete_all_files() + + # Delete the actual user + user.delete() + diff --git a/server/apps/users/templates/users/delete.html b/server/apps/users/templates/users/delete.html new file mode 100644 index 00000000..f2744e18 --- /dev/null +++ b/server/apps/users/templates/users/delete.html @@ -0,0 +1,44 @@ +{% extends 'main/application.html' %} + +{% block title %}AutSPACEs - {{title}} {% endblock %} + +{% load static %} +{% load custom_tags %} +{% load humanize %} + +{% block content %} + + +
+
+

+

OpenHumans ID: {{ oh_id }} + +

Are you sure you want to delete your AutSPACEs account? +

This will remove all your data from the AutSPACEs platform. This process cannot be undone. +

Select the switch below to also remove your stories from OpenHumans. +

+ {% csrf_token %} +
+ {% for field in form %} +
+
+
+ {{ field }} + +
+
+
+ {% endfor %} +
+
+ + Cancel +
+
+
+
+ +{% endblock %} + + diff --git a/server/apps/users/templates/users/goodbye.html b/server/apps/users/templates/users/goodbye.html new file mode 100644 index 00000000..44297e25 --- /dev/null +++ b/server/apps/users/templates/users/goodbye.html @@ -0,0 +1,31 @@ +{% extends 'main/application.html' %} + +{% block title %}AutSPACEs - {{title}} {% endblock %} + +{% load static %} +{% load custom_tags %} +{% load humanize %} + +{% block content %} + + +
+
+

+ +

We're sorry to see you go, but are grateful for your contribution to AutSPACEs. + {% if delete_oh_data %} +

All of your personal data has been removed from the AutSPACEs platform and your stories have been removed from OpenHumans. This won't affect any other data you may have stored on the OpenHumans platform. + {% else %} +

All of your personal data has been removed from the AutSPACEs platform, but please be aware that stories you entered here may still be stored on OpenHumans. You'll need to delete these separately. + {% endif %} +

If you'd like to contribute to AutSPACEs again in the future, please feel free to create a new account. +

+
+
+ +{% endblock %} + + diff --git a/server/apps/users/templates/users/profile.html b/server/apps/users/templates/users/profile.html index 4e331539..30366464 100644 --- a/server/apps/users/templates/users/profile.html +++ b/server/apps/users/templates/users/profile.html @@ -38,6 +38,8 @@

OpenHumans ID: {{ oh_id }} +

Delete AutSPACEs account +

{% csrf_token %} {% regroup form by field.group as field_groups %} diff --git a/server/apps/users/tests/fixtures/delete_user.yaml b/server/apps/users/tests/fixtures/delete_user.yaml new file mode 100644 index 00000000..079b697d --- /dev/null +++ b/server/apps/users/tests/fixtures/delete_user.yaml @@ -0,0 +1,92 @@ +interactions: +- request: + body: project_member_id=39896706&all_files=True + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '41' + Content-Type: + - application/x-www-form-urlencoded + User-Agent: + - python-requests/2.31.0 + method: POST + uri: https://www.openhumans.org/api/direct-sharing/project/files/delete/ + response: + body: + string: '{"ids":[69072773]}' + headers: + Allow: + - POST, OPTIONS + Cache-Control: + - max-age=0, no-cache, no-store, must-revalidate + Connection: + - keep-alive + Content-Length: + - '18' + Content-Type: + - application/json + Date: + - Fri, 28 Jul 2023 15:06:45 GMT + Expires: + - Fri, 28 Jul 2023 15:06:45 GMT + Server: + - gunicorn/20.0.4 + Vary: + - Accept, Authorization, Cookie, Origin + Via: + - 1.1 vegur + X-Frame-Options: + - SAMEORIGIN + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - python-requests/2.31.0 + method: POST + uri: https://www.openhumans.org/api/direct-sharing/project/remove-members/ + response: + body: + string: '"success"' + headers: + Allow: + - POST, OPTIONS + Cache-Control: + - max-age=0, no-cache, no-store, must-revalidate + Connection: + - keep-alive + Content-Length: + - '9' + Content-Type: + - application/json + Date: + - Fri, 28 Jul 2023 15:06:46 GMT + Expires: + - Fri, 28 Jul 2023 15:06:46 GMT + Server: + - gunicorn/20.0.4 + Vary: + - Accept, Authorization, Cookie, Origin + Via: + - 1.1 vegur + X-Frame-Options: + - SAMEORIGIN + status: + code: 200 + message: OK +version: 1 diff --git a/server/apps/users/tests/test_views.py b/server/apps/users/tests/test_views.py index 3c9f5900..464dafb7 100644 --- a/server/apps/users/tests/test_views.py +++ b/server/apps/users/tests/test_views.py @@ -1,6 +1,7 @@ from django.test import TestCase from django.conf import settings from django.test import Client +from django.contrib.auth.models import User from openhumans.models import OpenHumansMember @@ -9,6 +10,12 @@ user_profile_exists, user_submitted_profile, ) +from server.apps.main.models import ( + PublicExperience, + ExperienceHistory, +) + +import vcr class ViewTests(TestCase): """ @@ -196,3 +203,145 @@ def test_profile_rendering(self): self.assertContains(response, "abcdabcd") self.assertContains(response, "26-35") + def test_user_delete_rendering(self): + """ + Check that the user delete page is rendered correctly. + """ + c = Client() + c.force_login(self.user_b) + response = c.get("/users/delete/") + assert response.status_code == 200 + self.assertTemplateUsed(response, 'users/delete.html') + + def test_user_delete_logged_out(self): + """ + Check that the user delete page is not shown if the user isn't logged in. + """ + c = Client() + response = c.get("/users/delete/", follow=True) + self.assertRedirects(response, "/", + status_code=302, target_status_code=200) + self.assertTemplateUsed(response, 'main/home.html') + + def delete_user(self, delete_oh_data): + """ + Helper function that deletes a user and checks the result. + """ + # Create a user for us to delete + data = {"access_token": "123456", "refresh_token": "bar", "expires_in": 36000} + oh = OpenHumansMember.create(oh_id=97526814, data=data) + oh.save() + user = oh.user + user.openhumansmember = oh + user.set_password("password") + user.save() + # Create a user profile + up_data = { + "profile_submitted": False, + "autistic_identification": "unspecified", + "age_bracket": "18-25", + "age_public": False, + "gender": "see_description", + "gender_self_identification": "", + "gender_public": False, + "description": "Timelord", + "description_public": False, + "comms_review": False, + "abuse": False, + "violence": False, + "drug": True, + "mentalhealth": False, + "negbody": True, + "other": False, + } + up = UserProfile.objects.create(user=user, **up_data) + # Create a story + pe_data = { + "experience_text": "Here is some experience text", + "difference_text": "Here is some difference text", + "title_text": "Here is the title", + } + pe = PublicExperience.objects.create( + open_humans_member=oh, experience_id="69072773", **pe_data + ) + # Create a history entry + mh_data = { + "changed_by": oh, + "change_comments": "Local moderation comment", + "change_reply": "Moderation comment", + } + self.eh_a = ExperienceHistory.objects.create( + experience = pe, **mh_data + ) + + objects = PublicExperience.objects.filter( + open_humans_member=oh + ) + assert len(objects) > 0 + + objects = UserProfile.objects.filter( + user=user + ) + assert len(objects) > 0 + + objects = User.objects.filter( + id=user.id + ) + assert len(objects) > 0 + + objects = ExperienceHistory.objects.filter( + experience_id="69072773" + ) + assert len(objects) > 0 + + c = Client() + c.force_login(user) + response = c.post( + "/users/delete/", + { + "title": "Profile Deleted", + "delete_oh_data": delete_oh_data, + }, + follow=True, + ) + assert response.status_code == 200 + + objects = PublicExperience.objects.filter( + open_humans_member=oh + ) + assert len(objects) == 0 + + objects = UserProfile.objects.filter( + user=user + ) + assert len(objects) == 0 + + objects = User.objects.filter( + id=user.id + ) + assert len(objects) == 0 + + objects = ExperienceHistory.objects.filter( + experience_id="69072773" + + ) + assert len(objects) == 0 + + def test_user_delete_no_oh(self): + """ + Test that profile deletion works, without deleting the OpenHumans data. + """ + self.delete_user(False) + + @vcr.use_cassette( + 'server/apps/users/tests/fixtures/delete_user.yaml', + record_mode="none", + filter_query_parameters=['access_token'], + match_on=['path'], + ) + def test_user_delete_oh(self): + """ + Test that profile deletion works, including deleting OpenHumans data. + """ + self.delete_user(True) + diff --git a/server/apps/users/urls.py b/server/apps/users/urls.py index ef87b14a..7f707f88 100644 --- a/server/apps/users/urls.py +++ b/server/apps/users/urls.py @@ -6,4 +6,5 @@ urlpatterns = [ path("profile/", views.user_profile, name="profile"), path("greetings/", views.user_profile, {"first_visit": True}, name="greetings"), + path("delete/", views.user_profile_delete, name="delete"), ] diff --git a/server/apps/users/views.py b/server/apps/users/views.py index f8622306..20cff1df 100644 --- a/server/apps/users/views.py +++ b/server/apps/users/views.py @@ -5,12 +5,17 @@ from django.shortcuts import redirect, render from django.contrib import messages from django.forms.models import model_to_dict +from django.contrib.auth import logout from .models import UserProfile -from .forms import UserProfileForm +from .forms import ( + UserProfileForm, + UserProfileDeleteForm, +) from .helpers import ( user_profile_exists, get_user_profile, + delete_user, ) logger = logging.getLogger(__name__) @@ -50,3 +55,33 @@ def user_profile(request, first_visit=False): else: return redirect("index") +def user_profile_delete(request): + if request.user.is_authenticated: + oh_member = request.user.openhumansmember + if request.method == "POST": + form = UserProfileDeleteForm(request.POST) + + if form.is_valid(): + # Delete all the user's data + delete_oh_data = form.cleaned_data['delete_oh_data'] + delete_user(request.user, delete_oh_data) + # Log the user out. That's it. + logout(request) + # Say goodbye. + return render( + request, "users/goodbye.html", { + "title": "Profile Deleted", + "delete_oh_data": delete_oh_data, + }) + else: + form = UserProfileDeleteForm() + + return render( + request, "users/delete.html", { + "title": "Delete profile", + "form": form, + "oh_id": oh_member.oh_id, + }) + + else: + return redirect("index") diff --git a/static/css/main.css b/static/css/main.css index 41443a4a..b96ab36f 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -624,7 +624,7 @@ body { display: "hidden"; } -/* USER PROFILE PAGE */ +/* USER PROFILE PAGES */ .profile-section { padding: 3% 5%;