Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 51038c2

Browse files
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‎

Lines changed: 1 addition & 0 deletions
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‎

Lines changed: 40 additions & 0 deletions
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‎

Lines changed: 2 additions & 2 deletions
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‎

Lines changed: 40 additions & 2 deletions
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")
Lines changed: 35 additions & 0 deletions
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"
Lines changed: 38 additions & 0 deletions
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‎

Lines changed: 33 additions & 1 deletion
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‎

Lines changed: 19 additions & 0 deletions
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‎

Lines changed: 71 additions & 0 deletions
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‎

Lines changed: 20 additions & 0 deletions
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+
)

0 commit comments

Comments
(0)

AltStyle によって変換されたページ (->オリジナル) /