Basic email template for notifications #96

Merged
Anna Sirota merged 14 commits from notification-templates into main 2024-05-02 14:04:24 +02:00
16 changed files with 527 additions and 175 deletions

View File

@ -1,8 +1,19 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from factory.django import DjangoModelFactory from factory.django import DjangoModelFactory
from faker import Faker
import actstream.models
import factory 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 = [] RELATION_ALLOWED_MODELS = []
fake = Faker()
def generic_foreign_key_id_for_type_factory(generic_relation_type_field): 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 ContentTypeFactory(DjangoModelFactory):
class Meta: class Meta:
model = ContentType 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

View File

@ -0,0 +1,8 @@
from factory.django import DjangoModelFactory
from reviewers.models import ApprovalActivity
class ApprovalActivityFactory(DjangoModelFactory):
class Meta:
model = ApprovalActivity

View File

@ -45,8 +45,8 @@ class UserFactory(DjangoModelFactory):
oauth_info = factory.RelatedFactory(OAuthUserInfoFactory, factory_related_name='user') oauth_info = factory.RelatedFactory(OAuthUserInfoFactory, factory_related_name='user')
def create_moderator(): def create_moderator(**kwargs):
user = UserFactory() user = UserFactory(**kwargs)
moderators = Group.objects.get(name='moderators') moderators = Group.objects.get(name='moderators')
user.groups.add(moderators) user.groups.add(moderators)
return user return user

View File

@ -9,7 +9,8 @@ from django.utils.safestring import mark_safe
import django.core.mail import django.core.mail
from emails.models import Email 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__) logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
@ -92,16 +93,15 @@ class EmailPreviewAdmin(NoAddDeleteMixin, EmailAdmin):
def _get_email_sent_message(self, obj): def _get_email_sent_message(self, obj):
return f'Sent a test email "{obj.subject}" to {obj.to} from {obj.from_email}' 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): def get_object(self, request, object_id, from_field=None, fake_context=None):
"""Construct the Email on th fly from known subscription email templates.""" """Construct the Email on the fly from known email templates."""
context = { if not fake_context:
'user': User(), fake_context = self._get_emails_with_fake_context(request)
**get_template_context(), context = fake_context[object_id]
} mail_name = context.get('template', object_id)
mail_name = object_id
email_body_html, email_body_txt, subject = construct_email(mail_name, context) email_body_html, email_body_txt, subject = construct_email(mail_name, context)
return EmailPreview( return EmailPreview(
id=mail_name, id=object_id,
subject=subject, subject=subject,
from_email=settings.DEFAULT_FROM_EMAIL, from_email=settings.DEFAULT_FROM_EMAIL,
reply_to=settings.DEFAULT_REPLY_TO_EMAIL, reply_to=settings.DEFAULT_REPLY_TO_EMAIL,
@ -109,10 +109,23 @@ class EmailPreviewAdmin(NoAddDeleteMixin, EmailAdmin):
message=email_body_txt, 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): def _get_emails_list(self, request):
emails = [] emails = []
for mail_name in ('feedback',): fake_context = self._get_emails_with_fake_context(request)
emails.append(self.get_object(request, object_id=mail_name)) for mail_name in fake_context:
emails.append(self.get_object(request, object_id=mail_name, fake_context=fake_context))
return emails return emails
def _changeform_view(self, request, object_id, form_url, extra_context): def _changeform_view(self, request, object_id, form_url, extra_context):

View File

@ -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 %} <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 #} {# have a title instead of the logo with the remote image #}
<div style="text-align: center; font-weight: bold;">{{ subject }}</div> <div style="text-align: center; font-weight: bold;">{{ subject }}</div>
{% endblock header_logo %} {% 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 %} {% block body %}{% block content %}{% endblock content %}{% endblock body %}
<p>Dear {% firstof user.full_name user.email %},</p>
{% block content %}{% endblock content %} </div>
<p> <div class="footer" style="
--<br /> width: 100%;
Kind regards,<br /> color: #4C4D52;
Blender Extensions Team 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> </p>
{% endblock body %} {% 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 %}

View File

