Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: send triggered emails #121

Merged
merged 29 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8c3bcb9
update paths
SKairinos Jun 10, 2024
0599c48
try entrypoint script
SKairinos Jun 10, 2024
408a76c
remove manage script
SKairinos Jun 10, 2024
34d5b8c
load fixtures command
SKairinos Jun 10, 2024
c6776f1
fix: backends
SKairinos Jun 12, 2024
e3e7d96
allow anyone to get a CSRF cookie
SKairinos Jun 12, 2024
35d8aad
rename session cookie
SKairinos Jun 12, 2024
c4bf178
rename cookie
SKairinos Jun 13, 2024
c6ab1d8
add contact
SKairinos Jun 14, 2024
842bcf1
delete contact
SKairinos Jun 14, 2024
ef3bc0c
email user helper
SKairinos Jun 14, 2024
63adc8b
import contactable user
SKairinos Jun 14, 2024
5bc14d3
dotdigital settings
SKairinos Jun 14, 2024
0186f41
add personalization_values kwarg
SKairinos Jun 14, 2024
5e2b437
service site url
SKairinos Jun 14, 2024
a6e5ce6
fix signal helpers
SKairinos Jun 18, 2024
7e0f542
merge from main
SKairinos Jun 20, 2024
342d98c
remove unnecessary helper function
SKairinos Jun 20, 2024
3213241
fix: import
SKairinos Jun 20, 2024
567cbc0
set previous values
SKairinos Jun 20, 2024
f021d88
has previous values
SKairinos Jun 20, 2024
7d7f74b
get previous value
SKairinos Jun 20, 2024
d028ccd
fix check previous values
SKairinos Jun 20, 2024
8e7e28d
fix: previous_values_are_unequal
SKairinos Jun 20, 2024
f104a4e
fix: previous_values_are_unequal
SKairinos Jun 20, 2024
1e961b2
add none check
SKairinos Jun 21, 2024
b00d7de
previous_values_are_unequal
SKairinos Jun 21, 2024
1f19565
fix teacher properties
SKairinos Jun 21, 2024
c0f4a70
rename settings
SKairinos Jun 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 208 additions & 10 deletions codeforlife/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,214 @@
Dotdigital helpers.
"""

import os
import json
import logging
import typing as t
from dataclasses import dataclass

import requests
from django.conf import settings

from .types import JsonDict

# pylint: disable-next=unused-argument
def add_contact(email: str):
"""Add a new contact to Dotdigital."""
# TODO: implement

@dataclass
class Preference:
"""The marketing preferences for a Dotdigital contact."""

@dataclass
class Preference:
"""
The preference values to set in the category. Only supply if
is_preference is false, and therefore referring to a preference
category.
"""

id: int
is_preference: bool
is_opted_in: bool

id: int
is_preference: bool
preferences: t.Optional[t.List[Preference]] = None
is_opted_in: t.Optional[bool] = None


# pylint: disable-next=too-many-arguments
def add_contact(
email: str,
opt_in_type: t.Optional[
t.Literal["Unknown", "Single", "Double", "VerifiedDouble"]
] = None,
email_type: t.Optional[t.Literal["PlainText, Html"]] = None,
data_fields: t.Optional[t.Dict[str, str]] = None,
consent_fields: t.Optional[t.List[t.Dict[str, str]]] = None,
preferences: t.Optional[t.List[Preference]] = None,
region: str = "r1",
auth: t.Optional[str] = None,
timeout: int = 30,
):
# pylint: disable=line-too-long
"""Add a new contact to Dotdigital.

https://developer.dotdigital.com/reference/create-contact-with-consent-and-preferences

Args:
email: The email address of the contact.
opt_in_type: The opt-in type of the contact.
email_type: The email type of the contact.
data_fields: Each contact data field is a key-value pair; the key is a string matching the data field name in Dotdigital.
consent_fields: The consent fields that apply to the contact.
preferences: The marketing preferences to be applied.
region: The Dotdigital region id your account belongs to e.g. r1, r2 or r3.
auth: The authorization header used to enable API access. If None, the value will be retrieved from the MAIL_AUTH environment variable.
timeout: Send timeout to avoid hanging.

