Skip to content

Commit d3e3f2e

Browse files
committed
Round up the privacy feature
1 parent c2b65f2 commit d3e3f2e

File tree

9 files changed

+196
-4
lines changed

9 files changed

+196
-4
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.models.AnonymizeUserSignal
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.

docs/settings.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ Mail Auth settings
2525
If ``True``, the same token can only be used once and will be invalid the next try.
2626
If ``False``, the same token can be used multiple times and remains valid until expired.
2727

28+
.. attribute:: LOGIN_ANONYMOUS_ENABLED
29+
30+
Default: ``False``
31+
32+
Uses can be logged in anonymously, if enabled. This is related to the ``email_hash`` field.
33+
In that case they user might login without the email address being stored in the database.
34+
Since anonymization is meant to remove personal information, this is an odd behavior unless
35+
you care about completely anonymous user authentication.
36+
2837
Django related settings
2938
-----------------------
3039

mailauth/contrib/user/admin.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,45 @@
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+
def anonymize(self, request, queryset):
18+
count = queryset.count()
19+
for user in queryset.iterator():
20+
user.anonymize()
21+
22+
self.message_user(
23+
request,
24+
ngettext(
25+
"%(count)s %(obj_name)s has successfully been anonymized.",
26+
"%(count)s %(obj_name)s have successfully been anonymized.",
27+
count,
28+
)
29+
% {
30+
"count": count,
31+
"obj_name": self.model._meta.verbose_name_plural
32+
if count > 1
33+
else self.model._meta.verbose_name,
34+
},
35+
fail_silently=True,
36+
)
37+
38+
anonymize.short_description = _("Anonymize selected %(verbose_name_plural)s")
39+
40+
741
@admin.register(models.EmailUser)
8-
class EmailUserAdmin(admin.ModelAdmin):
9-
app_label = "asdf"
42+
class EmailUserAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
1043
list_display = ("email", "first_name", "last_name", "is_staff")
1144
list_filter = ("is_staff", "is_superuser", "is_active", "groups")
1245
search_fields = ("first_name", "last_name", "email")

mailauth/contrib/user/migrations/0005_emailuser_email_hash_alter_emailuser_email.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ class Migration(migrations.Migration):
2626
verbose_name="email address",
2727
),
2828
),
29-
3029
# email_hash field is added as nullable first
3130
migrations.AddField(
3231
model_name="emailuser",

mailauth/contrib/user/models.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.contrib.auth.base_user import BaseUserManager
55
from django.contrib.auth.models import AbstractUser
66
from django.db import models
7+
from django.dispatch import Signal
78
from django.utils.crypto import get_random_string, salted_hmac
89
from django.utils.translation import gettext_lazy as _
910

@@ -13,6 +14,22 @@
1314
from django.db.models import EmailField as CIEmailField
1415

1516

17+
AnonymizeUserSignal = Signal()
18+
"""
19+
Signal that is emitted when a user and all their data should be anonymized.
20+
21+
Usage::
22+
23+
from mailauth.contrib.user.models import AnonymizeUserSignal
24+
25+
@receiver(AnonymizeUserSignal)
26+
def anonymize_user(sender, user, **kwargs):
27+
# Do something with related user data
28+
user.related_model.delete()
29+
30+
"""
31+
32+
1633
class EmailUserManager(BaseUserManager):
1734
use_in_migrations = True
1835

@@ -123,6 +140,27 @@ def get_session_auth_hash(self):
123140
algorithm=algorithm,
124141
).hexdigest()
125142

143+
def anonymize(self, commit=True):
144+
"""
145+
Anonymize the user data for privacy purposes.
146+
147+
This method will erase the email address, the email hash, the password.
148+
You may overwrite this method to add additional fields to anonymize::
149+
150+
class MyUser(AbstractEmailUser):
151+
def anonymize(self, commit=True):
152+
super().anonymize(commit=False) # do not commit yet
153+
self.phone_number = None
154+
if commit:
155+
self.save()
156+
"""
157+
self.email = None
158+
self.first_name = ""
159+
self.last_name = ""
160+
if commit:
161+
self.save(update_fields=["email", "first_name", "last_name"])
162+
AnonymizeUserSignal.send(sender=self.__class__, user=self)
163+
126164

127165
delattr(AbstractEmailUser, "password")
128166

mailauth/forms.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import urllib
33

44
from django import forms
5+
from django.conf import settings
56
from django.contrib.auth import get_user_model
67
from django.contrib.sites.shortcuts import get_current_site
78
from django.core.mail import EmailMultiAlternatives
@@ -105,7 +106,8 @@ def __init__(self, request, *args, **kwargs):
105106
self.fields[self.field_name] = field
106107

107108
def get_users(self, email=None):
108-
if self.field_name == "email" and hasattr(get_user_model(), "email_hash"):
109+
anonymous_enabled = getattr(settings, "LOGIN_ANONYMOUS_ENABLED", False)
110+
if hasattr(get_user_model(), "email_hash") and anonymous_enabled:
109111
email_hash = hashlib.md5(email.lower().encode()).hexdigest()
110112
return get_user_model().objects.filter(email_hash=email_hash).iterator()
111113

tests/contrib/auth/test_admin.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from unittest.mock import Mock
2+
3+
import pytest
4+
from django.contrib import admin, messages
5+
6+
from mailauth.contrib.user.admin import AnonymizableAdminMixin
7+
from mailauth.contrib.user.models import EmailUser
8+
9+
10+
class TestAnonymizableAdminMixin:
11+
def test_anonymize__none(self, rf):
12+
class MyUserModel(EmailUser):
13+
class Meta:
14+
app_label = "test"
15+
verbose_name = "singular"
16+
verbose_name_plural = "plural"
17+
18+
class MyModelAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
19+
pass
20+
21+
request = rf.get("/")
22+
MyModelAdmin(MyUserModel, admin.site).anonymize(
23+
request, MyUserModel.objects.none()
24+
)
25+
26+
@pytest.mark.django_db
27+
def test_anonymize__one(self, rf, user, monkeypatch):
28+
class MyModelAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
29+
pass
30+
31+
monkeypatch.setattr(EmailUser, "anonymize", Mock())
32+
33+
request = rf.get("/")
34+
MyModelAdmin(type(user), admin.site).anonymize(
35+
request, type(user).objects.all()
36+
)
37+
assert EmailUser.anonymize.was_called_once_with(user)
38+
39+
@pytest.mark.django_db
40+
def test_anonymize__many(self, rf, user, monkeypatch):
41+
class MyModelAdmin(AnonymizableAdminMixin, admin.ModelAdmin):
42+
pass
43+
44+
monkeypatch.setattr(EmailUser, "anonymize", Mock())
45+
46+
request = rf.get("/")
47+
MyModelAdmin(type(user), admin.site).anonymize(
48+
request, type(user).objects.all()
49+
)
50+
assert EmailUser.anonymize.was_called_once_with(user)

tests/test_models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,23 @@ 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+
user.anonymize()
30+
assert not user.first_name
31+
assert not user.last_name
32+
assert not user.email
33+
assert user.email_hash
34+
35+
def test_anonymize__no_commit(self):
36+
user = models.EmailUser(
37+
email="ironman@avengers.com", first_name="Tony", last_name="Stark"
38+
)
39+
user.anonymize(commit=False)
40+
assert not user.first_name
41+
assert not user.last_name
42+
assert not user.email

0 commit comments

Comments
 (0)