Implement Web Assets' theme system and selection, and add 'light' theme #118
@ -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
|
||||||
|
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')
|
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
|
||||||
|
@ -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):
|
||||||
|
@ -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;">
|
||||||
|
|
||||||
|
<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 %}
|
{% 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 %}
|
||||||
|
@ -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
|
|
||||||
|
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 %}
|
{% block body %}
|
||||||
{{ email.html_message|safe }}
|
{{ 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."""
|
"""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']
|
||||||
|
|
||||||
|
|
||||||
|
@ -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],
|
|
||||||
)
|
|
||||||
|
@ -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:
|
||||||
|
@ -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/
|
||||||
|
"""
|
||||||
|
49
utils.py
49
utils.py
@ -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:])
|
||||||
|
Loading…
Reference in New Issue
Block a user