Skip to content

Commit

Permalink
Add ability to send notifications via email (mozilla#3456)
Browse files Browse the repository at this point in the history
This PR implements all the changes to the user settings as per the spec:
https://github.com/mozilla/pontoon/blob/main/specs/0120-transactional-emails.md#settings-changes

It also introduces a management command to send daily or weekly email notifications.

Related changes:
* Add PNG logo, needed for the display in emails (SVG is not supported in Gmail)
* Store notification categories in the data model
- Use these categories to determine to which category the user is subscribed
- Add data migration to store categories for old notifications

Other changes, not directly related to the PR:
* Reorder sections in the Settings page by moving Email and Notifications sections next to another.
* The settings.html file has been reformatted. Please hide whitespace in the diff for easier overview of changes.
* Minor changes to the check-box widget markup and styling.
* Minor changes to the success messages when toggling checkboxes.
* Explicity set from: email address everywhere we send emails.
  • Loading branch information
mathjazz authored Dec 3, 2024
1 parent 0d15df6 commit a025ed1
Show file tree
Hide file tree
Showing 32 changed files with 1,016 additions and 177 deletions.
3 changes: 2 additions & 1 deletion pontoon/base/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ def clean_github(self):
return github_username


class UserProfileVisibilityForm(forms.ModelForm):
class UserProfileToggleForm(forms.ModelForm):
"""
Form is responsible for controlling user profile visibility.
"""
Expand All @@ -291,6 +291,7 @@ class Meta:
"visibility_external_accounts",
"visibility_self_approval",
"visibility_approval",
"notification_email_frequency",
)


Expand Down
56 changes: 56 additions & 0 deletions pontoon/base/migrations/0068_userprofile_notification_emails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Generated by Django 4.2.16 on 2024-11-21 16:26

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("base", "0067_remove_userprofile_community_builder_level_and_more"),
]

operations = [
migrations.AddField(
model_name="userprofile",
name="comment_notifications_email",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userprofile",
name="monthly_activity_summary",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userprofile",
name="new_contributor_notifications_email",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userprofile",
name="new_string_notifications_email",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userprofile",
name="notification_email_frequency",
field=models.CharField(
choices=[("Daily", "Daily"), ("Weekly", "Weekly")],
default="Weekly",
max_length=10,
),
),
migrations.AddField(
model_name="userprofile",
name="project_deadline_notifications_email",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userprofile",
name="review_notifications_email",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userprofile",
name="unreviewed_suggestion_notifications_email",
field=models.BooleanField(default=False),
),
]
77 changes: 77 additions & 0 deletions pontoon/base/migrations/0069_notification_categories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import logging
import re

from django.db import migrations


log = logging.getLogger(__name__)


def get_category(notification):
verb = notification.verb
desc = notification.description

# New strings notifications
if re.match(r"updated with \d+ new string", verb):
return "new_string"

# Project target dates notifications
if re.match(r"due in \d+ days", verb):
return "project_deadline"

# Comments notifications
if re.match(r"has (pinned|added) a comment in", verb):
return "comment"

# New suggestions ready for review notifications
if verb == "":
return "unreviewed_suggestion"

if verb == "has reviewed suggestions":
# Review actions on own suggestions notifications
if desc.startswith("Your suggestions have been reviewed"):
return "review"

# New team contributors notifications
if "has made their first contribution to" in desc:
return "new_contributor"

if verb == "has sent a message in" or verb == "has sent you a message":
return "direct_message"

return None


def store_notification_categories(apps, schema_editor):
Notification = apps.get_model("notifications", "Notification")
notifications = Notification.objects.all()
unchanged = []

for notification in notifications:
category = get_category(notification)

if category == "direct_message":
notification.data["category"] = category
elif category:
notification.data = {"category": category}
else:
unchanged.append(notification)

Notification.objects.bulk_update(notifications, ["data"], batch_size=2000)

log.info(f"Notifications categorized: {len(notifications) - len(unchanged)}.")
log.info(f"Notifications left unchanged: {len(unchanged)}.")


class Migration(migrations.Migration):
dependencies = [
("base", "0068_userprofile_notification_emails"),
("notifications", "0009_alter_notification_options_and_more"),
]

operations = [
migrations.RunPython(
code=store_notification_categories,
reverse_code=migrations.RunPython.noop,
),
]
20 changes: 20 additions & 0 deletions pontoon/base/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,25 @@ def serialized_notifications(self):
}


def is_subscribed_to_notification(self, notification):
"""
Determines if the user has email subscription to the given notification.
"""
profile = self.profile
category = notification.data.get("category") if notification.data else None

CATEGORY_TO_FIELD = {
"new_string": profile.new_string_notifications_email,
"project_deadline": profile.project_deadline_notifications_email,
"comment": profile.comment_notifications_email,
"unreviewed_suggestion": profile.unreviewed_suggestion_notifications_email,
"review": profile.review_notifications_email,
"new_contributor": profile.new_contributor_notifications_email,
}

return CATEGORY_TO_FIELD.get(category, False)


def user_serialize(self):
"""Serialize Project contact"""

Expand Down Expand Up @@ -485,5 +504,6 @@ def latest_action(self):
User.add_to_class("menu_notifications", menu_notifications)
User.add_to_class("unread_notifications_display", unread_notifications_display)
User.add_to_class("serialized_notifications", serialized_notifications)
User.add_to_class("is_subscribed_to_notification", is_subscribed_to_notification)
User.add_to_class("serialize", user_serialize)
User.add_to_class("latest_action", latest_action)
57 changes: 56 additions & 1 deletion pontoon/base/models/user_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class UserProfile(models.Model):
contact_email_verified = models.BooleanField(default=False)
email_communications_enabled = models.BooleanField(default=False)
email_consent_dismissed_at = models.DateTimeField(null=True, blank=True)
monthly_activity_summary = models.BooleanField(default=False)

