Skip to content

Commit

Permalink
Preview emails using the full page editor and standard email rendering (
Browse files Browse the repository at this point in the history
#3778)

Adds the full page markdown editor for emails along with iframed
previews using the standard email rendering. As there are differences in
email clients and browsers a test email still needs to be sent out, but
at least the previews are isolated from the GC CSS/JS environment.

<img width="2286" alt="Screenshot 2025-01-09 at 11 37 36"
src="https://github.com/user-attachments/assets/cd82e12a-c77b-4412-8d3a-778fcbf61996"
/>

<img width="1199" alt="Screenshot 2025-01-09 at 11 37 56"
src="https://github.com/user-attachments/assets/9a75c852-c24a-4604-9b6d-27079c0883fe"
/>

Closes #3682
  • Loading branch information
jmsmkn authored Jan 14, 2025
1 parent ffcde69 commit ac3c84b
Show file tree
Hide file tree
Showing 14 changed files with 333 additions and 56 deletions.
24 changes: 18 additions & 6 deletions app/grandchallenge/emails/forms.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
from django import forms

from grandchallenge.core.forms import SaveFormInitMixin
from grandchallenge.core.widgets import MarkdownEditorInlineWidget
from grandchallenge.emails.models import Email
from grandchallenge.emails.widgets import MarkdownEditorEmailFullPageWidget
from grandchallenge.subdomains.utils import reverse


class EmailForm(SaveFormInitMixin, forms.ModelForm):
class EmailMetadataForm(SaveFormInitMixin, forms.ModelForm):
class Meta:
model = Email
fields = (
"subject",
"body",
fields = ("subject",)


class EmailBodyForm(SaveFormInitMixin, forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

self.fields["body"].widget = MarkdownEditorEmailFullPageWidget(
preview_url=reverse(
"emails:rendered-detail", kwargs={"pk": self.instance.pk}
)
)
widgets = {"body": MarkdownEditorInlineWidget}

class Meta:
model = Email
fields = ("body",)
7 changes: 1 addition & 6 deletions app/grandchallenge/emails/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,7 @@ class Migration(migrations.Migration):
),
),
("subject", models.CharField(max_length=1024)),
(
"body",
models.TextField(
help_text="Email body will be prepended with 'Dear [username],' and will end with 'Kind regards, Grand Challenge team' and a link to unsubscribe from the mailing list."
),
),
("body", models.TextField()),
("sent", models.BooleanField(default=False)),
("sent_at", models.DateTimeField(blank=True, null=True)),
(
Expand Down
25 changes: 22 additions & 3 deletions app/grandchallenge/emails/models.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
from django.contrib.sites.models import Site
from django.db import models
from django.urls import reverse
from guardian.utils import get_anonymous_user

from grandchallenge.core.models import UUIDModel
from grandchallenge.emails.emails import create_email_object
from grandchallenge.profiles.models import EmailSubscriptionTypes


class Email(models.Model):

subject = models.CharField(max_length=1024)
body = models.TextField(
help_text="Email body will be prepended with 'Dear [username],' and will end with 'Kind regards, Grand Challenge team' and a link to unsubscribe from the mailing list."
)
body = models.TextField()
sent = models.BooleanField(default=False)
sent_at = models.DateTimeField(blank=True, null=True)
status_report = models.JSONField(
Expand All @@ -25,6 +27,23 @@ class Meta:
def __str__(self):
return self.subject

@property
def rendered_body(self):
email = create_email_object(
recipient=get_anonymous_user(),
site=Site.objects.get_current(),
subject=self.subject,
markdown_message=self.body,
subscription_type=EmailSubscriptionTypes.SYSTEM,
connection=None,
)
alternatives = [
alternative
for alternative in email.alternatives
if alternative[1] == "text/html"
]
return alternatives[0][0]

def get_absolute_url(self):
return reverse("emails:detail", kwargs={"pk": self.pk})

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
document.addEventListener("DOMContentLoaded", event => {
const iframes = document.querySelectorAll(".markdownx-preview");
for (const iframe of iframes) {
const observer = new MutationObserver(() => {
iframe.srcdoc = iframe.innerHTML;
});
observer.observe(iframe, {
childList: true,
});
}
});
17 changes: 17 additions & 0 deletions app/grandchallenge/emails/templates/emails/email_body_update.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% extends "emails/email_form.html" %}
{% load crispy_forms_tags %}

{% block container %}container-fluid{% endblock %}

{% block content %}

<h2>Update Email</h2>

<div class="alert alert-warning ml-3" role="alert">
The body preview does not accurately represent how the result will be rendered in email applications!
Always send out a test version to check the formatting.
</div>

{% crispy form %}

{% endblock %}
26 changes: 14 additions & 12 deletions app/grandchallenge/emails/templates/emails/email_detail.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% load url %}
{% load bleach %}
{% load static %}

{% block title %}
{{ object.subject }} - Emails - {{ block.super }}
Expand All @@ -19,27 +20,28 @@ <h2>{{ object.subject }}</h2>
<p>Sent on {{ object.sent_at|date:"j N Y" }}</p>
{% endif %}
<hr>

<div class="alert alert-warning ml-3" role="alert">
The body preview does not accurately represent how the result will be rendered in email applications!
Always send out a test version to check the formatting.
</div>

<div class="row ml-1">
<div class="d-flex col-8 bg-light rounded p-4 justify-content-center">
{# The html email body is set to width:600px #}
<div class="bg-white rounded p-2" style="width:600px;">
<p>Dear user, </p>
{{ object.body|md2email_html }}
<p>&mdash; Your Grand Challenge Team</p>
</div>

<div class="col">
<iframe id="emailBodyFrame" sandbox="" class="w-100 vh-100" src="{% url "emails:rendered-detail" pk=object.pk %}"></iframe>
</div>

<div class="col-4">
{% if not object.sent and "emails.change_email" in perms %}
<div class="alert alert-warning ml-3" role="alert">
This email has not been sent yet.
You can come back and edit it. When it's ready to be sent,
please contact support@grand-challenge.org to send it for you.
please contact support to send it for you.
</div>
<div class="text-right">
<a href="{% url "emails:update" pk=object.pk %}"
class="btn btn-primary">
<i class="fa fa-edit"></i> Edit email
</a>
<a href="{% url "emails:body-update" pk=object.pk %}" class="btn btn-primary"><i class="fa fa-edit"></i> Edit Body</a>
<a href="{% url "emails:metadata-update" pk=object.pk %}" class="btn btn-primary"><i class="fa fa-tools"></i> Edit Metadata</a>
</div>
{% endif %}
</div>
Expand Down
13 changes: 9 additions & 4 deletions app/grandchallenge/emails/templates/emails/email_form.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% load static %}

{% block title %}
{{ object|yesno:"Update,Create Email" }} {% if object %} - {{ object }} {% else %} - Emails {% endif %} - {{ block.super }}
Expand All @@ -16,10 +17,14 @@
{% endblock %}

{% block content %}

<h2>{% if object %}Update{% else %}Create{% endif %} Email</h2>
<div class="alert alert-warning ml-3" role="alert">
The body preview does not accurately represent how the result will be rendered in email applications!
Always send out a limited test version to prevent potential embarrassments.
</div>

{% crispy form %}

{% endblock %}

{% block script %}
{{ block.super }}
<script type="text/javascript" src="{% static "js/unsavedform.js" %}"></script>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<div class="markdownx markdownx-full-page">
{% include "markdownx/partials/toolbar.html" %}
<div class="row">
<div class="col-md-6">
<div id="editor-{{ widget.attrs.id }}" aria-labelledby="editor-panel-{{ widget.attrs.id }}">
{% include "markdownx/partials/textarea.html" %}
</div>
</div>
<div class="col-md-6">
<div id="preview-{{ widget.attrs.id }}" aria-labelledby="preview-panel-{{ widget.attrs.id }}">
<iframe class="markdownx-preview w-100" sandbox=""></iframe>
</div>
</div>
</div>
</div>
3 changes: 2 additions & 1 deletion app/grandchallenge/emails/templates/emails/email_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ <h2 class="col-10">Emails</h2>
{% if object.sent %}
<span class="badge badge-success p-2">Sent on {{ object.sent_at|date:"j N Y" }}</span>
{% else %}
<a class="btn btn-primary btn-sm" href="{% url 'emails:update' pk=object.pk %}">Edit</a>
<a class="btn btn-primary btn-sm" href="{% url 'emails:body-update' pk=object.pk %}"><i class="fa fa-edit"></i> Edit Body</a>
<a class="btn btn-primary btn-sm" href="{% url 'emails:metadata-update' pk=object.pk %}"><i class="fa fa-tools"></i> Edit Metadata</a>
{% endif %}
</td>
</tr>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{ object.rendered_body }}
18 changes: 16 additions & 2 deletions app/grandchallenge/emails/urls.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from django.urls import path

from grandchallenge.emails.views import (
EmailBodyUpdate,
EmailCreate,
EmailDetail,
EmailList,
EmailUpdate,
EmailMetadataUpdate,
RenderedEmailDetail,
)

app_name = "emails"
Expand All @@ -13,5 +15,17 @@
path("", EmailList.as_view(), name="list"),
path("create/", EmailCreate.as_view(), name="create"),
path("<int:pk>/", EmailDetail.as_view(), name="detail"),
path("<int:pk>/update/", EmailUpdate.as_view(), name="update"),
path(
"<int:pk>/rendered/",
RenderedEmailDetail.as_view(),
name="rendered-detail",
),
path(
"<int:pk>/metadata-update/",
EmailMetadataUpdate.as_view(),
name="metadata-update",
),
path(
"<int:pk>/body-update/", EmailBodyUpdate.as_view(), name="body-update"
),
]
67 changes: 58 additions & 9 deletions app/grandchallenge/emails/views.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import CreateView, DetailView, ListView, UpdateView
from guardian.mixins import LoginRequiredMixin

from grandchallenge.emails.forms import EmailForm
from grandchallenge.emails.forms import EmailBodyForm, EmailMetadataForm
from grandchallenge.emails.models import Email
from grandchallenge.subdomains.utils import reverse


class EmailCreate(
Expand All @@ -13,26 +16,53 @@ class EmailCreate(
CreateView,
):
model = Email
form_class = EmailForm
form_class = EmailMetadataForm
permission_required = "emails.add_email"
raise_exception = True

def get_success_url(self):
"""On successful creation, go to content update."""
return reverse(
"emails:body-update",
kwargs={
"pk": self.object.pk,
},
)

class EmailUpdate(

class UnsentEmailRequiredMixin:
def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs)

if obj.sent:
raise PermissionDenied
else:
return obj


class EmailMetadataUpdate(
LoginRequiredMixin,
PermissionRequiredMixin,
UnsentEmailRequiredMixin,
UpdateView,
):
model = Email
form_class = EmailForm
form_class = EmailMetadataForm
permission_required = "emails.change_email"
raise_exception = True

def has_permission(self):
if self.get_object().sent:
raise PermissionDenied
else:
return super().has_permission()

class EmailBodyUpdate(
LoginRequiredMixin,
PermissionRequiredMixin,
UnsentEmailRequiredMixin,
UpdateView,
):
model = Email
form_class = EmailBodyForm
template_name_suffix = "_body_update"
permission_required = "emails.change_email"
raise_exception = True


class EmailDetail(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
Expand All @@ -41,6 +71,25 @@ class EmailDetail(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
raise_exception = True


@method_decorator(xframe_options_sameorigin, name="dispatch")
class RenderedEmailDetail(
LoginRequiredMixin, PermissionRequiredMixin, DetailView
):
model = Email
template_name_suffix = "_rendered_detail"
permission_required = "emails.view_email"
raise_exception = True

def post(self, request, *args, **kwargs):
"""Generate a preview of the email with the new content"""
self.object = self.get_object()

self.object.body = request.POST["content"]

context = self.get_context_data(object=self.object)
return self.render_to_response(context)


class EmailList(LoginRequiredMixin, PermissionRequiredMixin, ListView):
model = Email
permission_required = "emails.view_email"
Expand Down
23 changes: 23 additions & 0 deletions app/grandchallenge/emails/widgets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from grandchallenge.core.widgets import MarkdownEditorFullPageWidget


class MarkdownEditorEmailFullPageWidget(MarkdownEditorFullPageWidget):
template_name = "emails/email_full_page_markdown_widget.html"

def __init__(self, *args, preview_url, **kwargs):
super().__init__(*args, **kwargs)
self.preview_url = preview_url

def add_markdownx_attrs(self, *args, **kwargs):
attrs = super().add_markdownx_attrs(*args, **kwargs)
attrs.update(
{
"data-markdownx-urls-path": self.preview_url,
}
)
return attrs

class Media:
js = [
"js/emails/email_markdown_preview.mjs",
]
Loading

0 comments on commit ac3c84b

Please sign in to comment.