Raises:
AssertionError: If failed to add contact.
"""
# pylint: enable=line-too-long

if auth is None:
auth = settings.MAIL_AUTH

Check warning on line 77 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L76-L77

Added lines #L76 - L77 were not covered by tests

contact: JsonDict = {"email": email.lower()}
if opt_in_type is not None:
contact["optInType"] = opt_in_type
if email_type is not None:
contact["emailType"] = email_type
if data_fields is not None:
contact["dataFields"] = [

Check warning on line 85 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L79-L85

Added lines #L79 - L85 were not covered by tests
{"key": key, "value": value} for key, value in data_fields.items()
]

body: JsonDict = {"contact": contact}
if consent_fields is not None:
body["consentFields"] = [

Check warning on line 91 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L89-L91

Added lines #L89 - L91 were not covered by tests
{
"fields": [
{"key": key, "value": value}
for key, value in fields.items()
]
}
for fields in consent_fields
]
if preferences is not None:
body["preferences"] = [

Check warning on line 101 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L100-L101

Added lines #L100 - L101 were not covered by tests
{
"id": preference.id,
"isPreference": preference.is_preference,
**(
{}
if preference.is_opted_in is None
else {"isOptedIn": preference.is_opted_in}
),
**(
{}
if preference.preferences is None
else {
"preferences": [
{
"id": _preference.id,
"isPreference": _preference.is_preference,
"isOptedIn": _preference.is_opted_in,
}
for _preference in preference.preferences
]
}
),
}
for preference in preferences
]

if not settings.MAIL_ENABLED:
logging.info(

Check warning on line 129 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L128-L129

Added lines #L128 - L129 were not covered by tests
"Added contact to DotDigital:\n%s", json.dumps(body, indent=2)
)
return

Check warning on line 132 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L132

Added line #L132 was not covered by tests

response = requests.post(

Check warning on line 134 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L134

Added line #L134 was not covered by tests
# pylint: disable-next=line-too-long
url=f"https://{region}-api.dotdigital.com/v2/contacts/with-consent-and-preferences",
json=body,
headers={
"accept": "application/json",
"authorization": auth,
},
timeout=timeout,
)

assert response.ok, (

Check warning on line 145 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L145

Added line #L145 was not covered by tests
"Failed to add contact."
f" Reason: {response.reason}."
f" Text: {response.text}."
)


# pylint: disable-next=unused-argument
def remove_contact(email: str):
"""Remove an existing contact from Dotdigital."""
# TODO: implement
def remove_contact(
contact_identifier: str,
region: str = "r1",
auth: t.Optional[str] = None,
timeout: int = 30,
):
# pylint: disable=line-too-long
"""Remove an existing contact from Dotdigital.

https://developer.dotdigital.com/reference/get-contact
https://developer.dotdigital.com/reference/delete-contact

Args:
contact_identifier: Either the contact id or email address of the contact.
region: The Dotdigital region id your account belongs to e.g. r1, r2 or r3.
auth: The authorization header used to enable API access. If None, the value will be retrieved from the MAIL_AUTH environment variable.
timeout: Send timeout to avoid hanging.

Raises:
AssertionError: If failed to get contact.
AssertionError: If failed to delete contact.
"""
# pylint: enable=line-too-long

if not settings.MAIL_ENABLED:
logging.info("Removed contact from DotDigital: %s", contact_identifier)
return

Check warning on line 179 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L177-L179

Added lines #L177 - L179 were not covered by tests

if auth is None:
auth = settings.MAIL_AUTH

Check warning on line 182 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L181-L182

Added lines #L181 - L182 were not covered by tests

response = requests.get(

Check warning on line 184 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L184

Added line #L184 was not covered by tests
# pylint: disable-next=line-too-long
url=f"https://{region}-api.dotdigital.com/v2/contacts/{contact_identifier}",
headers={
"accept": "application/json",
"authorization": auth,
},
timeout=timeout,
)

assert response.ok, (

Check warning on line 194 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L194

Added line #L194 was not covered by tests
"Failed to get contact."
f" Reason: {response.reason}."
f" Text: {response.text}."
)

contact_id: int = response.json()["id"]

Check warning on line 200 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L200

Added line #L200 was not covered by tests

response = requests.delete(

Check warning on line 202 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L202

Added line #L202 was not covered by tests
url=f"https://{region}-api.dotdigital.com/v2/contacts/{contact_id}",
headers={
"accept": "application/json",
"authorization": auth,
},
timeout=timeout,
)

assert response.ok, (

Check warning on line 211 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L211

Added line #L211 was not covered by tests
"Failed to delete contact."
f" Reason: {response.reason}."
f" Text: {response.text}."
)


@dataclass
Expand Down Expand Up @@ -62,7 +253,7 @@
metadata: The metadata for your email. It can be either a single value or a series of values in a JSON object.
attachments: A Base64 encoded string. All attachment types are supported. Maximum file size: 15 MB.
region: The Dotdigital region id your account belongs to e.g. r1, r2 or r3.
auth: The authorization header used to enable API access. If None, the value will be retrieved from the DOTDIGITAL_AUTH environment variable.
auth: The authorization header used to enable API access. If None, the value will be retrieved from the MAIL_AUTH environment variable.
timeout: Send timeout to avoid hanging.

Raises:
Expand All @@ -71,7 +262,7 @@
# pylint: enable=line-too-long

if auth is None:
auth = os.environ["DOTDIGITAL_AUTH"]
auth = settings.MAIL_AUTH

Check warning on line 265 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L265

Added line #L265 was not covered by tests

body = {
"campaignId": campaign_id,
Expand Down Expand Up @@ -103,6 +294,13 @@
for attachment in attachments
]

if not settings.MAIL_ENABLED:
logging.info(

Check warning on line 298 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L297-L298

Added lines #L297 - L298 were not covered by tests
"Sent a triggered email with DotDigital:\n%s",
json.dumps(body, indent=2),
)
return

Check warning on line 302 in codeforlife/mail.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/mail.py#L302

Added line #L302 was not covered by tests

response = requests.post(
url=f"https://{region}-api.dotdigital.com/v2/email/triggered-campaign",
json=body,
Expand Down
6 changes: 1 addition & 5 deletions codeforlife/models/signals/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,5 @@
"""