@ -1,9 +1,3 @@
Dear {% firstof user.full_name user.email %},
{% block content %}{% endblock content %} {% block content %}{% endblock content %}
Manage your profile: {{ profile_url }} Manage your profile: {{ profile_url }}
--
Kind regards,
Blender Extensions Team

View 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 %}

View File

@ -1,4 +1,4 @@
{% extends "emails/email_base.html" %} {% extends "emails/base.html" %}
{% block body %} {% block body %}
{{ email.html_message|safe }} {{ email.html_message|safe }}

View File

@ -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>

View 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 %}

View 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 %}

View File

@ -1,35 +1,17 @@
"""Utilities for rendering email templates.""" """Utilities for rendering email templates."""
from typing import List, Optional, Tuple, Dict, Any from typing import List, Tuple, Dict, Any
import logging import logging
from django.conf import settings 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.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 = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) 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: def is_noreply(email: str) -> bool:
"""Return True if the email address is a no-reply address.""" """Return True if the email address is a no-reply address."""
return email.startswith('noreply@') or email.startswith('no-reply@') 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]: def get_template_context() -> Dict[str, str]:
"""Return additional context for use in an email template.""" """Return additional context for use in an email template."""
return { return {
'site_url': _get_site_url(), 'site_url': absolute_url('extensions:home'),
# 'profile_url': absolute_url('profile_update'), 'profile_url': absolute_url('users:my-profile'),
'notifications_url': absolute_url('notifications:notifications'),
'DEFAULT_REPLY_TO_EMAIL': settings.DEFAULT_REPLY_TO_EMAIL, '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]: def construct_email(email_name: str, context: Dict[str, Any]) -> Tuple[str, str, str]:
"""Construct an email message. """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) :return: tuple (html, text, subject)
""" """
context.update(**get_template_context())
base_path = 'emails' base_path = 'emails'
subj_tmpl, html_tmpl, txt_tmpl = ( subj_tmpl, html_tmpl, txt_tmpl = (
f'{base_path}/{email_name}_subject.txt', 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() context['subject'] = subject.strip()
email_body_html = loader.render_to_string(html_tmpl, context) email_body_html = loader.render_to_string(html_tmpl, context)
try:
email_body_txt = loader.render_to_string(txt_tmpl, context) 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'] return email_body_html, email_body_txt, context['subject']

View File

@ -1,11 +1,10 @@
"""Send user notifications as emails, at most once delivery.""" """Send user notifications as emails, at most once delivery."""
import logging import logging
from django.conf import settings
from django.core.mail import send_mail
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from emails.util import construct_and_send_email
from notifications.models import Notification from notifications.models import Notification
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -36,12 +35,6 @@ class Command(BaseCommand):
def send_notification_email(notification): def send_notification_email(notification):
# TODO construct a proper phrase, depending on the verb, template_name = notification.template_name
# possibly share a template with NotificationsView context = notification.get_template_context()
subject, message = notification.format_email() construct_and_send_email(template_name, context, recipient_list=[notification.recipient.email])
send_mail(
subject,
message,
settings.DEFAULT_FROM_EMAIL,
[notification.recipient.email],
)

View File

@ -1,11 +1,15 @@
import logging
from actstream.models import Action from actstream.models import Action
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.template import loader, TemplateDoesNotExist
from constants.activity import Verb from constants.activity import Verb
from utils import absolutify from utils import absolutify
User = get_user_model() User = get_user_model()
logger = logging.getLogger(__name__)
class Notification(models.Model): class Notification(models.Model):
@ -30,12 +34,49 @@ class Notification(models.Model):
] ]
unique_together = ['recipient', 'action'] 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 action = self.action
subject = f'New Activity: {action.actor} {action.verb} {action.target}' action_object = action.action_object
url = self.get_absolute_url() target_name = action.target.__class__.__name__.lower()
mesage = f'{action.actor} {action.verb} {action.target}: {url}' action_object_name = action_object.__class__.__name__.lower() if action_object else ''
return (subject, mesage) 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): def get_absolute_url(self):
if self.action.verb == Verb.RATED_EXTENSION: if self.action.verb == Verb.RATED_EXTENSION:

View File

