diff --git a/post_office/admin.py b/post_office/admin.py index 5ebff70c..e3508618 100644 --- a/post_office/admin.py +++ b/post_office/admin.py @@ -1,23 +1,30 @@ import re from django import forms -from django.db import models -from django.contrib import admin from django.conf import settings -from django.conf.urls import re_path +from django.contrib import admin from django.core.exceptions import ValidationError from django.core.mail.message import SafeMIMEText +from django.db import models from django.forms import BaseInlineFormSet from django.forms.widgets import TextInput -from django.http.response import HttpResponse, HttpResponseNotFound -from django.template import Context, Template +from django.http.response import HttpResponse +from django.http.response import HttpResponseNotFound +from django.template import Context +from django.template import Template +from django.urls import re_path from django.urls import reverse from django.utils.html import format_html from django.utils.text import Truncator from django.utils.translation import gettext_lazy as _ +from . import settings as post_office_settings from .fields import CommaSeparatedEmailField -from .models import Attachment, Log, Email, EmailTemplate, STATUS +from .models import Attachment +from .models import Email +from .models import EmailTemplate +from .models import Log +from .models import STATUS from .sanitizer import clean_html @@ -25,6 +32,7 @@ def get_message_preview(instance): return ('{0}...'.format(instance.message[:25]) if len(instance.message) > 25 else instance.message) + get_message_preview.short_description = 'Message' @@ -42,7 +50,8 @@ def get_queryset(self, request): a.id for a in queryset if isinstance(a.attachment.headers, dict) - and a.attachment.headers.get("Content-Disposition", "").startswith("inline") + and a.attachment.headers.get("Content-Disposition", + "").startswith("inline") ] return queryset.exclude(id__in=inlined_attachments) @@ -78,13 +87,16 @@ def requeue(modeladmin, request, queryset): """An admin action to requeue emails.""" queryset.update(status=STATUS.queued) + requeue.short_description = 'Requeue selected emails' class EmailAdmin(admin.ModelAdmin): - list_display = ['truncated_message_id', 'to_display', 'shortened_subject', 'status', 'last_updated', 'scheduled_time', 'use_template'] + list_display = ['truncated_message_id', 'to_display', 'shortened_subject', + 'status', 'last_updated', 'scheduled_time', 'use_template'] search_fields = ['to', 'subject'] - readonly_fields = ['message_id', 'render_subject', 'render_plaintext_body', 'render_html_body'] + readonly_fields = ['message_id', 'render_subject', 'render_plaintext_body', + 'render_html_body'] date_hierarchy = 'last_updated' inlines = [AttachmentInline, LogInline] list_filter = ['status', 'template__language', 'template__name'] @@ -95,7 +107,8 @@ class EmailAdmin(admin.ModelAdmin): def get_urls(self): urls = [ - re_path(r'^(?P\d+)/image/(?P[0-9a-f]{32})$', self.fetch_email_image, name='post_office_email_image'), + re_path(r'^(?P\d+)/image/(?P[0-9a-f]{32})$', + self.fetch_email_image, name='post_office_email_image'), ] urls.extend(super().get_urls()) return urls @@ -120,7 +133,8 @@ def has_add_permission(self, request): def shortened_subject(self, instance): if instance.template: - template_cache_key = '_subject_template_' + str(instance.template_id) + template_cache_key = '_subject_template_' + str( + instance.template_id) template = getattr(self, template_cache_key, None) if template is None: # cache compiled template to speed up rendering of list view @@ -141,7 +155,8 @@ def use_template(self, instance): use_template.boolean = True def get_fieldsets(self, request, obj=None): - fields = ['from_email', 'to', 'cc', 'bcc', 'priority', ('status', 'scheduled_time')] + fields = ['from_email', 'to', 'cc', 'bcc', 'priority', + ('status', 'scheduled_time')] if obj.message_id: fields.insert(0, 'message_id') fieldsets = [(None, {'fields': fields})] @@ -157,16 +172,19 @@ def get_fieldsets(self, request, obj=None): if has_html_content: fieldsets.append( - (_("HTML Email"), {'fields': ['render_subject', 'render_html_body']}) + (_("HTML Email"), + {'fields': ['render_subject', 'render_html_body']}) ) if has_plaintext_content: fieldsets.append( - (_("Text Email"), {'classes': ['collapse'], 'fields': ['render_plaintext_body']}) + (_("Text Email"), {'classes': ['collapse'], + 'fields': ['render_plaintext_body']}) ) elif has_plaintext_content: fieldsets.append( - (_("Text Email"), {'fields': ['render_subject', 'render_plaintext_body']}) - ) + (_("Text Email"), + {'fields': ['render_subject', 'render_plaintext_body']}) + ) return fieldsets @@ -178,17 +196,20 @@ def render_subject(self, instance): def render_plaintext_body(self, instance): for message in instance.email_message().message().walk(): - if isinstance(message, SafeMIMEText) and message.get_content_type() == 'text/plain': + if isinstance(message, + SafeMIMEText) and message.get_content_type() == 'text/plain': return format_html('
{}
', message.get_payload()) render_plaintext_body.short_description = _("Mail Body") def render_html_body(self, instance): pattern = re.compile('cid:([0-9a-f]{32})') - url = reverse('admin:post_office_email_image', kwargs={'pk': instance.id, 'content_id': 32 * '0'}) + url = reverse('admin:post_office_email_image', + kwargs={'pk': instance.id, 'content_id': 32 * '0'}) url = url.replace(32 * '0', r'\1') for message in instance.email_message().message().walk(): - if isinstance(message, SafeMIMEText) and message.get_content_type() == 'text/html': + if isinstance(message, + SafeMIMEText) and message.get_content_type() == 'text/html': payload = message.get_payload(decode=True).decode('utf-8') return clean_html(pattern.sub(url, payload)) @@ -197,8 +218,10 @@ def render_html_body(self, instance): def fetch_email_image(self, request, pk, content_id): instance = self.get_object(request, pk) for message in instance.email_message().message().walk(): - if message.get_content_maintype() == 'image' and message.get('Content-Id')[1:33] == content_id: - return HttpResponse(message.get_payload(decode=True), content_type=message.get_content_type()) + if message.get_content_maintype() == 'image' and message.get( + 'Content-Id')[1:33] == content_id: + return HttpResponse(message.get_payload(decode=True), + content_type=message.get_content_type()) return HttpResponseNotFound() @@ -239,7 +262,8 @@ class EmailTemplateAdminForm(forms.ModelForm): class Meta: model = EmailTemplate - fields = ['name', 'description', 'subject', 'content', 'html_content', 'language', + fields = ['name', 'description', 'subject', 'content', 'html_content', + 'language', 'default_template'] def __init__(self, *args, **kwargs): @@ -249,24 +273,54 @@ def __init__(self, *args, **kwargs): self.fields['language'].disabled = True +def _create_iframe(src, height): + return format_html(''' + + ''', height=height, src=src) + + class EmailTemplateInline(admin.StackedInline): form = EmailTemplateAdminForm formset = EmailTemplateAdminFormSet model = EmailTemplate extra = 0 - fields = ('language', 'subject', 'content', 'html_content',) + fields = ('language', 'subject', 'content', 'html_content', + 'rendered_content', 'rendered_html_content',) + readonly_fields = 'rendered_content', 'rendered_html_content' formfield_overrides = { models.CharField: {'widget': SubjectField} } + def rendered_content(self, instance): + if instance.content: + src = '?preview=text&language={}'.format(instance.language) + height = instance.content.count('\n') * 25 + return _create_iframe(src, height) + else: + return '' + + def rendered_html_content(self, instance): + if instance.html_content: + src = '?preview=text&language={}'.format(instance.language) + return _create_iframe(src, 800) + else: + return '' + def get_max_num(self, request, obj=None, **kwargs): return len(settings.LANGUAGES) class EmailTemplateAdmin(admin.ModelAdmin): form = EmailTemplateAdminForm - list_display = ('name', 'description_shortened', 'subject', 'languages_compact', 'created') + list_display = ( + 'name', 'description_shortened', 'subject', 'languages_compact', + 'created') search_fields = ('name', 'description', 'subject') + readonly_fields = 'rendered_content', 'rendered_html_content' fieldsets = [ (None, { 'fields': ('name', 'description'), @@ -274,23 +328,72 @@ class EmailTemplateAdmin(admin.ModelAdmin): (_("Default Content"), { 'fields': ('subject', 'content', 'html_content'), }), + (_("Preview"), { + 'fields': ('example_context', 'rendered_content', + 'rendered_html_content'), + }), ] inlines = (EmailTemplateInline,) if settings.USE_I18N else () formfield_overrides = { models.CharField: {'widget': SubjectField} } + change_form_template = 'admin/post_office/EmailTemplate/change_form.html' + + def change_view(self, request, object_id, form_url='', extra_context=None): + if request.GET.get('preview'): + instance = self.model.objects.get(id=object_id) + engine = post_office_settings.get_template_engine() + + if request.GET.get('language'): + template_instance = instance.translated_templates.filter( + language=request.GET.get('language'), + ).first() + else: + template_instance = instance + + if request.GET.get('preview') == 'html': + template = engine.from_string( + template_instance.html_content + .replace('inline_image', 'static') + .replace(' post_office ', ' static ')) + else: + template = engine.from_string( + '
%s
' % template_instance.content) + + return HttpResponse(clean_html(template.render( + instance.example_context))) + + return super(EmailTemplateAdmin, self).change_view( + request, object_id, form_url=form_url, extra_context=extra_context) + + def rendered_content(self, instance): + if instance.content: + src = '?preview=text' + height = instance.content.count('\n') * 25 + return _create_iframe(src, height) + else: + return '' + + def rendered_html_content(self, instance): + if instance.html_content: + return _create_iframe('?preview=html', 800) + else: + return '' def get_queryset(self, request): return self.model.objects.filter(default_template__isnull=True) def description_shortened(self, instance): return Truncator(instance.description.split('\n')[0]).chars(200) + description_shortened.short_description = _("Description") description_shortened.admin_order_field = 'description' def languages_compact(self, instance): - languages = [tt.language for tt in instance.translated_templates.order_by('language')] + languages = [tt.language for tt in + instance.translated_templates.order_by('language')] return ', '.join(languages) + languages_compact.short_description = _("Languages") def save_model(self, request, obj, form, change): @@ -305,6 +408,7 @@ class AttachmentAdmin(admin.ModelAdmin): list_display = ['name', 'file'] filter_horizontal = ['emails'] + admin.site.register(Email, EmailAdmin) admin.site.register(Log, LogAdmin) admin.site.register(EmailTemplate, EmailTemplateAdmin) diff --git a/post_office/migrations/0012_add_example_context.py b/post_office/migrations/0012_add_example_context.py new file mode 100644 index 00000000..492cb536 --- /dev/null +++ b/post_office/migrations/0012_add_example_context.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.10 on 2021-12-12 02:17 + +from django.db import migrations, models +import jsonfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('post_office', '0011_models_help_text'), + ] + + operations = [ + migrations.AddField( + model_name='emailtemplate', + name='example_context', + field=jsonfield.fields.JSONField(blank=True, null=True, verbose_name='Context'), + ), + ] diff --git a/post_office/models.py b/post_office/models.py index d736d48e..1033d6f0 100644 --- a/post_office/models.py +++ b/post_office/models.py @@ -269,6 +269,7 @@ class EmailTemplate(models.Model): default='', blank=True) default_template = models.ForeignKey('self', related_name='translated_templates', null=True, default=None, verbose_name=_('Default template'), on_delete=models.CASCADE) + example_context = context_field_class(_('Context'), blank=True, null=True) objects = EmailTemplateManager()