Skip to content

Commit e481607

Browse files
authored
Merge pull request #15 from django-denmark/unsubscribe-all
Adding new views for undoing all consent associated with an email
2 parents f879592 + dcc5014 commit e481607

File tree

11 files changed

+189
-9
lines changed

11 files changed

+189
-9
lines changed

src/django_consent/models.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,6 @@ class UserConsent(models.Model):
8080
email_confirmed = models.BooleanField(default=False)
8181
email_hash = models.UUIDField()
8282

83-
@property
84-
def email(self):
85-
return self.user.email
86-
8783
def email_confirmation(self, request=None):
8884
"""
8985
Sends a confirmation email if necessary
@@ -143,14 +139,14 @@ def capture_email_consent(cls, source, email, require_confirmation=False):
143139
consent_create_kwargs["email_confirmation_requested"] = timezone.now()
144140
return cls.objects.create(source=source, user=user, **consent_create_kwargs)
145141

146-
def optout(self):
142+
def optout(self, is_everything=False):
147143
"""
148144
Ensures that user is opted out of this consent.
149145
"""
150146
return EmailOptOut.objects.get_or_create(
151147
user=self.user,
152148
consent=self,
153-
is_everything=False,
149+
is_everything=is_everything,
154150
)[0]
155151

156152
def confirm(self):
@@ -175,6 +171,10 @@ def is_valid(self):
175171
or self.user.email_optouts.filter(is_everything=True).exists()
176172
)
177173

174+
@property
175+
def email(self):
176+
return self.user.email
177+
178178
@property
179179
def confirm_token(self):
180180
return utils.get_consent_token(self, salt=consent_settings.CONFIRM_SALT)

src/django_consent/settings.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@
66
settings, "CONSENT_UNSUBSCRIBE_SALT", "django-consent-unsubscribe"
77
)
88

9+
#: You can harden security by adding a different salt in your project's settings
10+
UNSUBSCRIBE_ALL_SALT = getattr(
11+
settings, "CONSENT_UNSUBSCRIBE_SALT", "django-consent-unsubscribe-all"
12+
)
13+
914
#: You can harden security by adding a different salt in your project's settings
1015
CONFIRM_SALT = getattr(settings, "CONSENT_CONFIRM_SALT", "django-consent-confirm")
1116

1217
#: For more information, `django-ratelimit <https://django-ratelimit.readthedocs.io/en/stable/>`__
13-
RATELIMIT = getattr(settings, "CONSENT_RATELIMIT", "100/h")
18+
RATELIMIT = getattr(settings, "CONSENT_RATELIMIT", "100/h")
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{% extends "consent/base.html" %}
22
{% block consent_content %}
3-
<p>Your consent has been withdrawn!</p>
3+
<p>A consent for <strong>{{ consent.email|default:"unknown/deleted" }}</strong> has been withdrawn!</p>
44

55
<a href="{% url "consent:unsubscribe_undo" pk=consent.id token=token %}"></a>
66
{% endblock %}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{% extends "consent/base.html" %}
22
{% block consent_content %}
3-
<p>Your consent optout was undone and consent is re-registered.</p>
3+
<p>A consent optout was undone and consent is re-registered.</p>
44
{% endblock %}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{% extends "consent/base.html" %}
2+
{% block consent_content %}
3+
<p>Your consent for everything associated to the email address <strong>{{ consent.email|default:"unknown/deleted" }}</strong> has been withdrawn!</p>
4+
5+
<a href="{% url "consent:unsubscribe_all_undo" pk=consent.id token=token %}"></a>
6+
{% endblock %}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{% extends "consent/base.html" %}
2+
{% block consent_content %}
3+
<p>Your consent optout for <strong>{{ consent.user.email|default:"unknown/deleted" }}</strong> was undone and consent is re-registered.</p>
4+
{% endblock %}

src/django_consent/urls.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,14 @@
2222
views.ConsentWithdrawUndoView.as_view(),
2323
name="unsubscribe_undo",
2424
),
25+
path(
26+
"unsubscribe-all/<int:pk>/<str:token>/",
27+
views.ConsentWithdrawAllView.as_view(),
28+
name="unsubscribe_all",
29+
),
30+
path(
31+
"unsubscribe-all/<int:pk>/<str:token>/undo/",
32+
views.ConsentWithdrawAllUndoView.as_view(),
33+
name="unsubscribe_all_undo",
34+
),
2535
]

src/django_consent/views.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ class ConsentWithdrawView(UserConsentActionView):
106106
template_name = "consent/user/unsubscribe/done.html"
107107
model = models.UserConsent
108108
context_object_name = "consent"
109+
token_salt = consent_settings.UNSUBSCRIBE_SALT
109110

110111
def action(self, consent):
111112
consent.optout()
@@ -120,11 +121,47 @@ class ConsentWithdrawUndoView(UserConsentActionView):
120121
"""
121122

122123
template_name = "consent/user/unsubscribe/undo.html"
124+
token_salt = consent_settings.UNSUBSCRIBE_SALT
123125

124126
def action(self, consent):
125127
consent.optouts.all().delete()
126128

127129

130+
class ConsentWithdrawAllView(UserConsentActionView):
131+
"""
132+
Withdraws a consent. In the case of a newsletter, it unsubscribes a user
133+
from receiving the newsletter.
134+
135+
Requires a valid link with a token.
136+
"""
137+
138+
template_name = "consent/user/unsubscribe_all/done.html"
139+
model = models.UserConsent
140+
context_object_name = "consent"
141+
token_salt = consent_settings.UNSUBSCRIBE_ALL_SALT
142+
143+
def action(self, consent):
144+
consent.optout(is_everything=True)
145+
146+
147+
class ConsentWithdrawAllUndoView(UserConsentActionView):
148+
"""
149+
This is related to undoing withdrawal of consent in case that the user
150+
clicked the wrong link.
151+
152+
Requires a valid link
153+
154+
This only cancels an withdrawal of everything that was related to this
155+
particular consent. Another withdrawal can still exist.
156+
"""
157+
158+
template_name = "consent/user/unsubscribe_all/undo.html"
159+
token_salt = consent_settings.UNSUBSCRIBE_ALL_SALT
160+
161+
def action(self, consent):
162+
consent.optouts.filter(is_everything=True).delete()
163+
164+
128165
class ConsentConfirmationReceiveView(UserConsentActionView):
129166
"""
130167
Marks a consent as confirmed, this is important for items that require a

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Needed for fixtures to be visible in all test_* modules
22
from .fixtures import base_consent # noqa
33
from .fixtures import create_user # noqa
4+
from .fixtures import many_consents # noqa
5+
from .fixtures import many_consents_per_user # noqa
46
from .fixtures import test_password # noqa
57
from .fixtures import user_consent # noqa

tests/fixtures.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,53 @@ def user_consent(base_consent):
5757
}
5858

5959

60+
@pytest.fixture
61+
def many_consents():
62+
"""
63+
Generate several consents
64+
"""
65+
sources = {
66+
"monthly newsletter": "You agree to receiving a newsletter about our activities every month",
67+
"vogon poetry": "You agree that the head bureaucrat can send you their poetry randomly",
68+
"messages": "Other members can send you a message",
69+
}
70+
consents = []
71+
for name, description in sources.items():
72+
consents.append(
73+
models.ConsentSource.objects.create(
74+
source_name=name,
75+
definition=description,
76+
)
77+
)
78+
return consents
79+
80+
81+
@pytest.fixture
82+
def many_consents_per_user(many_consents):
83+
"""
84+
This fixture creates several consents for random users
85+
"""
86+
user_consents = []
87+
for source in models.ConsentSource.objects.all():
88+
for __ in range(10):
89+
user_consents.append(
90+
models.UserConsent.capture_email_consent(
91+
source, get_random_email(), require_confirmation=False
92+
)
93+
)
94+
for __ in range(10):
95+
user_consents.append(
96+
models.UserConsent.capture_email_consent(
97+
source, get_random_email(), require_confirmation=True
98+
)
99+
)
100+
101+
return {
102+
"many_consents": many_consents,
103+
"user_consents": user_consents,
104+
}
105+
106+
60107
@pytest.fixture
61108
def test_password():
62109
return "strong-test-pass"

0 commit comments

Comments
 (0)