@ -1,7 +1,10 @@
from pathlib import Path from pathlib import Path
from django.core import mail
from django.core.management import call_command
from django.test import TestCase from django.test import TestCase
from django.urls import reverse 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.extensions import create_approved_version, create_version
from common.tests.factories.files import FileFactory from common.tests.factories.files import FileFactory
@ -17,7 +20,9 @@ class TestTasks(TestCase):
fixtures = ['dev', 'licenses'] fixtures = ['dev', 'licenses']
def test_ratings(self): 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() author = extension.authors.first()
notification_nr = Notification.objects.filter(recipient=author).count() notification_nr = Notification.objects.filter(recipient=author).count()
some_user = UserFactory() some_user = UserFactory()
@ -29,14 +34,35 @@ class TestTasks(TestCase):
new_notification_nr = Notification.objects.filter(recipient=author).count() new_notification_nr = Notification.objects.filter(recipient=author).count()
self.assertEqual(new_notification_nr, notification_nr + 1) 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): def test_abuse(self):
extension = create_approved_version(ratings=[]).extension 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() notification_nr = Notification.objects.filter(recipient=moderator).count()
some_user = UserFactory() some_user = UserFactory()
self.client.force_login(some_user) self.client.force_login(some_user)
url = extension.get_report_url() url = extension.get_report_url()
self.client.post( response = self.client.post(
url, url,
{ {
'message': 'test message', 'message': 'test message',
@ -44,11 +70,31 @@ class TestTasks(TestCase):
'version': '', 'version': '',
}, },
) )
self.assertEqual(response.status_code, 302)
report_url = response['Location']
new_notification_nr = Notification.objects.filter(recipient=moderator).count() new_notification_nr = Notification.objects.filter(recipient=moderator).count()
self.assertEqual(new_notification_nr, notification_nr + 1) 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): 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() notification_nr = Notification.objects.filter(recipient=moderator).count()
some_user = UserFactory() some_user = UserFactory()
file_data = { file_data = {
@ -117,10 +163,29 @@ class TestTasks(TestCase):
new_notification_nr = Notification.objects.filter(recipient=moderator).count() new_notification_nr = Notification.objects.filter(recipient=moderator).count()
self.assertEqual(new_notification_nr, notification_nr + 1) 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): def test_approval_queue_activity(self):
extension = create_approved_version(ratings=[]).extension extension = create_approved_version(ratings=[]).extension
author = extension.authors.first() author = extension.authors.first()
moderator = create_moderator() moderator = create_moderator(confirmed_email_at=timezone.now())
some_user = UserFactory() some_user = UserFactory()
notification_nrs = {} notification_nrs = {}
for user in [author, moderator, some_user]: 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[moderator.pk], notification_nrs[moderator.pk] + 1)
self.assertEqual(new_notification_nrs[some_user.pk], notification_nrs[some_user.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): def _leave_a_comment(self, user, extension, text):
self.client.force_login(user) self.client.force_login(user)
url = reverse('reviewers:approval-comment', args=[extension.slug]) url = reverse('reviewers:approval-comment', args=[extension.slug])
self.client.post(url, {'type': ApprovalActivity.ActivityType.COMMENT, 'message': text}) 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/
"""

View File

@ -1,3 +1,4 @@
from html.parser import HTMLParser
from typing import Optional from typing import Optional
from urllib.parse import urljoin from urllib.parse import urljoin
import datetime import datetime
@ -20,6 +21,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import validate_ipv46_address from django.core.validators import validate_ipv46_address
from django.http import HttpRequest from django.http import HttpRequest
from django.http.response import HttpResponseRedirectBase from django.http.response import HttpResponseRedirectBase
from django.urls import reverse
from django.utils.encoding import force_bytes, force_str from django.utils.encoding import force_bytes, force_str
from django.utils.http import _urlparse from django.utils.http import _urlparse
import django.utils.text import django.utils.text
@ -189,3 +191,50 @@ def absolutify(url: str, request=None) -> str:
proto = 'http' if settings.DEBUG else 'https' proto = 'http' if settings.DEBUG else 'https'
domain = get_current_site(request).domain domain = get_current_site(request).domain
return urljoin(f'{proto}://{domain}', url) 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:])