Skip to content

Commit

Permalink
Allow users to export or delete their data
Browse files Browse the repository at this point in the history
  • Loading branch information
ObserverOfTime committed Dec 6, 2023
1 parent 2e6b129 commit 04dce6d
Show file tree
Hide file tree
Showing 12 changed files with 219 additions and 29 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ It is written in Django, SCSS and Vanilla JS. No PHP, no Node.js, no jQuery.
* [x] OAuth registration.
* [x] Series bookmarks.
* [x] Chapter downloads.
* [x] GDPR compliant.
* [ ] Comments.
* [x] Supports Redis/Memcached.
* [x] Supports scheduled releases.
Expand Down
4 changes: 3 additions & 1 deletion docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ Changelog
v0.9.6
^^^^^^

* Added Render demo
* Added a data export button
* Added an account deletion button
* Added a Render demo deployment

v0.9.5
^^^^^^
Expand Down
14 changes: 12 additions & 2 deletions static/styles/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,17 @@ fieldset {
}
}

#user-other > form {
display: inline-block;
> .button {
font-size: 0.8em;
&.delete {
margin-left: 0.3em;
border-color: #F04747;
}
}
}

#sign-in .input {
width: 270px;
}
Expand Down Expand Up @@ -490,8 +501,7 @@ fieldset {
}
textarea { @extend %field; }
.button {
display: block;
margin: 5px auto 0;
margin: 0.4em auto 0;
font-size: 1.05em;
cursor: pointer;
}
Expand Down
22 changes: 5 additions & 17 deletions users/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Sequence, cast
from typing import TYPE_CHECKING, Sequence

from django.db.models import Prefetch
from django.urls import reverse
Expand Down Expand Up @@ -101,29 +101,17 @@ def get_object(self) -> UserProfile:

def perform_update(self, serializer: ProfileSerializer):
data = dict(serializer.validated_data)
profile = cast(UserProfile, serializer.instance)
user = profile.user
profile: UserProfile = serializer.instance # type: ignore
# update the underlying user first
if fields := data.pop('user', {}):
if fields := data.pop('user', None):
for k, v in fields.items():
setattr(user, k, v)
user.save(update_fields=list(fields))
setattr(profile.user, k, v)
profile.user.save(update_fields=list(fields))
if data: # and then update the profile
for k, v in data.items():
setattr(profile, k, v)
profile.save(update_fields=list(data))

def perform_destroy(self, instance: UserProfile):
# deactivate and anonymize the user
instance.user.is_active = False
instance.user.first_name = ''
instance.user.last_name = ''
instance.user.api_key.delete()
instance.user.save(update_fields=(
'is_active', 'first_name', 'last_name'
))
instance.delete()

@classmethod
def as_view(cls, **initkwargs):
return super().as_view(actions={
Expand Down
48 changes: 48 additions & 0 deletions users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from datetime import datetime as dt
from hashlib import blake2b
from pathlib import PurePath
from secrets import token_hex
Expand Down Expand Up @@ -96,6 +97,53 @@ def get_directory(self) -> PurePath:
"""
return PurePath('users', str(self.id))

def delete(self):
"""Delete or anonymize data associated with the user."""
self.user.username = f'ANON-{token_hex(8)}'
self.user.email = ''
self.user.first_name = ''
self.user.last_name = ''
self.user.is_active = False
self.user.last_login = None
self.user.date_joined = dt.fromtimestamp(0)
self.user.save(update_fields=(
'username', 'first_name', 'last_name',
'is_active', 'email', 'date_joined', 'last_login'
))
self.user.bookmarks.all().delete()
self.user.emailaddress_set.all().delete()
self.user.socialaccount_set.all().delete()
ApiKey.objects.filter(user_id=self.user.id).delete()
if self.avatar:
self.avatar.delete()
super().delete()

def export(self) -> dict:
"""Export the data associated with the user."""
bookmarks = self.user.bookmarks.values(
slug=models.F('series__slug'),
title=models.F('series__title')
)
api_key = ApiKey.objects.filter(
user_id=self.user.id
).values('key', 'created').first()
avatar = self.avatar.url if self.avatar else None
return {
'username': self.user.username,
'email': self.user.email,
'first_name': self.user.first_name,
'last_name': self.user.last_name,
'date_joined': self.user.date_joined,
'last_login': self.user.last_login,
'bio': self.bio,
'avatar': avatar,
'api_key': api_key,
'bookmarks': {
'token': self.token,
'series': list(bookmarks)
}
}

def __str__(self) -> str:
"""
Return a string representing the object.
Expand Down
23 changes: 23 additions & 0 deletions users/templates/delete.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{% extends 'layout.html' %}
{% load account custom_tags %}
{% block robots %}
<meta name="robots" content="noindex,nofollow">
{% endblock %}
{% with user=user_display %}
{% block title %}
<meta name="title" content="Delete">
<meta property="og:title" content="Delete ~ {{ config.NAME }}">
<title>Delete ~ {{ config.NAME }}</title>
{% endblock %}
{% endwith %}
{% block content %}
<h1 class="text-shadow alter-bg">Delete Account</h1>
<article class="user-action" id="user-delete">
<div>Are you sure you want to delete your account?</div>
<div>This will remove all associated data.</div>
<form action="{% url 'user_delete' %}" method="POST">
{% csrf_token %}
<input class="button" type="submit" value="Delete">
</form>
</article>
{% endblock %}
11 changes: 10 additions & 1 deletion users/templates/edit_user.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ <h1 class="text-shadow alter-bg">Settings</h1>
{% endfor %}
</div>
{% endfor %}
<input class="button" type="submit" value="Save">
<input class="button" type="submit" value="Save profile">
</form>
<section class="user-action" id="user-other">
<form action="{% url 'user_data' %}" method="POST">
{% csrf_token %}
<input class="button" type="submit" value="Export data">
</form>
<form action="{% url 'user_delete' %}" method="GET">
<input class="button delete" type="submit" value="Delete account">
</form>
</section>
</article>
{% endblock %}
7 changes: 5 additions & 2 deletions users/tests/fixtures/users.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
"first_name": "evangelos",
"last_name": "ch",
"email": "[email protected]",
"is_staff": false,
"is_staff": true,
"is_active": true,
"date_joined": "2019-12-31T21:16:47.153Z",
"last_login": "2019-12-31T21:16:47.153Z",
"groups": [],
"user_permissions": []
}
Expand All @@ -38,6 +39,7 @@
"is_staff": true,
"is_active": true,
"date_joined": "2019-12-31T21:16:47.155Z",
"last_login": "2019-12-31T21:16:47.155Z",
"groups": [1],
"user_permissions": []
}
Expand All @@ -53,9 +55,10 @@
"first_name": "",
"last_name": "",
"email": "",
"is_staff": false,
"is_staff": true,
"is_active": true,
"date_joined": "2019-12-31T21:16:47.156Z",
"last_login": "2019-12-31T21:16:47.156Z",
"groups": [],
"user_permissions": []
}
Expand Down
19 changes: 19 additions & 0 deletions users/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ def test_create(self):
assert self.profile.get_absolute_url() \
.endswith(f'?id={self.profile.id}')

