Add ability to send Monthly activity summary (#3474)

The changeset also factors out the base HTML email template, which is currently shared with the notifications email template. In the future, we'll use it for all HTML emails we send.

In addition to the spec, a configurable Thank you note is added. The day of the month to send the summary emails on is also configurable.
This commit is contained in:
Matjaž Horvat 2024-12-11 09:49:19 +01:00 коммит произвёл GitHub
Родитель d3d5cf111f
Коммит eb65ceedae
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
7 изменённых файлов: 569 добавлений и 149 удалений

Просмотреть файл

@ -165,6 +165,10 @@ you create:
Optional. Text to be shown in the footer of the non-transactional emails sent
using the Messaging Center, just above the unsubscribe text.
``+EMAIL_MONTHLY_ACTIVITY_SUMMARY_INTRO``
Optional. Custom text to be shown in the Monthly activity summary emails after the
greeting and before the stats.
``ENABLE_BUGS_TAB``
Optional. Enables Bugs tab on team pages, which pulls team data from
bugzilla.mozilla.org. Specific for Mozilla deployments.
@ -209,6 +213,11 @@ you create:
Optional. Set your `Microsoft Translator API`_ key to use machine translation
by Microsoft.
``MONTHLY_ACTIVITY_SUMMARY_DAY``
Optional. Integer representing a day of the month on which the Monthly
activity summary emails will be sent. 1 represents the first day of the month.
The default value is 1.
``NEW_RELIC_API_KEY``
Optional. API key for accessing the New Relic REST API. Used to mark deploys
on New Relic.

Просмотреть файл

@ -1,23 +1,251 @@
import calendar
import datetime
import logging
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from notifications.models import Notification
from django.conf import settings
from django.contrib.auth.models import User
from django.core.mail import EmailMultiAlternatives
from django.db.models import Q
from django.db.models import Count, F, Min, Prefetch, Q, Sum
from django.template.loader import get_template
from django.utils import timezone
from pontoon.actionlog.models import ActionLog
from pontoon.base.models import Locale
from pontoon.insights.models import LocaleInsightsSnapshot
from pontoon.messaging.utils import html_to_plain_text_with_links
log = logging.getLogger(__name__)
def _get_monthly_user_actions(users, months_ago):
month_date = timezone.now() - relativedelta(months=months_ago)
actions = (
ActionLog.objects.filter(
performed_by__in=users,
created_at__month=month_date.month,
created_at__year=month_date.year,
)
.values("performed_by")
.annotate(
submitted=Count("id", filter=Q(action_type="translation:created")),
reviewed=Count(
"id",
filter=Q(
action_type__in=["translation:approved", "translation:rejected"]
),
),
)
)
return {action["performed_by"]: action for action in actions}
def _get_monthly_locale_actions(months_ago):
month_date = timezone.now() - relativedelta(months=months_ago)
snapshots = (
LocaleInsightsSnapshot.objects.filter(
created_at__month=month_date.month,
created_at__year=month_date.year,
)
.values("locale")
.annotate(
added_source_strings=Sum("new_source_strings"),
submitted=Sum(F("human_translations") + F("machinery_translations")),
reviewed=Sum(F("peer_approved") + F("rejected")),
)
)
return {snapshot["locale"]: snapshot for snapshot in snapshots}
def _get_monthly_locale_stats(months_ago):
month_date = timezone.now() - relativedelta(months=months_ago)
last_day = calendar.monthrange(month_date.year, month_date.month)[1]
snapshots = LocaleInsightsSnapshot.objects.filter(
created_at__day=last_day,
created_at__month=month_date.month,
created_at__year=month_date.year,
)
return {snapshot.locale_id: snapshot for snapshot in snapshots}
def _get_monthly_locale_contributors(locales, months_ago):
month_date = timezone.now() - relativedelta(months=months_ago)
actions = ActionLog.objects.filter(
performed_by__profile__system_user=False,
# Exclude system projects
translation__entity__resource__project__system_project=False,
)
# Get contributors that started contributing to the locale in the given month
first_contributions = (
actions.values("performed_by", "translation__locale")
.annotate(first_contribution_date=Min("created_at"))
.filter(
first_contribution_date__month=month_date.month,
first_contribution_date__year=month_date.year,
)
)
new_locale_contributors = {
(entry["translation__locale"], entry["performed_by"])
for entry in first_contributions
}
# Get all contributors in the given month,
# grouped by locale and orderd by contribution count
monthly_contributors = (
actions.filter(
created_at__month=month_date.month,
created_at__year=month_date.year,
)
.values("translation__locale", "performed_by")
.annotate(contribution_count=Count("id"))
.order_by("-contribution_count")
)
# Group contributors by locale and user role
results = {}
all_locale_pks = [entry["translation__locale"] for entry in monthly_contributors]
locales_dict = {locale.pk: locale for locale in locales}
all_user_pks = [entry["performed_by"] for entry in monthly_contributors]
users = User.objects.filter(pk__in=all_user_pks)
users_dict = {user.pk: user for user in users}
for locale_pk in all_locale_pks:
locale_entries = [
entry
for entry in monthly_contributors
if entry["translation__locale"] == locale_pk
]
locale = locales_dict.get(locale_pk)
new_contributors = []
active_managers = []
active_translators = []
active_contributors = []
for entry in locale_entries:
user_pk = entry["performed_by"]
user = users_dict.get(user_pk)
if (locale_pk, user_pk) in new_locale_contributors:
# Exclude staff users from new contributors
if not user.is_staff:
new_contributors.append(user)
if locale.managers_group.fetched_managers:
active_managers.append(user)
elif locale.translators_group.fetched_translators:
active_translators.append(user)
else:
active_contributors.append(user)
results[locale_pk] = {
"new_contributors": new_contributors,
"active_managers": active_managers,
"active_translators": active_translators,
"active_contributors": active_contributors,
}
return results
def send_monthly_activity_summary():
"""
Sends Monthly activity summary emails.
"""
log.info("Start sending Monthly activity summary emails.")
# Get user monthly actions
users = User.objects.filter(profile__monthly_activity_summary=True)
user_month_actions = _get_monthly_user_actions(users, months_ago=1)
previous_user_month_actions = _get_monthly_user_actions(users, months_ago=2)
for user in users:
user.month_actions = user_month_actions.get(user.pk, {})
user.previous_month_actions = previous_user_month_actions.get(user.pk, {})
# Get locale monthly actions
locales = Locale.objects.prefetch_related(
Prefetch("managers_group__user_set", to_attr="fetched_managers"),
Prefetch("translators_group__user_set", to_attr="fetched_translators"),
)
locale_month_actions = _get_monthly_locale_actions(months_ago=1)
locale_previous_month_actions = _get_monthly_locale_actions(months_ago=2)
locale_month_stats = _get_monthly_locale_stats(months_ago=1)
locale_previous_month_stats = _get_monthly_locale_stats(months_ago=2)
locale_contributors = _get_monthly_locale_contributors(locales, months_ago=1)
for locale in locales:
locale.month_actions = locale_month_actions.get(locale.pk, {})
locale.previous_month_actions = locale_previous_month_actions.get(locale.pk, {})
locale.month_stats = locale_month_stats.get(locale.pk, {})
locale.previous_month_stats = locale_previous_month_stats.get(locale.pk, {})
locale.contributors = locale_contributors.get(locale.pk, {})
# Create a map of users to locales in which they are managers or translators,
# which determines if the user should receive the Team activity section of the email
user_locales = defaultdict(set)
for locale in locales:
for user in locale.managers_group.fetched_managers:
user_locales[user].add(locale)
for user in locale.translators_group.fetched_translators:
user_locales[user].add(locale)
# Process and send email for each user
subject = "Monthly activity summary"
template = get_template("messaging/emails/monthly_activity_summary.html")
now = timezone.now()
current_month = now.month
report_month = calendar.month_name[(current_month - 1) or 12]
current_year = now.year
report_year = current_year if current_month > 1 else current_year - 1
for user in users:
body_html = template.render(
{
"subject": subject,
"month": report_month,
"year": report_year,
"user": user,
"locales": user_locales.get(user, []),
"settings": settings,
}
)
body_text = html_to_plain_text_with_links(body_html)
msg = EmailMultiAlternatives(
subject=subject,
body=body_text,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[user.contact_email],
)
msg.attach_alternative(body_html, "text/html")
msg.send()
recipient_count = len(users)
log.info(f"Monthly activity summary emails sent to {recipient_count} users.")
def send_notification_digest(frequency="Daily"):
"""
Sends notification email digests to users based on the specified frequency (Daily or Weekly).

Просмотреть файл

@ -0,0 +1,25 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils.timezone import now
from pontoon.messaging.emails import send_monthly_activity_summary
class Command(BaseCommand):
help = "Send monthly activity summary emails."
def add_arguments(self, parser):
parser.add_argument(
"--force",
action="store_true",
help="Force sending regardless of the current date.",
)
def handle(self, *args, **options):
# Only send on the given day of the month or when --force is used
if options["force"] or now().day == settings.MONTHLY_ACTIVITY_SUMMARY_DAY:
send_monthly_activity_summary()
else:
self.stdout.write(
f"This command can only be run on day {settings.MONTHLY_ACTIVITY_SUMMARY_DAY} of the month. Use --force to bypass."
)

Просмотреть файл

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<title>{% block title %}{% endblock %}</title>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600&display=swap" rel="stylesheet">
<style>
body {
margin: 0;
padding: 0;
}
table {
border-spacing: 0;
color: #444444;
font-family: 'Open Sans', sans-serif;
font-weight: 300;
margin: 0 auto;
width: 100%;
}
a {
text-decoration: none;
}
img {
max-width: 100px;
height: auto;
}
table.inner {
max-width: 600px;
padding: 70px 0;
}
table.inner a {
color: #F36;
}
.logo {
padding-bottom: 30px;
text-align: right;
}
.header {
font-size: 36px;
margin: 20px 0;
padding-bottom: 10px;
}
.subheader {
font-size: 15px;
margin: 10px 0 20px 0;
padding-bottom: 30px;
}
.footer {
font-size: 12px;
color: #888888;
margin-top: 20px;
padding-top: 20px;
}
{% block extend_css %}{% endblock %}
</style>
</head>
<body>
<table class="outer">
<tr>
<td>
<table class="inner">
<tr>
<td class="logo">
<a href="{{ full_url('pontoon.homepage') }}">
<img src="{{ full_static('img/logo.png') }}" alt="Pontoon logo" width="52" height="64">
</a>
</td>
</tr>
<tr>
<td class="header">
{% block header %}{% endblock %}
</td>
</tr>
<tr>
<td class="subheader">
{% block subheader %}{% endblock %}
</td>
</tr>
<tr>
<td class="content {% block content_class %}{% endblock %}">
{% block content %}{% endblock %}
</td>
</tr>
<tr>
<td class="footer">
{% block footer %}{% endblock %}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

Просмотреть файл

@ -0,0 +1,130 @@
{% extends 'messaging/emails/base.html' %}
{% block title %}{{ subject }}{% endblock %}
{% block extend_css %}
.subheader p:not(:last-child) {
padding-bottom: 10px;
}
h2 {
font-size: 18px;
font-weight: 100;
margin: 0;
padding-bottom: 10px;
text-transform: uppercase;
}
h2.team-activity {
padding-top: 30px;
}
p {
font-size: 15px;
line-height: 1.6em;
margin: 0;
}
p.completion {
padding-top: 20px;
}
.bullet {
display: inline-block;
border: 1px solid;
border-radius: 50%;
margin: 1px 4px;
height: 7px;
width: 7px;
}
.bullet.missing {
background: #bec7d1;
border-color: #bec7d1;
}
.bullet.unreviewed {
background: #4fc4f6;
border-color: #4fc4f6;
}
.bullet.pretranslated {
background: #c0ff00;
border-color: #c0ff00;
}
{% endblock %}
{% block header %}Hello.{% endblock %}
{% block subheader %}
<p>Here's your monthly activity summary for {{ month }} {{ year }}.</p>
{% if settings.EMAIL_MONTHLY_ACTIVITY_SUMMARY_INTRO %}
<p>{{ settings.EMAIL_MONTHLY_ACTIVITY_SUMMARY_INTRO }}</p>
{% endif %}
{% endblock %}
{% block content_class %}monthly-activity-summary{% endblock %}
{% block content %}
<h2>You personal activity</h2>
{{ actions_item("Translations submitted", user, "submitted") }}
{{ actions_item("Suggestions reviewed", user, "reviewed") }}
{% for locale in locales %}
<h2 class="team-activity">Team activity for {{ locale.name }} ({{ locale.code }})</h2>
{{ actions_item("New source strings added", locale, "added_source_strings") }}
{{ actions_item("Translations submitted", locale, "submitted") }}
{{ actions_item("Suggestions reviewed", locale, "reviewed") }}
{{ contributors_item("New contributors", locale.contributors.new_contributors) }}
{{ contributors_item("Recently active managers", locale.contributors.active_managers) }}
{{ contributors_item("Recently active translators", locale.contributors.active_translators) }}
{{ contributors_item("Recently active contributors", locale.contributors.active_contributors) }}
{{ status_item("Completion", locale, "completion", "completion", "", "%") }}
{{ status_item("Strings with missing translations", locale, "missing_strings", "", "missing") }}
{{ status_item("Unreviewed suggestions", locale, "unreviewed_strings", "", "unreviewed") }}
{{ status_item("Unreviewed pretranslations", locale, "pretranslated_strings", "", "pretranslated") }}
{% endfor %}
{% endblock %}
{% block footer %}
If you no longer want to receive these activity summaries, you can turn off the “Monthly activity summary“ feature in
your <a href="{{ full_url('pontoon.contributors.settings') }}">Settings</a>.
{% endblock %}
{% macro actions_item(label, actions_object, attribute) %}
<p>
<span class="bullet"></span>
{{ label }}:
{{ actions_object.month_actions[attribute] or 0 }} (vs. {{ actions_object.previous_month_actions[attribute] or 0 }} in the previous month)
</p>
{% endmacro %}
{% macro contributors_item(label, contributors) %}
{% if contributors %}
<p>
<span class="bullet"></span>
{{ label }}:
{% for contributor in contributors %}
<a href="{{ full_url('pontoon.contributors.contributor.username', contributor.username) }}">
<span class="name">{{ contributor.name_or_email }}</span></a>{% if not loop.last %}, {% endif %}
{% endfor %}
</p>
{% endif %}
{% endmacro %}
{% macro status_item(label, locale, attribute, cls="", url_parameter="", unit="") %}
<p class="{{ cls }}">
<span class="bullet {{ url_parameter }}"></span>
{{ label }}:
{% if url_parameter %}
<a href="{{ full_url('pontoon.translate', locale.code, 'all-projects', 'all-resources') }}?status={{ url_parameter }}">{{ locale.month_stats[attribute] or 0 }}{{ unit }}</a>
{% else %}
{{ locale.month_stats[attribute] or 0 }}{{ unit }}
{% endif %}
(vs. {{ locale.previous_month_stats[attribute] or 0 }}{{ unit }} in the previous month)
</p>
{% endmacro %}

Просмотреть файл

@ -1,165 +1,75 @@
{% extends 'messaging/emails/base.html' %}
{% import "contributors/widgets/notifications_menu.html" as Notifications with context %}
<!DOCTYPE html>
<html lang="en">
{% block title %}{{ subject }}{% endblock %}
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
{% block extend_css %}
.notifications {
font-size: 15px;
padding: 0;
text-align: left;
}
<title>{{ subject }}</title>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600&display=swap" rel="stylesheet">
.notifications ul {
border-bottom: 1px solid #ededed;
list-style-type: none;
margin: 0;
padding: 0;
}
<style>
/* General Styles */
body {
margin: 0;
padding: 0;
}
.notifications li {
margin: 0;
}
table {
border-spacing: 0;
color: #444444;
font-family: 'Open Sans', sans-serif;
font-weight: 300;
margin: 0 auto;
width: 100%;
}
.notifications li.horizontal-separator {
border-top: 1px solid #ededed;
}
a {
text-decoration: none;
}
.notifications li .item-content {
padding: 10px 0;
}
img {
max-width: 100px;
height: auto;
}
.notifications li .item-content .message {
color: #888888;
padding: 10px;
}
table.inner {
max-width: 600px;
padding: 70px 0;
}
.notifications li .item-content .message p {
margin: 0;
}
table.inner a {
color: #F36;
}
.notifications li .item-content .description ul {
border: none;
list-style: inside;
padding-top: 10px;
}
.logo {
padding-bottom: 30px;
text-align: right;
}
.notifications li .item-content .description ul li {
margin: 0;
padding: 2px 4px;
}
.title {
font-size: 36px;
margin: 20px 0;
padding-bottom: 10px;
}
.notifications li .item-content .timeago {
color: #888888;
display: block;
font-size: 11px;
font-weight: normal;
margin-top: 8px;
text-align: right;
text-transform: uppercase;
}
{% endblock %}
.subtitle {
font-size: 15px;
margin: 10px 0 20px 0;
padding-bottom: 30px;
}
{% block header %}Hello.{% endblock %}
.footer {
font-size: 12px;
color: #888888;
margin-top: 20px;
padding-top: 20px;
}
{% block subheader %}Here's a summary of the Pontoon notifications you've subscribed to.{% endblock %}
/* Notifications */
.notifications {
font-size: 15px;
padding: 0;
text-align: left;
}
{% block content_class %}notifications{% endblock %}
.notifications ul {
border-bottom: 1px solid #ededed;
list-style-type: none;
margin: 0;
padding: 0;
}
{% block content %}
{{ Notifications.list(notifications=notifications) }}
{% endblock %}
.notifications li {
margin: 0;
}
.notifications li.horizontal-separator {
border-top: 1px solid #ededed;
}
.notifications li .item-content {
padding: 10px 0;
}
.notifications li .item-content .message {
color: #888888;
padding: 10px;
}
.notifications li .item-content .message p {
margin: 0;
}
.notifications li .item-content .description ul {
border: none;
list-style: inside;
padding-top: 10px;
}
.notifications li .item-content .description ul li {
margin: 0;
padding: 2px 4px;
}
.notifications li .item-content .timeago {
color: #888888;
display: block;
font-size: 11px;
font-weight: normal;
margin-top: 8px;
text-align: right;
text-transform: uppercase;
}
</style>
</head>
<body>
<table class="outer">
<tr>
<td>
<table class="inner">
<tr>
<td class="logo">
<a href="{{ full_url('pontoon.homepage') }}">
<img src="{{ full_static('img/logo.png') }}" alt="Pontoon logo" width="52" height="64">
</a>
</td>
</tr>
<tr>
<td class="title">Hello.</td>
</tr>
<tr>
<td class="subtitle">Here's a summary of the Pontoon notifications you've subscribed to.</td>
</tr>
<tr>
<td class="notifications">
{{ Notifications.list(notifications=notifications) }}
</td>
</tr>
<tr>
<td class="footer">
You can adjust your notifications subscriptions at any time from your
<a href="{{ full_url('pontoon.contributors.settings') }}">Settings</a>.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
{% block footer %}
You can adjust your notifications subscriptions at any time from your <a href="{{ full_url('pontoon.contributors.settings') }}">Settings</a>.
{% endblock %}

Просмотреть файл

@ -226,6 +226,9 @@ EMAIL_COMMUNICATIONS_HELP_TEXT = os.environ.get("EMAIL_COMMUNICATIONS_HELP_TEXT"
EMAIL_COMMUNICATIONS_FOOTER_PRE_TEXT = os.environ.get(
"EMAIL_COMMUNICATIONS_FOOTER_PRE_TEXT", ""
)
EMAIL_MONTHLY_ACTIVITY_SUMMARY_INTRO = os.environ.get(
"EMAIL_MONTHLY_ACTIVITY_SUMMARY_INTRO", ""
)
# Log emails to console if the SendGrid credentials are missing.
if EMAIL_HOST_USER and EMAIL_HOST_PASSWORD:
@ -1124,6 +1127,10 @@ SUGGESTION_NOTIFICATIONS_DAY = os.environ.get("SUGGESTION_NOTIFICATIONS_DAY", 4)
# is 4 (Friday).
NOTIFICATION_DIGEST_DAY = os.environ.get("NOTIFICATION_DIGEST_DAY", 4)
# Integer representing a day of the month on which the Monthly activity summary
# email will be sent.
MONTHLY_ACTIVITY_SUMMARY_DAY = os.environ.get("MONTHLY_ACTIVITY_SUMMARY_DAY", 1)
# Date from which badge data collection starts
badges_start_date = os.environ.get("BADGES_START_DATE", "1970-01-01")
try: