Skip to content

Commit 51038c2

Browse files
committed
Add anonymization feature for private user data
1 parent afa829f commit 51038c2

File tree

11 files changed

+318
-5
lines changed

11 files changed

+318
-5
lines changed

docs/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ All Contents
1010

1111
usage
1212
templates
13+
privacy
1314
customizing
1415
settings
1516
contributing

docs/privacy.rst

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
Privacy
2+
========
3+
4+
Anonymization
5+
-------------
6+
7+
User privacy is important, not only to meet local regulations, but also to
8+
protect your users and allow them to exercise their rights. However,
9+
it's not always practical to delete users, especially if they have dependent
10+
objects, that are relevant for statistical analysis.
11+
12+
Anonymization is a process of removing the user's personal data whilst keeping
13+
related data intact. This is done by using the ``anomymize`` method.
14+
15+
16+
17+
.. automethod:: mailauth.contrib.user.models.AbstractEmailUser.anonymize
18+
:noindex:
19+
20+
This method may be overwritten to provide anonymization for you custom user model.
21+
22+
Related objects may also listen to the anonymize signal.
23+
24+
.. autoclass:: mailauth.contrib.user.signals.anonymize
25+
26+
All those methods can be conveniently triggered via the ``anonymize`` admin action.
27+
28+
.. autoclass:: mailauth.contrib.user.admin.AnonymizableAdminMixin
29+
:members:
30+
31+
Liability Waiver
32+
----------------
33+
34+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
35+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
36+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
37+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
38+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
39+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
40+
SOFTWARE.

mailauth/contrib/admin/locale/de/LC_MESSAGES/django.po

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ msgid ""
77
msgstr ""
88
"Project-Id-Version: \n"
99
"Report-Msgid-Bugs-To: \n"
10-
"POT-Creation-Date: 2019-04-12 18:14+0200\n"
10+
"POT-Creation-Date: 2022-04-14 16:57+0200\n"
1111
"PO-Revision-Date: 2019-04-12 18:15+0200\n"
1212
"Last-Translator: Johannes Hoppe <info@johanneshoppe.com>\n"
1313
"Language-Team: \n"
@@ -65,6 +65,6 @@ msgstr ""
6565
msgid "Resend login email"
6666
msgstr "Login E-Mail erneut senden"
6767

68-
#: views.py:16
68+
#: views.py:17
6969
msgid "Log in"
7070
msgstr "Anmelden"

mailauth/contrib/user/admin.py

+40-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,50 @@
11
from django.contrib import admin
22
from django.contrib.auth.models import Group, Permission
3+
from django.utils.translation import gettext_lazy as _, ngettext
34

45
from . import models
56

67

8+
class AnonymizableAdminMixin:
9+
"""
10+
Mixin for admin classes that provides a `anonymize` action.
11+
12+
This mixin calls the `anonymize` method of all user model instances.
13+
"""
14+
15+
actions = ["anonymize"]
16+
17+
@admin.action(
18+
permissions=["anonymize"],
19+
description=_("Anonymize selected %(verbose_name_plural)s"),
20+
)
21+
def anonymize(self, request, queryset):
22+
count = queryset.count()
23+
for user in queryset.iterator():
24+
user.anonymize()
25+
26+
self.message_user(
27+
request,
28+
ngettext(
29+
"%(count)s %(obj_name)s has successfully been anonymized.",
30+
"%(count)s %(obj_name)s have successfully been anonymized.",
31+
count,
32+
)
33+
% {
34+
"count": count,
35+
"obj_name": self.model._meta.verbose_name_plural
36+
if count > 1
37+
else self.model._meta.verbose_name,
38+
},
39+
fail_silently=True,
40+
)
41+
42+
def has_anonymize_permission(self, request, obj=None):
43+
return request.user.has_perm(f"{self.opts.app_label}.anonymize", obj=obj)
44+
45+
746
@admin.register(models.EmailUser)
8-
class EmailUserAdmin(admin.ModelAdmin):
9-
app_label = "asdf"
47+
class EmailUserAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
1048
list_display = ("email", "first_name", "last_name", "is_staff")
1149
list_filter = ("is_staff", "is_superuser", "is_active", "groups")
1250
search_fields = ("first_name", "last_name", "email")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# SOME DESCRIPTIVE TITLE.
2+
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3+
# This file is distributed under the same license as the PACKAGE package.
4+
# Johannes Hoppe <info@johanneshoppe.com>, 2019.
5+
#
6+
msgid ""
7+
msgstr ""
8+
"Project-Id-Version: \n"
9+
"Report-Msgid-Bugs-To: \n"
10+
"POT-Creation-Date: 2022-04-14 16:56+0200\n"
11+
"PO-Revision-Date: 2022-04-14 15:38+0200\n"
12+
"Last-Translator: Johannes Maron <johannes@maron.family>\n"
13+
"Language-Team: \n"
14+
"Language: de\n"
15+
"MIME-Version: 1.0\n"
16+
"Content-Type: text/plain; charset=UTF-8\n"
17+
"Content-Transfer-Encoding: 8bit\n"
18+
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
19+
"X-Generator: Poedit 3.0.1\n"
20+
21+
#: admin.py:19
22+
#, python-format
23+
msgid "Anonymize selected %(verbose_name_plural)s"
24+
msgstr "Ausgewählte %(verbose_name_plural)s anonymisieren"
25+
26+
#: admin.py:29
27+
#, python-format
28+
msgid "%(count)s %(obj_name)s has successfully been anonymized."
29+
msgid_plural "%(count)s %(obj_name)s have successfully been anonymized."
30+
msgstr[0] "%(count)s %(obj_name)s wurde erfolgreich anonymisiert."
31+
msgstr[1] "%(count)s %(obj_name)s wurden erfolgreich anonymisiert."
32+
33+
#: models.py:56
34+
msgid "email address"
35+
msgstr "E-Mail-Adresse"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from django.db import migrations, models
2+
3+
try:
4+
from django.contrib.postgres.fields import CIEmailField
5+
except ImportError:
6+
CIEmailField = models.EmailField
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("mailauth_user", "0004_auto_20200812_0722"),
13+
]
14+
15+
operations = [
16+
# add new permissions
17+
migrations.AlterModelOptions(
18+
name="emailuser",
19+
options={
20+
"permissions": [("anonymize", "Can anonymize user")],
21+
"verbose_name": "user",
22+
"verbose_name_plural": "users",
23+
},
24+
),
25+
# email is now nullable
26+
migrations.AlterField(
27+
model_name="emailuser",
28+
name="email",
29+
field=CIEmailField(
30+
blank=True,
31+
db_index=True,
32+
max_length=254,
33+
null=True,
34+
unique=True,
35+
verbose_name="email address",
36+
),
37+
),
38+
]

mailauth/contrib/user/models.py

+33-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from django.utils.crypto import get_random_string, salted_hmac
66
from django.utils.translation import gettext_lazy as _
77

8+
from . import signals
9+
810
try:
911
from django.contrib.postgres.fields import CIEmailField
1012
except ImportError:
@@ -50,7 +52,9 @@ class AbstractEmailUser(AbstractUser):
5052
username = None
5153
password = None
5254

53-
email = CIEmailField(_("email address"), unique=True, db_index=True)
55+
email = CIEmailField(
56+
_("email address"), blank=True, null=True, unique=True, db_index=True
57+
)
5458
"""Unique and case insensitive to serve as a better username."""
5559

5660
session_salt = models.CharField(
@@ -67,6 +71,9 @@ def has_usable_password(self):
6771

6872
class Meta(AbstractUser.Meta):
6973
abstract = True
74+
permissions = [
75+
("anonymize", "Can anonymize user"),
76+
]
7077

7178
def _legacy_get_session_auth_hash(self):
7279
# RemovedInDjango40Warning: pre-Django 3.1 hashes will be invalid.
@@ -92,6 +99,31 @@ def get_session_auth_hash(self):
9299
algorithm=algorithm,
93100
).hexdigest()
94101

102+
def anonymize(self, commit=True):
103+
"""
104+
Anonymize the user data for privacy purposes.
105+
106+
This method will erase the email address, first and last name.
107+
You may overwrite this method to add additional fields to anonymize::
108+
109+
class MyUser(AbstractEmailUser):
110+
def anonymize(self, commit=True):
111+
super().anonymize(commit=False) # do not commit yet
112+
self.phone_number = None
113+
if commit:
114+
self.save()
115+
"""
116+
self.email = None
117+
self.first_name = ""
118+
self.last_name = ""
119+
update_fields = ["email", "first_name", "last_name"]
120+
if commit:
121+
self.save(update_fields=update_fields)
122+
signals.anonymize.send(
123+
sender=self.__class__, instance=self, update_fields=tuple(update_fields)
124+
)
125+
return update_fields
126+
95127

96128
delattr(AbstractEmailUser, "password")
97129

mailauth/contrib/user/signals.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from django.dispatch import Signal
2+
3+
anonymize = Signal()
4+
"""
5+
Signal that is emitted when a user and all their data should be anonymized.
6+
7+
Usage::
8+
9+
from django.dispatch import receiver
10+
from mailauth.contrib.user.models import EmailUser
11+
from mailauth.contrib.user.signals import anonymize
12+
13+
14+
@receiver(anonymize, sender=EmailUser)
15+
def anonymize_user(sender, instance, update_fields, **kwargs):
16+
# Do something with related user data
17+
instance.related_model.delete()
18+
19+
"""

tests/contrib/auth/test_admin.py

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from unittest.mock import Mock
2+
3+
import pytest
4+
from django.contrib import admin
5+
from django.contrib.auth.models import Permission
6+
7+
from mailauth.contrib.user.admin import AnonymizableAdminMixin
8+
from mailauth.contrib.user.models import EmailUser
9+
10+
11+
class TestAnonymizableAdminMixin:
12+
def test_anonymize__none(self, rf):
13+
class MyUserModel(EmailUser):
14+
class Meta:
15+
app_label = "test"
16+
verbose_name = "singular"
17+
verbose_name_plural = "plural"
18+
19+
class MyModelAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
20+
pass
21+
22+
request = rf.get("/")
23+
MyModelAdmin(MyUserModel, admin.site).anonymize(
24+
request, MyUserModel.objects.none()
25+
)
26+
27+
@pytest.mark.django_db
28+
def test_anonymize__one(self, rf, user, monkeypatch):
29+
class MyModelAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
30+
pass
31+
32+
monkeypatch.setattr(EmailUser, "anonymize", Mock())
33+
34+
request = rf.get("/")
35+
MyModelAdmin(type(user), admin.site).anonymize(
36+
request, type(user).objects.all()
37+
)
38+
assert EmailUser.anonymize.was_called_once_with(user)
39+
40+
@pytest.mark.django_db
41+
def test_anonymize__many(self, rf, user, monkeypatch):
42+
class MyModelAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
43+
pass
44+
45+
monkeypatch.setattr(EmailUser, "anonymize", Mock())
46+
47+
request = rf.get("/")
48+
MyModelAdmin(type(user), admin.site).anonymize(
49+
request, type(user).objects.all()
50+
)
51+
assert EmailUser.anonymize.was_called_once_with(user)
52+
53+
def test_has_anonymize_permission(self, rf, user):
54+
class MyModelAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
55+
pass
56+
57+
user.is_staff = True
58+
user.save()
59+
request = rf.get("/")
60+
request.user = user
61+
assert not MyModelAdmin(type(user), admin.site).has_anonymize_permission(
62+
request
63+
)
64+
65+
permission = Permission.objects.get(
66+
codename="anonymize",
67+
)
68+
user.user_permissions.add(permission)
69+
del user._perm_cache
70+
del user._user_perm_cache
71+
assert MyModelAdmin(type(user), admin.site).has_anonymize_permission(request)

tests/contrib/auth/test_signals.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from unittest.mock import Mock
2+
3+
import pytest
4+
from django.dispatch import receiver
5+
6+
from mailauth.contrib.user.signals import anonymize
7+
8+
9+
@pytest.mark.django_db
10+
def test_anonymize(user):
11+
handler = Mock()
12+
receiver(anonymize, sender=user.__class__)(handler)
13+
handler.assert_not_called()
14+
user.anonymize()
15+
handler.assert_called_once_with(
16+
signal=anonymize,
17+
sender=user.__class__,
18+
instance=user,
19+
update_fields=("email", "first_name", "last_name"),
20+
)

tests/test_models.py

+19
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,22 @@ def test_email__ci_unique(self, db):
2020
models.EmailUser.objects.create_user("IronMan@avengers.com")
2121
with pytest.raises(IntegrityError):
2222
models.EmailUser.objects.create_user("ironman@avengers.com")
23+
24+
@pytest.mark.django_db
25+
def test_anonymize(self):
26+
user = models.EmailUser.objects.create_user(
27+
email="ironman@avengers.com", first_name="Tony", last_name="Stark"
28+
)
29+
assert user.anonymize() == ["email", "first_name", "last_name"]
30+
assert not user.first_name
31+
assert not user.last_name
32+
assert not user.email
33+
34+
def test_anonymize__no_commit(self):
35+
user = models.EmailUser(
36+
email="ironman@avengers.com", first_name="Tony", last_name="Stark"
37+
)
38+
user.anonymize(commit=False)
39+
assert not user.first_name
40+
assert not user.last_name
41+
assert not user.email

0 commit comments

Comments
 (0)