Skip to content

Commit 394cc1b

Browse files
authored
feat: send triggered emails (#121)
* update paths * try entrypoint script * remove manage script * load fixtures command * fix: backends * allow anyone to get a CSRF cookie * rename session cookie * rename cookie * add contact * delete contact * email user helper * import contactable user * dotdigital settings * add personalization_values kwarg * service site url * fix signal helpers * merge from main * remove unnecessary helper function * fix: import * set previous values * has previous values * get previous value * fix check previous values * fix: previous_values_are_unequal * fix: previous_values_are_unequal * add none check * previous_values_are_unequal * fix teacher properties * rename settings
1 parent 567b237 commit 394cc1b

File tree

9 files changed

+394
-64
lines changed

9 files changed

+394
-64
lines changed

codeforlife/mail.py

Lines changed: 208 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,214 @@
55
Dotdigital helpers.
66
"""
77

8-
import os
8+
import json
9+
import logging
910
import typing as t
1011
from dataclasses import dataclass
1112

1213
import requests
14+
from django.conf import settings
1315

16+
from .types import JsonDict
1417

15-
# pylint: disable-next=unused-argument
16-
def add_contact(email: str):
17-
"""Add a new contact to Dotdigital."""
18-
# TODO: implement
18+
19+
@dataclass
20+
class Preference:
21+
"""The marketing preferences for a Dotdigital contact."""
22+
23+
@dataclass
24+
class Preference:
25+
"""
26+
The preference values to set in the category. Only supply if
27+
is_preference is false, and therefore referring to a preference
28+
category.
29+
"""
30+
31+
id: int
32+
is_preference: bool
33+
is_opted_in: bool
34+
35+
id: int
36+
is_preference: bool
37+
preferences: t.Optional[t.List[Preference]] = None
38+
is_opted_in: t.Optional[bool] = None
39+
40+
41+
# pylint: disable-next=too-many-arguments
42+
def add_contact(
43+
email: str,
44+
opt_in_type: t.Optional[
45+
t.Literal["Unknown", "Single", "Double", "VerifiedDouble"]
46+
] = None,
47+
email_type: t.Optional[t.Literal["PlainText, Html"]] = None,
48+
data_fields: t.Optional[t.Dict[str, str]] = None,
49+
consent_fields: t.Optional[t.List[t.Dict[str, str]]] = None,
50+
preferences: t.Optional[t.List[Preference]] = None,
51+
region: str = "r1",
52+
auth: t.Optional[str] = None,
53+
timeout: int = 30,
54+
):
55+
# pylint: disable=line-too-long
56+
"""Add a new contact to Dotdigital.
57+
58+
https://developer.dotdigital.com/reference/create-contact-with-consent-and-preferences
59+
60+
Args:
61+
email: The email address of the contact.
62+
opt_in_type: The opt-in type of the contact.
63+
email_type: The email type of the contact.
64+
data_fields: Each contact data field is a key-value pair; the key is a string matching the data field name in Dotdigital.
65+
consent_fields: The consent fields that apply to the contact.
66+
preferences: The marketing preferences to be applied.
67+
region: The Dotdigital region id your account belongs to e.g. r1, r2 or r3.
68+
auth: The authorization header used to enable API access. If None, the value will be retrieved from the MAIL_AUTH environment variable.
69+
timeout: Send timeout to avoid hanging.
70+
71+
Raises:
72+
AssertionError: If failed to add contact.
73+
"""
74+
# pylint: enable=line-too-long
75+
76+
if auth is None:
77+
auth = settings.MAIL_AUTH
78+
79+
contact: JsonDict = {"email": email.lower()}
80+
if opt_in_type is not None:
81+
contact["optInType"] = opt_in_type
82+
if email_type is not None:
83+
contact["emailType"] = email_type
84+
if data_fields is not None:
85+
contact["dataFields"] = [
86+
{"key": key, "value": value} for key, value in data_fields.items()
87+
]
88+
89+
body: JsonDict = {"contact": contact}
90+
if consent_fields is not None:
91+
body["consentFields"] = [
92+
{
93+
"fields": [
94+
{"key": key, "value": value}
95+
for key, value in fields.items()
96+
]
97+
}
98+
for fields in consent_fields
99+
]
100+
if preferences is not None:
101+
body["preferences"] = [
102+
{
103+
"id": preference.id,
104+
"isPreference": preference.is_preference,
105+
**(
106+
{}
107+
if preference.is_opted_in is None
108+
else {"isOptedIn": preference.is_opted_in}
109+
),
110+
**(
111+
{}
112+
if preference.preferences is None
113+
else {
114+
"preferences": [
115+
{
116+
"id": _preference.id,
117+
"isPreference": _preference.is_preference,
118+
"isOptedIn": _preference.is_opted_in,
119+
}
120+
for _preference in preference.preferences
121+
]
122+
}
123+
),
124+
}
125+
for preference in preferences
126+
]
127+
128+
if not settings.MAIL_ENABLED:
129+
logging.info(
130+
"Added contact to DotDigital:\n%s", json.dumps(body, indent=2)
131+
)
132+
return
133+
134+
response = requests.post(
135+
# pylint: disable-next=line-too-long
136+
url=f"https://{region}-api.dotdigital.com/v2/contacts/with-consent-and-preferences",
137+
json=body,
138+
headers={
139+
"accept": "application/json",
140+
"authorization": auth,
141+
},
142+
timeout=timeout,
143+
)
144+
145+
assert response.ok, (
146+
"Failed to add contact."
147+
f" Reason: {response.reason}."
148+
f" Text: {response.text}."
149+
)
19150

20151

21152
# pylint: disable-next=unused-argument
22-
def remove_contact(email: str):
23-
"""Remove an existing contact from Dotdigital."""
24-
# TODO: implement
153+
def remove_contact(
154+
contact_identifier: str,
155+
region: str = "r1",
156+
auth: t.Optional[str] = None,
157+
timeout: int = 30,
158+
):
159+
# pylint: disable=line-too-long
160+
"""Remove an existing contact from Dotdigital.
161+
162+
https://developer.dotdigital.com/reference/get-contact
163+
https://developer.dotdigital.com/reference/delete-contact
164+
165+
Args:
166+
contact_identifier: Either the contact id or email address of the contact.
167+
region: The Dotdigital region id your account belongs to e.g. r1, r2 or r3.
168+
auth: The authorization header used to enable API access. If None, the value will be retrieved from the MAIL_AUTH environment variable.
169+
timeout: Send timeout to avoid hanging.
170+
171+
Raises:
172+
AssertionError: If failed to get contact.
173+
AssertionError: If failed to delete contact.
174+
"""
175+
# pylint: enable=line-too-long
176+
177+
if not settings.MAIL_ENABLED:
178+
logging.info("Removed contact from DotDigital: %s", contact_identifier)
179+
return
180+
181+
if auth is None:
182+
auth = settings.MAIL_AUTH
183+
184+
response = requests.get(
185+
# pylint: disable-next=line-too-long
186+
url=f"https://{region}-api.dotdigital.com/v2/contacts/{contact_identifier}",
187+
headers={
188+
"accept": "application/json",
189+
"authorization": auth,
190+
},
191+
timeout=timeout,
192+
)
193+
194+
assert response.ok, (
195+
"Failed to get contact."
196+
f" Reason: {response.reason}."
197+
f" Text: {response.text}."
198+
)
199+
200+
contact_id: int = response.json()["id"]
201+
202+
response = requests.delete(
203+
url=f"https://{region}-api.dotdigital.com/v2/contacts/{contact_id}",
204+
headers={
205+
"accept": "application/json",
206+
"authorization": auth,
207+
},
208+
timeout=timeout,
209+
)
210+
211+
assert response.ok, (
212+
"Failed to delete contact."
213+
f" Reason: {response.reason}."
214+
f" Text: {response.text}."
215+
)
25216

26217

27218
@dataclass
@@ -62,7 +253,7 @@ def send_mail(
62253
metadata: The metadata for your email. It can be either a single value or a series of values in a JSON object.
63254
attachments: A Base64 encoded string. All attachment types are supported. Maximum file size: 15 MB.
64255
region: The Dotdigital region id your account belongs to e.g. r1, r2 or r3.
65-
auth: The authorization header used to enable API access. If None, the value will be retrieved from the DOTDIGITAL_AUTH environment variable.
256+
auth: The authorization header used to enable API access. If None, the value will be retrieved from the MAIL_AUTH environment variable.
66257
timeout: Send timeout to avoid hanging.
67258
68259
Raises:
@@ -71,7 +262,7 @@ def send_mail(
71262
# pylint: enable=line-too-long
72263

73264
if auth is None:
74-
auth = os.environ["DOTDIGITAL_AUTH"]
265+
auth = settings.MAIL_AUTH
75266

76267
body = {
77268
"campaignId": campaign_id,
@@ -103,6 +294,13 @@ def send_mail(
103294
for attachment in attachments
104295
]
105296

297+
if not settings.MAIL_ENABLED:
298+
logging.info(
299+
"Sent a triggered email with DotDigital:\n%s",
300+
json.dumps(body, indent=2),
301+
)
302+
return
303+
106304
response = requests.post(
107305
url=f"https://{region}-api.dotdigital.com/v2/email/triggered-campaign",
108306
json=body,

codeforlife/models/signals/__init__.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,5 @@
77
"""
88

99

10-
from .general import (
11-
UpdateFields,
12-
assert_update_fields_includes,
13-
update_fields_includes,
14-
)
10+
from .general import UpdateFields, update_fields_includes
1511
from .receiver import model_receiver

codeforlife/models/signals/general.py

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,28 +19,6 @@ def update_fields_includes(update_fields: UpdateFields, includes: t.Set[str]):
1919
includes: The fields that should be included in the update-fields.
2020
2121
Returns:
22-
The fields missing in the update-fields. If update-fields is None, None
23-
is returned.
22+
A flag designating if the fields are included in the update-fields.
2423
"""
25-
26-
if update_fields is None:
27-
return None
28-
29-
return includes.difference(update_fields)
30-
31-
32-
def assert_update_fields_includes(
33-
update_fields: UpdateFields, includes: t.Set[str]
34-
):
35-
"""Assert the call to .save() includes the update-fields specified.
36-
37-
Args:
38-
update_fields: The update-fields provided in the call to .save().
39-
includes: The fields that should be included in the update-fields.
40-
"""
41-
missing_update_fields = update_fields_includes(update_fields, includes)
42-
if missing_update_fields is not None:
43-
assert not missing_update_fields, (
44-
"Call to .save() did not include the following update-fields: "
45-
f"{', '.join(missing_update_fields)}."
46-
)
24+
return update_fields and includes.issubset(update_fields)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""
2+
© Ocado Group
3+
Created on 20/06/2024 at 11:46:02(+01:00).
4+
5+
Helpers for module "django.db.models.signals.post_save".
6+
https://docs.djangoproject.com/en/3.2/ref/signals/#post-save
7+
"""
8+
9+
import typing as t
10+
11+
from . import general as _
12+
from .pre_save import PREVIOUS_VALUE_KEY
13+
14+
FieldValue = t.TypeVar("FieldValue")
15+
16+
17+
def check_previous_values(
18+
instance: _.AnyModel,
19+
predicates: t.Dict[str, t.Callable[[t.Any], bool]],
20+
):
21+
# pylint: disable=line-too-long
22+
"""Check if the previous values are as expected. If the previous value's key
23+
is not on the model, this check returns False.
24+
25+
Args:
26+
instance: The current instance.
27+
predicates: A predicate for each field. The previous value is passed in as an arg and it should return True if the previous value is as expected.
28+
29+
Returns:
30+
If all the previous values are as expected.
31+
"""
32+
# pylint: enable=line-too-long
33+
34+
for field, predicate in predicates.items():
35+
previous_value_key = PREVIOUS_VALUE_KEY.format(field=field)
36+
37+
if not hasattr(instance, previous_value_key) or not predicate(
38+
getattr(instance, previous_value_key)
39+
):
40+
return False
41+
42+
return True
43+
44+
45+
def previous_values_are_unequal(instance: _.AnyModel, fields: t.Set[str]):
46+
# pylint: disable=line-too-long
47+
"""Check if all the previous values are not equal to the current values. If
48+
the previous value's key is not on the model, this check returns False.
49+
50+
Args:
51+
instance: The current instance.
52+
fields: The fields that should not be equal.
53+
54+
Returns:
55+
If all the previous values are not equal to the current values.
56+
"""
57+
# pylint: enable=line-too-long
58+
59+
for field in fields:
60+
previous_value_key = PREVIOUS_VALUE_KEY.format(field=field)
61+
62+
if not hasattr(instance, previous_value_key) or (
63+
getattr(instance, field) == getattr(instance, previous_value_key)
64+
):
65+
return False
66+
67+
return True
68+
69+
70+
def get_previous_value(
71+
instance: _.AnyModel, field: str, cls: t.Type[FieldValue]
72+
):
73+
# pylint: disable=line-too-long
74+
"""Get a previous value from the instance and assert the value is of the
75+
expected type.
76+
77+
Args:
78+
instance: The current instance.
79+
field: The field to get the previous value for.
80+
cls: The expected type of the value.
81+
82+
Returns:
83+
The previous value of the field.
84+
"""
85+
# pylint: enable=line-too-long
86+
87+
previous_value = getattr(instance, PREVIOUS_VALUE_KEY.format(field=field))
88+
89+
assert isinstance(previous_value, cls)
90+
91+
return previous_value

0 commit comments

Comments
 (0)