Basic email template for notifications #96
@ -1,8 +1,19 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from factory.django import DjangoModelFactory
|
||||
from faker import Faker
|
||||
import actstream.models
|
||||
import factory
|
||||
|
||||
from common.tests.factories.extensions import ExtensionFactory, RatingFactory
|
||||
from common.tests.factories.reviewers import ApprovalActivityFactory
|
||||
from common.tests.factories.users import UserFactory
|
||||
from constants.activity import Verb
|
||||
import notifications.models
|
||||
import reviewers.models
|
||||
|
||||
|
||||
RELATION_ALLOWED_MODELS = []
|
||||
fake = Faker()
|
||||
|
||||
|
||||
def generic_foreign_key_id_for_type_factory(generic_relation_type_field):
|
||||
@ -17,3 +28,58 @@ def generic_foreign_key_id_for_type_factory(generic_relation_type_field):
|
||||
class ContentTypeFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = ContentType
|
||||
|
||||
|
||||
class ActionFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = actstream.models.Action
|
||||
|
||||
|
||||
class NotificationFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = notifications.models.Notification
|
||||
|
||||
action = factory.SubFactory(ActionFactory)
|
||||
|
||||
|
||||
def construct_fake_notifications() -> list['NotificationFactory']:
|
||||
"""Construct notifications of known types without persisting them in the DB."""
|
||||
fake_extension = ExtensionFactory.build(slug='test')
|
||||
verb_to_action_object = {
|
||||
Verb.APPROVED: ApprovalActivityFactory.build(
|
||||
extension=fake_extension,
|
||||
type=reviewers.models.ApprovalActivity.ActivityType.APPROVED,
|
||||
message=fake.paragraph(nb_sentences=1),
|
||||
),
|
||||
Verb.COMMENTED: ApprovalActivityFactory.build(
|
||||
extension=fake_extension,
|
||||
type=reviewers.models.ApprovalActivity.ActivityType.COMMENT,
|
||||
message=fake.paragraph(nb_sentences=1),
|
||||
),
|
||||
Verb.RATED_EXTENSION: RatingFactory.build(
|
||||
text=fake.paragraph(nb_sentences=2),
|
||||
),
|
||||
Verb.REPORTED_EXTENSION: None, # TODO: fake action_object
|
||||
Verb.REPORTED_RATING: None, # TODO: fake action_object
|
||||
Verb.REQUESTED_CHANGES: ApprovalActivityFactory.build(
|
||||
extension=fake_extension,
|
||||
type=reviewers.models.ApprovalActivity.ActivityType.AWAITING_CHANGES,
|
||||
message=fake.paragraph(nb_sentences=1),
|
||||
),
|
||||
Verb.REQUESTED_REVIEW: ApprovalActivityFactory.build(
|
||||
extension=fake_extension,
|
||||
type=reviewers.models.ApprovalActivity.ActivityType.AWAITING_REVIEW,
|
||||
message=fake.paragraph(nb_sentences=1),
|
||||
),
|
||||
}
|
||||
fake_notifications = [
|
||||
NotificationFactory.build(
|
||||
recipient=UserFactory.build(),
|
||||
action__actor=UserFactory.build(),
|
||||
action__target=fake_extension,
|
||||
action__verb=verb,
|
||||
action__action_object=action_object,
|
||||
)
|
||||
for verb, action_object in verb_to_action_object.items()
|
||||
]
|
||||
return fake_notifications
|
||||
|
8
common/tests/factories/reviewers.py
Normal file
8
common/tests/factories/reviewers.py
Normal file
@ -0,0 +1,8 @@
|
||||
from factory.django import DjangoModelFactory
|
||||
|
||||
from reviewers.models import ApprovalActivity
|
||||
|
||||
|
||||
class ApprovalActivityFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = ApprovalActivity
|
@ -45,8 +45,8 @@ class UserFactory(DjangoModelFactory):
|
||||
oauth_info = factory.RelatedFactory(OAuthUserInfoFactory, factory_related_name='user')
|
||||
|
||||
|
||||
def create_moderator():
|
||||
user = UserFactory()
|
||||
def create_moderator(**kwargs):
|
||||
user = UserFactory(**kwargs)
|
||||
moderators = Group.objects.get(name='moderators')
|
||||
user.groups.add(moderators)
|
||||
return user
|
||||
|
@ -9,7 +9,8 @@ from django.utils.safestring import mark_safe
|
||||
import django.core.mail
|
||||
|
||||
from emails.models import Email
|
||||
from emails.util import construct_email, get_template_context
|
||||
from emails.util import construct_email
|
||||
from common.tests.factories.notifications import construct_fake_notifications
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
@ -92,16 +93,15 @@ class EmailPreviewAdmin(NoAddDeleteMixin, EmailAdmin):
|
||||
def _get_email_sent_message(self, obj):
|
||||
return f'Sent a test email "{obj.subject}" to {obj.to} from {obj.from_email}'
|
||||
|
||||
def get_object(self, request, object_id, from_field=None):
|
||||
"""Construct the Email on th fly from known subscription email templates."""
|
||||
context = {
|
||||
'user': User(),
|
||||
**get_template_context(),
|
||||
}
|
||||
mail_name = object_id
|
||||
def get_object(self, request, object_id, from_field=None, fake_context=None):
|
||||
"""Construct the Email on the fly from known email templates."""
|
||||
if not fake_context:
|
||||
fake_context = self._get_emails_with_fake_context(request)
|
||||
context = fake_context[object_id]
|
||||
mail_name = context.get('template', object_id)
|
||||
email_body_html, email_body_txt, subject = construct_email(mail_name, context)
|
||||
return EmailPreview(
|
||||
id=mail_name,
|
||||
id=object_id,
|
||||
subject=subject,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
reply_to=settings.DEFAULT_REPLY_TO_EMAIL,
|
||||
@ -109,10 +109,23 @@ class EmailPreviewAdmin(NoAddDeleteMixin, EmailAdmin):
|
||||
message=email_body_txt,
|
||||
)
|
||||
|
||||
def _get_emails_with_fake_context(self, request):
|
||||
email_with_fake_context = {'feedback': {}}
|
||||
|
||||
fake_notifications = construct_fake_notifications()
|
||||
for fake_notification in fake_notifications:
|
||||
mail_name = fake_notification.original_template_name
|
||||
email_with_fake_context[mail_name] = {
|
||||
'template': fake_notification.template_name,
|
||||
**fake_notification.get_template_context(),
|
||||
}
|
||||
return email_with_fake_context
|
||||
|
||||
def _get_emails_list(self, request):
|
||||
emails = []
|
||||
for mail_name in ('feedback',):
|
||||
emails.append(self.get_object(request, object_id=mail_name))
|
||||
fake_context = self._get_emails_with_fake_context(request)
|
||||
for mail_name in fake_context:
|
||||
emails.append(self.get_object(request, object_id=mail_name, fake_context=fake_context))
|
||||
return emails
|
||||
|
||||
def _changeform_view(self, request, object_id, form_url, extra_context):
|
||||
|
@ -1,16 +1,97 @@
|
||||
{% extends "emails/email_base.html" %}
|
||||
{% spaceless %}
|
||||
<html style="
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;">
|
||||
<head>
|
||||
<style>
|
||||
a {color: #0030aa;}
|
||||
a:hover {color: #009eff}
|
||||
</style>
|
||||
</head>
|
||||
<body style="
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;">
|
||||
<div style="
|
||||
background-color: #E9ECEF;
|
||||
color: #4C4D52;
|
||||
text-align:center;
|
||||
font-family: 'Lucida Grande', 'Helvetica Neue', 'Helvetica', 'Arial', 'Verdana', sans-serif;">
|
||||
|
||||
{% block header_logo %}
|
||||
{# have a title instead of the logo with the remote image #}
|
||||
<div style="text-align: center; font-weight: bold;">{{ subject }}</div>
|
||||
{% endblock header_logo %}
|
||||
<div class="header" style="
|
||||
width: 100%;
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
background-color: #FFFFFF;
|
||||
border-bottom: thin solid #E9ECEF;
|
||||
">
|
||||
<div class="container" style="
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
margin-left: auto; margin-right: auto;
|
||||
font-family: 'Lucida Grande', 'Helvetica Neue', 'Helvetica', 'Arial', 'Verdana', sans-serif;
|
||||
text-align: left;">
|
||||
{% block header_logo %}
|
||||
{# have a title instead of the logo with the remote image #}
|
||||
<div style="text-align: center; font-weight: bold;">{{ subject }}</div>
|
||||
{% endblock %}
|
||||
<div style="clear:both"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body" style="
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
background-color: #FFFFFF;
|
||||
margin-top: 20px; margin-bottom: 20px;
|
||||
margin-left: auto; margin-right: auto;
|
||||
padding-top: 10px; padding-bottom: 10px;
|
||||
padding-left: 20px; padding-right: 20px;
|
||||
border: 1px solid #E9ECEF;
|
||||
border-right: 1px solid #E9ECEF;
|
||||
border-bottom: 1px solid #E9ECEF;
|
||||
border-radius: 8px;
|
||||
text-align: left;">
|
||||
|
||||
{% block body %}
|
||||
<p>Dear {% firstof user.full_name user.email %},</p>
|
||||
{% block content %}{% endblock content %}
|
||||
<p>
|
||||
--<br />
|
||||
Kind regards,<br />
|
||||
Blender Extensions Team
|
||||
</p>
|
||||
{% endblock body %}
|
||||
{% block body %}{% block content %}{% endblock content %}{% endblock body %}
|
||||
|
||||
</div>
|
||||
<div class="footer" style="
|
||||
width: 100%;
|
||||
color: #4C4D52;
|
||||
font-family: 'Lucida Sans Unicode', 'Lucida Grande', sans-serif;
|
||||
font-size: 0.7em;
|
||||
font-weight: lighter;
|
||||
padding-top: 20px; padding-bottom: 20px;
|
||||
background-color: #FFFFFF;
|
||||
border-top: 1px solid #E9ECEF;
|
||||
">
|
||||
<div class="container" style="
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
margin-left: auto; margin-right: auto;
|
||||
text-align: left;">
|
||||
|
||||
{% block footer_links %}
|
||||
<p style="margin: auto; text-align: center">
|
||||
Manage your profile here: <a href="{{ profile_url }}">{{ profile_url }}</a>
|
||||
</p>
|
||||
{% endblock footer_links %}
|
||||
{% block footer_brand %}
|
||||
<p style="margin: auto; text-align: center; margin-top: 1em;">
|
||||
<a href="{{ site_url }}" style="text-decoration: none; color: #4C4D52;">
|
||||
<strong>BLENDER EXTENSIONS</strong>
|
||||
</a>
|
||||
</p>
|
||||
{% endblock footer_brand %}
|
||||
|
||||
{% block footer %}{% endblock footer %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{% endspaceless %}
|
||||
|
@ -1,9 +1,3 @@
|
||||
Dear {% firstof user.full_name user.email %},
|
||||
{% block content %}{% endblock content %}
|
||||
|
||||
Manage your profile: {{ profile_url }}
|
||||
|
||||
--
|
||||
Kind regards,
|
||||
|
||||
Blender Extensions Team
|
||||
|
21
emails/templates/emails/components/new_activity_action
Normal file
21
emails/templates/emails/components/new_activity_action
Normal file
@ -0,0 +1,21 @@
|
||||
{% spaceless %}{% load i18n %}
|
||||
{% with target_type=target.get_type_display what=target|safe someone=action.actor verb=action.verb %}
|
||||
{% if verb == Verb.APPROVED %}
|
||||
{% blocktrans %}{{ someone }} {{ verb }} {{ what }}{% endblocktrans %}
|
||||
{% elif action.verb == Verb.COMMENTED %}
|
||||
{% blocktrans %}{{ someone }} {{ verb }} on {{ what }}{% endblocktrans %}
|
||||
{% elif verb == Verb.RATED_EXTENSION %}
|
||||
{% blocktrans %}{{ someone }} {{ verb }} {{ what }}{% endblocktrans %}
|
||||
{% elif verb == Verb.REPORTED_EXTENSION %}
|
||||
{% blocktrans %}{{ someone }} reported {{ what }}{% endblocktrans %}
|
||||
{% elif verb == Verb.REPORTED_RATING %}
|
||||
{% blocktrans %}{{ someone }} {{ verb }} of {{ what }}{% endblocktrans %}
|
||||
{% elif verb == Verb.REQUESTED_CHANGES %}
|
||||
{% blocktrans %}{{ someone }} {{ verb }} on {{ what }}{% endblocktrans %}
|
||||
{% elif verb == Verb.REQUESTED_REVIEW %}
|
||||
{% blocktrans %}{{ someone }} {{ verb }} of {{ what }}{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans %}{{ someone }} {{ verb }} {{ what }}{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endspaceless %}
|
@ -1,4 +1,4 @@
|
||||
{% extends "emails/email_base.html" %}
|
||||
{% extends "emails/base.html" %}
|
||||
|
||||
{% block body %}
|
||||
{{ email.html_message|safe }}
|
||||
|
@ -1,95 +0,0 @@
|
||||
<html style="
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;">
|
||||
<head>
|
||||
<style>
|
||||
a {color: #0030aa;}
|
||||
a:hover {color: #009eff}
|
||||
</style>
|
||||
</head>
|
||||
<body style="
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;">
|
||||
<div style="
|
||||
background-color: #E9ECEF;
|
||||
color: #4C4D52;
|
||||
text-align:center;
|
||||
font-family: 'Lucida Grande', 'Helvetica Neue', 'Helvetica', 'Arial', 'Verdana', sans-serif;">
|
||||
|
||||
<div class="header" style="
|
||||
width: 100%;
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
background-color: #FFFFFF;
|
||||
border-bottom: thin solid #E9ECEF;
|
||||
">
|
||||
<div class="container" style="
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
margin-left: auto; margin-right: auto;
|
||||
font-family: 'Lucida Grande', 'Helvetica Neue', 'Helvetica', 'Arial', 'Verdana', sans-serif;
|
||||
text-align: left;">
|
||||
{% block header_logo %}
|
||||
<a style="float: left; text-decoration: none; line-height: 0;" href="https://extensions.blender.org">
|
||||
</a>
|
||||
{% endblock %}
|
||||
<div style="clear:both"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body" style="
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
background-color: #FFFFFF;
|
||||
margin-top: 20px; margin-bottom: 20px;
|
||||
margin-left: auto; margin-right: auto;
|
||||
padding-top: 10px; padding-bottom: 10px;
|
||||
padding-left: 20px; padding-right: 20px;
|
||||
border: 1px solid #E9ECEF;
|
||||
border-right: 1px solid #E9ECEF;
|
||||
border-bottom: 1px solid #E9ECEF;
|
||||
border-radius: 8px;
|
||||
text-align: left;">
|
||||
|
||||
{% block body %}{% endblock body %}
|
||||
|
||||
</div>
|
||||
<div class="footer" style="
|
||||
width: 100%;
|
||||
color: #4C4D52;
|
||||
font-family: 'Lucida Sans Unicode', 'Lucida Grande', sans-serif;
|
||||
font-size: 0.7em;
|
||||
font-weight: lighter;
|
||||
padding-top: 20px; padding-bottom: 20px;
|
||||
background-color: #FFFFFF;
|
||||
border-top: 1px solid #E9ECEF;
|
||||
">
|
||||
<div class="container" style="
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
margin-left: auto; margin-right: auto;
|
||||
text-align: left;">
|
||||
|
||||
{% block footer_links %}
|
||||
<p style="margin: auto; text-align: center">
|
||||
Manage your profile here: <a href="{{ profile_url }}">{{ profile_url }}</a>
|
||||
</p>
|
||||
{% endblock footer_links %}
|
||||
{% block footer_brand %}
|
||||
<p style="margin: auto; text-align: center; margin-top: 1em;">
|
||||
<a href="{{ site_url }}" style="text-decoration: none; color: #4C4D52;">
|
||||
<strong>BLENDER EXTENSIONS</strong>
|
||||
</a>
|
||||
</p>
|
||||
{% endblock footer_brand %}
|
||||
|
||||
{% block footer %}{% endblock footer %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
24
emails/templates/emails/new_activity.html
Normal file
24
emails/templates/emails/new_activity.html
Normal file
@ -0,0 +1,24 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block content %}{% spaceless %}
|
||||
<div>
|
||||
{% include "emails/components/new_activity_action" %}{% if quoted_message %}:{% endif %}
|
||||
{% if quoted_message %}
|
||||
<p>
|
||||
<q>{{ quoted_message|truncatewords:15|truncatechars:140 }}</q>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p><a href="{{ url }}">View it at Blender Extensions</a></p>
|
||||
<p>
|
||||
Read all notifications at {{ notifications_url }}
|
||||
</p>
|
||||
{# TODO: store follow flags on Notifications, otherwise it's impossible to tell why this email is sent #}
|
||||
{% comment %}
|
||||
You are receiving this email because you are a moderator subscribed to notification emails.
|
||||
You are receiving this email because you are subscribed to notifications on this extension.
|
||||
{% endcomment %}
|
||||
{% endspaceless %}{% endblock content %}
|
||||
|
||||
{% block footer_links %}{% spaceless %}
|
||||
Unsubscribe by adjusting your preferences at {{ profile_url }}
|
||||
{% endspaceless %}{% endblock footer_links %}
|
21
emails/templates/emails/new_activity_subject.txt
Normal file
21
emails/templates/emails/new_activity_subject.txt
Normal file
@ -0,0 +1,21 @@
|
||||
{% spaceless %}{% load i18n %}
|
||||
{% with target_type=target.get_type_display what=target|safe name=target.name someone=action.actor verb=action.verb %}
|
||||
{% if verb == Verb.APPROVED %}
|
||||
{% blocktrans %}{{ target_type }} approved: "{{ name }}"{% endblocktrans %}
|
||||
{% elif verb == Verb.COMMENTED %}
|
||||
{% blocktrans %}New comment on {{ what }}{% endblocktrans %}
|
||||
{% elif verb == Verb.RATED_EXTENSION %}
|
||||
{% blocktrans %}{{ target_type }} rated: "{{ name }}"{% endblocktrans %}
|
||||
{% elif verb == Verb.REPORTED_EXTENSION %}
|
||||
{% blocktrans %}{{ target_type }} reported: "{{ name }}"{% endblocktrans %}
|
||||
{% elif verb == Verb.REPORTED_RATING %}
|
||||
{% blocktrans %}{{ target_type }} rating reported: "{{ name }}"{% endblocktrans %}
|
||||
{% elif verb == Verb.REQUESTED_CHANGES %}
|
||||
{% blocktrans %}{{ target_type }} changes requested: "{{ name }}"{% endblocktrans %}
|
||||
{% elif verb == Verb.REQUESTED_REVIEW %}
|
||||
{% blocktrans %}{{ target_type }} review requested: "{{ name }}"{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans %}{{ someone }} {{ verb }} on {{ what }}{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endspaceless %}
|
@ -1,35 +1,17 @@
|
||||
"""Utilities for rendering email templates."""
|
||||
from typing import List, Optional, Tuple, Dict, Any
|
||||
from typing import List, Tuple, Dict, Any
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.template import loader
|
||||
from django.core.mail import get_connection, EmailMultiAlternatives
|
||||
from django.urls import reverse
|
||||
from django.template import loader, TemplateDoesNotExist
|
||||
|
||||
from utils import absolute_url, html_to_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
def _get_site_url():
|
||||
domain = get_current_site(None).domain
|
||||
return f'https://{domain}'
|
||||
|
||||
|
||||
def absolute_url(
|
||||
view_name: str, args: Optional[tuple] = None, kwargs: Optional[dict] = None
|
||||
) -> str:
|
||||
"""Same as django.urls.reverse() but then as absolute URL.
|
||||
|
||||
For simplicity this assumes HTTPS is used.
|
||||
"""
|
||||
from urllib.parse import urljoin
|
||||
|
||||
relative_url = reverse(view_name, args=args, kwargs=kwargs)
|
||||
return urljoin(_get_site_url(), relative_url)
|
||||
|
||||
|
||||
def is_noreply(email: str) -> bool:
|
||||
"""Return True if the email address is a no-reply address."""
|
||||
return email.startswith('noreply@') or email.startswith('no-reply@')
|
||||
@ -38,8 +20,9 @@ def is_noreply(email: str) -> bool:
|
||||
def get_template_context() -> Dict[str, str]:
|
||||
"""Return additional context for use in an email template."""
|
||||
return {
|
||||
'site_url': _get_site_url(),
|
||||
# 'profile_url': absolute_url('profile_update'),
|
||||
'site_url': absolute_url('extensions:home'),
|
||||
'profile_url': absolute_url('users:my-profile'),
|
||||
'notifications_url': absolute_url('notifications:notifications'),
|
||||
'DEFAULT_REPLY_TO_EMAIL': settings.DEFAULT_REPLY_TO_EMAIL,
|
||||
}
|
||||
|
||||
@ -47,8 +30,11 @@ def get_template_context() -> Dict[str, str]:
|
||||
def construct_email(email_name: str, context: Dict[str, Any]) -> Tuple[str, str, str]:
|
||||
"""Construct an email message.
|
||||
|
||||
If plain text template is not found, text version will be generated from the HTML of the email.
|
||||
|
||||
:return: tuple (html, text, subject)
|
||||
"""
|
||||
context.update(**get_template_context())
|
||||
base_path = 'emails'
|
||||
subj_tmpl, html_tmpl, txt_tmpl = (
|
||||
f'{base_path}/{email_name}_subject.txt',
|
||||
@ -60,7 +46,11 @@ def construct_email(email_name: str, context: Dict[str, Any]) -> Tuple[str, str,
|
||||
context['subject'] = subject.strip()
|
||||
|
||||
email_body_html = loader.render_to_string(html_tmpl, context)
|
||||
email_body_txt = loader.render_to_string(txt_tmpl, context)
|
||||
try:
|
||||
email_body_txt = loader.render_to_string(txt_tmpl, context)
|
||||
except TemplateDoesNotExist:
|
||||
# Generate plain text content from the HTML one
|
||||
email_body_txt = html_to_text(email_body_html)
|
||||
return email_body_html, email_body_txt, context['subject']
|
||||
|
||||
|
||||
|
@ -1,11 +1,10 @@
|
||||
"""Send user notifications as emails, at most once delivery."""
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from emails.util import construct_and_send_email
|
||||
from notifications.models import Notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -36,12 +35,6 @@ class Command(BaseCommand):
|
||||
|
||||
|
||||
def send_notification_email(notification):
|
||||
# TODO construct a proper phrase, depending on the verb,
|
||||
# possibly share a template with NotificationsView
|
||||
subject, message = notification.format_email()
|
||||
send_mail(
|
||||
subject,
|
||||
message,
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[notification.recipient.email],
|
||||
)
|
||||
template_name = notification.template_name
|
||||
context = notification.get_template_context()
|
||||
construct_and_send_email(template_name, context, recipient_list=[notification.recipient.email])
|
||||
|
@ -1,11 +1,15 @@
|
||||
import logging
|
||||
|
||||
from actstream.models import Action
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.template import loader, TemplateDoesNotExist
|
||||
|
||||
from constants.activity import Verb
|
||||
from utils import absolutify
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
@ -30,12 +34,49 @@ class Notification(models.Model):
|
||||
]
|
||||
unique_together = ['recipient', 'action']
|
||||
|
||||
def format_email(self):
|
||||
@property
|
||||
def original_template_name(self) -> str:
|
||||
"""Template name constructed from action type, target and so on.
|
||||
|
||||
If we want to override email template for this specific notification,
|
||||
this is the name of the template that should be created.
|
||||
"""
|
||||
action = self.action
|
||||
subject = f'New Activity: {action.actor} {action.verb} {action.target}'
|
||||
url = self.get_absolute_url()
|
||||
mesage = f'{action.actor} {action.verb} {action.target}: {url}'
|
||||
return (subject, mesage)
|
||||
action_object = action.action_object
|
||||
target_name = action.target.__class__.__name__.lower()
|
||||
action_object_name = action_object.__class__.__name__.lower() if action_object else ''
|
||||
return f"new_activity_{action_object_name}_{target_name}_{action.verb.replace(' ', '_')}"
|
||||
|
||||
@property
|
||||
def template_name(self) -> str:
|
||||
"""Template name to be used for constructing notification email."""
|
||||
default_name = 'new_activity'
|
||||
name = self.original_template_name
|
||||
args = {'name': name, 'default_name': default_name}
|
||||
|
||||
try:
|
||||
loader.get_template(f'emails/{name}.html')
|
||||
except TemplateDoesNotExist:
|
||||
logger.warning('Template %(name)s does not exist, using %(default_name)s', args)
|
||||
return default_name
|
||||
return name
|
||||
|
||||
def get_template_context(self):
|
||||
"""Return template context to be used in the notification email."""
|
||||
action = self.action
|
||||
action_object = action.action_object
|
||||
quoted_message = getattr(action_object, 'message', getattr(action_object, 'text', ''))
|
||||
context = {
|
||||
'Verb': Verb,
|
||||
'action': action,
|
||||
'action_object': action_object,
|
||||
'notification': self,
|
||||
'quoted_message': quoted_message,
|
||||
'target': action.target,
|
||||
'url': self.get_absolute_url(),
|
||||
'user': self.recipient,
|
||||
}
|
||||
return context
|
||||
|
||||
def get_absolute_url(self):
|
||||
if self.action.verb == Verb.RATED_EXTENSION:
|
||||
|
@ -1,7 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.core import mail
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from common.tests.factories.extensions import create_approved_version, create_version
|
||||
from common.tests.factories.files import FileFactory
|
||||
@ -17,7 +20,9 @@ class TestTasks(TestCase):
|
||||
fixtures = ['dev', 'licenses']
|
||||
|
||||
def test_ratings(self):
|
||||
extension = create_approved_version(ratings=[]).extension
|
||||
extension = create_approved_version(
|
||||
ratings=[], file__user__confirmed_email_at=timezone.now()
|
||||
).extension
|
||||
author = extension.authors.first()
|
||||
notification_nr = Notification.objects.filter(recipient=author).count()
|
||||
some_user = UserFactory()
|
||||
@ -29,14 +34,35 @@ class TestTasks(TestCase):
|
||||
new_notification_nr = Notification.objects.filter(recipient=author).count()
|
||||
self.assertEqual(new_notification_nr, notification_nr + 1)
|
||||
|
||||
# Call the command that sends notification emails
|
||||
call_command('send_notification_emails')
|
||||
|
||||
# Test that one message has been sent.
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
email = mail.outbox[0]
|
||||
self.assertEqual(email.subject, f'Add-on rated: "{extension.name}"')
|
||||
self.assertEqual(email.to, [author.email])
|
||||
email_text = email.body
|
||||
self.maxDiff = None
|
||||
expected_text = expected_rated_text.format(**locals())
|
||||
self.assertEqual(email_text, expected_text)
|
||||
|
||||
# Check that most important action and relevant URL are in the HTML version
|
||||
email_html = email.alternatives[0][0]
|
||||
self.assertIn(' rated ', email_html)
|
||||
self.assertIn(
|
||||
f'https://extensions.local:8111/add-ons/{extension.slug}/reviews/', email_html
|
||||
)
|
||||
|
||||
def test_abuse(self):
|
||||
extension = create_approved_version(ratings=[]).extension
|
||||
moderator = create_moderator()
|
||||
moderator = create_moderator(confirmed_email_at=timezone.now())
|
||||
notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||
some_user = UserFactory()
|
||||
self.client.force_login(some_user)
|
||||
url = extension.get_report_url()
|
||||
self.client.post(
|
||||
response = self.client.post(
|
||||
url,
|
||||
{
|
||||
'message': 'test message',
|
||||
@ -44,11 +70,31 @@ class TestTasks(TestCase):
|
||||
'version': '',
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
report_url = response['Location']
|
||||
new_notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||
self.assertEqual(new_notification_nr, notification_nr + 1)
|
||||
|
||||
call_command('send_notification_emails')
|
||||
|
||||
# Test that one message has been sent.
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
email = mail.outbox[0]
|
||||
self.assertEqual(email.subject, f'Add-on reported: "{extension.name}"')
|
||||
self.assertEqual(email.to, [moderator.email])
|
||||
email_text = email.body
|
||||
self.maxDiff = None
|
||||
expected_text = expected_abuse_report_text.format(**locals())
|
||||
self.assertEqual(email_text, expected_text)
|
||||
|
||||
# Check that most important action and relevant URL are in the HTML version
|
||||
email_html = email.alternatives[0][0]
|
||||
self.assertIn(report_url, email_html)
|
||||
self.assertIn(' reported ', email_html)
|
||||
|
||||
def test_new_extension_submitted(self):
|
||||
moderator = create_moderator()
|
||||
moderator = create_moderator(confirmed_email_at=timezone.now())
|
||||
notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||
some_user = UserFactory()
|
||||
file_data = {
|
||||
@ -117,10 +163,29 @@ class TestTasks(TestCase):
|
||||
new_notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||
self.assertEqual(new_notification_nr, notification_nr + 1)
|
||||
|
||||
call_command('send_notification_emails')
|
||||
|
||||
# Test that one message has been sent.
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
email = mail.outbox[0]
|
||||
extension = file.version.extension
|
||||
self.assertEqual(email.subject, 'Add-on review requested: "Edit Breakdown"')
|
||||
self.assertEqual(email.to, [moderator.email])
|
||||
email_text = email.body
|
||||
self.maxDiff = None
|
||||
expected_text = expected_review_requested_text.format(**locals())
|
||||
self.assertEqual(email_text, expected_text)
|
||||
|
||||
# Check that most important action and relevant URL are in the HTML version
|
||||
email_html = email.alternatives[0][0]
|
||||
self.assertIn(' requested review of ', email_html)
|
||||
self.assertIn(f'https://extensions.local:8111/approval-queue/{extension.slug}/', email_html)
|
||||
|
||||
def test_approval_queue_activity(self):
|
||||
extension = create_approved_version(ratings=[]).extension
|
||||
author = extension.authors.first()
|
||||
moderator = create_moderator()
|
||||
moderator = create_moderator(confirmed_email_at=timezone.now())
|
||||
some_user = UserFactory()
|
||||
notification_nrs = {}
|
||||
for user in [author, moderator, some_user]:
|
||||
@ -136,7 +201,88 @@ class TestTasks(TestCase):
|
||||
self.assertEqual(new_notification_nrs[moderator.pk], notification_nrs[moderator.pk] + 1)
|
||||
self.assertEqual(new_notification_nrs[some_user.pk], notification_nrs[some_user.pk] + 1)
|
||||
|
||||
call_command('send_notification_emails')
|
||||
|
||||
# Test that one message has been sent (only moderator has email confirmed here).
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
email = mail.outbox[0]
|
||||
self.assertEqual(email.subject, f'New comment on Add-on "{extension.name}"')
|
||||
email_text = email.body
|
||||
expected_text = expected_new_comment_text.format(**locals())
|
||||
self.maxDiff = None
|
||||
self.assertEqual(email_text, expected_text)
|
||||
|
||||
# Check that most important action and relevant URL are in the HTML version
|
||||
email_html = email.alternatives[0][0]
|
||||
self.assertIn(' commented on ', email_html)
|
||||
self.assertIn(f'https://extensions.local:8111/approval-queue/{extension.slug}/', email_html)
|
||||
|
||||
def _leave_a_comment(self, user, extension, text):
|
||||
self.client.force_login(user)
|
||||
url = reverse('reviewers:approval-comment', args=[extension.slug])
|
||||
self.client.post(url, {'type': ApprovalActivity.ActivityType.COMMENT, 'message': text})
|
||||
|
||||
# TODO: test for notifications about a reported rating
|
||||
# TODO: test for notifications about extension approved by moderators
|
||||
|
||||
|
||||
expected_abuse_report_text = """Add-on reported: "{extension.name}"
|
||||
{some_user.full_name} reported Add-on "{extension.name}"
|
||||
:
|
||||
|
||||
“test message”
|
||||
|
||||
https://extensions.local:8111{report_url}
|
||||
Read all notifications at https://extensions.local:8111/notifications/
|
||||
|
||||
|
||||
Unsubscribe by adjusting your preferences at https://extensions.local:8111/settings/profile/
|
||||
|
||||
https://extensions.local:8111/
|
||||
"""
|
||||
|
||||
expected_new_comment_text = """New comment on Add-on "{extension.name}"
|
||||
{some_user.full_name} commented on Add-on "{extension.name}"
|
||||
:
|
||||
|
||||
“this is bad”
|
||||
|
||||
https://extensions.local:8111/approval-queue/{extension.slug}/
|
||||
Read all notifications at https://extensions.local:8111/notifications/
|
||||
|
||||
|
||||
Unsubscribe by adjusting your preferences at https://extensions.local:8111/settings/profile/
|
||||
|
||||
https://extensions.local:8111/
|
||||
"""
|
||||
|
||||
expected_rated_text = """Add-on rated: "{extension.name}"
|
||||
{some_user.full_name} rated extension Add-on "{extension.name}"
|
||||
:
|
||||
|
||||
“rating text”
|
||||
|
||||
https://extensions.local:8111/add-ons/{extension.slug}/reviews/
|
||||
Read all notifications at https://extensions.local:8111/notifications/
|
||||
|
||||
|
||||
Unsubscribe by adjusting your preferences at https://extensions.local:8111/settings/profile/
|
||||
|
||||
https://extensions.local:8111/
|
||||
"""
|
||||
|
||||
expected_review_requested_text = """Add-on review requested: "Edit Breakdown"
|
||||
{some_user.full_name} requested review of Add-on "Edit Breakdown"
|
||||
:
|
||||
|
||||
“Extension is ready for initial review”
|
||||
|
||||
https://extensions.local:8111/approval-queue/edit-breakdown/
|
||||
Read all notifications at https://extensions.local:8111/notifications/
|
||||
|
||||
|
||||
Unsubscribe by adjusting your preferences at https://extensions.local:8111/settings/profile/
|
||||
|
||||
https://extensions.local:8111/
|
||||
"""
|
||||
|
49
utils.py
49
utils.py
@ -1,3 +1,4 @@
|
||||
from html.parser import HTMLParser
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin
|
||||
import datetime
|
||||
@ -20,6 +21,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_ipv46_address
|
||||
from django.http import HttpRequest
|
||||
from django.http.response import HttpResponseRedirectBase
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import force_bytes, force_str
|
||||
from django.utils.http import _urlparse
|
||||
import django.utils.text
|
||||
@ -189,3 +191,50 @@ def absolutify(url: str, request=None) -> str:
|
||||
proto = 'http' if settings.DEBUG else 'https'
|
||||
domain = get_current_site(request).domain
|
||||
return urljoin(f'{proto}://{domain}', url)
|
||||
|
||||
|
||||
def absolute_url(
|
||||
view_name: str, args: Optional[tuple] = None, kwargs: Optional[dict] = None
|
||||
) -> str:
|
||||
"""Same as django.urls.reverse() but returned as an absolute URL."""
|
||||
relative_url = reverse(view_name, args=args, kwargs=kwargs)
|
||||
return absolutify(relative_url)
|
||||
|
||||
|
||||
class HTMLFilter(HTMLParser):
|
||||
skip_text_of = ('a', 'style')
|
||||
text = ''
|
||||
skip_tag_text = False
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in self.skip_text_of:
|
||||
self.skip_tag_text = True
|
||||
for name, value in attrs:
|
||||
if name == 'href':
|
||||
self.skip_tag_text = True
|
||||
self.text += value
|
||||
if tag in ('quote', 'q'):
|
||||
self.text += '“'
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in self.skip_text_of:
|
||||
self.skip_tag_text = False
|
||||
if tag in ('quote', 'q'):
|
||||
self.text += '”\n\n'
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.skip_tag_text:
|
||||
return
|
||||
self.text += data
|
||||
|
||||
|
||||
def html_to_text(data: str) -> str:
|
||||
f = HTMLFilter()
|
||||
f.feed(data)
|
||||
lines = [_.lstrip(' \t') for _ in f.text.split('\n')]
|
||||
skip_empty = 0
|
||||
for line in lines:
|
||||
if not re.match(r'^\s*$', line):
|
||||
break
|
||||
skip_empty += 1
|
||||
return '\n'.join(lines[skip_empty:])
|
||||
|
Loading…
Reference in New Issue
Block a user