Skip to content

Commit 5e870d4

Browse files
ericholscherhumitosstsewd
authored
Change mailing list subscription to when the user validates their email (#9384)
* Change mailing list subscription to when the user validates their email This change also adds support for subscribing the user to an onboarding group. Users in this group will get custom onboarding message(s), to help them along on their usage of the platform. * Update tests * Update readthedocs/core/signals.py Co-authored-by: Santos Gallegos <[email protected]> * Migrate date NULL values to `timezone.now()` * TODO note to migrate the signal into a Celery task * Typo * Darker files * Update readthedocs/core/signals.py Co-authored-by: Eric Holscher <[email protected]> Co-authored-by: Manuel Kaufmann <[email protected]> Co-authored-by: Santos Gallegos <[email protected]>
1 parent e5c1d5b commit 5e870d4

File tree

6 files changed

+187
-91
lines changed

6 files changed

+187
-91
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Generated by Django 3.2.13 on 2022-06-28 21:52
2+
3+
import django.utils.timezone
4+
import django_extensions.db.fields
5+
from django.db import migrations, models
6+
from django.db.models import Q
7+
8+
9+
def migrate_null_values(apps, schema_editor):
10+
UserProfile = apps.get_model("core", "UserProfile")
11+
query = Q(created__isnull=True) | Q(modified__isnull=True)
12+
queryset = UserProfile.objects.filter(query)
13+
now = django.utils.timezone.now()
14+
for profile in queryset.iterator():
15+
profile.created = profile.modified = now
16+
profile.save()
17+
18+
19+
class Migration(migrations.Migration):
20+
21+
dependencies = [
22+
("core", "0011_alter_historicaluser_first_name"),
23+
]
24+
25+
operations = [
26+
migrations.AddField(
27+
model_name="historicaluserprofile",
28+
name="mailing_list",
29+
field=models.BooleanField(
30+
default=False,
31+
help_text="Subscribe to our mailing list, and get helpful onboarding suggestions.",
32+
),
33+
),
34+
migrations.AddField(
35+
model_name="userprofile",
36+
name="mailing_list",
37+
field=models.BooleanField(
38+
default=False,
39+
help_text="Subscribe to our mailing list, and get helpful onboarding suggestions.",
40+
),
41+
),
42+
# Make NULL values for modified/created to be ``timezone.now()``
43+
migrations.RunPython(migrate_null_values),
44+
migrations.AlterField(
45+
model_name="historicaluserprofile",
46+
name="created",
47+
field=django_extensions.db.fields.CreationDateTimeField(
48+
auto_now_add=True,
49+
default=django.utils.timezone.now,
50+
verbose_name="created",
51+
),
52+
preserve_default=False,
53+
),
54+
migrations.AlterField(
55+
model_name="historicaluserprofile",
56+
name="modified",
57+
field=django_extensions.db.fields.ModificationDateTimeField(
58+
auto_now=True,
59+
default=django.utils.timezone.now,
60+
verbose_name="modified",
61+
),
62+
preserve_default=False,
63+
),
64+
migrations.AlterField(
65+
model_name="userprofile",
66+
name="created",
67+
field=django_extensions.db.fields.CreationDateTimeField(
68+
auto_now_add=True,
69+
default=django.utils.timezone.now,
70+
verbose_name="created",
71+
),
72+
preserve_default=False,
73+
),
74+
migrations.AlterField(
75+
model_name="userprofile",
76+
name="modified",
77+
field=django_extensions.db.fields.ModificationDateTimeField(
78+
auto_now=True,
79+
default=django.utils.timezone.now,
80+
verbose_name="modified",
81+
),
82+
preserve_default=False,
83+
),
84+
]

readthedocs/core/models.py

+16-19
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,6 @@
66
from django.urls import reverse
77
from django.utils.translation import gettext
88
from django.utils.translation import gettext_lazy as _
9-
from django_extensions.db.fields import (
10-
CreationDateTimeField,
11-
ModificationDateTimeField,
12-
)
139
from django_extensions.db.models import TimeStampedModel
1410
from simple_history import register
1511

@@ -20,33 +16,34 @@ class UserProfile(TimeStampedModel):
2016

2117
"""Additional information about a User."""
2218

23-
# TODO: Overridden from TimeStampedModel just to allow null values,
24-
# remove after deploy.
25-
created = CreationDateTimeField(
26-
_('created'),
27-
null=True,
28-
blank=True,
29-
)
30-
modified = ModificationDateTimeField(
31-
_('modified'),
32-
null=True,
33-
blank=True,
34-
)
35-
3619
user = AutoOneToOneField(
3720
User,
3821
verbose_name=_('User'),
3922
related_name='profile',
4023
on_delete=models.CASCADE,
4124
)
42-
whitelisted = models.BooleanField(_('Whitelisted'), default=False)
43-
banned = models.BooleanField(_('Banned'), default=False)
25+
# Shown on the users profile
4426
homepage = models.CharField(_('Homepage'), max_length=100, blank=True)
27+
28+
# User configuration options
4529
allow_ads = models.BooleanField(
4630
_('See paid advertising'),
4731
help_text=_('If unchecked, you will still see community ads.'),
4832
default=True,
4933
)
34+
35+
mailing_list = models.BooleanField(
36+
default=False,
37+
help_text=_(
38+
"Subscribe to our mailing list, and get helpful onboarding suggestions."
39+
),
40+
)
41+
42+
# Internal tracking
43+
whitelisted = models.BooleanField(_("Whitelisted"), default=False)
44+
banned = models.BooleanField(_("Banned"), default=False)
45+
46+
# Model history
5047
history = ExtraHistoricalRecords()
5148

5249
def __str__(self):

readthedocs/core/signals.py

+53
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Signal handling for core app."""
22

3+
import requests
34
import structlog
5+
from allauth.account.signals import email_confirmed
46
from corsheaders import signals
57
from django.conf import settings
68
from django.db.models.signals import pre_delete
@@ -11,6 +13,7 @@
1113

1214
from readthedocs.analytics.utils import get_client_ip
1315
from readthedocs.builds.models import Version
16+
from readthedocs.core.models import UserProfile
1417
from readthedocs.core.unresolver import unresolve
1518
from readthedocs.organizations.models import Organization
1619
from readthedocs.projects.models import Project
@@ -33,6 +36,56 @@
3336
post_collectstatic = Signal()
3437

3538

39+
@receiver(email_confirmed)
40+
def process_email_confirmed(request, email_address, **kwargs):
41+
"""
42+
Steps to take after a users email address is confirmed.
43+
44+
We currently:
45+
46+
* Subscribe the user to the mailing list if they set this in their signup.
47+
* Add them to a drip campaign for new users in the new user group.
48+
"""
49+
50+
# email_address is an object
51+
# https://github.com/pennersr/django-allauth/blob/6315e25/allauth/account/models.py#L15
52+
user = email_address.user
53+
profile = UserProfile.objects.filter(user=user).first()
54+
if profile and profile.mailing_list:
55+
# TODO: Unsubscribe users if they unset `mailing_list`.
56+
log.bind(
57+
email=email_address.email,
58+
username=user.username,
59+
)
60+
log.info("Subscribing user to newsletter and onboarding group.")
61+
62+
# Try subscribing user to a group, then fallback to normal subscription API
63+
url = settings.MAILERLITE_API_ONBOARDING_GROUP_URL
64+
if not url:
65+
url = settings.MAILERLITE_API_SUBSCRIBERS_URL
66+
67+
payload = {
68+
"email": email_address.email,
69+
"resubscribe": True,
70+
}
71+
headers = {
72+
"X-MailerLite-ApiKey": settings.MAILERLITE_API_KEY,
73+
}
74+
try:
75+
# TODO: migrate this signal to a Celery task since it has a `requests.post` on it.
76+
resp = requests.post(
77+
url,
78+
json=payload,
79+
headers=headers,
80+
timeout=3, # seconds
81+
)
82+
resp.raise_for_status()
83+
except requests.Timeout:
84+
log.warning("Timeout subscribing user to newsletter.")
85+
except Exception: # noqa
86+
log.exception("Unknown error subscribing user to newsletter.")
87+
88+
3689
def _has_donate_app():
3790
"""
3891
Check if the current app has the sustainability API.

readthedocs/core/tests/test_signup.py

+27-41
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,48 @@
1-
from http import HTTPStatus
21
from unittest.mock import patch
32

4-
import requests
5-
import requests_mock
6-
7-
from django.test import TestCase
3+
from allauth.account.models import EmailAddress
4+
from allauth.account.signals import email_confirmed
85
from django.conf import settings
9-
10-
from readthedocs.forms import SignupFormWithNewsletter
6+
from django.test import TestCase
117

128

139
class TestNewsletterSignup(TestCase):
14-
form_data = {
15-
"email": "[email protected]",
16-
"username": "test123",
17-
"password1": "123456",
18-
"password2": "123456",
19-
}
20-
form_data_plus_checkbox = form_data.copy()
21-
form_data_plus_checkbox["receive_newsletter"] = True
22-
23-
@patch("readthedocs.forms.requests.post")
10+
def setUp(self):
11+
self.form_data = {
12+
"email": "[email protected]",
13+
"username": "test123",
14+
"password1": "123456",
15+
"password2": "123456",
16+
}
17+
self.form_data_plus_checkbox = self.form_data.copy()
18+
self.form_data_plus_checkbox["receive_newsletter"] = True
19+
20+
@patch("readthedocs.core.signals.requests.post")
2421
def test_signup_without_checkbox_does_not_subscribe(self, mock_requests_post):
2522
response = self.client.post("/accounts/signup/", data=self.form_data)
26-
27-
assert response.status_code == HTTPStatus.FOUND
23+
email_confirmed.send(
24+
sender=None,
25+
request=None,
26+
email_address=EmailAddress.objects.get(email=self.form_data["email"]),
27+
)
2828

2929
mock_requests_post.assert_not_called()
3030

31-
32-
@patch("readthedocs.forms.requests.post")
33-
def test_signup_with_checkbox_calls_subscribe_api(self, mock_requests_post):
31+
@patch("readthedocs.core.signals.requests.post")
32+
def test_signup_calls_subscribe_api(self, mock_requests_post):
3433
response = self.client.post("/accounts/signup/", data=self.form_data_plus_checkbox)
35-
36-
assert response.status_code == HTTPStatus.FOUND, response
34+
email_confirmed.send(
35+
sender=None,
36+
request=None,
37+
email_address=EmailAddress.objects.get(email=self.form_data["email"]),
38+
)
3739

3840
mock_requests_post.assert_called_with(
3941
settings.MAILERLITE_API_SUBSCRIBERS_URL,
4042
json={
41-
"email": self.form_data_plus_checkbox["email"],
43+
"email": self.form_data["email"],
4244
"resubscribe": True,
4345
},
4446
headers={"X-MailerLite-ApiKey": settings.MAILERLITE_API_KEY},
4547
timeout=3,
4648
)
47-
48-
@requests_mock.Mocker(kw='mock_request')
49-
def test_signup_with_checkbox_succeeds_if_timeout(self, mock_request):
50-
mock_request.post(settings.MAILERLITE_API_SUBSCRIBERS_URL, exc=requests.Timeout)
51-
52-
response = self.client.post("/accounts/signup/", data=self.form_data_plus_checkbox)
53-
54-
assert response.status_code == HTTPStatus.FOUND, response
55-
56-
@requests_mock.Mocker(kw='mock_request')
57-
def test_signup_with_checkbox_succeeds_if_bad_response(self, mock_request):
58-
mock_request.post(settings.MAILERLITE_API_SUBSCRIBERS_URL, status_code=400)
59-
60-
response = self.client.post("/accounts/signup/", data=self.form_data_plus_checkbox)
61-
62-
assert response.status_code == HTTPStatus.FOUND, response

readthedocs/forms.py

+6-31
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
"""Community site-wide form overrides."""
22

33
import structlog
4-
5-
import requests
6-
74
from allauth.account.forms import SignupForm
8-
from django.conf import settings
95
from django import forms
106

7+
from readthedocs.core.models import UserProfile
8+
119
log = structlog.get_logger(__name__) # noqa
1210

1311

@@ -33,32 +31,9 @@ class SignupFormWithNewsletter(SignupForm):
3331
def save(self, request):
3432
user = super().save(request)
3533

36-
if self.cleaned_data.get("receive_newsletter"):
37-
log.bind(
38-
user_email=self.cleaned_data["email"],
39-
user_username=user.username,
40-
)
41-
log.info('Subscribing user to newsletter.')
42-
43-
url = settings.MAILERLITE_API_SUBSCRIBERS_URL
44-
payload = {
45-
'email': self.cleaned_data["email"],
46-
'resubscribe': True,
47-
}
48-
headers = {
49-
'X-MailerLite-ApiKey': settings.MAILERLITE_API_KEY,
50-
}
51-
try:
52-
resp = requests.post(
53-
url,
54-
json=payload,
55-
headers=headers,
56-
timeout=3, # seconds
57-
)
58-
resp.raise_for_status()
59-
except requests.Timeout:
60-
log.warning('Timeout subscribing user to newsletter.')
61-
except Exception: # noqa
62-
log.exception('Unknown error subscribing user to newsletter.')
34+
receive_newsletter = self.cleaned_data.get("receive_newsletter")
35+
profile, _ = UserProfile.objects.get_or_create(user=user)
36+
profile.mailing_list = receive_newsletter
37+
profile.save()
6338

6439
return user

readthedocs/settings/base.py

+1
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,7 @@ def DOCKER_LIMITS(self):
974974

975975
# MailerLite API for newsletter signups
976976
MAILERLITE_API_SUBSCRIBERS_URL = 'https://api.mailerlite.com/api/v2/subscribers'
977+
MAILERLITE_API_ONBOARDING_GROUP_URL = None
977978
MAILERLITE_API_KEY = None
978979

979980
RTD_EMBED_API_EXTERNAL_DOMAINS = [

0 commit comments

Comments
 (0)