# Theme
class Themes(models.TextChoices):
Expand Down Expand Up @@ -79,14 +80,33 @@ class VisibilityLoggedIn(models.TextChoices):
choices=Visibility.choices,
)

# Notification subscriptions
# In-app Notification subscriptions
new_string_notifications = models.BooleanField(default=True)
project_deadline_notifications = models.BooleanField(default=True)
comment_notifications = models.BooleanField(default=True)
unreviewed_suggestion_notifications = models.BooleanField(default=True)
review_notifications = models.BooleanField(default=True)
new_contributor_notifications = models.BooleanField(default=True)

# Email Notification subscriptions
new_string_notifications_email = models.BooleanField(default=False)
project_deadline_notifications_email = models.BooleanField(default=False)
comment_notifications_email = models.BooleanField(default=False)
unreviewed_suggestion_notifications_email = models.BooleanField(default=False)
review_notifications_email = models.BooleanField(default=False)
new_contributor_notifications_email = models.BooleanField(default=False)

# Email Notification frequencies
class EmailFrequencies(models.TextChoices):
DAILY = "Daily", "Daily"
WEEKLY = "Weekly", "Weekly"

notification_email_frequency = models.CharField(
max_length=10,
choices=EmailFrequencies.choices,
default=EmailFrequencies.WEEKLY,
)

# Translation settings
quality_checks = models.BooleanField(default=True)
force_suggestions = models.BooleanField(default=False)
Expand Down Expand Up @@ -122,3 +142,38 @@ def preferred_locales(self):
def sorted_locales(self):
locales = self.preferred_locales
return sorted(locales, key=lambda locale: self.locales_order.index(locale.pk))

def save(self, *args, **kwargs):
notification_fields = [
(
"new_string_notifications",
"new_string_notifications_email",
),
(
"project_deadline_notifications",
"project_deadline_notifications_email",
),
(
"comment_notifications",
"comment_notifications_email",
),
(
"unreviewed_suggestion_notifications",
"unreviewed_suggestion_notifications_email",
),
(
"review_notifications",
"review_notifications_email",
),
(
"new_contributor_notifications",
"new_contributor_notifications_email",
),
]

# Ensure notification email fields are False if the corresponding non-email notification field is False
for non_email_field, email_field in notification_fields:
if not getattr(self, non_email_field):
setattr(self, email_field, False)

super().save(*args, **kwargs)
4 changes: 2 additions & 2 deletions pontoon/base/static/css/check-box.css
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
#main .check-list {
cursor: pointer;
list-style: none;
margin: 0;
display: inline-block;
text-align: left;

.check-box {
cursor: pointer;
padding: 4px 0;

[type='checkbox'] {
Expand Down Expand Up @@ -35,7 +35,7 @@
}

.fa {
margin-left: 27px;
margin-left: 23px;
margin-right: 0;
}
}
3 changes: 2 additions & 1 deletion pontoon/base/static/css/dark-theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
--input-background-1: #333941;
--input-color-1: #aaaaaa;
--input-color-2: #ffffff;
--toggle-color-1: #777777;
--toggle-color-1: #666666;
--toggle-color-2: #333941;
--icon-background-1: #3f4752;
--icon-border-1: #4d5967;

Expand Down
3 changes: 2 additions & 1 deletion pontoon/base/static/css/light-theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
--input-background-1: #ffffff;
--input-color-1: #000000;
--input-color-2: #000000;
--toggle-color-1: #888888;
--toggle-color-1: #999999;
--toggle-color-2: #d0d0d0;
--icon-background-1: #d0d0d0;
--icon-border-1: #bbbbbb;

Expand Down
Binary file added pontoon/base/static/img/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions pontoon/base/templates/widgets/checkbox.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% macro checkbox(label, class, attribute, is_enabled=False, title=False, help=False, form_field=None) %}
<li class="check-box {{ class }}{% if is_enabled %} enabled{% endif %}" data-attribute="{{ attribute }}"{% if title %} title="{{title}}"{% endif %}>
<div class="check-box {{ class }}{% if is_enabled %} enabled{% endif %}" data-attribute="{{ attribute }}"{% if title %} title="{{title}}"{% endif %}>
<div class="check-box-wrapper">
<span class="label">{{ label }}</span>
<i class="fa fa-fw"></i>
Expand All @@ -10,5 +10,5 @@
{% if form_field %}
{{ form_field }}
{% endif %}
</li>
</div>
{% endmacro %}
14 changes: 14 additions & 0 deletions pontoon/base/templatetags/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import re

from datetime import timedelta
from urllib.parse import urljoin

import markupsafe

Expand Down Expand Up @@ -35,6 +36,13 @@ def url(viewname, *args, **kwargs):
return reverse(viewname, args=args, kwargs=kwargs)


@library.global_function
def full_url(viewname, *args, **kwargs):
"""Generate an absolute URL."""
path = reverse(viewname, args=args, kwargs=kwargs)
return urljoin(settings.SITE_URL, path)


@library.global_function
def return_url(request):
"""Get an url of the previous page."""
Expand Down Expand Up @@ -72,6 +80,12 @@ def static(path):
return staticfiles_storage.url(path)


@library.global_function
def full_static(path):
"""Generate an absolute URL for a static file."""
return urljoin(settings.SITE_URL, static(path))


@library.filter
def to_json(value):
return json.dumps(value, cls=DjangoJSONEncoder)
Expand Down
Loading

0 comments on commit a025ed1

Please sign in to comment.