diff --git a/coldfront_notifications/campaign_sender.py b/coldfront_notifications/campaign_sender.py index abf70eb..b653a67 100644 --- a/coldfront_notifications/campaign_sender.py +++ b/coldfront_notifications/campaign_sender.py @@ -13,8 +13,9 @@ import time from django.conf import settings -from django.core.mail import EmailMessage, get_connection +from django.core.mail import EmailMessage, EmailMultiAlternatives, get_connection from django.utils import timezone +from django.utils.html import strip_tags from .conf import BATCH_SIZE, BATCH_DELAY, MAX_RETRIES, RETRY_DELAY from .filters import RecipientResolver @@ -239,14 +240,15 @@ def _deliver_emails(self, resolved_emails, snapshot): if bcc: bcc = list(dev_list) - message = EmailMessage( + message = EmailMultiAlternatives( subject=rendered_subject, - body=rendered_body, + body=strip_tags(rendered_body), from_email=campaign.sender, to=to, bcc=bcc, reply_to=[campaign.reply_to] if campaign.reply_to else [], ) + message.attach_alternative(rendered_body, "text/html") send_error = self.delivery.send_with_retry(message, connection) if send_error is None: diff --git a/coldfront_notifications/static/coldfront_notifications/css/compose.css b/coldfront_notifications/static/coldfront_notifications/css/compose.css index dc5fe0c..cbda566 100644 --- a/coldfront_notifications/static/coldfront_notifications/css/compose.css +++ b/coldfront_notifications/static/coldfront_notifications/css/compose.css @@ -175,7 +175,7 @@ display: inline-flex; align-items: center; gap: 4px; margin: 0; white-space: nowrap; } .recip-page-size-select { - width: auto !important; display: inline-block; padding: 2px 6px; + width: 52px !important; display: inline-block; padding: 2px 4px; font-size: .78rem; height: auto !important; } diff --git a/coldfront_notifications/static/coldfront_notifications/js/campaign_detail.js b/coldfront_notifications/static/coldfront_notifications/js/campaign_detail.js index 2ff440c..5c6a555 100644 --- a/coldfront_notifications/static/coldfront_notifications/js/campaign_detail.js +++ b/coldfront_notifications/static/coldfront_notifications/js/campaign_detail.js @@ -3,6 +3,11 @@ // CAMPAIGN_STATUS - string ('queued', 'sending', 'sent', etc.) // CAMPAIGN_PROGRESS_URL - URL for the progress JSON endpoint +// Blur focused element before modal hides to avoid aria-hidden warning. +$('#emailViewModal').on('hide.bs.modal', function() { + if (document.activeElement) document.activeElement.blur(); +}); + var STATUS_CLASSES = { sent: 'badge-success', sending: 'badge-primary', @@ -49,7 +54,7 @@ $(function(){ // Only init DataTable when there are real data rows. var realRows = $('#logTable tbody tr td:not([colspan])').length; if (realRows > 0) { - $('#logTable').DataTable({ order:[[2,'asc']], columnDefs:[{orderable:false,targets:[3]}] }); + $('#logTable').DataTable({ order:[[2,'asc']], columnDefs:[{orderable:false,targets:[3]}], pagingType:'simple_numbers', pageLength:50, lengthMenu:[[50,100,250,-1],[50,100,250,'All']] }); } // Start polling if campaign is in-flight. if (CAMPAIGN_STATUS === 'sending' || CAMPAIGN_STATUS === 'queued') { @@ -62,6 +67,6 @@ $(document).on('click', '.view-email-btn', function() { $('#em-replyto').text($(this).attr('data-replyto') || '—'); $('#em-to').text($(this).attr('data-email')); $('#em-subject').text($(this).attr('data-subject')); - $('#em-body').text($(this).attr('data-body')); + $('#em-body').html($(this).attr('data-body')); $('#emailViewModal').modal('show'); }); diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_draft.js b/coldfront_notifications/static/coldfront_notifications/js/compose_draft.js index 4c84a3c..0bee2fc 100644 --- a/coldfront_notifications/static/coldfront_notifications/js/compose_draft.js +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_draft.js @@ -31,7 +31,7 @@ function _collectDraftData() { csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), draft_pk: DRAFT_PK || '', subject: $('#id_subject').val() || '', - body: $('#id_body').val() || '', + body: getBodyContent() || '', sender: $('#id_sender').val() || '', reply_to: $('#id_reply_to').val() || '', template_id: $('#hidden_template_id').val() || '', @@ -91,7 +91,7 @@ function restoreDraft(draftData) { // Restore form fields if (draftData.subject) $('#id_subject').val(draftData.subject); - if (draftData.body) $('#id_body').val(draftData.body); + if (draftData.body) setBodyContent(draftData.body); if (draftData.sender) $('#id_sender').val(draftData.sender); if (draftData.reply_to) $('#id_reply_to').val(draftData.reply_to); if (draftData.template_id) $('#hidden_template_id').val(draftData.template_id); diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_init.js b/coldfront_notifications/static/coldfront_notifications/js/compose_init.js index d120283..957a8ab 100644 --- a/coldfront_notifications/static/coldfront_notifications/js/compose_init.js +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_init.js @@ -8,6 +8,7 @@ // ── Serialize filter hidden fields before submit ───────────────── $('#notifForm').on('submit', function() { + if (typeof tinymce !== 'undefined') tinymce.triggerSave(); $('#h_projects').val(JSON.stringify($('#f_project').val() || [])); $('#h_allocations').val(JSON.stringify($('#f_allocation').val() || [])); $('#h_depts').val(JSON.stringify($('#f_dept').val() || [])); @@ -26,6 +27,21 @@ $(document).ready(function() { width: '100%', placeholder: function() { return $(this).data('placeholder'); } }); + + // Auto-select template if ?template=ID is in the URL + var params = new URLSearchParams(window.location.search); + var templateId = params.get('template'); + if (templateId) { + // Delay slightly to ensure TinyMCE is initialized + setTimeout(function() { + var $item = $('.tmpl-item[data-id="' + templateId + '"]'); + if ($item.length) { + $('.tmpl-item').removeClass('active'); + $item.addClass('active'); + loadTemplate($item); + } + }, 500); + } }); // ── pageshow: reset validation state + refresh active template ──── diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_preview.js b/coldfront_notifications/static/coldfront_notifications/js/compose_preview.js index e0557a8..aa22aaa 100644 --- a/coldfront_notifications/static/coldfront_notifications/js/compose_preview.js +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_preview.js @@ -22,14 +22,14 @@ function renderPreviewSample(idx) { // "Template" — no recipient, raw tokens $('#prev-to').text('—'); $('#prev-subject').text($('#id_subject').val() || '—'); - $('#prev-body').text($('#id_body').val() || '—'); + $('#prev-body').html(getBodyContent() || '—'); $('#prev-error').hide(); $('#prev-footer').text('Showing template with literal placeholders'); return; } $('#prev-to').text((s.recipient.name || s.recipient.username) + ' <' + s.recipient.email + '>'); $('#prev-subject').text(s.subject || '—'); - $('#prev-body').text(s.body || '—'); + $('#prev-body').html(s.body || '—'); if (s.error) { $('#prev-error').text('Resolution error: ' + s.error).show(); } else { @@ -58,7 +58,7 @@ $(document).on('click', '#previewBtn', function() { data: { csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), subject: $('#id_subject').val() || '', - body: $('#id_body').val() || '', + body: getBodyContent() || '', filters: JSON.stringify(collectFilters()), dedupe_users: JSON.stringify(getDedupeUsers()), }, @@ -212,7 +212,7 @@ function fetchPreviewPage(page) { alloc_status: $('#f_status').val() || [], roles: $('#f_role').val() || [], subject: $('#id_subject').val() || '', - body: $('#id_body').val() || '', + body: getBodyContent() || '', dedupe_users: JSON.stringify(getDedupeUsers()), preview: 'true', page: PREVIEW_PAGE, diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_templates.js b/coldfront_notifications/static/coldfront_notifications/js/compose_templates.js index fcbb648..32e9a74 100644 --- a/coldfront_notifications/static/coldfront_notifications/js/compose_templates.js +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_templates.js @@ -40,7 +40,7 @@ function renderVars(vars, filter) { // ── Extract tokens from subject + body ─────────────────────────── var TOKEN_RE = /\{\{(\w+)\}\}/g; function extractTokens() { - var text = ($('#id_subject').val() || '') + '\n' + ($('#id_body').val() || ''); + var text = ($('#id_subject').val() || '') + '\n' + (getBodyContent() || ''); var seen = {}; var out = []; var m; @@ -66,7 +66,7 @@ function loadTemplate($el) { if (!id) { ACTIVE_VARS = []; renderVars([], ''); return; } $.getJSON(URLS.templateJson.replace('{id}', id), function(t) { $('#id_subject').val(t.subject || ''); - $('#id_body').val(t.body || ''); + setBodyContent(t.body || ''); ACTIVE_VARS = t.variables || []; renderVars(ACTIVE_VARS, ''); if (HAS_VALIDATED) runValidation(); @@ -99,10 +99,16 @@ $(document).on('input', '#varSearch', function() { // ── Variable insert at cursor ──────────────────────────────────── $(document).on('click', '.var-btn', function() { - var ta = document.getElementById('id_body'); var token = $(this).attr('data-var'); - var s = ta.selectionStart, e = ta.selectionEnd; - ta.value = ta.value.substring(0, s) + token + ta.value.substring(e); - ta.selectionStart = ta.selectionEnd = s + token.length; - ta.focus(); + var editor = typeof tinymce !== 'undefined' && tinymce.get('id_body'); + if (editor) { + editor.insertContent(token); + editor.focus(); + } else { + var ta = document.getElementById('id_body'); + var s = ta.selectionStart, e = ta.selectionEnd; + ta.value = ta.value.substring(0, s) + token + ta.value.substring(e); + ta.selectionStart = ta.selectionEnd = s + token.length; + ta.focus(); + } }); diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_validate.js b/coldfront_notifications/static/coldfront_notifications/js/compose_validate.js index 48f1947..c0bac51 100644 --- a/coldfront_notifications/static/coldfront_notifications/js/compose_validate.js +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_validate.js @@ -67,7 +67,7 @@ function runValidation() { data: { csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), subject: $('#id_subject').val() || '', - body: $('#id_body').val() || '', + body: getBodyContent() || '', filters: JSON.stringify(collectFilters()), dedupe_users: JSON.stringify(getDedupeUsers()), }, diff --git a/coldfront_notifications/static/coldfront_notifications/js/datatables_init.js b/coldfront_notifications/static/coldfront_notifications/js/datatables_init.js index 269b49b..858e1f6 100644 --- a/coldfront_notifications/static/coldfront_notifications/js/datatables_init.js +++ b/coldfront_notifications/static/coldfront_notifications/js/datatables_init.js @@ -4,9 +4,26 @@ // DT_CONFIG - object with: { tableId, order, nonOrderableTargets, paging, searching, info } // All fields except tableId are optional. +// Fix DataTables "Show entries" dropdown and search layout +$('");const y=r(e),u=c(e),v=' + {% endblock %} diff --git a/coldfront_notifications/templates/coldfront_notifications/campaign_list.html b/coldfront_notifications/templates/coldfront_notifications/campaign_list.html index 7ec4ee4..2be6cd2 100644 --- a/coldfront_notifications/templates/coldfront_notifications/campaign_list.html +++ b/coldfront_notifications/templates/coldfront_notifications/campaign_list.html @@ -26,46 +26,39 @@
| # | Subject | Template | -Recipients | Delivered | -Failed | Sent At | Status | - |
|---|---|---|---|---|---|---|---|---|
| {{ c.pk|stringformat:"03d" }} | -{{ c.display_subject }} | -{{ c.template.slug|default:"—" }} | -{{ c.recipient_count }} | -{{ c.delivered_count }} | -{{ c.failed_count|default:"—" }} | -{{ c.sent_at|date:"Y-m-d H:i"|default:"—" }} | -- {% if c.status == 'sent' %}Sent - {% elif c.status == 'sending' %}Sending… - {% elif c.status == 'partial' %}Partial - {% elif c.status == 'failed' %}Failed - {% elif c.status == 'draft' %}Draft - {% else %}{{ c.status }}{% endif %} - | -- {% if c.status == 'draft' %} - - - {% endif %} - | -
| No notifications yet. Send one → | ||||||||
| # | Subject | Template | +Recipients | Delivered | +Failed | Sent At | Status | + |
|---|---|---|---|---|---|---|---|---|
| {{ c.pk|stringformat:"03d" }} | +{{ c.display_subject }} | +{{ c.template.slug|default:"—" }} | +{{ c.recipient_count }} | +{{ c.delivered_count }} | +{{ c.failed_count|default:"—" }} | +{{ c.sent_at|date:"Y-m-d H:i"|default:"—" }} | + {% include "coldfront_notifications/_status_badge_td.html" with campaign=c %} ++ {% if c.status == 'draft' %} + + + {% endif %} + | +|
| No notifications yet. Send one → | ||||||||
Are you sure you want to delete this draft notification?
+{% block delete_title %}Delete Draft{% endblock %} +{% block delete_heading %}Delete Draft{% endblock %} -| Subject | {{ campaign.subject|default:"(no subject)" }} |
| Template | {{ campaign.template.slug }} |
| Created | {{ campaign.created_at|date:"Y-m-d H:i" }} |
| Filters | - {% for badge in campaign.filter_summary %} - {{ badge }} - {% endfor %} - |
Are you sure you want to delete this draft notification?
+{% endblock %} -- This draft has never been sent. Deleting it is permanent and cannot be undone. -
+{% block details_rows %} +{{ campaign.template.slug }}+ This draft has never been sent. Deleting it is permanent and cannot be undone. +
{% endblock %} + +{% block cancel_url %}{% url 'notifications:campaign-list' %}{% endblock %} +{% block delete_button_label %}Delete Draft{% endblock %} diff --git a/coldfront_notifications/templates/coldfront_notifications/resend_compose.html b/coldfront_notifications/templates/coldfront_notifications/resend_compose.html index dd21cd2..eda9dbd 100644 --- a/coldfront_notifications/templates/coldfront_notifications/resend_compose.html +++ b/coldfront_notifications/templates/coldfront_notifications/resend_compose.html @@ -160,114 +160,14 @@Are you sure you want to delete the variable {% templatetag openvariable %}{{ variable.key }}{% templatetag closevariable %}?
| Label | {{ variable.label }} |
| Source | {{ variable.get_source_display }} |
| Query | {{ variable.resolver_key }} |
Are you sure you want to delete the variable {% templatetag openvariable %}{{ variable.key }}{% templatetag closevariable %}?
- These templates will fail validation until the {% templatetag openvariable %}{{ variable.key }}{% templatetag closevariable %} token is removed from their subject/body.
-
- No active templates reference this variable. -
- {% endif %} +{% block details_rows %} +{{ variable.resolver_key }}
+ These templates will fail validation until the {% templatetag openvariable %}{{ variable.key }}{% templatetag closevariable %} token is removed from their subject/body.
+
+ No active templates reference this variable. +
+{% endif %} +{% endblock %} + +{% block cancel_url %}{% url 'notifications:variable-list' %}{% endblock %} +{% block delete_button_label %}Delete Variable{% endblock %} diff --git a/coldfront_notifications/templates/coldfront_notifications/variable_form.html b/coldfront_notifications/templates/coldfront_notifications/variable_form.html index b810933..5cc4351 100644 --- a/coldfront_notifications/templates/coldfront_notifications/variable_form.html +++ b/coldfront_notifications/templates/coldfront_notifications/variable_form.html @@ -163,24 +163,8 @@
- Deleting {% templatetag openvariable %}{{ variable.key }}{% templatetag closevariable %} will cause templates that use it
- to fail pre-flight validation.
-
| Token | -Label | -Source | -Query / Widget | -Example | -Required | -- |
|---|---|---|---|---|---|---|
{% templatetag openvariable %}{{ v.key }}{% templatetag closevariable %} |
- {{ v.label }}
- {% if v.description %} {{ v.description }}{% endif %} - |
- - {% if v.source == "query" %} - Query - {% else %} - Manual - {% endif %} - | -
- {% if v.source == "query" %}
- {{ v.resolver_key }}
- {% else %}
- {{ v.get_input_widget_display }}
- {% endif %}
- |
- {{ v.example|default:"—" }} | -- {% if v.is_required %}{% else %}—{% endif %} - | -- - - - - - - | -
| No variables defined yet. - Create the first one → - | ||||||
| Token | +Label | +Source | +Query / Widget | +Example | +Required | ++ |
|---|---|---|---|---|---|---|
{% templatetag openvariable %}{{ v.key }}{% templatetag closevariable %} |
+ {{ v.label }}
+ {% if v.description %} {{ v.description }}{% endif %} + |
+ + {% if v.source == "query" %} + Query + {% else %} + Manual + {% endif %} + | +
+ {% if v.source == "query" %}
+ {{ v.resolver_key }}
+ {% else %}
+ {{ v.get_input_widget_display }}
+ {% endif %}
+ |
+ {{ v.example|default:"—" }} | ++ {% if v.is_required %}{% else %}—{% endif %} + | ++ + + + + + + | +
| No variables defined yet. + Create the first one → + | ||||||