from .general import (
UpdateFields,
assert_update_fields_includes,
update_fields_includes,
)
from .general import UpdateFields, update_fields_includes
from .receiver import model_receiver
26 changes: 2 additions & 24 deletions codeforlife/models/signals/general.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,6 @@
includes: The fields that should be included in the update-fields.

Returns:
The fields missing in the update-fields. If update-fields is None, None
is returned.
A flag designating if the fields are included in the update-fields.
"""

if update_fields is None:
return None

return includes.difference(update_fields)


def assert_update_fields_includes(
update_fields: UpdateFields, includes: t.Set[str]
):
"""Assert the call to .save() includes the update-fields specified.

Args:
update_fields: The update-fields provided in the call to .save().
includes: The fields that should be included in the update-fields.
"""
missing_update_fields = update_fields_includes(update_fields, includes)
if missing_update_fields is not None:
assert not missing_update_fields, (
"Call to .save() did not include the following update-fields: "
f"{', '.join(missing_update_fields)}."
)
return update_fields and includes.issubset(update_fields)

Check warning on line 24 in codeforlife/models/signals/general.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/general.py#L24

Added line #L24 was not covered by tests
91 changes: 91 additions & 0 deletions codeforlife/models/signals/post_save.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""
© Ocado Group
Created on 20/06/2024 at 11:46:02(+01:00).

Helpers for module "django.db.models.signals.post_save".
https://docs.djangoproject.com/en/3.2/ref/signals/#post-save
"""

import typing as t

Check warning on line 9 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L9

Added line #L9 was not covered by tests

from . import general as _
from .pre_save import PREVIOUS_VALUE_KEY

Check warning on line 12 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L11-L12

Added lines #L11 - L12 were not covered by tests

FieldValue = t.TypeVar("FieldValue")

Check warning on line 14 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L14

Added line #L14 was not covered by tests


def check_previous_values(

Check warning on line 17 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L17

Added line #L17 was not covered by tests
instance: _.AnyModel,
predicates: t.Dict[str, t.Callable[[t.Any], bool]],
):
# pylint: disable=line-too-long
"""Check if the previous values are as expected. If the previous value's key
is not on the model, this check returns False.

Args:
instance: The current instance.
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.

Returns:
If all the previous values are as expected.
"""
# pylint: enable=line-too-long

for field, predicate in predicates.items():
previous_value_key = PREVIOUS_VALUE_KEY.format(field=field)

Check warning on line 35 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L34-L35

Added lines #L34 - L35 were not covered by tests

if not hasattr(instance, previous_value_key) or not predicate(

Check warning on line 37 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L37

Added line #L37 was not covered by tests
getattr(instance, previous_value_key)
):
return False

Check warning on line 40 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L40

Added line #L40 was not covered by tests

return True

Check warning on line 42 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L42

Added line #L42 was not covered by tests


def previous_values_are_unequal(instance: _.AnyModel, fields: t.Set[str]):

Check warning on line 45 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L45

Added line #L45 was not covered by tests
# pylint: disable=line-too-long
"""Check if all the previous values are not equal to the current values. If
the previous value's key is not on the model, this check returns False.

Args:
instance: The current instance.
fields: The fields that should not be equal.

Returns:
If all the previous values are not equal to the current values.
"""
# pylint: enable=line-too-long

for field in fields:
previous_value_key = PREVIOUS_VALUE_KEY.format(field=field)

Check warning on line 60 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L59-L60

Added lines #L59 - L60 were not covered by tests

if not hasattr(instance, previous_value_key) or (

Check warning on line 62 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L62

Added line #L62 was not covered by tests
getattr(instance, field) == getattr(instance, previous_value_key)
):
return False

Check warning on line 65 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L65

Added line #L65 was not covered by tests

return True

Check warning on line 67 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L67

Added line #L67 was not covered by tests


def get_previous_value(

Check warning on line 70 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L70

Added line #L70 was not covered by tests
instance: _.AnyModel, field: str, cls: t.Type[FieldValue]
):
# pylint: disable=line-too-long
"""Get a previous value from the instance and assert the value is of the
expected type.

Args:
instance: The current instance.
field: The field to get the previous value for.
cls: The expected type of the value.

Returns:
The previous value of the field.
"""
# pylint: enable=line-too-long

previous_value = getattr(instance, PREVIOUS_VALUE_KEY.format(field=field))

Check warning on line 87 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L87

Added line #L87 was not covered by tests

assert isinstance(previous_value, cls)

Check warning on line 89 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L89

Added line #L89 was not covered by tests

return previous_value

Check warning on line 91 in codeforlife/models/signals/post_save.py

View check run for this annotation

Codecov / codecov/patch

codeforlife/models/signals/post_save.py#L91

Added line #L91 was not covered by tests
Loading
Loading