def test_export(self):
"""Test data export."""
data = self.profile.export()
assert data['bio'] == 'Test'
assert data['username'] == self.user.username
assert data['api_key'] is None
assert len(data['bookmarks']['series']) == 0

def test_delete(self):
"""Test account deletion."""
self.profile.delete()
self.user.refresh_from_db()
assert self.user.email == ''
assert self.user.first_name == ''
assert self.user.last_name == ''
assert self.user.last_login is None
assert self.user.date_joined.year == 1970
assert self.user.username.startswith('ANON-')


class TestApiKey(UsersTestBase):
def setup_method(self):
Expand Down
26 changes: 25 additions & 1 deletion users/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from json import loads
from urllib.parse import urlencode

from django.urls import reverse

from pytest import mark

from users.models import Bookmark, User
from users.models import Bookmark, User, UserProfile

from . import UsersTestBase

Expand All @@ -13,6 +14,7 @@ class UsersViewTestBase(UsersTestBase):
def setup_method(self):
super().setup_method()
self.client.force_login(self.user)
UserProfile.objects.get_or_create(user_id=self.user.id)


class TestEditUser(UsersViewTestBase):
Expand Down Expand Up @@ -96,6 +98,28 @@ def test_invalid_id(self):
assert r.status_code == 404


class TestExport(UsersViewTestBase):
URL = reverse('user_data')

def test_get(self):
r = self.client.get(self.URL)
assert r.status_code == 200
data = loads(r.getvalue().decode())
assert data['username'] == self.user.username


class TestDelete(UsersViewTestBase):
URL = reverse('user_delete')

def test_get(self):
r = self.client.get(self.URL)
assert r.status_code == 200

def test_post(self):
r = self.client.post(self.URL)
assert r.status_code == 302


class TestBookmarks(UsersViewTestBase):
URL = reverse('user_bookmarks')
CONTENT_TYPE = 'application/x-www-form-urlencoded'
Expand Down
6 changes: 5 additions & 1 deletion users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
from allauth.urls import urlpatterns as allauth_urls

from .feeds import BookmarksAtom, BookmarksRSS
from .views import Bookmarks, EditUser, Logout, PasswordReset, profile
from .views import (
Bookmarks, Delete, EditUser, Logout, PasswordReset, export, profile
)

#: The URL patterns of the users app.
urlpatterns = [
path('', profile, name='user_profile'),
path('data/', export, name='user_data'),
path('edit/', EditUser.as_view(), name='user_edit'),
path('delete/', Delete.as_view(), name='user_delete'),
path('logout/', Logout.as_view(), name='account_logout'),
path('bookmarks/', Bookmarks.as_view(), name='user_bookmarks'),
path('bookmarks.atom', BookmarksAtom(), name='user_bookmarks.atom'),
Expand Down
Loading

0 comments on commit 04dce6d

Please sign in to comment.