From 256f0445d682683a3d06ee0079263a18f1cab4b6 Mon Sep 17 00:00:00 2001 From: Antonio Rodriguez Date: Tue, 12 May 2026 12:14:42 +0200 Subject: [PATCH 01/10] Rewrite compose filters as cross-filter computation, split views and tests Replace the fragile client-side cascade chain with a single backend endpoint that computes all 6 filter options in one pass on every change. Each filter excludes its own selection but considers all other active selections, eliminating stale-option and circular-pruning bugs. Filters: - Backend computes all filters from all selections every request - Each filter excludes itself (no self-narrowing) - Status is independent of Allocation selection to prevent circular pruning - Project/Department are mutual Tier 1 peers; Allocation/Resource/Status are Tier 2 peers scoped by Tier 1; Role is a leaf - Filter summary tooltips, inline chips, and details modal show live counts, breakdowns, and which filters are narrowing each dropdown - Selections pruned server-side; stale values dropped from response - Filters lock (disable) during in-flight AJAX requests Compose UX: - Form starts empty (no template pre-selected) - Removed hardcoded demo data from filter dropdowns - Removed "All recipients will be BCC'd" badge - Template JSON URL uses Django routing instead of hardcoded path - Validate panel shows active filter summary with human-readable labels - Details button opens full filter breakdown modal Recipient preview: - Always uses project-level scope so role/project/allocation columns are populated regardless of template tokens - Enumerates without server-side dedup so multi-user detection works across pages and dedup-all button persists - Fixed pagination bug (page clamping before row slicing) Code structure: - Split views.py (1116 lines) into views/ package: compose.py, filters.py, campaigns.py, templates.py, settings.py, dashboard.py - Split compose.js (624 lines) into 5 modules: compose_filters.js, compose_validate.js, compose_templates.js, compose_preview.js, compose_init.js - Split test_unit.py (726 lines) into test_resolvers.py, test_validators.py, test_tasks_unit.py, test_conf.py with shared django_setup.py - Removed redundant repo-root static/ directory - Updated CI workflow for new test module paths - Added FILTERS.md documenting the filter architecture - 18 new integration tests; 91 unit tests preserved (verified by name) --- .github/workflows/tests.yml | 7 +- .gitignore | 2 +- FILTERS.md | 182 ++++ README.md | 10 +- .../coldfront_notifications/css/compose.css | 50 +- .../coldfront_notifications/js/compose.js | 624 -------------- .../js/compose_filters.js | 210 +++++ .../js/compose_init.js | 53 ++ .../js/compose_preview.js | 282 ++++++ .../js/compose_templates.js | 108 +++ .../js/compose_validate.js | 122 +++ .../templates/compose.html | 113 ++- coldfront_notifications/tests/django_setup.py | 23 + coldfront_notifications/tests/test_conf.py | 36 + .../tests/test_integration.py | 322 ++++++- .../tests/test_resolvers.py | 162 ++++ .../tests/test_tasks_unit.py | 221 +++++ coldfront_notifications/tests/test_unit.py | 726 ---------------- .../tests/test_validators.py | 250 ++++++ coldfront_notifications/validators.py | 4 +- coldfront_notifications/views.py | 809 ------------------ coldfront_notifications/views/__init__.py | 17 + coldfront_notifications/views/campaigns.py | 116 +++ coldfront_notifications/views/compose.py | 459 ++++++++++ coldfront_notifications/views/dashboard.py | 26 + coldfront_notifications/views/filters.py | 320 +++++++ coldfront_notifications/views/settings.py | 107 +++ coldfront_notifications/views/templates.py | 80 ++ .../coldfront_notifications/css/compose.css | 164 ---- .../css/template_form.css | 44 - .../css/variable_form.css | 16 - .../js/campaign_detail.js | 67 -- static/coldfront_notifications/js/compose.js | 624 -------------- .../js/datatables_init.js | 23 - .../js/template_form.js | 73 -- .../js/variable_form.js | 90 -- 36 files changed, 3186 insertions(+), 3356 deletions(-) create mode 100644 FILTERS.md delete mode 100644 coldfront_notifications/static/coldfront_notifications/js/compose.js create mode 100644 coldfront_notifications/static/coldfront_notifications/js/compose_filters.js create mode 100644 coldfront_notifications/static/coldfront_notifications/js/compose_init.js create mode 100644 coldfront_notifications/static/coldfront_notifications/js/compose_preview.js create mode 100644 coldfront_notifications/static/coldfront_notifications/js/compose_templates.js create mode 100644 coldfront_notifications/static/coldfront_notifications/js/compose_validate.js create mode 100644 coldfront_notifications/tests/django_setup.py create mode 100644 coldfront_notifications/tests/test_conf.py create mode 100644 coldfront_notifications/tests/test_resolvers.py create mode 100644 coldfront_notifications/tests/test_tasks_unit.py delete mode 100644 coldfront_notifications/tests/test_unit.py create mode 100644 coldfront_notifications/tests/test_validators.py delete mode 100644 coldfront_notifications/views.py create mode 100644 coldfront_notifications/views/__init__.py create mode 100644 coldfront_notifications/views/campaigns.py create mode 100644 coldfront_notifications/views/compose.py create mode 100644 coldfront_notifications/views/dashboard.py create mode 100644 coldfront_notifications/views/filters.py create mode 100644 coldfront_notifications/views/settings.py create mode 100644 coldfront_notifications/views/templates.py delete mode 100644 static/coldfront_notifications/css/compose.css delete mode 100644 static/coldfront_notifications/css/template_form.css delete mode 100644 static/coldfront_notifications/css/variable_form.css delete mode 100644 static/coldfront_notifications/js/campaign_detail.js delete mode 100644 static/coldfront_notifications/js/compose.js delete mode 100644 static/coldfront_notifications/js/datatables_init.js delete mode 100644 static/coldfront_notifications/js/template_form.js delete mode 100644 static/coldfront_notifications/js/variable_form.js diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cb297e3..811afaf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,7 +30,12 @@ jobs: - name: Run unit tests run: | - coverage run -m unittest coldfront_notifications.tests.test_unit -v + coverage run -m unittest \ + coldfront_notifications.tests.test_resolvers \ + coldfront_notifications.tests.test_validators \ + coldfront_notifications.tests.test_tasks_unit \ + coldfront_notifications.tests.test_conf \ + -v coverage report --show-missing --fail-under=80 \ --include="coldfront_notifications/resolvers.py,coldfront_notifications/validators.py,coldfront_notifications/conf.py,coldfront_notifications/__init__.py" env: diff --git a/.gitignore b/.gitignore index 0f03392..08da9a7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ __pycache__/ dist/ build/ *.whl - +*bak # Virtual environments .venv/ venv/ diff --git a/FILTERS.md b/FILTERS.md new file mode 100644 index 0000000..e3e740b --- /dev/null +++ b/FILTERS.md @@ -0,0 +1,182 @@ +# Compose Notification — Filter System + +## Overview + +The compose notification page has 6 recipient filters that are interdependent. +Every filter change sends a single AJAX request to the backend with the current +state of all selections. The backend computes the full option list for every +filter in one pass and returns the complete state — the frontend blindly applies +it with no local cascade logic. + +Each filter's options are computed considering all **other** active selections +(no self-narrowing). This prevents circular pruning while keeping all filters +consistent with each other. + +## Filters + +| Filter | DOM ID | Value type | DB Source | +|-------------------|-----------------|--------------------|----------------------------------------| +| Project | `#f_project` | Project PKs | `coldfront.core.project.Project` | +| Allocation | `#f_allocation` | Allocation PKs | `coldfront.core.allocation.Allocation` | +| Department | `#f_dept` | Dept names (str) | `ifxuser.Organization` (rank=dept) | +| Resource | `#f_resource` | Resource PKs | `coldfront.core.resource.Resource` | +| Allocation Status | `#f_status` | Status names (str) | `AllocationStatusChoice` | +| User Role | `#f_role` | Role names (str) | `ProjectUserRoleChoice` | + +## Database Relationships + +``` +Project <──M2M──> Department/Organization + │ (via ProjectOrganization bridge table, + │ FKs to both Project and Organization) + │ + ├── FK ── Allocation + │ ├── M2M ── Resource (Allocation.resources ManyToManyField) + │ └── FK ── AllocationStatusChoice (Allocation.status) + │ + └── FK ── ProjectUser + ├── FK ── User + └── FK ── ProjectUserRoleChoice (ProjectUser.role) +``` + +Key details: +- **Allocation → Resource is M2M** (one allocation can reference multiple resources). +- **Project ↔ Department** is M2M via `ProjectOrganization` (in `coldfront.plugins.ifx.models`). +- **Role** is reached through `ProjectUser`, which has `unique_together = ('user', 'project')`. +- **Department** is actually an `Organization` with `rank="department"` (proxy model). + +## Filter Tiers + +Filters are organized into tiers that determine narrowing direction: + +``` +Tier 1 (top): Project <--> Department (mutual peers) +Tier 2 (mid): Allocation · Resource · Status (peers under Tier 1) +Tier 3 (leaf): Role +``` + +- **Between tiers**: top-down only (Tier 1 narrows Tier 2, never the reverse). +- **Within Tier 1**: mutual peers — each narrows the other. +- **Within Tier 2**: each filter's options are scoped by the other Tier 2 + selections, with one exception: **Status is independent of Allocation + selection** to prevent circular pruning (selecting an allocation would drop + statuses, which would cascade-remove other allocations). +- **Tier 3**: Role is scoped by the Tier 1 project scope but does not narrow + any other filter. + +## How Each Filter's Options Are Computed + +All filters are always computed on every request, regardless of which filter +changed. The event name is used only for request validation. + +### Tier 1 + +| Filter | Scoped by | Excludes own selection? | +|------------|------------------------|-------------------------| +| Project | `sel_departments` | Yes (not `sel_projects`) | +| Department | `sel_projects` | Yes (not `sel_departments`) | + +### Full Project Scope (for Tier 2) + +Both `sel_projects` and `sel_departments` are applied to create the project +scope used by all Tier 2 filters. + +### Tier 2 + +All Tier 2 filters start from `Allocation.filter(project__in=project_scope)`. + +| Filter | Additional scoping | Excludes | +|-------------------|---------------------------------------------|-----------------------------| +| Allocation | `sel_statuses` + `sel_resources` | `sel_allocations` | +| Resource | `sel_statuses` + `sel_allocations` | `sel_resources` | +| Allocation Status | `sel_resources` only | `sel_allocations` + `sel_statuses` | + +**Why Status excludes `sel_allocations`**: if the user selects statuses +"Active" + "Denied" then picks an Active allocation, computing statuses from +that allocation alone would drop "Denied" — removing the other allocations the +user hasn't selected yet. + +### Tier 3 + +| Filter | Scoped by | +|--------|----------------| +| Role | `project_scope` | + +## Protocol + +### Request + +``` +POST /filter-options/ +Content-Type: application/json +X-CSRFToken: + +{ + "event": "ALLOCATION_STATUS_UPDATED", + "selections": { + "projects": [], + "allocations": [], + "departments": [], + "resources": [], + "statuses": ["Active", "Denied"], + "roles": [] + } +} +``` + +Valid events: `PROJECT_UPDATED`, `DEPARTMENT_UPDATED`, `ALLOCATION_UPDATED`, +`RESOURCE_UPDATED`, `ALLOCATION_STATUS_UPDATED`, `ROLE_UPDATED`. + +### Response + +```json +{ + "projects": { + "options": [{"id": 1, "label": "Project Alpha"}, ...], + "selected": [1], + "summary": {"count": 11, "breakdown": "11 Active", "filtered_by": ""} + }, + "allocations": { ... }, + "departments": { ... }, + "resources": { ... }, + "statuses": { ... }, + "roles": { ... } +} +``` + +- `options` — full list of valid choices for the dropdown. +- `selected` — the user's prior selections pruned to only values still in `options`. +- `summary` — structured data for the filter info tooltip and details modal. + - `count` — number of options available. + - `breakdown` — grouped description (e.g., "7 Active, 1 Expired"). + - `filtered_by` — which other filters narrowed this one. Empty string if the + filter shows its full unfiltered set. + +## Frontend Architecture + +The compose page JS is split into 5 modules loaded in order: + +1. **`compose_filters.js`** — filter cascade, `collectFilters()`, + `getDedupeUsers()`, `setDedupeUsers()`, summary tooltips, inline summary + chips, filter details modal, filter locking during AJAX. +2. **`compose_validate.js`** — validation AJAX, send-bar UI, input + invalidation, filter summary bar in validation panel. +3. **`compose_templates.js`** — template list/search/load, variable chip + rendering, variable insert at cursor. +4. **`compose_preview.js`** — email preview modal, recipient preview modal + (with dedupe and pagination). +5. **`compose_init.js`** — Select2 init, form submit serialization, pageshow + reset. Must load last. + +## Backend Architecture + +Filter logic lives in `views/filters.py`: + +- `build_filter_summaries()` — generates `{count, breakdown, filtered_by}` + dicts for each filter, grouped by meaningful attributes (projects by status, + allocations by status, resources by type, etc.). +- `get_filter_context()` — initial filter data for the compose page template + (options + summaries for all 6 filters, senders, reply-tos). +- `compute_filter_options(selections)` — the core cross-filter computation. + Called by the `filter_options` view. Returns the full response dict ready for + `JsonResponse`. diff --git a/README.md b/README.md index 2eb6fbd..65d69c5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A [ColdFront](https://coldfront.readthedocs.io/) plugin for sending bulk email n ## Features ### Compose & Send -- **Cascading recipient filters** — filter by Project, Department, Allocation, Resource, Allocation Status, and User Role. Each filter narrows the downstream options in real time. +- **Cascading recipient filters** — filter by Project, Department, Allocation, Resource, Allocation Status, and User Role. Filters cross-narrow each other in real time via a single backend computation per change. See [FILTERS.md](FILTERS.md) for the full architecture. - **Template-based composition** — pick a saved template or write a one-off. Templates support `{{variable}}` placeholders that resolve per recipient. - **Live email preview** — preview the rendered email for sample recipients before sending. A dropdown lets you switch between recipients to see how variable substitution looks for each. - **Per-user dedupe control** — users matched on multiple projects/allocations can receive one email per context or be deduped to one. Controlled per-user in the recipient preview with a "Dedupe all" option. @@ -66,6 +66,14 @@ INSTALLED_APPS += ["coldfront_notifications"] EXTRA_APPS_URLS = [ ("notifications/", "coldfront_notifications.urls", "notifications"), ] + +# Templates and static files (adjust the path to your install location) +TEMPLATES[0]['DIRS'] += [ + '/usr/src/app/coldfront_notifications/coldfront_notifications/templates', +] +STATICFILES_DIRS += [ + '/usr/src/app/coldfront_notifications/coldfront_notifications/static', +] ``` If your ColdFront instance doesn't already wire `EXTRA_APPS_URLS`, add this to your main `urls.py`: diff --git a/coldfront_notifications/static/coldfront_notifications/css/compose.css b/coldfront_notifications/static/coldfront_notifications/css/compose.css index 9b02095..e8c20d7 100644 --- a/coldfront_notifications/static/coldfront_notifications/css/compose.css +++ b/coldfront_notifications/static/coldfront_notifications/css/compose.css @@ -8,13 +8,28 @@ align-items: end; } -/* recipients: row 2 — count pill + buttons */ +/* recipients: row 2 — actions | inline summary | count pill */ .recip-actions { display: flex; align-items: center; gap: 10px; margin-top: 14px; padding-top: 12px; border-top: 1px solid #eef0f3; } +.recip-actions-left { + display: flex; gap: 6px; flex-shrink: 0; +} +.recip-inline-summary { + flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; + font-size: .75rem; color: #6c757d; + display: flex; align-items: center; gap: 4px; +} +.recip-inline-summary .ris-chip { + display: inline-block; background: #f4f6f9; border: 1px solid #dee2e8; + border-radius: 3px; padding: 1px 7px; font-size: .72rem; color: #495057; + white-space: nowrap; +} +.recip-inline-summary .ris-chip b { color: #A51C30; font-weight: 600; } .recip-count-pill { + flex-shrink: 0; background: #fdf5f6; border: 1px solid #e8b4bb; border-radius: 5px; padding: 5px 14px; text-align: center; } @@ -162,3 +177,36 @@ } .select2-container--default .select2-selection__choice__remove { color: rgba(255,255,255,.7) !important; } .select2-container--default .select2-selection__choice__remove:hover { color: #fff !important; } + +/* filter summary bar (shown after Validate) */ +.filter-summary-bar { + background: #f4f6f9; border: 1px solid #dee2e8; border-radius: 5px; + padding: 8px 14px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; +} +.filter-summary-bar .fs-label { + font-weight: 600; color: #6c757d; font-size: .75rem; margin-right: 4px; +} +.filter-summary-bar .fs-chip { + display: inline-block; background: #fff; border: 1px solid #ced4da; + border-radius: 3px; padding: 2px 8px; font-size: .75rem; color: #495057; +} +.filter-summary-bar .fs-chip strong { color: #A51C30; font-weight: 600; } +.filter-summary-bar .fs-none { + color: #adb5bd; font-style: italic; font-size: .75rem; +} + +/* filter info icon */ +.filter-info { + font-size: .68rem; cursor: help; margin-left: 3px; opacity: .5; + transition: opacity .15s; +} +.filter-info:hover { opacity: 1; } + +/* jQuery UI tooltip override for filter summaries */ +.filter-summary-tip { + background: #333 !important; color: #fff !important; + border: none !important; border-radius: 5px !important; + padding: 8px 12px !important; font-size: .78rem !important; + max-width: 340px !important; line-height: 1.5 !important; + box-shadow: 0 2px 8px rgba(0,0,0,.25) !important; +} diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose.js b/coldfront_notifications/static/coldfront_notifications/js/compose.js deleted file mode 100644 index d6ab45f..0000000 --- a/coldfront_notifications/static/coldfront_notifications/js/compose.js +++ /dev/null @@ -1,624 +0,0 @@ -// ── compose.js ────────────────────────────────────────────────── -// Expects the following globals set inline by the Django template: -// TEMPLATES - array of template objects -// VARIABLES - array of variable objects -// URLS.previewRender - URL for preview-render endpoint -// URLS.filterOptions - URL for filter-options endpoint -// URLS.validate - URL for validate endpoint -// URLS.recipientCount - URL for recipient-count endpoint - -// Index VARIABLES by key for O(1) lookup -var VAR_BY_KEY = {}; -VARIABLES.forEach(function(v) { VAR_BY_KEY[v.key] = v; }); - -// ── Render chip list ───────────────────────────────────────────── -function renderVars(vars, filter) { - var $list = $('#varsList').empty(); - var filtered = filter - ? vars.filter(function(v) { return v.toLowerCase().indexOf(filter.toLowerCase()) !== -1; }) - : vars; - if (!filtered.length) { - $list.append('
No matches
'); - return; - } - filtered.forEach(function(v) { - var token = '{{' + v + '}}'; - var def = VAR_BY_KEY[v] || {}; - var example = def.example || ''; - var badge = def.source === 'manual' ? ' [manual]' : ''; - var $btn = $('') - .attr('data-var', token) - .attr('title', (def.description || def.label || '') + (example ? ' — ' + example : '')) - .html( - '' + token + badge + '' + - '' + example + '' - ); - $list.append($btn); - }); -} - -// ── Extract tokens from subject + body ─────────────────────────── -var TOKEN_RE = /\{\{(\w+)\}\}/g; -function extractTokens() { - var text = ($('#id_subject').val() || '') + '\n' + ($('#id_body').val() || ''); - var seen = {}; - var out = []; - var m; - while ((m = TOKEN_RE.exec(text))) { - if (!seen[m[1]]) { seen[m[1]] = true; out.push(m[1]); } - } - return out; -} - -// ── Load template into compose fields ──────────────────────────── -// Always fetch fresh content from the server — avoids stale data when -// an admin edits a template in another tab and returns to compose. -var ACTIVE_VARS = []; -function loadTemplate($el) { - var id = parseInt($el.attr('data-id') || 0); - var slug = $el.attr('data-slug') || ''; - $('#active-slug').text(slug); - $('#hidden_template_id').val(id); - $('#varSearch').val(''); - $('#validationPanel').hide(); - $('#sendBtn').prop('disabled', true); - $('#sendWarn').hide(); - setDedupeUsers([]); - if (!id) { ACTIVE_VARS = []; renderVars([], ''); return; } - $.getJSON('/notifications/templates/' + id + '/json/', function(t) { - $('#id_subject').val(t.subject || ''); - $('#id_body').val(t.body || ''); - ACTIVE_VARS = t.variables || []; - renderVars(ACTIVE_VARS, ''); - if (HAS_VALIDATED) runValidation(); - }).fail(function() { - ACTIVE_VARS = []; - renderVars([], ''); - }); -} - -// ── Template click ─────────────────────────────────────────────── -$(document).on('click', '.tmpl-item', function() { - $('.tmpl-item').removeClass('active'); - $(this).addClass('active'); - loadTemplate($(this)); -}); - -// ── Template search ───────────────────────────────────────────── -$(document).on('input', '#tmplSearch', function() { - var q = $(this).val().toLowerCase(); - $('.tmpl-item').each(function() { - var text = $(this).text().toLowerCase(); - $(this).toggle(!q || text.indexOf(q) !== -1); - }); -}); - -// ── Variable search ────────────────────────────────────────────── -$(document).on('input', '#varSearch', function() { - renderVars(ACTIVE_VARS, $(this).val()); -}); - -// ── 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(); -}); - -// ── Preview modal ──────────────────────────────────────────────── -// Samples returned from /preview-render/; indexed by select option value. -var PREVIEW_SAMPLES = []; - -function renderPreviewSample(idx) { - var s = PREVIEW_SAMPLES[idx]; - if (!s) { - // "Template" — no recipient, raw tokens - $('#prev-to').text('—'); - $('#prev-subject').text($('#id_subject').val() || '—'); - $('#prev-body').text($('#id_body').val() || '—'); - $('#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 || '—'); - if (s.error) { - $('#prev-error').text('Resolution error: ' + s.error).show(); - } else { - $('#prev-error').hide(); - } - var ctx = []; - if (s.recipient.project) ctx.push('project: ' + s.recipient.project); - if (s.recipient.allocation) ctx.push('allocation: ' + s.recipient.allocation); - $('#prev-footer').text(ctx.join(' · ') || 'recipient-only context'); -} - -$(document).on('click', '#previewBtn', function() { - $('#prev-from').text($('#id_sender').val()); - $('#prev-replyto').text($('#id_reply_to').val()); - // Show the raw template immediately while samples load - PREVIEW_SAMPLES = []; - $('#prev-recipient-picker').html('' - + ''); - renderPreviewSample(null); - $('#prev-scope-badge').text(''); - $('#previewModal').modal('show'); - - $.ajax({ - url: URLS.previewRender, - type: 'POST', - data: { - csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), - subject: $('#id_subject').val() || '', - body: $('#id_body').val() || '', - filters: JSON.stringify(collectFilters()), - dedupe_users: JSON.stringify(getDedupeUsers()), - }, - success: function(data) { - PREVIEW_SAMPLES = data.samples || []; - var opts = ['']; - PREVIEW_SAMPLES.forEach(function(s, i) { - var lbl = (s.recipient.name || s.recipient.username) + ' <' + s.recipient.email + '>'; - if (s.recipient.project) lbl += ' · ' + s.recipient.project; - opts.push(''); - }); - if (!PREVIEW_SAMPLES.length) { - opts.push(''); - } - $('#prev-recipient-picker').html(opts.join('')); - if (data.scope) $('#prev-scope-badge').text('scope: ' + data.scope + ' · ' + (data.total_emails || 0) + ' total'); - - // Auto-select the first real sample if available - if (PREVIEW_SAMPLES.length) { - $('#prev-recipient-picker').val('0'); - renderPreviewSample(0); - } else { - renderPreviewSample(null); - } - }, - error: function() { - $('#prev-recipient-picker').html(''); - $('#prev-footer').text('Preview failed to load — showing raw template'); - }, - }); -}); - -$(document).on('change', '#prev-recipient-picker', function() { - var v = $(this).val(); - renderPreviewSample(v === '' ? null : parseInt(v, 10)); -}); - -// ── Recipient filters stale on change ──────────────────────────── -$(document).on('change', '.s2', function() { - $('#recipNum').text('?'); - $('#recipLbl').text('press Validate'); - $('#sendBtn').prop('disabled', true); - $('#previewRecipBtn').prop('disabled', true); - $('#sendWarn').hide(); - // Changing filters may change the user pool — dedupe choices are user-scoped, - // so reset them to avoid stale selections. - setDedupeUsers([]); -}); - -// ── Cascading filter options ───────────────────────────────────── -// Rebuild a '; - } else { - cbCell = ''; - } - html += '' - + cbCell - + '' + (r.full_name||'') + (isMulti && first ? ' multi' : '') + '' - + '' + (r.email||'') + '' - + '' + (r.role||'') + '' - + '' + (r.project||'') + '' - + '' + (r.allocation||'') + '' - + ''; - }); - }); - $('#recipRows').html(html || 'No recipients matched.'); - - // Effective email count = sum of group length, capped at 1 when deduped - var effective = 0; - order.forEach(function(u) { - effective += dedupeSet[u] ? 1 : byUser[u].length; - }); - var scopeLbl = PREVIEW_SCOPE ? 'scope: ' + PREVIEW_SCOPE : ''; - $('#recipModalCount').text( - effective + ' emails → ' + order.length + ' users' - + (effective !== PREVIEW_ROWS.length ? ' (was ' + PREVIEW_ROWS.length + ' before dedupe)' : '') - ); - if (PREVIEW_SCOPE) { $('#recipModalScope').text(scopeLbl).show(); } else { $('#recipModalScope').hide(); } - -} - -function getPageSize() { - return parseInt($('#recipPageSize').val(), 10) || 10; -} - -function updatePaginationControls() { - var atFirst = PREVIEW_PAGE <= 1; - var atLast = PREVIEW_PAGE >= PREVIEW_TOTAL_PAGES; - $('#recipPageInfo').text('Page ' + PREVIEW_PAGE + ' of ' + PREVIEW_TOTAL_PAGES); - $('#recipFirstBtn').prop('disabled', atFirst); - $('#recipPrevBtn').prop('disabled', atFirst); - $('#recipNextBtn').prop('disabled', atLast); - $('#recipLastBtn').prop('disabled', atLast); -} - -function fetchPreviewPage(page) { - PREVIEW_PAGE = page || 1; - $('#recipSearch').val(''); - $('#recipRows').html('Loading…'); - - $.ajax({ - url: URLS.recipientCount, - type: 'POST', - data: { - csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), - projects: $('#f_project').val() || [], - allocations: $('#f_allocation').val() || [], - departments: $('#f_dept').val() || [], - resources: $('#f_resource').val() || [], - alloc_status: $('#f_status').val() || [], - roles: $('#f_role').val() || [], - subject: $('#id_subject').val() || '', - body: $('#id_body').val() || '', - dedupe_users: JSON.stringify(getDedupeUsers()), - preview: 'true', - page: PREVIEW_PAGE, - page_size: getPageSize(), - }, - traditional: true, - success: function(data) { - PREVIEW_ROWS = data.recipients || []; - PREVIEW_SCOPE = data.scope || null; - PREVIEW_TOTAL = data.count || 0; - PREVIEW_TOTAL_PAGES = data.total_pages || 1; - PREVIEW_PAGE = data.page || 1; - ALL_MULTI_USERS = (data.multi_users || []).map(function(m) { return m.username; }); - renderPreviewRows(); - // Show dedupe-all row if any multi-email users exist across all pages - if (ALL_MULTI_USERS.length) { - $('#dedupeAllRow').show(); - var allDeduped = ALL_MULTI_USERS.every(function(u) { - return getDedupeUsers().indexOf(u) !== -1; - }); - $('#dedupeAllCb').prop('checked', allDeduped); - } else { - $('#dedupeAllRow').hide(); - } - updatePaginationControls(); - }, - error: function() { - $('#recipRows').html('Failed to load.'); - } - }); -} - -$(document).on('click', '#previewRecipBtn', function() { - $('#recipModal').modal('show'); - fetchPreviewPage(1); -}); -$(document).on('click', '#recipFirstBtn', function() { - if (PREVIEW_PAGE > 1) fetchPreviewPage(1); -}); -$(document).on('click', '#recipPrevBtn', function() { - if (PREVIEW_PAGE > 1) fetchPreviewPage(PREVIEW_PAGE - 1); -}); -$(document).on('click', '#recipNextBtn', function() { - if (PREVIEW_PAGE < PREVIEW_TOTAL_PAGES) fetchPreviewPage(PREVIEW_PAGE + 1); -}); -$(document).on('click', '#recipLastBtn', function() { - if (PREVIEW_PAGE < PREVIEW_TOTAL_PAGES) fetchPreviewPage(PREVIEW_TOTAL_PAGES); -}); -$(document).on('change', '#recipPageSize', function() { - fetchPreviewPage(1); -}); - -// Client-side search: filter visible rows in the current page -$(document).on('input', '#recipSearch', function() { - var q = $(this).val().toLowerCase(); - $('#recipRows tr').each(function() { - var text = $(this).text().toLowerCase(); - $(this).toggle(!q || text.indexOf(q) !== -1); - }); -}); - -// All multi-email usernames across all pages (from server response). -var ALL_MULTI_USERS = []; - -// Bulk dedupe: toggle ALL multi-email users across all pages. -$(document).on('change', '#dedupeAllCb', function() { - if ($(this).is(':checked')) { - setDedupeUsers(ALL_MULTI_USERS.slice()); - } else { - setDedupeUsers([]); - } - renderPreviewRows(); - runValidation(); -}); - -// Per-user dedupe toggle inside the modal. -$(document).on('change', '.dedupe-cb', function() { - var username = $(this).attr('data-username'); - var cur = getDedupeUsers(); - var idx = cur.indexOf(username); - if ($(this).is(':checked')) { - if (idx === -1) cur.push(username); - } else { - if (idx !== -1) cur.splice(idx, 1); - } - setDedupeUsers(cur); - renderPreviewRows(); - runValidation(); -}); - - -// ── Serialize filter hidden fields before submit ───────────────── -$('#notifForm').on('submit', function() { - $('#h_projects').val(JSON.stringify($('#f_project').val() || [])); - $('#h_allocations').val(JSON.stringify($('#f_allocation').val() || [])); - $('#h_depts').val(JSON.stringify($('#f_dept').val() || [])); - $('#h_resources').val(JSON.stringify($('#f_resource').val() || [])); - $('#h_statuses').val(JSON.stringify($('#f_status').val() || [])); - $('#h_roles').val(JSON.stringify($('#f_role').val() || [])); -}); - -// ── Select2 init ───────────────────────────────────────────────── -$(document).ready(function() { - $('.s2').select2({ - allowClear: true, - width: '100%', - placeholder: function() { return $(this).data('placeholder'); } - }); - - // Load first template - var $first = $('.tmpl-item').first(); - if ($first.length) loadTemplate($first); -}); - -// ── pageshow: reset validation state + refresh active template ──── -// When a user edits a template in another tab and comes back, bfcache -// restores the DOM with the OLD textarea content and any stale errors. -// Force-hide the panel and re-fetch the active template so the textarea -// reflects the latest body before the next Validate click. -$(window).on('pageshow', function() { - $('#validationPanel').hide(); - $('#validationOK').hide(); - $('#validationBad').hide(); - $('#sendWarn').hide(); - $('#sendBtn').prop('disabled', true); - $('#recipNum').text('?'); - $('#recipLbl').text('press Validate'); - $('#emailCount').text('—'); - $('#multiBadge').hide(); - $('#multiWarn').hide(); - setDedupeUsers([]); - HAS_VALIDATED = false; - var $active = $('.tmpl-item.active'); - if ($active.length) loadTemplate($active); -}); diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_filters.js b/coldfront_notifications/static/coldfront_notifications/js/compose_filters.js new file mode 100644 index 0000000..4041238 --- /dev/null +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_filters.js @@ -0,0 +1,210 @@ +// ── compose_filters.js ────────────────────────────────────────── +// Event-driven cascading filter logic + shared filter helpers. +// +// Globals used: URLS, FILTER_SUMMARIES (from Django template) +// Globals exported: collectFilters, getDedupeUsers, setDedupeUsers + +// ── Shared helpers (used by validate, preview, submit) ────────── +function collectFilters() { + return { + projects: $('#f_project').val() || [], + allocations: $('#f_allocation').val() || [], + resources: $('#f_resource').val() || [], + departments: $('#f_dept').val() || [], + statuses: $('#f_status').val() || [], + roles: $('#f_role').val() || [], + }; +} + +function getDedupeUsers() { + try { + var v = JSON.parse($('#h_dedupe_users').val() || '[]'); + return Array.isArray(v) ? v : []; + } catch (e) { return []; } +} + +function setDedupeUsers(list) { + $('#h_dedupe_users').val(JSON.stringify(list || [])); +} + +// ── Filter summary tooltips + inline summary ──────────────────── +// summary: {count: N, breakdown: "...", filtered_by: "..."} + +var FILTER_LABELS = { + projects: 'Projects', allocations: 'Allocations', departments: 'Departments', + resources: 'Resources', statuses: 'Statuses', roles: 'Roles' +}; +var LATEST_SUMMARIES = {}; // kept in sync for the detail modal + +function _formatTooltip(s) { + if (!s || typeof s === 'string') return s || ''; + var count = s.count || 0; + var parts = '' + count + ' available'; + if (s.filtered_by) { + parts += '
Filtered by: ' + s.filtered_by + ''; + } else { + parts += '
No filters applied'; + } + if (s.breakdown && s.filtered_by) { + parts += '
' + s.breakdown + ''; + } + return parts; +} + +function _renderInlineSummary(summaries) { + var $bar = $('#inlineSummary').empty(); + var keys = ['projects','allocations','departments','resources','statuses','roles']; + var chips = []; + + keys.forEach(function(k) { + var s = summaries[k]; + if (!s) return; + var count = s.count || 0; + var label = FILTER_LABELS[k] || k; + // Keep chips short — count + label only; breakdown lives in the modal. + chips.push('' + count + ' ' + label); + }); + + chips.forEach(function(html) { + $bar.append('' + html + ''); + }); +} + +function showFilterDetailModal() { + var keys = ['projects','allocations','departments','resources','statuses','roles']; + var html = ''; + keys.forEach(function(k) { + var s = LATEST_SUMMARIES[k]; + if (!s) return; + var label = FILTER_LABELS[k] || k; + var count = s.count || 0; + var breakdown = s.breakdown ? $('').text(s.breakdown).html() : '—'; + var filtered = s.filtered_by ? $('').text(s.filtered_by).html() : 'None'; + html += '' + + '' + label + '' + + '' + count + '' + + '' + breakdown + '' + + '' + filtered + '' + + ''; + }); + $('#filterDetailRows').html(html); + $('#filterDetailModal').modal('show'); +} + +// Blur focused element before modal hides to avoid aria-hidden warning. +$('#filterDetailModal').on('hide.bs.modal', function() { + document.activeElement && document.activeElement.blur(); +}); + +function updateFilterSummaries(summaries) { + LATEST_SUMMARIES = summaries || {}; + + // Update i-icon tooltips + $('.filter-info').each(function() { + var key = $(this).data('filter'); + var raw = summaries && summaries[key]; + var html = _formatTooltip(raw); + if ($(this).data('ui-tooltip')) $(this).tooltip('destroy'); + $(this).attr('title', '').tooltip({ content: html, tooltipClass: 'filter-summary-tip' }); + }); + + // Update inline summary bar + _renderInlineSummary(summaries); +} + +// ── Event-driven filter cascade ───────────────────────────────── +// Each filter maps to an event name. On change we POST the event + all +// current selections; the backend returns full {options, selected} for +// every filter. Filters are disabled while a request is in flight to +// prevent stale/overlapping cascades. + +var FILTER_EVENT = { + f_project: 'PROJECT_UPDATED', + f_allocation: 'ALLOCATION_UPDATED', + f_dept: 'DEPARTMENT_UPDATED', + f_resource: 'RESOURCE_UPDATED', + f_status: 'ALLOCATION_STATUS_UPDATED', + f_role: 'ROLE_UPDATED' +}; + +var FILTER_SELECTS = { + projects: '#f_project', + allocations: '#f_allocation', + departments: '#f_dept', + resources: '#f_resource', + statuses: '#f_status', + roles: '#f_role' +}; + +var _filterXhr = null; // tracks in-flight request + +function setFiltersLocked(locked) { + $.each(FILTER_SELECTS, function(_key, sel) { + $(sel).prop('disabled', locked); + // Select2 reads the underlying from the server response without firing cascade + // change events (use change.select2 to update the widget only). + var summaries = {}; + $.each(FILTER_SELECTS, function(key, sel) { + var state = data[key]; + if (!state) return; + var $sel = $(sel); + var html = (state.options || []).map(function(o) { + var esc = $('
').text(o.label).html(); + return ''; + }).join(''); + $sel.html(html).val(state.selected || []).trigger('change.select2'); + if (state.summary) summaries[key] = state.summary; + }); + updateFilterSummaries(summaries); +} + +function onFilterChange(eventName) { + // Mark validation stale. + $('#recipNum').text('?'); + $('#recipLbl').text('press Validate'); + $('#sendBtn').prop('disabled', true); + $('#previewRecipBtn').prop('disabled', true); + $('#sendWarn').hide(); + setDedupeUsers([]); + + // Abort any in-flight cascade request. + if (_filterXhr) _filterXhr.abort(); + + setFiltersLocked(true); + + _filterXhr = $.ajax({ + url: URLS.filterOptions, + type: 'POST', + contentType: 'application/json', + headers: { 'X-CSRFToken': $('input[name=csrfmiddlewaretoken]').val() }, + data: JSON.stringify({ + event: eventName, + selections: collectFilters() + }), + success: function(data) { + applyFilterState(data); + }, + error: function(xhr) { + // Silently ignore aborted requests (we triggered a newer one). + if (xhr.statusText === 'abort') return; + }, + complete: function() { + _filterXhr = null; + setFiltersLocked(false); + } + }); +} + +// Bind a single change handler per filter select. +$.each(FILTER_EVENT, function(domId, eventName) { + $(document).on('change', '#' + domId, function() { + onFilterChange(eventName); + }); +}); diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_init.js b/coldfront_notifications/static/coldfront_notifications/js/compose_init.js new file mode 100644 index 0000000..43e12f5 --- /dev/null +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_init.js @@ -0,0 +1,53 @@ +// ── compose_init.js ───────────────────────────────────────────── +// Select2 initialization, form submit serialization, pageshow reset. +// Must be loaded LAST (after all other compose_*.js files). +// +// Globals used: loadTemplate (from compose_templates.js), +// setDedupeUsers (from compose_filters.js), +// HAS_VALIDATED (from compose_validate.js) + +// ── Serialize filter hidden fields before submit ───────────────── +$('#notifForm').on('submit', function() { + $('#h_projects').val(JSON.stringify($('#f_project').val() || [])); + $('#h_allocations').val(JSON.stringify($('#f_allocation').val() || [])); + $('#h_depts').val(JSON.stringify($('#f_dept').val() || [])); + $('#h_resources').val(JSON.stringify($('#f_resource').val() || [])); + $('#h_statuses').val(JSON.stringify($('#f_status').val() || [])); + $('#h_roles').val(JSON.stringify($('#f_role').val() || [])); +}); + +// ── Select2 init ───────────────────────────────────────────────── +$(document).ready(function() { + $('.s2').select2({ + allowClear: true, + width: '100%', + placeholder: function() { return $(this).data('placeholder'); } + }); + + // Start with an empty compose form — no template pre-selected. + + // Initialize filter summary tooltips from server-rendered data. + updateFilterSummaries(FILTER_SUMMARIES || {}); +}); + +// ── pageshow: reset validation state + refresh active template ──── +// When a user edits a template in another tab and comes back, bfcache +// restores the DOM with the OLD textarea content and any stale errors. +// Force-hide the panel and re-fetch the active template so the textarea +// reflects the latest body before the next Validate click. +$(window).on('pageshow', function() { + $('#validationPanel').hide(); + $('#validationOK').hide(); + $('#validationBad').hide(); + $('#sendWarn').hide(); + $('#sendBtn').prop('disabled', true); + $('#recipNum').text('?'); + $('#recipLbl').text('press Validate'); + $('#emailCount').text('—'); + $('#multiBadge').hide(); + $('#multiWarn').hide(); + setDedupeUsers([]); + HAS_VALIDATED = false; + var $active = $('.tmpl-item.active'); + if ($active.length) loadTemplate($active); +}); diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_preview.js b/coldfront_notifications/static/coldfront_notifications/js/compose_preview.js new file mode 100644 index 0000000..674a60e --- /dev/null +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_preview.js @@ -0,0 +1,282 @@ +// ── compose_preview.js ────────────────────────────────────────── +// Email preview modal and recipient preview modal (with dedupe + pagination). +// +// Globals used: URLS (from Django template), +// collectFilters, getDedupeUsers, setDedupeUsers (from compose_filters.js), +// runValidation (from compose_validate.js) + +// Blur focused element before modals hide to avoid aria-hidden warning. +$('#previewModal, #recipModal').on('hide.bs.modal', function() { + document.activeElement && document.activeElement.blur(); +}); + +// ═══════════════════════════════════════════════════════════════════ +// EMAIL PREVIEW MODAL +// ═══════════════════════════════════════════════════════════════════ + +var PREVIEW_SAMPLES = []; + +function renderPreviewSample(idx) { + var s = PREVIEW_SAMPLES[idx]; + if (!s) { + // "Template" — no recipient, raw tokens + $('#prev-to').text('—'); + $('#prev-subject').text($('#id_subject').val() || '—'); + $('#prev-body').text($('#id_body').val() || '—'); + $('#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 || '—'); + if (s.error) { + $('#prev-error').text('Resolution error: ' + s.error).show(); + } else { + $('#prev-error').hide(); + } + var ctx = []; + if (s.recipient.project) ctx.push('project: ' + s.recipient.project); + if (s.recipient.allocation) ctx.push('allocation: ' + s.recipient.allocation); + $('#prev-footer').text(ctx.join(' · ') || 'recipient-only context'); +} + +$(document).on('click', '#previewBtn', function() { + $('#prev-from').text($('#id_sender').val()); + $('#prev-replyto').text($('#id_reply_to').val()); + // Show the raw template immediately while samples load + PREVIEW_SAMPLES = []; + $('#prev-recipient-picker').html('' + + ''); + renderPreviewSample(null); + $('#prev-scope-badge').text(''); + $('#previewModal').modal('show'); + + $.ajax({ + url: URLS.previewRender, + type: 'POST', + data: { + csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), + subject: $('#id_subject').val() || '', + body: $('#id_body').val() || '', + filters: JSON.stringify(collectFilters()), + dedupe_users: JSON.stringify(getDedupeUsers()), + }, + success: function(data) { + PREVIEW_SAMPLES = data.samples || []; + var opts = ['']; + PREVIEW_SAMPLES.forEach(function(s, i) { + var lbl = (s.recipient.name || s.recipient.username) + ' <' + s.recipient.email + '>'; + if (s.recipient.project) lbl += ' · ' + s.recipient.project; + opts.push(''); + }); + if (!PREVIEW_SAMPLES.length) { + opts.push(''); + } + $('#prev-recipient-picker').html(opts.join('')); + if (data.scope) $('#prev-scope-badge').text('scope: ' + data.scope + ' · ' + (data.total_emails || 0) + ' total'); + + // Auto-select the first real sample if available + if (PREVIEW_SAMPLES.length) { + $('#prev-recipient-picker').val('0'); + renderPreviewSample(0); + } else { + renderPreviewSample(null); + } + }, + error: function() { + $('#prev-recipient-picker').html(''); + $('#prev-footer').text('Preview failed to load — showing raw template'); + }, + }); +}); + +$(document).on('change', '#prev-recipient-picker', function() { + var v = $(this).val(); + renderPreviewSample(v === '' ? null : parseInt(v, 10)); +}); + +// ═══════════════════════════════════════════════════════════════════ +// RECIPIENT PREVIEW MODAL (with dedupe + pagination) +// ═══════════════════════════════════════════════════════════════════ + +var PREVIEW_ROWS = []; +var PREVIEW_SCOPE = null; +var PREVIEW_PAGE = 1; +var PREVIEW_TOTAL_PAGES = 1; +var PREVIEW_TOTAL = 0; +var PREVIEW_USER_COUNT = 0; +var ALL_MULTI_USERS = []; + +function renderPreviewRows() { + // Group rows by username preserving order. + var order = [], byUser = {}; + PREVIEW_ROWS.forEach(function(r) { + var u = r.username; + if (!(u in byUser)) { order.push(u); byUser[u] = []; } + byUser[u].push(r); + }); + + var dedupeSet = {}; + getDedupeUsers().forEach(function(u) { dedupeSet[u] = true; }); + + // Build a set from the server's cross-page multi-user list for O(1) lookup. + var multiSet = {}; + ALL_MULTI_USERS.forEach(function(u) { multiSet[u] = true; }); + + var html = ''; + order.forEach(function(u) { + var group = byUser[u]; + var isMulti = !!multiSet[u]; + var isDeduped = !!dedupeSet[u]; + group.forEach(function(r, idx) { + var first = idx === 0; + var muted = (isDeduped && !first); + if (isDeduped && group.length === 1 && isMulti) muted = true; + var styleAttr = muted + ? ' style="color:#aab0b7;text-decoration:line-through;font-style:italic;"' + : ''; + var cbCell = ''; + if (first && isMulti) { + cbCell = ''; + } else { + cbCell = ''; + } + html += '' + + cbCell + + '' + (r.full_name||'') + (isMulti && first ? ' multi' : '') + '' + + '' + (r.email||'') + '' + + '' + (r.role||'') + '' + + '' + (r.project||'') + '' + + '' + (r.allocation||'') + '' + + ''; + }); + }); + $('#recipRows').html(html || 'No recipients matched.'); + + $('#recipModalCount').text(PREVIEW_TOTAL + ' emails, ' + PREVIEW_USER_COUNT + ' users'); + $('#recipModalScope').hide(); +} + +function getPageSize() { + return parseInt($('#recipPageSize').val(), 10) || 10; +} + +function updatePaginationControls() { + var atFirst = PREVIEW_PAGE <= 1; + var atLast = PREVIEW_PAGE >= PREVIEW_TOTAL_PAGES; + $('#recipPageInfo').text('Page ' + PREVIEW_PAGE + ' of ' + PREVIEW_TOTAL_PAGES); + $('#recipFirstBtn').prop('disabled', atFirst); + $('#recipPrevBtn').prop('disabled', atFirst); + $('#recipNextBtn').prop('disabled', atLast); + $('#recipLastBtn').prop('disabled', atLast); +} + +function fetchPreviewPage(page) { + PREVIEW_PAGE = page || 1; + $('#recipSearch').val(''); + $('#recipRows').html('Loading…'); + + $.ajax({ + url: URLS.recipientCount, + type: 'POST', + data: { + csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), + projects: $('#f_project').val() || [], + allocations: $('#f_allocation').val() || [], + departments: $('#f_dept').val() || [], + resources: $('#f_resource').val() || [], + alloc_status: $('#f_status').val() || [], + roles: $('#f_role').val() || [], + subject: $('#id_subject').val() || '', + body: $('#id_body').val() || '', + dedupe_users: JSON.stringify(getDedupeUsers()), + preview: 'true', + page: PREVIEW_PAGE, + page_size: getPageSize(), + }, + traditional: true, + success: function(data) { + PREVIEW_ROWS = data.recipients || []; + PREVIEW_SCOPE = data.scope || null; + PREVIEW_TOTAL = data.count || 0; + PREVIEW_USER_COUNT = data.user_count || 0; + PREVIEW_TOTAL_PAGES = data.total_pages || 1; + PREVIEW_PAGE = data.page || 1; + ALL_MULTI_USERS = (data.multi_users || []).map(function(m) { return m.username; }); + renderPreviewRows(); + // Show dedupe-all row if any multi-email users exist across all pages + if (ALL_MULTI_USERS.length) { + $('#dedupeAllRow').show(); + var allDeduped = ALL_MULTI_USERS.every(function(u) { + return getDedupeUsers().indexOf(u) !== -1; + }); + $('#dedupeAllCb').prop('checked', allDeduped); + } else { + $('#dedupeAllRow').hide(); + } + updatePaginationControls(); + }, + error: function() { + $('#recipRows').html('Failed to load.'); + } + }); +} + +$(document).on('click', '#previewRecipBtn', function() { + $('#recipModal').modal('show'); + fetchPreviewPage(1); +}); +$(document).on('click', '#recipFirstBtn', function() { + if (PREVIEW_PAGE > 1) fetchPreviewPage(1); +}); +$(document).on('click', '#recipPrevBtn', function() { + if (PREVIEW_PAGE > 1) fetchPreviewPage(PREVIEW_PAGE - 1); +}); +$(document).on('click', '#recipNextBtn', function() { + if (PREVIEW_PAGE < PREVIEW_TOTAL_PAGES) fetchPreviewPage(PREVIEW_PAGE + 1); +}); +$(document).on('click', '#recipLastBtn', function() { + if (PREVIEW_PAGE < PREVIEW_TOTAL_PAGES) fetchPreviewPage(PREVIEW_TOTAL_PAGES); +}); +$(document).on('change', '#recipPageSize', function() { + fetchPreviewPage(1); +}); + +// Client-side search: filter visible rows in the current page +$(document).on('input', '#recipSearch', function() { + var q = $(this).val().toLowerCase(); + $('#recipRows tr').each(function() { + var text = $(this).text().toLowerCase(); + $(this).toggle(!q || text.indexOf(q) !== -1); + }); +}); + +// Bulk dedupe: toggle ALL multi-email users across all pages. +$(document).on('change', '#dedupeAllCb', function() { + if ($(this).is(':checked')) { + setDedupeUsers(ALL_MULTI_USERS.slice()); + } else { + setDedupeUsers([]); + } + renderPreviewRows(); + runValidation(); +}); + +// Per-user dedupe toggle inside the modal. +$(document).on('change', '.dedupe-cb', function() { + var username = $(this).attr('data-username'); + var cur = getDedupeUsers(); + var idx = cur.indexOf(username); + if ($(this).is(':checked')) { + if (idx === -1) cur.push(username); + } else { + if (idx !== -1) cur.splice(idx, 1); + } + setDedupeUsers(cur); + renderPreviewRows(); + runValidation(); +}); diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_templates.js b/coldfront_notifications/static/coldfront_notifications/js/compose_templates.js new file mode 100644 index 0000000..fcbb648 --- /dev/null +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_templates.js @@ -0,0 +1,108 @@ +// ── compose_templates.js ──────────────────────────────────────── +// Template list, search, loading, variable chips, and variable insert. +// +// Globals used: VARIABLES, VAR_BY_KEY, URLS (from Django template), +// HAS_VALIDATED, runValidation (from compose_validate.js), +// setDedupeUsers (from compose_filters.js) + +// Index VARIABLES by key for O(1) lookup +var VAR_BY_KEY = {}; +VARIABLES.forEach(function(v) { VAR_BY_KEY[v.key] = v; }); + +// ── Render chip list ───────────────────────────────────────────── +var ACTIVE_VARS = []; + +function renderVars(vars, filter) { + var $list = $('#varsList').empty(); + var filtered = filter + ? vars.filter(function(v) { return v.toLowerCase().indexOf(filter.toLowerCase()) !== -1; }) + : vars; + if (!filtered.length) { + $list.append('
No matches
'); + return; + } + filtered.forEach(function(v) { + var token = '{{' + v + '}}'; + var def = VAR_BY_KEY[v] || {}; + var example = def.example || ''; + var badge = def.source === 'manual' ? ' [manual]' : ''; + var $btn = $('') + .attr('data-var', token) + .attr('title', (def.description || def.label || '') + (example ? ' — ' + example : '')) + .html( + '' + token + badge + '' + + '' + example + '' + ); + $list.append($btn); + }); +} + +// ── Extract tokens from subject + body ─────────────────────────── +var TOKEN_RE = /\{\{(\w+)\}\}/g; +function extractTokens() { + var text = ($('#id_subject').val() || '') + '\n' + ($('#id_body').val() || ''); + var seen = {}; + var out = []; + var m; + while ((m = TOKEN_RE.exec(text))) { + if (!seen[m[1]]) { seen[m[1]] = true; out.push(m[1]); } + } + return out; +} + +// ── Load template into compose fields ──────────────────────────── +// Always fetch fresh content from the server — avoids stale data when +// an admin edits a template in another tab and returns to compose. +function loadTemplate($el) { + var id = parseInt($el.attr('data-id') || 0); + var slug = $el.attr('data-slug') || ''; + $('#active-slug').text(slug); + $('#hidden_template_id').val(id); + $('#varSearch').val(''); + $('#validationPanel').hide(); + $('#sendBtn').prop('disabled', true); + $('#sendWarn').hide(); + setDedupeUsers([]); + if (!id) { ACTIVE_VARS = []; renderVars([], ''); return; } + $.getJSON(URLS.templateJson.replace('{id}', id), function(t) { + $('#id_subject').val(t.subject || ''); + $('#id_body').val(t.body || ''); + ACTIVE_VARS = t.variables || []; + renderVars(ACTIVE_VARS, ''); + if (HAS_VALIDATED) runValidation(); + }).fail(function() { + ACTIVE_VARS = []; + renderVars([], ''); + }); +} + +// ── Template click ─────────────────────────────────────────────── +$(document).on('click', '.tmpl-item', function() { + $('.tmpl-item').removeClass('active'); + $(this).addClass('active'); + loadTemplate($(this)); +}); + +// ── Template search ───────────────────────────────────────────── +$(document).on('input', '#tmplSearch', function() { + var q = $(this).val().toLowerCase(); + $('.tmpl-item').each(function() { + var text = $(this).text().toLowerCase(); + $(this).toggle(!q || text.indexOf(q) !== -1); + }); +}); + +// ── Variable search ────────────────────────────────────────────── +$(document).on('input', '#varSearch', function() { + renderVars(ACTIVE_VARS, $(this).val()); +}); + +// ── 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(); +}); diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_validate.js b/coldfront_notifications/static/coldfront_notifications/js/compose_validate.js new file mode 100644 index 0000000..517e3d1 --- /dev/null +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_validate.js @@ -0,0 +1,122 @@ +// ── compose_validate.js ───────────────────────────────────────── +// Validation AJAX, send-bar UI state, and input invalidation. +// +// Globals used: URLS (from Django template), +// collectFilters, getDedupeUsers (from compose_filters.js) +// Globals exported: HAS_VALIDATED, runValidation + +var HAS_VALIDATED = false; + +function runValidation() { + var $btn = $('#recalcBtn'); + $btn.prop('disabled', true).html(''); + + $.ajax({ + url: URLS.validate, + type: 'POST', + data: { + csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), + subject: $('#id_subject').val() || '', + body: $('#id_body').val() || '', + filters: JSON.stringify(collectFilters()), + dedupe_users: JSON.stringify(getDedupeUsers()), + }, + success: function(data) { + var users = data.user_count || 0; + var emails = data.email_count || 0; + $('#recipNum').text(users); + $('#recipLbl').text('users'); + $('#emailCount').text(emails); + $('#warnCount').text(emails); + $('#sendCount').text(emails); + $('#hidden_recip_count').val(emails); + + // Filter summary bar + var af = data.active_filters || []; + if (af.length) { + var chips = af.map(function(f) { + var vals = f.values.map(function(v) { return '' + $('').text(v).html() + ''; }).join(', '); + return '' + $('').text(f.label).html() + ': ' + vals + ''; + }).join(''); + $('#filterSummaryBar') + .html('
Filters:' + chips + '
') + .show(); + } else { + $('#filterSummaryBar') + .html('
Filters:No filters applied — targeting all active users
') + .show(); + } + + // Multi-email users + if (data.multi_emails && data.multi_emails.length) { + $('#multiCount').text(data.multi_emails.length); + $('#multiBadge').show(); + var lines = data.multi_emails.map(function(m) { + return '
  • ' + m.username + ' — ' + m.count + ' emails
  • '; + }).join(''); + $('#multiList').html(lines); + $('#multiWarnText').text( + data.multi_emails.length + ' user(s) will receive multiple emails ' + + '(different project/allocation context each)' + ); + $('#multiWarn').show(); + } else { + $('#multiBadge').hide(); + $('#multiWarn').hide(); + } + + // Preview recipients is independent of variable resolution — as + // long as the filter matches anyone, admins can see who's in scope. + $('#previewRecipBtn').prop('disabled', users === 0); + HAS_VALIDATED = true; + + // Errors and missing tokens + $('#validationPanel').show(); + var errs = data.errors || []; + var miss = data.missing_tokens || []; + if (errs.length || miss.length) { + var title = []; + if (miss.length) title.push(miss.length + ' unknown token(s): ' + miss.join(', ')); + if (errs.length) title.push(errs.length + ' resolution error(s)'); + $('#validationBadTitle').text('Blocking — ' + title.join('; ')); + var $ul = $('#validationErrors').empty(); + errs.slice(0, 30).forEach(function(e) { + $ul.append('
  • ' + e.email + ': {{' + e.token + '}} — ' + e.reason + '
  • '); + }); + if (errs.length > 30) $ul.append('
  • … and ' + (errs.length - 30) + ' more
  • '); + $('#validationBad').show(); + $('#validationOK').hide(); + $('#sendBtn').prop('disabled', true); + $('#sendWarn').hide(); + } else { + $('#validationOK') + .html('' + + users + ' users → ' + emails + ' emails. All variables resolve.') + .show(); + $('#validationBad').hide(); + $('#sendBtn').prop('disabled', emails === 0); + if (emails > 0) $('#sendWarn').show(); + } + }, + error: function() { + $('#recipNum').text('—'); + $('#recipLbl').text('Error — try again'); + }, + complete: function() { + $btn.prop('disabled', false).html('Validate'); + } + }); +} + +$(document).on('click', '#recalcBtn, #recalcBtn2', function() { runValidation(); }); + +$(document).on('click', '#multiShowBtn', function() { + $('#multiList').toggle(); +}); + +// Body/subject input changes invalidate previous validation. +$(document).on('input', '#id_body, #id_subject', function() { + $('#validationPanel').hide(); + $('#sendBtn').prop('disabled', true); + $('#sendWarn').hide(); +}); diff --git a/coldfront_notifications/templates/compose.html b/coldfront_notifications/templates/compose.html index 2ff81d2..3af143d 100644 --- a/coldfront_notifications/templates/compose.html +++ b/coldfront_notifications/templates/compose.html @@ -23,7 +23,6 @@

    Recipients

    - All recipients will be BCC'd
    @@ -31,47 +30,25 @@

    - +
    - +
    - + {% for r in resources %} - {% empty %} - - - - - {% endfor %}
    - +
    - +
    - +
    +
    + + + +
    +
    —
    recipients
    @@ -134,13 +107,6 @@

    — emails

    - -

    @@ -168,7 +134,7 @@

    {% for t in templates %} -
    {{ t.name }}
    @@ -290,6 +256,7 @@

    + + + {% endblock %} {% block javascript %} @@ -459,8 +449,15 @@

    previewRender: '{% url "notifications:preview-render" %}', filterOptions: '{% url "notifications:filter-options" %}', validate: '{% url "notifications:validate" %}', - recipientCount: '{% url "notifications:recipient-count" %}' + recipientCount: '{% url "notifications:recipient-count" %}', + templateJson: '{% url "notifications:template-json" pk=0 %}'.replace('/0/', '/{id}/') }; +var FILTER_SUMMARIES = {{ filter_summaries_json|safe }}; - + + + + + + {% endblock %} \ No newline at end of file diff --git a/coldfront_notifications/tests/django_setup.py b/coldfront_notifications/tests/django_setup.py new file mode 100644 index 0000000..23eedd7 --- /dev/null +++ b/coldfront_notifications/tests/django_setup.py @@ -0,0 +1,23 @@ +""" +Minimal Django configuration for unit tests that don't need the full +ColdFront stack. Import this module before any Django/plugin imports. +""" +import django +from django.conf import settings + +if not settings.configured: + settings.configure( + INSTALLED_APPS=[ + "django.contrib.contenttypes", + "django.contrib.auth", + "coldfront_notifications", + ], + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } + }, + DEFAULT_AUTO_FIELD="django.db.models.BigAutoField", + ) + django.setup() diff --git a/coldfront_notifications/tests/test_conf.py b/coldfront_notifications/tests/test_conf.py new file mode 100644 index 0000000..744e873 --- /dev/null +++ b/coldfront_notifications/tests/test_conf.py @@ -0,0 +1,36 @@ +"""Unit tests for coldfront_notifications.conf defaults.""" +import unittest + +import coldfront_notifications.tests.django_setup # noqa: F401 + + +class TestConfDefaults(unittest.TestCase): + """Tests that conf.py exports sensible defaults.""" + + def test_batch_size_default(self): + from coldfront_notifications.conf import BATCH_SIZE + self.assertEqual(BATCH_SIZE, 50) + + def test_batch_delay_default(self): + from coldfront_notifications.conf import BATCH_DELAY + self.assertEqual(BATCH_DELAY, 2.0) + + def test_max_retries_default(self): + from coldfront_notifications.conf import MAX_RETRIES + self.assertEqual(MAX_RETRIES, 3) + + def test_retry_delay_default(self): + from coldfront_notifications.conf import RETRY_DELAY + self.assertEqual(RETRY_DELAY, 1.0) + + def test_preview_page_size_default(self): + from coldfront_notifications.conf import PREVIEW_PAGE_SIZE + self.assertEqual(PREVIEW_PAGE_SIZE, 50) + + def test_preview_render_limit_default(self): + from coldfront_notifications.conf import PREVIEW_RENDER_LIMIT + self.assertEqual(PREVIEW_RENDER_LIMIT, 10) + + +if __name__ == "__main__": + unittest.main() diff --git a/coldfront_notifications/tests/test_integration.py b/coldfront_notifications/tests/test_integration.py index f529d0a..8da8774 100644 --- a/coldfront_notifications/tests/test_integration.py +++ b/coldfront_notifications/tests/test_integration.py @@ -5,6 +5,7 @@ """ import json from datetime import timedelta +import sys from unittest.mock import patch from django.contrib.auth import get_user_model @@ -467,20 +468,273 @@ def test_soft_deleted_variable_excluded_from_list(self): class FilterOptionsViewTest(NotificationIntegrationTestCase): - """Tests for the filter-options AJAX endpoint.""" + """Tests for the event-driven filter-options AJAX endpoint.""" - def test_filter_options_with_project(self): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + # Extra fixtures for richer cascade testing. + cls.resource2 = Resource.objects.create( + name="GPU Cluster", + resource_type=cls.resource_type, + is_allocatable=True, + ) + # An expired allocation on proj2 with resource2. + cls.alloc3 = Allocation.objects.create( + project=cls.proj2, + status=cls.alloc_status_expired, + justification="Old GPU allocation", + ) + cls.alloc3.resources.add(cls.resource2) + + def _post_event(self, event, selections=None): + """Helper: POST an event to filter-options and return parsed JSON.""" + sel = { + "projects": [], "allocations": [], "departments": [], + "resources": [], "statuses": [], "roles": [], + } + if selections: + sel.update(selections) resp = self.client.post( reverse("notifications:filter-options"), - {"projects": [self.proj1.pk]}, + json.dumps({"event": event, "selections": sel}), + content_type="application/json", ) self.assertEqual(resp.status_code, 200) - data = resp.json() - self.assertIn("allocations", data) - # Should include alloc1 (proj1) but not alloc2 (proj2) - alloc_ids = [a["id"] for a in data["allocations"]] + return resp.json() + + def _ids(self, data, key): + """Extract option ids from the response for a given filter key.""" + return [o["id"] for o in data[key]["options"]] + + # ── Response shape ────────────────────────────────────────────── + def test_response_contains_all_six_filters(self): + data = self._post_event("ROLE_UPDATED") + for key in ("projects", "allocations", "departments", + "resources", "statuses", "roles"): + self.assertIn(key, data) + self.assertIn("options", data[key]) + self.assertIn("selected", data[key]) + + # ── PROJECT_UPDATED ───────────────────────────────────────────── + def test_project_updated_narrows_allocations(self): + data = self._post_event("PROJECT_UPDATED", { + "projects": [self.proj1.pk], + }) + alloc_ids = self._ids(data, "allocations") + self.assertIn(self.alloc1.pk, alloc_ids) + self.assertNotIn(self.alloc2.pk, alloc_ids) + self.assertNotIn(self.alloc3.pk, alloc_ids) + + def test_project_updated_narrows_departments(self): + data = self._post_event("PROJECT_UPDATED", { + "projects": [self.proj1.pk], + }) + dept_ids = self._ids(data, "departments") + self.assertIn("Engineering Dept", dept_ids) + + def test_project_updated_narrows_departments_excludes_unlinked(self): + data = self._post_event("PROJECT_UPDATED", { + "projects": [self.proj2.pk], + }) + # proj2 has no department link + dept_ids = self._ids(data, "departments") + self.assertNotIn("Engineering Dept", dept_ids) + + def test_project_updated_narrows_resources(self): + data = self._post_event("PROJECT_UPDATED", { + "projects": [self.proj2.pk], + }) + res_ids = self._ids(data, "resources") + # proj2 has alloc2 (Test Storage) and alloc3 (GPU Cluster) + self.assertIn(self.resource.pk, res_ids) + self.assertIn(self.resource2.pk, res_ids) + + def test_project_updated_narrows_statuses(self): + data = self._post_event("PROJECT_UPDATED", { + "projects": [self.proj2.pk], + }) + status_ids = self._ids(data, "statuses") + # proj2 has Active (alloc2) and Expired (alloc3) + self.assertIn("Active", status_ids) + self.assertIn("Expired", status_ids) + + def test_project_updated_narrows_roles(self): + data = self._post_event("PROJECT_UPDATED", { + "projects": [self.proj1.pk], + }) + role_ids = self._ids(data, "roles") + # proj1 has PI (user1) and User (user2), no Manager + self.assertIn("Principal Investigator", role_ids) + self.assertIn("User", role_ids) + self.assertNotIn("Manager", role_ids) + + # ── DEPARTMENT_UPDATED ────────────────────────────────────────── + def test_department_updated_narrows_projects(self): + data = self._post_event("DEPARTMENT_UPDATED", { + "departments": ["Engineering Dept"], + }) + proj_ids = self._ids(data, "projects") + self.assertIn(self.proj1.pk, proj_ids) + self.assertNotIn(self.proj2.pk, proj_ids) + + def test_department_updated_narrows_allocations(self): + data = self._post_event("DEPARTMENT_UPDATED", { + "departments": ["Engineering Dept"], + }) + alloc_ids = self._ids(data, "allocations") + self.assertIn(self.alloc1.pk, alloc_ids) + self.assertNotIn(self.alloc2.pk, alloc_ids) + + # ── ALLOCATION_UPDATED ────────────────────────────────────────── + def test_allocation_updated_narrows_resources(self): + data = self._post_event("ALLOCATION_UPDATED", { + "allocations": [self.alloc3.pk], + }) + res_ids = self._ids(data, "resources") + self.assertIn(self.resource2.pk, res_ids) + self.assertNotIn(self.resource.pk, res_ids) + + def test_allocation_updated_does_not_narrow_statuses(self): + """Status is an independent peer — not narrowed by allocation selection.""" + data = self._post_event("ALLOCATION_UPDATED", { + "allocations": [self.alloc3.pk], + }) + status_ids = self._ids(data, "statuses") + # All statuses in the project scope remain available. + self.assertIn("Expired", status_ids) + self.assertIn("Active", status_ids) + + def test_allocation_updated_preserves_status_scoping(self): + """Allocation options should still reflect status selections.""" + data = self._post_event("ALLOCATION_UPDATED", { + "allocations": [self.alloc3.pk], + "statuses": ["Expired"], + }) + alloc_ids = self._ids(data, "allocations") + # Only expired allocations in the allocation options. + self.assertIn(self.alloc3.pk, alloc_ids) + self.assertNotIn(self.alloc1.pk, alloc_ids) + self.assertNotIn(self.alloc2.pk, alloc_ids) + + def test_allocation_updated_does_not_narrow_projects(self): + """Upstream filters must not be narrowed.""" + data = self._post_event("ALLOCATION_UPDATED", { + "allocations": [self.alloc1.pk], + }) + proj_ids = self._ids(data, "projects") + # Both projects should still appear. + self.assertIn(self.proj1.pk, proj_ids) + self.assertIn(self.proj2.pk, proj_ids) + + # ── RESOURCE_UPDATED ──────────────────────────────────────────── + def test_resource_updated_narrows_allocations(self): + data = self._post_event("RESOURCE_UPDATED", { + "resources": [self.resource2.pk], + }) + alloc_ids = self._ids(data, "allocations") + # Only alloc3 uses resource2 + self.assertIn(self.alloc3.pk, alloc_ids) + self.assertNotIn(self.alloc1.pk, alloc_ids) + self.assertNotIn(self.alloc2.pk, alloc_ids) + + def test_resource_updated_narrows_statuses(self): + data = self._post_event("RESOURCE_UPDATED", { + "resources": [self.resource2.pk], + }) + status_ids = self._ids(data, "statuses") + self.assertIn("Expired", status_ids) + self.assertNotIn("Active", status_ids) + + def test_resource_updated_does_not_narrow_projects(self): + data = self._post_event("RESOURCE_UPDATED", { + "resources": [self.resource2.pk], + }) + proj_ids = self._ids(data, "projects") + self.assertIn(self.proj1.pk, proj_ids) + self.assertIn(self.proj2.pk, proj_ids) + + # ── ALLOCATION_STATUS_UPDATED ─────────────────────────────────── + def test_status_updated_narrows_allocations(self): + data = self._post_event("ALLOCATION_STATUS_UPDATED", { + "statuses": ["Expired"], + }) + alloc_ids = self._ids(data, "allocations") + self.assertIn(self.alloc3.pk, alloc_ids) + self.assertNotIn(self.alloc1.pk, alloc_ids) + self.assertNotIn(self.alloc2.pk, alloc_ids) + + def test_status_updated_narrows_resources(self): + data = self._post_event("ALLOCATION_STATUS_UPDATED", { + "statuses": ["Expired"], + }) + res_ids = self._ids(data, "resources") + self.assertIn(self.resource2.pk, res_ids) + self.assertNotIn(self.resource.pk, res_ids) + + def test_status_updated_does_not_narrow_projects(self): + data = self._post_event("ALLOCATION_STATUS_UPDATED", { + "statuses": ["Expired"], + }) + proj_ids = self._ids(data, "projects") + self.assertIn(self.proj1.pk, proj_ids) + self.assertIn(self.proj2.pk, proj_ids) + + # ── ROLE_UPDATED ──────────────────────────────────────────────── + def test_role_updated_is_leaf(self): + """Role is a leaf filter — nothing should be narrowed.""" + data_all = self._post_event("ROLE_UPDATED") + data_pi = self._post_event("ROLE_UPDATED", { + "roles": ["Principal Investigator"], + }) + # All other filters should have the same options regardless of role. + for key in ("projects", "allocations", "departments", "resources", "statuses"): + self.assertEqual( + self._ids(data_all, key), self._ids(data_pi, key), + f"{key} options should not change on ROLE_UPDATED", + ) + + # ── Selection pruning ─────────────────────────────────────────── + def test_stale_selection_pruned_from_selected(self): + """Selections that are no longer valid should be dropped.""" + data = self._post_event("PROJECT_UPDATED", { + "projects": [self.proj1.pk], + # alloc2 belongs to proj2, not proj1 — should be pruned. + "allocations": [self.alloc1.pk, self.alloc2.pk], + }) + self.assertIn(self.alloc1.pk, data["allocations"]["selected"]) + self.assertNotIn(self.alloc2.pk, data["allocations"]["selected"]) + + # ── Tier 1 + Tier 2 combined ──────────────────────────────────── + def test_status_respects_upstream_project_scope(self): + """When both project and status are set, allocations should be + scoped to the project first, then filtered by status.""" + data = self._post_event("ALLOCATION_STATUS_UPDATED", { + "projects": [self.proj1.pk], + "statuses": ["Active"], + }) + alloc_ids = self._ids(data, "allocations") + # Only alloc1 is Active in proj1. self.assertIn(self.alloc1.pk, alloc_ids) self.assertNotIn(self.alloc2.pk, alloc_ids) + self.assertNotIn(self.alloc3.pk, alloc_ids) + + # ── Error handling ────────────────────────────────────────────── + def test_unknown_event_returns_400(self): + resp = self.client.post( + reverse("notifications:filter-options"), + json.dumps({"event": "BOGUS_EVENT", "selections": {}}), + content_type="application/json", + ) + self.assertEqual(resp.status_code, 400) + + def test_invalid_json_returns_400(self): + resp = self.client.post( + reverse("notifications:filter-options"), + "not json", + content_type="application/json", + ) + self.assertEqual(resp.status_code, 400) class ValidateViewTest(NotificationIntegrationTestCase): @@ -504,7 +758,9 @@ def test_validate_returns_counts(self): self.assertIn("user_count", data) self.assertIn("email_count", data) self.assertIn("scope", data) - self.assertEqual(data["scope"], "user") + # Scope is elevated to at least "project" so counts match the + # preview modal (which always shows project-level tuples). + self.assertEqual(data["scope"], "project") class RecipientCountViewTest(NotificationIntegrationTestCase): @@ -539,30 +795,32 @@ def test_preview_returns_paginated_recipients(self): class ComposeViewTest(NotificationIntegrationTestCase): """Tests for the compose view POST (send action).""" - @patch("coldfront_notifications.views._dispatch_send") - def test_compose_send_creates_campaign(self, mock_dispatch): - resp = self.client.post(reverse("notifications:compose"), { - "action": "send", - "subject": "Hello {{maint_date}}", - "body": "Maintenance on {{maint_date}}.", - "sender": "rchelp@example.com", - "reply_to": "", - "template_id": "", - "filter_projects": json.dumps([]), - "filter_allocations": json.dumps([]), - "filter_resources": json.dumps([]), - "filter_departments": json.dumps([]), - "filter_statuses": json.dumps([]), - "filter_roles": json.dumps([]), - "extra_recipients": "", - "dedupe_users": "[]", - }) - # Should redirect to campaign detail - self.assertEqual(resp.status_code, 302) - campaign = NotificationCampaign.objects.latest("created_at") - self.assertEqual(campaign.subject, "Hello {{maint_date}}") - self.assertEqual(campaign.status, NotificationCampaign.STATUS_QUEUED) - mock_dispatch.assert_called_once_with(campaign.pk) + def test_compose_send_creates_campaign(self): + # patch.object on the module directly to avoid __init__.py name shadowing + compose_mod = sys.modules["coldfront_notifications.views.compose"] + with patch.object(compose_mod, "_dispatch_send") as mock_dispatch: + resp = self.client.post(reverse("notifications:compose"), { + "action": "send", + "subject": "Hello {{maint_date}}", + "body": "Maintenance on {{maint_date}}.", + "sender": "rchelp@example.com", + "reply_to": "", + "template_id": "", + "filter_projects": json.dumps([]), + "filter_allocations": json.dumps([]), + "filter_resources": json.dumps([]), + "filter_departments": json.dumps([]), + "filter_statuses": json.dumps([]), + "filter_roles": json.dumps([]), + "extra_recipients": "", + "dedupe_users": "[]", + }) + # Should redirect to campaign detail + self.assertEqual(resp.status_code, 302) + campaign = NotificationCampaign.objects.latest("created_at") + self.assertEqual(campaign.subject, "Hello {{maint_date}}") + self.assertEqual(campaign.status, NotificationCampaign.STATUS_QUEUED) + mock_dispatch.assert_called_once_with(campaign.pk) class StaffRequiredTest(NotificationIntegrationTestCase): diff --git a/coldfront_notifications/tests/test_resolvers.py b/coldfront_notifications/tests/test_resolvers.py new file mode 100644 index 0000000..e2de7a8 --- /dev/null +++ b/coldfront_notifications/tests/test_resolvers.py @@ -0,0 +1,162 @@ +"""Unit tests for coldfront_notifications.resolvers.""" +import unittest +from unittest.mock import MagicMock + +import coldfront_notifications.tests.django_setup # noqa: F401 + +from coldfront_notifications.resolvers import ( + QUERY_CHOICES, + QUERY_RESOLVERS, + QUERY_SCOPES, + MissingValue, + _fmt, + _need, + _primary_resource, + _scope_for, + resolve, +) + + +class TestFmt(unittest.TestCase): + """Tests for _fmt().""" + + def test_valid_string(self): + self.assertEqual(_fmt("hello"), "hello") + + def test_strips_whitespace(self): + self.assertEqual(_fmt(" world "), "world") + + def test_none_raises_missing(self): + with self.assertRaises(MissingValue): + _fmt(None) + + def test_empty_string_raises_missing(self): + with self.assertRaises(MissingValue): + _fmt("") + + def test_whitespace_only_raises_missing(self): + with self.assertRaises(MissingValue): + _fmt(" ") + + def test_coerces_to_string(self): + self.assertEqual(_fmt(42), "42") + + +class TestNeed(unittest.TestCase): + """Tests for _need().""" + + def test_present_key_returns_value(self): + ctx = {"user": "alice"} + self.assertEqual(_need(ctx, "user"), "alice") + + def test_missing_key_raises_missing(self): + ctx = {"user": "alice"} + with self.assertRaises(MissingValue): + _need(ctx, "project") + + def test_none_value_raises_missing(self): + ctx = {"project": None} + with self.assertRaises(MissingValue): + _need(ctx, "project") + + +class TestResolve(unittest.TestCase): + """Tests for resolve().""" + + def test_valid_resolver_key_dispatches(self): + user = MagicMock() + user.username = "jdoe" + ctx = {"user": user, "project": None, "allocation": None} + result = resolve("user.username", ctx) + self.assertEqual(result, "jdoe") + + def test_unknown_key_raises_missing(self): + ctx = {"user": MagicMock(), "project": None, "allocation": None} + with self.assertRaises(MissingValue): + resolve("nonexistent.key", ctx) + + def test_attribute_error_raises_missing(self): + user = MagicMock(spec=[]) + ctx = {"user": user, "project": None, "allocation": None} + with self.assertRaises(MissingValue): + resolve("user.username", ctx) + + def test_resolve_user_email(self): + user = MagicMock() + user.email = "alice@example.com" + ctx = {"user": user, "project": None, "allocation": None} + self.assertEqual(resolve("user.email", ctx), "alice@example.com") + + def test_resolve_user_full_name(self): + user = MagicMock() + user.get_full_name.return_value = "Jane Doe" + ctx = {"user": user, "project": None, "allocation": None} + self.assertEqual(resolve("user.full_name", ctx), "Jane Doe") + + def test_resolve_user_full_name_falls_back_to_username(self): + user = MagicMock() + user.get_full_name.return_value = "" + user.username = "jdoe" + ctx = {"user": user, "project": None, "allocation": None} + self.assertEqual(resolve("user.full_name", ctx), "jdoe") + + +class TestQueryChoicesConsistency(unittest.TestCase): + """Tests that QUERY_CHOICES, QUERY_RESOLVERS, and QUERY_SCOPES are consistent.""" + + def test_choices_keys_match_resolvers(self): + choice_keys = {k for k, _label, _group in QUERY_CHOICES} + resolver_keys = set(QUERY_RESOLVERS.keys()) + self.assertEqual(choice_keys, resolver_keys) + + def test_scopes_maps_every_choice(self): + choice_keys = {k for k, _label, _group in QUERY_CHOICES} + scope_keys = set(QUERY_SCOPES.keys()) + self.assertEqual(choice_keys, scope_keys) + + +class TestScopeFor(unittest.TestCase): + """Tests for _scope_for() classification.""" + + def test_user_scope(self): + self.assertEqual(_scope_for("user.username"), "user") + self.assertEqual(_scope_for("user.email"), "user") + self.assertEqual(_scope_for("user.ifxid"), "user") + + def test_project_scope(self): + self.assertEqual(_scope_for("project.title"), "project") + self.assertEqual(_scope_for("project.pi.email"), "project") + self.assertEqual(_scope_for("projectuser.role.name"), "project") + + def test_allocation_scope(self): + self.assertEqual(_scope_for("allocation.id"), "allocation") + self.assertEqual(_scope_for("allocation.status.name"), "allocation") + + def test_resource_scope(self): + self.assertEqual(_scope_for("resource.name"), "allocation") + self.assertEqual(_scope_for("resource.description"), "allocation") + + +class TestPrimaryResource(unittest.TestCase): + + def test_none_allocation_returns_none(self): + self.assertIsNone(_primary_resource(None)) + + def test_returns_get_parent_resource(self): + alloc = MagicMock() + alloc.get_parent_resource = "StorageA" + self.assertEqual(_primary_resource(alloc), "StorageA") + + +class TestResolveExceptionHandling(unittest.TestCase): + + def test_arbitrary_exception_becomes_missing_value(self): + user = MagicMock() + type(user).username = property(lambda self: (_ for _ in ()).throw(TypeError("boom"))) + ctx = {"user": user, "project": None, "allocation": None} + with self.assertRaises(MissingValue): + resolve("user.username", ctx) + + +if __name__ == "__main__": + unittest.main() diff --git a/coldfront_notifications/tests/test_tasks_unit.py b/coldfront_notifications/tests/test_tasks_unit.py new file mode 100644 index 0000000..3fe032b --- /dev/null +++ b/coldfront_notifications/tests/test_tasks_unit.py @@ -0,0 +1,221 @@ +"""Unit tests for coldfront_notifications.tasks (pure logic, no DB).""" +import smtplib +import unittest +from unittest.mock import MagicMock, patch + +import coldfront_notifications.tests.django_setup # noqa: F401 + +from coldfront_notifications.resolvers import MissingValue +from coldfront_notifications.tasks import ( + _build_values, + _is_transient, + _render, + _send_with_retry, +) + + +class TestRender(unittest.TestCase): + """Tests for _render().""" + + def test_no_tokens_passthrough(self): + self.assertEqual(_render("Hello world", {}), "Hello world") + + def test_one_token_substituted(self): + self.assertEqual(_render("Hello {{name}}", {"name": "Alice"}), "Hello Alice") + + def test_missing_key_left_as_literal(self): + self.assertEqual(_render("Hello {{name}}", {}), "Hello {{name}}") + + def test_multiple_tokens_all_substituted(self): + template = "Dear {{first}} {{last}}, welcome to {{org}}." + values = {"first": "Jane", "last": "Doe", "org": "FASRC"} + self.assertEqual(_render(template, values), "Dear Jane Doe, welcome to FASRC.") + + def test_repeated_token_substituted(self): + self.assertEqual( + _render("{{x}} and {{x}}", {"x": "hi"}), + "hi and hi", + ) + + def test_none_template_returns_empty(self): + self.assertEqual(_render(None, {"foo": "bar"}), "") + + def test_empty_template(self): + self.assertEqual(_render("", {"foo": "bar"}), "") + + def test_partial_match(self): + result = _render("{{a}} {{b}}", {"a": "X"}) + self.assertEqual(result, "X {{b}}") + + +class TestIsTransient(unittest.TestCase): + """Tests for _is_transient().""" + + def test_smtp_421_is_transient(self): + exc = smtplib.SMTPResponseException(421, "Service not available") + self.assertTrue(_is_transient(exc)) + + def test_smtp_450_is_transient(self): + exc = smtplib.SMTPResponseException(450, "Mailbox busy") + self.assertTrue(_is_transient(exc)) + + def test_smtp_499_is_transient(self): + exc = smtplib.SMTPResponseException(499, "Custom 4xx") + self.assertTrue(_is_transient(exc)) + + def test_smtp_550_is_not_transient(self): + exc = smtplib.SMTPResponseException(550, "Mailbox not found") + self.assertFalse(_is_transient(exc)) + + def test_smtp_500_is_not_transient(self): + exc = smtplib.SMTPResponseException(500, "Syntax error") + self.assertFalse(_is_transient(exc)) + + def test_smtp_354_is_not_transient(self): + exc = smtplib.SMTPResponseException(354, "Start mail input") + self.assertFalse(_is_transient(exc)) + + def test_smtp_server_disconnected_is_transient(self): + exc = smtplib.SMTPServerDisconnected("Connection lost") + self.assertTrue(_is_transient(exc)) + + def test_connection_error_is_transient(self): + exc = ConnectionError("refused") + self.assertTrue(_is_transient(exc)) + + def test_connection_reset_is_transient(self): + exc = ConnectionResetError("reset by peer") + self.assertTrue(_is_transient(exc)) + + def test_value_error_is_not_transient(self): + exc = ValueError("bad value") + self.assertFalse(_is_transient(exc)) + + def test_runtime_error_is_not_transient(self): + exc = RuntimeError("something broke") + self.assertFalse(_is_transient(exc)) + + +class TestBuildValues(unittest.TestCase): + + def _make_var(self, source="manual", resolver_key="", value="", is_required=True): + v = MagicMock() + v.source = source + v.SOURCE_MANUAL = "manual" + v.resolver_key = resolver_key + v.value = value + v.is_required = is_required + v.key = "test_var" + return v + + def test_manual_var_with_value(self): + var = self._make_var(value="2026-06-01") + result = _build_values(["date"], {"date": var}, {"user": MagicMock()}) + self.assertEqual(result["date"], "2026-06-01") + + def test_manual_var_required_empty_raises(self): + var = self._make_var(value="", is_required=True) + with self.assertRaises(MissingValue): + _build_values(["date"], {"date": var}, {"user": MagicMock()}) + + def test_manual_var_not_required_empty_returns_empty(self): + var = self._make_var(value="", is_required=False) + result = _build_values(["date"], {"date": var}, {"user": MagicMock()}) + self.assertEqual(result["date"], "") + + @patch("coldfront_notifications.resolvers.resolve", return_value="alice@test.com") + def test_query_var_delegates_to_resolve(self, mock_resolve): + var = self._make_var(source="query", resolver_key="user.email") + ctx = {"user": MagicMock(email="alice@test.com")} + result = _build_values(["email"], {"email": var}, ctx) + self.assertEqual(result["email"], "alice@test.com") + mock_resolve.assert_called_once_with("user.email", ctx) + + @patch("coldfront_notifications.resolvers.resolve", side_effect=MissingValue("no value")) + def test_query_var_resolution_failure_includes_context(self, _): + var = self._make_var(source="query", resolver_key="project.title") + user = MagicMock() + user.email = "bob@test.com" + with self.assertRaises(MissingValue) as ctx: + _build_values(["title"], {"title": var}, {"user": user}) + self.assertIn("project.title", str(ctx.exception)) + self.assertIn("bob@test.com", str(ctx.exception)) + + def test_unknown_token_raises(self): + with self.assertRaises(MissingValue) as ctx: + _build_values(["nonexistent"], {}, {"user": MagicMock()}) + self.assertIn("nonexistent", str(ctx.exception)) + + +class TestSendWithRetry(unittest.TestCase): + + def _make_msg_and_conn(self): + msg = MagicMock() + conn = MagicMock() + return msg, conn + + def test_success_first_try(self): + msg, conn = self._make_msg_and_conn() + result = _send_with_retry(msg, conn, max_retries=2, base_delay=0) + self.assertIsNone(result) + msg.send.assert_called_once() + + def test_permanent_error_returns_immediately(self): + msg, conn = self._make_msg_and_conn() + exc = smtplib.SMTPResponseException(550, "Mailbox not found") + msg.send.side_effect = exc + result = _send_with_retry(msg, conn, max_retries=2, base_delay=0) + self.assertIs(result, exc) + self.assertEqual(msg.send.call_count, 1) + + @patch("coldfront_notifications.tasks.time.sleep") + def test_transient_then_success(self, mock_sleep): + msg, conn = self._make_msg_and_conn() + exc = smtplib.SMTPResponseException(421, "Try again") + msg.send.side_effect = [exc, None] + result = _send_with_retry(msg, conn, max_retries=2, base_delay=0.01) + self.assertIsNone(result) + self.assertEqual(msg.send.call_count, 2) + + @patch("coldfront_notifications.tasks.time.sleep") + def test_transient_exhausts_retries(self, mock_sleep): + msg, conn = self._make_msg_and_conn() + exc = smtplib.SMTPResponseException(450, "Busy") + msg.send.side_effect = exc + result = _send_with_retry(msg, conn, max_retries=2, base_delay=0.01) + self.assertIs(result, exc) + self.assertEqual(msg.send.call_count, 3) + + @patch("coldfront_notifications.tasks.time.sleep") + def test_disconnect_reopens_connection(self, mock_sleep): + msg, conn = self._make_msg_and_conn() + exc = smtplib.SMTPServerDisconnected("gone") + msg.send.side_effect = [exc, None] + result = _send_with_retry(msg, conn, max_retries=2, base_delay=0.01) + self.assertIsNone(result) + conn.close.assert_called() + conn.open.assert_called() + + @patch("coldfront_notifications.tasks.time.sleep") + def test_reopen_failure_returns_exception(self, mock_sleep): + msg, conn = self._make_msg_and_conn() + msg.send.side_effect = smtplib.SMTPServerDisconnected("gone") + reopen_exc = ConnectionError("cannot reopen") + conn.open.side_effect = reopen_exc + result = _send_with_retry(msg, conn, max_retries=2, base_delay=0.01) + self.assertIs(result, reopen_exc) + + @patch("coldfront_notifications.tasks.time.sleep") + def test_close_failure_is_swallowed(self, mock_sleep): + """connection.close() throwing should not prevent the retry.""" + msg, conn = self._make_msg_and_conn() + exc = smtplib.SMTPResponseException(421, "Try again") + msg.send.side_effect = [exc, None] + conn.close.side_effect = OSError("close failed") + result = _send_with_retry(msg, conn, max_retries=2, base_delay=0.01) + self.assertIsNone(result) + self.assertEqual(msg.send.call_count, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/coldfront_notifications/tests/test_unit.py b/coldfront_notifications/tests/test_unit.py deleted file mode 100644 index 8e805cd..0000000 --- a/coldfront_notifications/tests/test_unit.py +++ /dev/null @@ -1,726 +0,0 @@ -""" -Unit tests for coldfront_notifications plugin. - -These tests exercise pure logic functions and do NOT require a database. -Run with: python -m pytest coldfront_notifications/tests/test_unit.py -""" -import re -import smtplib -import unittest -from unittest.mock import MagicMock, patch - - -# --------------------------------------------------------------------------- -# We need Django settings configured before importing plugin modules. -# --------------------------------------------------------------------------- -import django -from django.conf import settings - -if not settings.configured: - settings.configure( - INSTALLED_APPS=[ - "django.contrib.contenttypes", - "django.contrib.auth", - "coldfront_notifications", - ], - DATABASES={ - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": ":memory:", - } - }, - DEFAULT_AUTO_FIELD="django.db.models.BigAutoField", - ) - django.setup() - - -from coldfront_notifications.resolvers import ( - QUERY_CHOICES, - QUERY_RESOLVERS, - QUERY_SCOPES, - MissingValue, - _fmt, - _need, - _primary_resource, - _scope_for, - resolve, -) -from coldfront_notifications.tasks import ( - TOKEN_RE as TASKS_TOKEN_RE, - _build_values, - _is_transient, - _render, - _send_with_retry, -) -from coldfront_notifications.validators import ( - TOKEN_RE as VALIDATORS_TOKEN_RE, - _extract_tokens, - _required_scope, - validate_campaign, -) - - -# =========================================================================== -# resolvers.py tests -# =========================================================================== - - -class TestFmt(unittest.TestCase): - """Tests for _fmt().""" - - def test_valid_string(self): - self.assertEqual(_fmt("hello"), "hello") - - def test_strips_whitespace(self): - self.assertEqual(_fmt(" world "), "world") - - def test_none_raises_missing(self): - with self.assertRaises(MissingValue): - _fmt(None) - - def test_empty_string_raises_missing(self): - with self.assertRaises(MissingValue): - _fmt("") - - def test_whitespace_only_raises_missing(self): - with self.assertRaises(MissingValue): - _fmt(" ") - - def test_coerces_to_string(self): - self.assertEqual(_fmt(42), "42") - - -class TestNeed(unittest.TestCase): - """Tests for _need().""" - - def test_present_key_returns_value(self): - ctx = {"user": "alice"} - self.assertEqual(_need(ctx, "user"), "alice") - - def test_missing_key_raises_missing(self): - ctx = {"user": "alice"} - with self.assertRaises(MissingValue): - _need(ctx, "project") - - def test_none_value_raises_missing(self): - ctx = {"project": None} - with self.assertRaises(MissingValue): - _need(ctx, "project") - - -class TestResolve(unittest.TestCase): - """Tests for resolve().""" - - def test_valid_resolver_key_dispatches(self): - user = MagicMock() - user.username = "jdoe" - ctx = {"user": user, "project": None, "allocation": None} - result = resolve("user.username", ctx) - self.assertEqual(result, "jdoe") - - def test_unknown_key_raises_missing(self): - ctx = {"user": MagicMock(), "project": None, "allocation": None} - with self.assertRaises(MissingValue): - resolve("nonexistent.key", ctx) - - def test_attribute_error_raises_missing(self): - # user with no .username attribute at all - user = MagicMock(spec=[]) - ctx = {"user": user, "project": None, "allocation": None} - with self.assertRaises(MissingValue): - resolve("user.username", ctx) - - def test_resolve_user_email(self): - user = MagicMock() - user.email = "alice@example.com" - ctx = {"user": user, "project": None, "allocation": None} - self.assertEqual(resolve("user.email", ctx), "alice@example.com") - - def test_resolve_user_full_name(self): - user = MagicMock() - user.get_full_name.return_value = "Jane Doe" - ctx = {"user": user, "project": None, "allocation": None} - self.assertEqual(resolve("user.full_name", ctx), "Jane Doe") - - def test_resolve_user_full_name_falls_back_to_username(self): - user = MagicMock() - user.get_full_name.return_value = "" - user.username = "jdoe" - ctx = {"user": user, "project": None, "allocation": None} - self.assertEqual(resolve("user.full_name", ctx), "jdoe") - - -class TestQueryChoicesConsistency(unittest.TestCase): - """Tests that QUERY_CHOICES, QUERY_RESOLVERS, and QUERY_SCOPES are consistent.""" - - def test_choices_keys_match_resolvers(self): - choice_keys = {k for k, _label, _group in QUERY_CHOICES} - resolver_keys = set(QUERY_RESOLVERS.keys()) - self.assertEqual(choice_keys, resolver_keys) - - def test_scopes_maps_every_choice(self): - choice_keys = {k for k, _label, _group in QUERY_CHOICES} - scope_keys = set(QUERY_SCOPES.keys()) - self.assertEqual(choice_keys, scope_keys) - - -class TestScopeFor(unittest.TestCase): - """Tests for _scope_for() classification.""" - - def test_user_scope(self): - self.assertEqual(_scope_for("user.username"), "user") - self.assertEqual(_scope_for("user.email"), "user") - self.assertEqual(_scope_for("user.ifxid"), "user") - - def test_project_scope(self): - self.assertEqual(_scope_for("project.title"), "project") - self.assertEqual(_scope_for("project.pi.email"), "project") - self.assertEqual(_scope_for("projectuser.role.name"), "project") - - def test_allocation_scope(self): - self.assertEqual(_scope_for("allocation.id"), "allocation") - self.assertEqual(_scope_for("allocation.status.name"), "allocation") - - def test_resource_scope(self): - self.assertEqual(_scope_for("resource.name"), "allocation") - self.assertEqual(_scope_for("resource.description"), "allocation") - - -# =========================================================================== -# validators.py tests -# =========================================================================== - - -class TestExtractTokens(unittest.TestCase): - """Tests for _extract_tokens().""" - - def test_no_tokens(self): - self.assertEqual(_extract_tokens("Hello world", "No tokens here"), []) - - def test_one_token_in_subject(self): - self.assertEqual(_extract_tokens("Hello {{name}}", "body"), ["name"]) - - def test_one_token_in_body(self): - self.assertEqual(_extract_tokens("subj", "Hello {{name}}"), ["name"]) - - def test_duplicates_deduped_order_preserved(self): - result = _extract_tokens("{{a}} {{b}} {{a}}", "{{b}} {{c}}") - self.assertEqual(result, ["a", "b", "c"]) - - def test_nested_braces_only_valid_matches(self): - # {{{foo}}} should still find 'foo' via \w+ inside {{ }} - result = _extract_tokens("{{{foo}}}", "") - self.assertEqual(result, ["foo"]) - - def test_invalid_tokens_ignored(self): - # Tokens with spaces or special chars shouldn't match \w+ - result = _extract_tokens("{{hello world}} {{good}}", "") - self.assertEqual(result, ["good"]) - - def test_empty_strings(self): - self.assertEqual(_extract_tokens("", ""), []) - - def test_none_subject(self): - self.assertEqual(_extract_tokens(None, "{{x}}"), ["x"]) - - def test_multiple_tokens(self): - result = _extract_tokens("{{first}} {{last}}", "Dear {{first}}") - self.assertEqual(result, ["first", "last"]) - - -class TestRequiredScope(unittest.TestCase): - """Tests for _required_scope().""" - - def _make_var(self, resolver_key, source="query"): - v = MagicMock() - v.source = "query" - v.SOURCE_QUERY = "query" - v.resolver_key = resolver_key - return v - - def _make_manual_var(self): - v = MagicMock() - v.source = "manual" - v.SOURCE_QUERY = "query" - v.resolver_key = None - return v - - def test_no_vars_returns_user(self): - self.assertEqual(_required_scope([]), "user") - - def test_only_user_vars_returns_user(self): - vars_list = [self._make_var("user.email"), self._make_var("user.username")] - self.assertEqual(_required_scope(vars_list), "user") - - def test_project_vars_returns_project(self): - vars_list = [self._make_var("project.title")] - self.assertEqual(_required_scope(vars_list), "project") - - def test_allocation_vars_returns_allocation(self): - vars_list = [self._make_var("allocation.id")] - self.assertEqual(_required_scope(vars_list), "allocation") - - def test_resource_vars_returns_allocation(self): - vars_list = [self._make_var("resource.name")] - self.assertEqual(_required_scope(vars_list), "allocation") - - def test_mix_highest_wins_allocation(self): - vars_list = [ - self._make_var("user.email"), - self._make_var("project.title"), - self._make_var("allocation.id"), - ] - self.assertEqual(_required_scope(vars_list), "allocation") - - def test_mix_project_beats_user(self): - vars_list = [ - self._make_var("user.email"), - self._make_var("project.title"), - ] - self.assertEqual(_required_scope(vars_list), "project") - - def test_manual_vars_dont_affect_scope(self): - vars_list = [self._make_manual_var()] - self.assertEqual(_required_scope(vars_list), "user") - - def test_manual_with_allocation_still_allocation(self): - vars_list = [self._make_manual_var(), self._make_var("allocation.id")] - self.assertEqual(_required_scope(vars_list), "allocation") - - -# =========================================================================== -# tasks.py tests -# =========================================================================== - - -class TestRender(unittest.TestCase): - """Tests for _render().""" - - def test_no_tokens_passthrough(self): - self.assertEqual(_render("Hello world", {}), "Hello world") - - def test_one_token_substituted(self): - self.assertEqual(_render("Hello {{name}}", {"name": "Alice"}), "Hello Alice") - - def test_missing_key_left_as_literal(self): - self.assertEqual(_render("Hello {{name}}", {}), "Hello {{name}}") - - def test_multiple_tokens_all_substituted(self): - template = "Dear {{first}} {{last}}, welcome to {{org}}." - values = {"first": "Jane", "last": "Doe", "org": "FASRC"} - self.assertEqual(_render(template, values), "Dear Jane Doe, welcome to FASRC.") - - def test_repeated_token_substituted(self): - self.assertEqual( - _render("{{x}} and {{x}}", {"x": "hi"}), - "hi and hi", - ) - - def test_none_template_returns_empty(self): - self.assertEqual(_render(None, {"foo": "bar"}), "") - - def test_empty_template(self): - self.assertEqual(_render("", {"foo": "bar"}), "") - - def test_partial_match(self): - # Only some tokens have values - result = _render("{{a}} {{b}}", {"a": "X"}) - self.assertEqual(result, "X {{b}}") - - -class TestIsTransient(unittest.TestCase): - """Tests for _is_transient().""" - - def test_smtp_421_is_transient(self): - exc = smtplib.SMTPResponseException(421, "Service not available") - self.assertTrue(_is_transient(exc)) - - def test_smtp_450_is_transient(self): - exc = smtplib.SMTPResponseException(450, "Mailbox busy") - self.assertTrue(_is_transient(exc)) - - def test_smtp_499_is_transient(self): - exc = smtplib.SMTPResponseException(499, "Custom 4xx") - self.assertTrue(_is_transient(exc)) - - def test_smtp_550_is_not_transient(self): - exc = smtplib.SMTPResponseException(550, "Mailbox not found") - self.assertFalse(_is_transient(exc)) - - def test_smtp_500_is_not_transient(self): - exc = smtplib.SMTPResponseException(500, "Syntax error") - self.assertFalse(_is_transient(exc)) - - def test_smtp_354_is_not_transient(self): - exc = smtplib.SMTPResponseException(354, "Start mail input") - self.assertFalse(_is_transient(exc)) - - def test_smtp_server_disconnected_is_transient(self): - exc = smtplib.SMTPServerDisconnected("Connection lost") - self.assertTrue(_is_transient(exc)) - - def test_connection_error_is_transient(self): - exc = ConnectionError("refused") - self.assertTrue(_is_transient(exc)) - - def test_connection_reset_is_transient(self): - exc = ConnectionResetError("reset by peer") - self.assertTrue(_is_transient(exc)) - - def test_value_error_is_not_transient(self): - exc = ValueError("bad value") - self.assertFalse(_is_transient(exc)) - - def test_runtime_error_is_not_transient(self): - exc = RuntimeError("something broke") - self.assertFalse(_is_transient(exc)) - - -# =========================================================================== -# conf.py tests -# =========================================================================== - - -class TestConfDefaults(unittest.TestCase): - """Tests that conf.py exports sensible defaults.""" - - def test_batch_size_default(self): - from coldfront_notifications.conf import BATCH_SIZE - self.assertEqual(BATCH_SIZE, 50) - - def test_batch_delay_default(self): - from coldfront_notifications.conf import BATCH_DELAY - self.assertEqual(BATCH_DELAY, 2.0) - - def test_max_retries_default(self): - from coldfront_notifications.conf import MAX_RETRIES - self.assertEqual(MAX_RETRIES, 3) - - def test_retry_delay_default(self): - from coldfront_notifications.conf import RETRY_DELAY - self.assertEqual(RETRY_DELAY, 1.0) - - def test_preview_page_size_default(self): - from coldfront_notifications.conf import PREVIEW_PAGE_SIZE - self.assertEqual(PREVIEW_PAGE_SIZE, 50) - - def test_preview_render_limit_default(self): - from coldfront_notifications.conf import PREVIEW_RENDER_LIMIT - self.assertEqual(PREVIEW_RENDER_LIMIT, 10) - - -# =========================================================================== -# TOKEN_RE pattern tests (shared pattern used in tasks and validators) -# =========================================================================== - - -class TestTokenRegex(unittest.TestCase): - """Tests for the TOKEN_RE pattern used across modules.""" - - def test_matches_simple_token(self): - m = TASKS_TOKEN_RE.search("{{foo}}") - self.assertIsNotNone(m) - self.assertEqual(m.group(1), "foo") - - def test_matches_underscore_token(self): - m = TASKS_TOKEN_RE.search("{{first_name}}") - self.assertIsNotNone(m) - self.assertEqual(m.group(1), "first_name") - - def test_no_match_spaces(self): - m = TASKS_TOKEN_RE.search("{{ foo }}") - self.assertIsNone(m) - - def test_no_match_special_chars(self): - m = TASKS_TOKEN_RE.search("{{foo-bar}}") - self.assertIsNone(m) - - def test_validators_and_tasks_same_pattern(self): - self.assertEqual(TASKS_TOKEN_RE.pattern, VALIDATORS_TOKEN_RE.pattern) - - -# =========================================================================== -# resolvers.py — _primary_resource + resolve exception handling -# =========================================================================== - - -class TestPrimaryResource(unittest.TestCase): - - def test_none_allocation_returns_none(self): - self.assertIsNone(_primary_resource(None)) - - def test_returns_get_parent_resource(self): - alloc = MagicMock() - alloc.get_parent_resource = "StorageA" - self.assertEqual(_primary_resource(alloc), "StorageA") - - -class TestResolveExceptionHandling(unittest.TestCase): - - def test_arbitrary_exception_becomes_missing_value(self): - """resolve() must not let random exceptions (TypeError, KeyError, etc.) - bubble up — they should all collapse to MissingValue.""" - user = MagicMock() - # Force .username to raise TypeError when _fmt calls str() - type(user).username = property(lambda self: (_ for _ in ()).throw(TypeError("boom"))) - ctx = {"user": user, "project": None, "allocation": None} - with self.assertRaises(MissingValue): - resolve("user.username", ctx) - - -# =========================================================================== -# tasks.py — _build_values -# =========================================================================== - - -class TestBuildValues(unittest.TestCase): - - def _make_var(self, source="manual", resolver_key="", value="", is_required=True): - v = MagicMock() - v.source = source - v.SOURCE_MANUAL = "manual" - v.resolver_key = resolver_key - v.value = value - v.is_required = is_required - v.key = "test_var" - return v - - def test_manual_var_with_value(self): - var = self._make_var(value="2026-06-01") - result = _build_values(["date"], {"date": var}, {"user": MagicMock()}) - self.assertEqual(result["date"], "2026-06-01") - - def test_manual_var_required_empty_raises(self): - var = self._make_var(value="", is_required=True) - with self.assertRaises(MissingValue): - _build_values(["date"], {"date": var}, {"user": MagicMock()}) - - def test_manual_var_not_required_empty_returns_empty(self): - var = self._make_var(value="", is_required=False) - result = _build_values(["date"], {"date": var}, {"user": MagicMock()}) - self.assertEqual(result["date"], "") - - @patch("coldfront_notifications.resolvers.resolve", return_value="alice@test.com") - def test_query_var_delegates_to_resolve(self, mock_resolve): - var = self._make_var(source="query", resolver_key="user.email") - ctx = {"user": MagicMock(email="alice@test.com")} - result = _build_values(["email"], {"email": var}, ctx) - self.assertEqual(result["email"], "alice@test.com") - mock_resolve.assert_called_once_with("user.email", ctx) - - @patch("coldfront_notifications.resolvers.resolve", side_effect=MissingValue("no value")) - def test_query_var_resolution_failure_includes_context(self, _): - var = self._make_var(source="query", resolver_key="project.title") - user = MagicMock() - user.email = "bob@test.com" - with self.assertRaises(MissingValue) as ctx: - _build_values(["title"], {"title": var}, {"user": user}) - self.assertIn("project.title", str(ctx.exception)) - self.assertIn("bob@test.com", str(ctx.exception)) - - def test_unknown_token_raises(self): - with self.assertRaises(MissingValue) as ctx: - _build_values(["nonexistent"], {}, {"user": MagicMock()}) - self.assertIn("nonexistent", str(ctx.exception)) - - -# =========================================================================== -# tasks.py — _send_with_retry -# =========================================================================== - - -class TestSendWithRetry(unittest.TestCase): - - def _make_msg_and_conn(self): - msg = MagicMock() - conn = MagicMock() - return msg, conn - - def test_success_first_try(self): - msg, conn = self._make_msg_and_conn() - result = _send_with_retry(msg, conn, max_retries=2, base_delay=0) - self.assertIsNone(result) - msg.send.assert_called_once() - - def test_permanent_error_returns_immediately(self): - msg, conn = self._make_msg_and_conn() - exc = smtplib.SMTPResponseException(550, "Mailbox not found") - msg.send.side_effect = exc - result = _send_with_retry(msg, conn, max_retries=2, base_delay=0) - self.assertIs(result, exc) - # Should NOT retry on 5xx - self.assertEqual(msg.send.call_count, 1) - - @patch("coldfront_notifications.tasks.time.sleep") - def test_transient_then_success(self, mock_sleep): - msg, conn = self._make_msg_and_conn() - exc = smtplib.SMTPResponseException(421, "Try again") - msg.send.side_effect = [exc, None] # fail once, then succeed - result = _send_with_retry(msg, conn, max_retries=2, base_delay=0.01) - self.assertIsNone(result) - self.assertEqual(msg.send.call_count, 2) - - @patch("coldfront_notifications.tasks.time.sleep") - def test_transient_exhausts_retries(self, mock_sleep): - msg, conn = self._make_msg_and_conn() - exc = smtplib.SMTPResponseException(450, "Busy") - msg.send.side_effect = exc # always fails - result = _send_with_retry(msg, conn, max_retries=2, base_delay=0.01) - self.assertIs(result, exc) - # 1 initial + 2 retries = 3 attempts - self.assertEqual(msg.send.call_count, 3) - - @patch("coldfront_notifications.tasks.time.sleep") - def test_disconnect_reopens_connection(self, mock_sleep): - msg, conn = self._make_msg_and_conn() - exc = smtplib.SMTPServerDisconnected("gone") - msg.send.side_effect = [exc, None] - result = _send_with_retry(msg, conn, max_retries=2, base_delay=0.01) - self.assertIsNone(result) - conn.close.assert_called() - conn.open.assert_called() - - @patch("coldfront_notifications.tasks.time.sleep") - def test_reopen_failure_returns_exception(self, mock_sleep): - msg, conn = self._make_msg_and_conn() - msg.send.side_effect = smtplib.SMTPServerDisconnected("gone") - reopen_exc = ConnectionError("cannot reopen") - conn.open.side_effect = reopen_exc - result = _send_with_retry(msg, conn, max_retries=2, base_delay=0.01) - self.assertIs(result, reopen_exc) - - @patch("coldfront_notifications.tasks.time.sleep") - def test_close_failure_is_swallowed(self, mock_sleep): - """connection.close() throwing should not prevent the retry.""" - msg, conn = self._make_msg_and_conn() - exc = smtplib.SMTPResponseException(421, "Try again") - msg.send.side_effect = [exc, None] - conn.close.side_effect = OSError("close failed") - result = _send_with_retry(msg, conn, max_retries=2, base_delay=0.01) - self.assertIsNone(result) - self.assertEqual(msg.send.call_count, 2) - - -# =========================================================================== -# validators.py — validate_campaign -# =========================================================================== - - -class TestValidateCampaign(unittest.TestCase): - - PATCH_ENUM = "coldfront_notifications.validators.enumerate_recipients_deduped" - PATCH_RESOLVE = "coldfront_notifications.validators.resolve" - - def _patch_nv_objects(self, return_value): - """Patch the local `from .models import NotificationVariable` inside - validate_campaign so .objects.filter() returns our fake vars.""" - patcher = patch( - "coldfront_notifications.models.NotificationVariable.objects" - ) - mock_objects = patcher.start() - mock_objects.filter.return_value = return_value - self.addCleanup(patcher.stop) - - def _make_var(self, key, source="query", resolver_key="user.email", - value="", is_required=True): - v = MagicMock() - v.key = key - v.source = source - v.SOURCE_QUERY = "query" - v.SOURCE_MANUAL = "manual" - v.resolver_key = resolver_key - v.value = value - v.is_required = is_required - return v - - def _make_user(self, pk, username, email): - u = MagicMock() - u.pk = pk - u.username = username - u.email = email - return u - - @patch(PATCH_ENUM) - def test_all_tokens_resolve_cleanly(self, mock_enum): - var = self._make_var("name", resolver_key="user.full_name") - self._patch_nv_objects([var]) - - user = self._make_user(1, "alice", "alice@test.com") - mock_enum.return_value = [(user, None, None)] - - with patch(self.PATCH_RESOLVE, return_value="Alice"): - result = validate_campaign("Hi {{name}}", "body", {}, {}) - - self.assertEqual(result["errors"], []) - self.assertEqual(result["missing_tokens"], []) - self.assertEqual(result["user_count"], 1) - self.assertEqual(result["email_count"], 1) - - @patch(PATCH_ENUM) - def test_unknown_token_in_missing_tokens(self, mock_enum): - self._patch_nv_objects([]) # no vars match - mock_enum.return_value = [] - result = validate_campaign("Hi {{bogus}}", "body", {}, {}) - self.assertIn("bogus", result["missing_tokens"]) - - @patch(PATCH_ENUM) - def test_manual_var_empty_value_reported(self, mock_enum): - var = self._make_var("date", source="manual", value="", is_required=True) - self._patch_nv_objects([var]) - - user = self._make_user(1, "bob", "bob@test.com") - mock_enum.return_value = [(user, None, None)] - - result = validate_campaign("Due {{date}}", "body", {}, {}) - self.assertEqual(len(result["errors"]), 1) - self.assertEqual(result["errors"][0]["token"], "date") - - @patch(PATCH_ENUM) - def test_query_var_resolution_failure_reported(self, mock_enum): - var = self._make_var("title", resolver_key="project.title") - self._patch_nv_objects([var]) - - user = self._make_user(1, "carol", "carol@test.com") - mock_enum.return_value = [(user, None, None)] - - with patch(self.PATCH_RESOLVE, side_effect=MissingValue()): - result = validate_campaign("Re: {{title}}", "body", {}, {}) - - self.assertEqual(len(result["errors"]), 1) - self.assertIn("project.title", result["errors"][0]["reason"]) - - @patch(PATCH_ENUM) - def test_multi_email_users_reported(self, mock_enum): - var = self._make_var("ptitle", resolver_key="project.title") - self._patch_nv_objects([var]) - - user = self._make_user(1, "alice", "alice@test.com") - proj1, proj2 = MagicMock(), MagicMock() - # alice appears on 2 projects → 2 tuples - mock_enum.return_value = [(user, proj1, None), (user, proj2, None)] - - with patch(self.PATCH_RESOLVE, return_value="Proj"): - result = validate_campaign("{{ptitle}}", "body", {}, {}) - - self.assertEqual(result["email_count"], 2) - self.assertEqual(result["user_count"], 1) - self.assertEqual(len(result["multi_emails"]), 1) - self.assertEqual(result["multi_emails"][0]["username"], "alice") - self.assertEqual(result["multi_emails"][0]["count"], 2) - - @patch(PATCH_ENUM) - def test_scope_propagated_correctly(self, mock_enum): - var = self._make_var("aid", resolver_key="allocation.id") - self._patch_nv_objects([var]) - mock_enum.return_value = [] - - result = validate_campaign("Alloc {{aid}}", "body", {}, {}) - self.assertEqual(result["scope"], "allocation") - # Verify enumerate was called with allocation scope - mock_enum.assert_called_once() - call_args = mock_enum.call_args - self.assertEqual(call_args[0][1], "allocation") - - -if __name__ == "__main__": - unittest.main() diff --git a/coldfront_notifications/tests/test_validators.py b/coldfront_notifications/tests/test_validators.py new file mode 100644 index 0000000..dde55cf --- /dev/null +++ b/coldfront_notifications/tests/test_validators.py @@ -0,0 +1,250 @@ +"""Unit tests for coldfront_notifications.validators.""" +import unittest +from unittest.mock import MagicMock, patch + +import coldfront_notifications.tests.django_setup # noqa: F401 + +from coldfront_notifications.resolvers import MissingValue +from coldfront_notifications.tasks import TOKEN_RE as TASKS_TOKEN_RE +from coldfront_notifications.validators import ( + TOKEN_RE as VALIDATORS_TOKEN_RE, + _extract_tokens, + _required_scope, + validate_campaign, +) + + +class TestExtractTokens(unittest.TestCase): + """Tests for _extract_tokens().""" + + def test_no_tokens(self): + self.assertEqual(_extract_tokens("Hello world", "No tokens here"), []) + + def test_one_token_in_subject(self): + self.assertEqual(_extract_tokens("Hello {{name}}", "body"), ["name"]) + + def test_one_token_in_body(self): + self.assertEqual(_extract_tokens("subj", "Hello {{name}}"), ["name"]) + + def test_duplicates_deduped_order_preserved(self): + result = _extract_tokens("{{a}} {{b}} {{a}}", "{{b}} {{c}}") + self.assertEqual(result, ["a", "b", "c"]) + + def test_nested_braces_only_valid_matches(self): + result = _extract_tokens("{{{foo}}}", "") + self.assertEqual(result, ["foo"]) + + def test_invalid_tokens_ignored(self): + result = _extract_tokens("{{hello world}} {{good}}", "") + self.assertEqual(result, ["good"]) + + def test_empty_strings(self): + self.assertEqual(_extract_tokens("", ""), []) + + def test_none_subject(self): + self.assertEqual(_extract_tokens(None, "{{x}}"), ["x"]) + + def test_multiple_tokens(self): + result = _extract_tokens("{{first}} {{last}}", "Dear {{first}}") + self.assertEqual(result, ["first", "last"]) + + +class TestRequiredScope(unittest.TestCase): + """Tests for _required_scope().""" + + def _make_var(self, resolver_key, source="query"): + v = MagicMock() + v.source = "query" + v.SOURCE_QUERY = "query" + v.resolver_key = resolver_key + return v + + def _make_manual_var(self): + v = MagicMock() + v.source = "manual" + v.SOURCE_QUERY = "query" + v.resolver_key = None + return v + + def test_no_vars_returns_user(self): + self.assertEqual(_required_scope([]), "user") + + def test_only_user_vars_returns_user(self): + vars_list = [self._make_var("user.email"), self._make_var("user.username")] + self.assertEqual(_required_scope(vars_list), "user") + + def test_project_vars_returns_project(self): + vars_list = [self._make_var("project.title")] + self.assertEqual(_required_scope(vars_list), "project") + + def test_allocation_vars_returns_allocation(self): + vars_list = [self._make_var("allocation.id")] + self.assertEqual(_required_scope(vars_list), "allocation") + + def test_resource_vars_returns_allocation(self): + vars_list = [self._make_var("resource.name")] + self.assertEqual(_required_scope(vars_list), "allocation") + + def test_mix_highest_wins_allocation(self): + vars_list = [ + self._make_var("user.email"), + self._make_var("project.title"), + self._make_var("allocation.id"), + ] + self.assertEqual(_required_scope(vars_list), "allocation") + + def test_mix_project_beats_user(self): + vars_list = [ + self._make_var("user.email"), + self._make_var("project.title"), + ] + self.assertEqual(_required_scope(vars_list), "project") + + def test_manual_vars_dont_affect_scope(self): + vars_list = [self._make_manual_var()] + self.assertEqual(_required_scope(vars_list), "user") + + def test_manual_with_allocation_still_allocation(self): + vars_list = [self._make_manual_var(), self._make_var("allocation.id")] + self.assertEqual(_required_scope(vars_list), "allocation") + + +class TestTokenRegex(unittest.TestCase): + """Tests for the TOKEN_RE pattern used across modules.""" + + def test_matches_simple_token(self): + m = TASKS_TOKEN_RE.search("{{foo}}") + self.assertIsNotNone(m) + self.assertEqual(m.group(1), "foo") + + def test_matches_underscore_token(self): + m = TASKS_TOKEN_RE.search("{{first_name}}") + self.assertIsNotNone(m) + self.assertEqual(m.group(1), "first_name") + + def test_no_match_spaces(self): + m = TASKS_TOKEN_RE.search("{{ foo }}") + self.assertIsNone(m) + + def test_no_match_special_chars(self): + m = TASKS_TOKEN_RE.search("{{foo-bar}}") + self.assertIsNone(m) + + def test_validators_and_tasks_same_pattern(self): + self.assertEqual(TASKS_TOKEN_RE.pattern, VALIDATORS_TOKEN_RE.pattern) + + +class TestValidateCampaign(unittest.TestCase): + + PATCH_ENUM = "coldfront_notifications.validators.enumerate_recipients_deduped" + PATCH_RESOLVE = "coldfront_notifications.validators.resolve" + + def _patch_nv_objects(self, return_value): + patcher = patch( + "coldfront_notifications.models.NotificationVariable.objects" + ) + mock_objects = patcher.start() + mock_objects.filter.return_value = return_value + self.addCleanup(patcher.stop) + + def _make_var(self, key, source="query", resolver_key="user.email", + value="", is_required=True): + v = MagicMock() + v.key = key + v.source = source + v.SOURCE_QUERY = "query" + v.SOURCE_MANUAL = "manual" + v.resolver_key = resolver_key + v.value = value + v.is_required = is_required + return v + + def _make_user(self, pk, username, email): + u = MagicMock() + u.pk = pk + u.username = username + u.email = email + return u + + @patch(PATCH_ENUM) + def test_all_tokens_resolve_cleanly(self, mock_enum): + var = self._make_var("name", resolver_key="user.full_name") + self._patch_nv_objects([var]) + + user = self._make_user(1, "alice", "alice@test.com") + mock_enum.return_value = [(user, None, None)] + + with patch(self.PATCH_RESOLVE, return_value="Alice"): + result = validate_campaign("Hi {{name}}", "body", {}, {}) + + self.assertEqual(result["errors"], []) + self.assertEqual(result["missing_tokens"], []) + self.assertEqual(result["user_count"], 1) + self.assertEqual(result["email_count"], 1) + + @patch(PATCH_ENUM) + def test_unknown_token_in_missing_tokens(self, mock_enum): + self._patch_nv_objects([]) + mock_enum.return_value = [] + result = validate_campaign("Hi {{bogus}}", "body", {}, {}) + self.assertIn("bogus", result["missing_tokens"]) + + @patch(PATCH_ENUM) + def test_manual_var_empty_value_reported(self, mock_enum): + var = self._make_var("date", source="manual", value="", is_required=True) + self._patch_nv_objects([var]) + + user = self._make_user(1, "bob", "bob@test.com") + mock_enum.return_value = [(user, None, None)] + + result = validate_campaign("Due {{date}}", "body", {}, {}) + self.assertEqual(len(result["errors"]), 1) + self.assertEqual(result["errors"][0]["token"], "date") + + @patch(PATCH_ENUM) + def test_query_var_resolution_failure_reported(self, mock_enum): + var = self._make_var("title", resolver_key="project.title") + self._patch_nv_objects([var]) + + user = self._make_user(1, "carol", "carol@test.com") + mock_enum.return_value = [(user, None, None)] + + with patch(self.PATCH_RESOLVE, side_effect=MissingValue()): + result = validate_campaign("Re: {{title}}", "body", {}, {}) + + self.assertEqual(len(result["errors"]), 1) + self.assertIn("project.title", result["errors"][0]["reason"]) + + @patch(PATCH_ENUM) + def test_multi_email_users_reported(self, mock_enum): + var = self._make_var("ptitle", resolver_key="project.title") + self._patch_nv_objects([var]) + + user = self._make_user(1, "alice", "alice@test.com") + proj1, proj2 = MagicMock(), MagicMock() + mock_enum.return_value = [(user, proj1, None), (user, proj2, None)] + + with patch(self.PATCH_RESOLVE, return_value="Proj"): + result = validate_campaign("{{ptitle}}", "body", {}, {}) + + self.assertEqual(result["email_count"], 2) + self.assertEqual(result["user_count"], 1) + self.assertEqual(len(result["multi_emails"]), 1) + self.assertEqual(result["multi_emails"][0]["username"], "alice") + self.assertEqual(result["multi_emails"][0]["count"], 2) + + @patch(PATCH_ENUM) + def test_scope_propagated_correctly(self, mock_enum): + var = self._make_var("aid", resolver_key="allocation.id") + self._patch_nv_objects([var]) + mock_enum.return_value = [] + + result = validate_campaign("Alloc {{aid}}", "body", {}, {}) + self.assertEqual(result["scope"], "allocation") + mock_enum.assert_called_once() + call_args = mock_enum.call_args + self.assertEqual(call_args[0][1], "allocation") + + +if __name__ == "__main__": + unittest.main() diff --git a/coldfront_notifications/validators.py b/coldfront_notifications/validators.py index 37f5a6f..74fdabb 100644 --- a/coldfront_notifications/validators.py +++ b/coldfront_notifications/validators.py @@ -53,7 +53,7 @@ def _required_scope(variables) -> str: def validate_campaign(subject: str, body: str, filters: dict, extra_context: dict, - dedupe_users=None) -> dict: + dedupe_users=None, scope_override=None) -> dict: from .models import NotificationVariable tokens = _extract_tokens(subject, body) @@ -61,7 +61,7 @@ def validate_campaign(subject: str, body: str, filters: dict, extra_context: dic missing_tokens = [t for t in tokens if t not in vars_by_key] used_vars = [vars_by_key[t] for t in tokens if t in vars_by_key] - scope = _required_scope(used_vars) + scope = scope_override or _required_scope(used_vars) errors = [] user_pks = set() diff --git a/coldfront_notifications/views.py b/coldfront_notifications/views.py deleted file mode 100644 index 348d513..0000000 --- a/coldfront_notifications/views.py +++ /dev/null @@ -1,809 +0,0 @@ -import json -import logging - -from django.contrib import messages -from django.contrib.admin.views.decorators import staff_member_required as staff_required -from django.http import JsonResponse -from django.shortcuts import get_object_or_404, redirect, render -from django.utils import timezone -from django.utils.text import slugify -from django.views.decorators.http import require_POST - -from .forms import NotificationTemplateForm, NotificationVariableForm, SenderConfigForm -from .models import ( - NotificationCampaign, - NotificationLog, - NotificationTemplate, - NotificationVariable, - SenderConfig, -) -from .utils import recipient_count -from .validators import validate_campaign - -logger = logging.getLogger(__name__) - - -def _dispatch_send(campaign_pk: int): - """ - Queue the send task on Celery. Falls back to a background thread when - the broker is unreachable so the HTTP response returns immediately. - """ - from .tasks import send_notification_campaign - try: - send_notification_campaign.delay(campaign_pk) - except Exception as exc: - import threading - logger.warning("Celery broker unreachable (%s) — sending in background thread", exc) - - def _run(): - from django import db - try: - db.close_old_connections() - send_notification_campaign(campaign_pk) - except Exception as thread_exc: - logger.error("Background send thread crashed for campaign %s: %s", - campaign_pk, thread_exc, exc_info=True) - - t = threading.Thread(target=_run, daemon=True) - t.start() - - -def _get_filter_context(): - from coldfront.core.project.models import Project, ProjectUserRoleChoice - from coldfront.core.allocation.models import Allocation, AllocationStatusChoice - from coldfront.core.resource.models import Resource - from ifxuser.models import Organization - - return { - "projects": Project.objects.order_by("title"), - "allocations": Allocation.objects.select_related("project").order_by("project__title", "pk"), - "resources": Resource.objects.order_by("name"), - # Dedupe by name — same dept name often exists across multiple org_trees. - "departments": sorted(set( - Organization.objects.filter(rank="department").values_list("name", flat=True) - )), - "statuses": AllocationStatusChoice.objects.values_list("name", flat=True).order_by("name"), - "roles": ProjectUserRoleChoice.objects.values_list("name", flat=True).order_by("name"), - "senders": SenderConfig.objects.all(), - "reply_tos": SenderConfig.objects.all(), - } - - -@staff_required -def dashboard(request): - from datetime import timedelta - thirty_days_ago = timezone.now() - timedelta(days=30) - - recent = NotificationCampaign.objects.select_related("template")[:10] - stats = { - "total_sent": NotificationLog.objects.filter(status="delivered").count(), - "total_failed_30": NotificationCampaign.objects.filter( - status=NotificationCampaign.STATUS_FAILED, - created_at__gte=thirty_days_ago, - ).count(), - "sending_now": NotificationCampaign.objects.filter(status="sending").count(), - "total_campaigns": NotificationCampaign.objects.count(), - } - return render(request, "dashboard.html", { - "recent_campaigns": recent, - **stats, - }) - - -@staff_required -def campaign_list(request): - status_filter = request.GET.get("status", "") - qs = NotificationCampaign.objects.select_related("template") - if status_filter: - qs = qs.filter(status=status_filter) - return render(request, "campaign_list.html", { - "campaigns": qs, - "status_filter": status_filter, - }) - - -@staff_required -def campaign_detail(request, pk): - campaign = get_object_or_404(NotificationCampaign, pk=pk) - logs = campaign.logs.all() - return render(request, "campaign_detail.html", { - "campaign": campaign, - "recipient_logs": logs, - }) - - -@staff_required -def campaign_progress(request, pk): - """Lightweight JSON endpoint for AJAX progress polling.""" - campaign = get_object_or_404(NotificationCampaign, pk=pk) - return JsonResponse({ - "status": campaign.status, - "status_display": campaign.get_status_display(), - "recipient_count": campaign.recipient_count, - "delivered_count": campaign.delivered_count, - "failed_count": campaign.failed_count, - "delivery_pct": campaign.delivery_pct, - }) - - -@staff_required -@require_POST -def resend_failed(request, pk): - campaign = get_object_or_404(NotificationCampaign, pk=pk) - failed_emails = list( - campaign.logs.filter(status=NotificationLog.STATUS_FAILED) - .values_list("email", flat=True) - ) - if not failed_emails: - messages.info(request, "No failed recipients to resend.") - return redirect("notifications:campaign-detail", pk=pk) - - campaign.logs.filter(status=NotificationLog.STATUS_FAILED).update( - status=NotificationLog.STATUS_QUEUED - ) - campaign.status = NotificationCampaign.STATUS_QUEUED - campaign.save(update_fields=["status"]) - - _dispatch_send(campaign.pk) - messages.info(request, "Resend queued — delivery in progress.") - - return redirect("notifications:campaign-detail", pk=pk) - - -@staff_required -def compose(request): - templates = NotificationTemplate.objects.filter(is_deleted=False) - ctx = _get_filter_context() - - templates_json = json.dumps([ - { - "id": t.pk, - "slug": t.slug, - "name": t.name, - "subject": t.subject, - "body": t.body, - "variables": t.variables, - } - for t in templates - ]) - - variables_json = json.dumps([ - { - "key": v.key, - "label": v.label, - "description": v.description, - "example": v.example, - "source": v.source, - "input_widget": v.input_widget, - "is_required": v.is_required, - } - for v in NotificationVariable.objects.filter(is_deleted=False) - ]) - - if request.method == "POST": - action = request.POST.get("action", "send") - - def _filter(field): - # JS submits each filter as one JSON-encoded hidden field. - raw = request.POST.get(field, "") - if not raw: - return [] - try: - val = json.loads(raw) - return val if isinstance(val, list) else [val] - except (ValueError, TypeError): - return request.POST.getlist(field) - - filters = { - "projects": _filter("filter_projects"), - "allocations": _filter("filter_allocations"), - "resources": _filter("filter_resources"), - "departments": _filter("filter_departments"), - "statuses": _filter("filter_statuses"), - "roles": _filter("filter_roles"), - } - extra = [ - line.strip() - for line in request.POST.get("extra_recipients", "").splitlines() - if line.strip() - ] - - subject = request.POST.get("subject", "").strip() - body = request.POST.get("body", "").strip() - sender = request.POST.get("sender", "") - reply_to = request.POST.get("reply_to", "") - tmpl_id = request.POST.get("template_id") - - # Per-user dedupe list (chosen in the preview modal) - try: - dedupe_users = json.loads(request.POST.get("dedupe_users", "[]") or "[]") - if not isinstance(dedupe_users, list): - dedupe_users = [] - except (ValueError, TypeError): - dedupe_users = [] - - if not subject or not body: - messages.error(request, "Subject and body are required.") - ctx.update({ - "templates": templates, - "templates_json": templates_json, - }) - return render(request, "compose.html", ctx) - - # Pre-flight validation; block send (and draft) on hard errors. - if action == "send": - v = validate_campaign(subject, body, filters, {}, - dedupe_users=dedupe_users) - if v["errors"] or v["missing_tokens"]: - messages.error( - request, - f"Cannot send: {len(v['errors'])} resolution error(s), " - f"{len(v['missing_tokens'])} unknown token(s).", - ) - ctx.update({ - "validation": v, - "templates": templates, - "templates_json": templates_json, - }) - return render(request, "compose.html", ctx) - - filters["extra_recipients"] = extra # preserved in snapshot - filters["dedupe_users"] = dedupe_users - campaign = NotificationCampaign.objects.create( - template_id=tmpl_id or None, - subject=subject, - body=body, - sender=sender, - reply_to=reply_to, - status=(NotificationCampaign.STATUS_DRAFT - if action == "draft" - else NotificationCampaign.STATUS_QUEUED), - filters_snapshot=filters, - extra_context={}, - recipient_count=0, - created_by=request.user, - ) - - # Logs are created per-tuple during send; drafts carry no logs. - if action == "send": - _dispatch_send(campaign.pk) - messages.info(request, "Notification queued — sending in progress. This page refreshes automatically.") - return redirect("notifications:campaign-detail", pk=campaign.pk) - - messages.success(request, f"Draft \"{subject[:50]}\" saved.") - return redirect("notifications:campaign-list") - - ctx.update({ - "templates": templates, - "templates_json": templates_json, - "variables_json": variables_json, - }) - return render(request, "compose.html", ctx) - - -@staff_required -@require_POST -def filter_options(request): - """ - Return downstream filter option lists given the current selection. - - Cascade direction: (project | department) → allocation → resource → status. - Roles are narrowed by projects-linked-to-dept only. - """ - from coldfront.core.project.models import Project, ProjectUser, ProjectUserRoleChoice - from coldfront.core.allocation.models import Allocation - from coldfront.core.resource.models import Resource - from ifxuser.models import Organization - - projects = request.POST.getlist("projects") - allocations = request.POST.getlist("allocations") - resources = request.POST.getlist("resources") - departments = request.POST.getlist("departments") # names - - # Eligible project set given project + dept filters. - project_scope = Project.objects.all() - if projects: - project_scope = project_scope.filter(pk__in=projects) - if departments: - project_scope = project_scope.filter( - projectorganization__organization__rank="department", - projectorganization__organization__name__in=departments, - ) - - # Narrow allocations progressively so each facet only sees its upstream. - alloc_by_p = Allocation.objects.filter(project__in=project_scope) - alloc_by_pa = alloc_by_p.filter(pk__in=allocations) if allocations else alloc_by_p - alloc_by_par = alloc_by_pa.filter(resources__pk__in=resources) if resources else alloc_by_pa - - allocations_out = [ - {"id": a.pk, "label": f"{a.project.title} — {a.get_parent_resource or ''}"} - for a in alloc_by_p.select_related("project").distinct().order_by("project__title", "pk") - ] - - resource_pks = alloc_by_pa.values_list("resources__pk", flat=True).distinct() - resources_out = [ - {"id": r.pk, "label": r.name} - for r in Resource.objects.filter(pk__in=resource_pks).order_by("name") - ] - - status_names = alloc_by_par.values_list("status__name", flat=True).distinct() - statuses_out = sorted({n for n in status_names if n}) - - role_pks = ( - ProjectUser.objects.filter(project__in=project_scope) - .values_list("role__pk", flat=True) - .distinct() - ) - roles_out = sorted( - ProjectUserRoleChoice.objects.filter(pk__in=role_pks).values_list("name", flat=True) - ) - - # Departments narrow by project scope but ignore their own selection so users - # can switch between depts without the others disappearing. - dept_project_scope = Project.objects.all() - if projects: - dept_project_scope = dept_project_scope.filter(pk__in=projects) - dept_names = ( - Organization.objects.filter( - rank="department", - projectorganization__project__in=dept_project_scope, - ) - .values_list("name", flat=True) - .distinct() - ) - departments_out = sorted(set(dept_names)) - - return JsonResponse({ - "allocations": allocations_out, - "resources": resources_out, - "statuses": statuses_out, - "roles": roles_out, - "departments": departments_out, - }) - - -@staff_required -@require_POST -def recipient_count_view(request): - filters = { - "projects": request.POST.getlist("projects"), - "allocations": request.POST.getlist("allocations"), - "resources": request.POST.getlist("resources"), - "departments": request.POST.getlist("departments"), - "statuses": request.POST.getlist("alloc_status"), - "roles": request.POST.getlist("roles"), - } - preview = request.POST.get("preview") == "true" - subject = request.POST.get("subject", "") - body = request.POST.get("body", "") - try: - dedupe_users = json.loads(request.POST.get("dedupe_users", "[]") or "[]") - except (ValueError, TypeError): - dedupe_users = [] - - if not preview: - return JsonResponse({"count": recipient_count(filters)}) - - try: - page = max(1, int(request.POST.get("page", 1))) - except (ValueError, TypeError): - page = 1 - from .conf import PREVIEW_PAGE_SIZE - try: - page_size = max(10, min(100, int(request.POST.get("page_size", PREVIEW_PAGE_SIZE)))) - except (ValueError, TypeError): - page_size = PREVIEW_PAGE_SIZE - - # Determine scope the same way the validator/sender will. - from .utils import enumerate_recipients_deduped - from .validators import _extract_tokens, _required_scope - from collections import Counter - - tokens = _extract_tokens(subject, body) - used_vars = list(NotificationVariable.objects.filter(key__in=tokens)) - scope = _required_scope(used_vars) - - # Single pass: collect counts + page rows in one iteration. - from coldfront.core.project.models import ProjectUser - - user_info = {} # username → {email, full_name} - user_counts = Counter() - page_rows = [] - total = 0 - want_start = (page - 1) * page_size - want_end = want_start + page_size - - for user, project, allocation in enumerate_recipients_deduped(filters, scope, dedupe_users): - u = user.username - user_counts[u] += 1 - if u not in user_info: - user_info[u] = { - "email": user.email, - "full_name": user.get_full_name() or user.username, - } - - # Only collect details for rows in the requested page. - if want_start <= total < want_end: - page_rows.append({ - "username": u, - "full_name": user.get_full_name() or u, - "email": user.email, - "user_pk": user.pk, - "project_pk": project.pk if project else None, - "project_title": project.title if project else "", - "allocation": ( - f"{allocation.pk} — {allocation.get_parent_resource}" - if allocation else "" - ), - }) - - total += 1 - - total_pages = max(1, (total + page_size - 1) // page_size) - page = min(page, total_pages) - - # Batch-load roles for the page's rows in one query. - if page_rows: - pairs = [(r["user_pk"], r["project_pk"]) for r in page_rows if r["project_pk"]] - if pairs: - from django.db.models import Q - q = Q() - for uid, pid in pairs: - q |= Q(user_id=uid, project_id=pid) - role_map = {} - for pu in ProjectUser.objects.filter(q, status__name="Active").select_related("role"): - role_map[(pu.user_id, pu.project_id)] = pu.role.name if pu.role else "" - else: - role_map = {} - for r in page_rows: - r["role"] = role_map.get((r["user_pk"], r["project_pk"]), "") - r["project"] = r.pop("project_title") - del r["user_pk"] - del r["project_pk"] - - multi_users = sorted([ - {"username": u, "email": user_info[u]["email"], - "full_name": user_info[u]["full_name"], "count": c} - for u, c in user_counts.items() if c > 1 - ], key=lambda x: -x["count"]) - - return JsonResponse({ - "count": total, - "user_count": len(user_info), - "recipients": page_rows, - "multi_users": multi_users, - "scope": scope, - "page": page, - "page_size": page_size, - "total_pages": total_pages, - }) - - -@staff_required -def template_list(request): - return render(request, "template_list.html", { - "templates": NotificationTemplate.objects.filter(is_deleted=False), - }) - - -@staff_required -def template_form(request, pk=None): - instance = get_object_or_404(NotificationTemplate, pk=pk) if pk else None - if request.method == "POST": - form = NotificationTemplateForm(request.POST, instance=instance) - if form.is_valid(): - obj = form.save(commit=False) - if not obj.slug: - obj.slug = slugify(obj.name) - obj.save() - action_word = "updated" if instance else "created" - messages.success(request, f'Template "{obj.name}" {action_word}.') - return redirect("notifications:template-list") - else: - form = NotificationTemplateForm(instance=instance) - - variables_json = json.dumps([ - { - "key": v.key, - "label": v.label, - "description": v.description, - "example": v.example, - "source": v.source, - } - for v in NotificationVariable.objects.filter(is_deleted=False) - ]) - - return render(request, "template_form.html", { - "form": form, - "template": instance, - "variables_json": variables_json, - }) - - -@staff_required -def template_delete(request, pk): - instance = get_object_or_404(NotificationTemplate, pk=pk) - # Count notifications that used this template - notification_count = NotificationCampaign.objects.filter(template=instance).count() - if request.method == "POST": - instance.is_deleted = True - instance.save(update_fields=["is_deleted"]) - messages.success(request, f'Template "{instance.name}" deleted.') - return redirect("notifications:template-list") - return render(request, "template_delete_confirm.html", { - "template": instance, - "notification_count": notification_count, - }) - - -@staff_required -def variable_list(request): - return render(request, "variable_list.html", { - "variables": NotificationVariable.objects.filter(is_deleted=False), - }) - - -@staff_required -def variable_form(request, pk=None): - instance = get_object_or_404(NotificationVariable, pk=pk) if pk else None - if request.method == "POST": - form = NotificationVariableForm(request.POST, instance=instance) - if form.is_valid(): - obj = form.save() - action_word = "updated" if instance else "created" - messages.success(request, f'Variable "{{{{{obj.key}}}}}" {action_word}.') - return redirect("notifications:variable-list") - else: - form = NotificationVariableForm(instance=instance) - - # Dropdown metadata for conditional UI rendering - from .resolvers import QUERY_CHOICES - return render(request, "variable_form.html", { - "form": form, - "variable": instance, - "query_choices": QUERY_CHOICES, - }) - - -@staff_required -def variable_delete(request, pk): - instance = get_object_or_404(NotificationVariable, pk=pk) - # Find templates that reference this variable's key - affected = [ - t for t in NotificationTemplate.objects.filter(is_deleted=False) - if instance.key in t.variables - ] - if request.method == "POST": - instance.is_deleted = True - instance.save(update_fields=["is_deleted"]) - messages.success(request, f'Variable "{{{{{instance.key}}}}}" deleted.') - return redirect("notifications:variable-list") - return render(request, "variable_delete_confirm.html", { - "variable": instance, - "affected_templates": affected, - }) - - -@staff_required -def settings_view(request): - senders = SenderConfig.objects.all() - return render(request, "settings.html", {"senders": senders}) - - -@staff_required -def sender_form(request, pk=None): - instance = get_object_or_404(SenderConfig, pk=pk) if pk else None - if request.method == "POST": - form = SenderConfigForm(request.POST, instance=instance) - if form.is_valid(): - obj = form.save() - action_word = "updated" if instance else "added" - messages.success(request, f'Address "{obj.email}" {action_word}.') - return redirect("notifications:settings") - else: - form = SenderConfigForm(instance=instance) - return render(request, "sender_form.html", { - "form": form, - "sender": instance, - }) - - -@staff_required -@require_POST -def sender_delete(request, pk): - obj = get_object_or_404(SenderConfig, pk=pk) - email = obj.email - obj.delete() - messages.success(request, f'Address "{email}" removed.') - return redirect("notifications:settings") - - -@staff_required -def template_json(request, pk): - """Return a single template's live content for the compose-page sidebar.""" - t = get_object_or_404(NotificationTemplate, pk=pk) - return JsonResponse({ - "id": t.pk, - "slug": t.slug, - "name": t.name, - "subject": t.subject, - "body": t.body, - "variables": t.variables, - }) - - -@staff_required -def variables_view(request): - """Return the full catalog of NotificationVariables as JSON.""" - return JsonResponse({ - "variables": [ - { - "key": v.key, - "label": v.label, - "description": v.description, - "example": v.example, - "source": v.source, - "input_widget": v.input_widget, - "is_required": v.is_required, - } - for v in NotificationVariable.objects.filter(is_deleted=False) - ] - }) - - -@staff_required -@require_POST -def preview_render_view(request): - """ - Render the subject+body for up to N sample recipient tuples so the admin - can see substitution in action before sending. Does NOT hard-fail on - resolution errors — it annotates the sample instead. - """ - from .utils import enumerate_recipients_deduped - from .validators import _extract_tokens, _required_scope - from .resolvers import MissingValue - from .tasks import _build_values, _render - - def _json(field, default): - raw = request.POST.get(field, "") - if not raw: - return default - try: - return json.loads(raw) - except (ValueError, TypeError): - return default - - subject = request.POST.get("subject", "") - body = request.POST.get("body", "") - filters = _json("filters", {}) or {} - dedupe_users = _json("dedupe_users", []) or [] - try: - from .conf import PREVIEW_RENDER_LIMIT - limit = max(1, min(10, int(request.POST.get("limit", PREVIEW_RENDER_LIMIT)))) - except (ValueError, TypeError): - limit = 3 - - for k in ("projects","allocations","resources","departments","statuses","roles"): - filters.setdefault(k, []) - if not isinstance(filters[k], list): - filters[k] = [filters[k]] - - tokens = _extract_tokens(subject, body) - vars_by_key = {v.key: v for v in NotificationVariable.objects.filter(key__in=tokens)} - scope = _required_scope(list(vars_by_key.values())) - - samples = [] - total = 0 - for user, project, allocation in enumerate_recipients_deduped(filters, scope, dedupe_users): - total += 1 - if len(samples) >= limit: - continue - ctx = {"user": user, "project": project, "allocation": allocation} - try: - values = _build_values(tokens, vars_by_key, ctx) - rendered_subject = _render(subject, values) - rendered_body = _render(body, values) - error = None - except MissingValue as exc: - rendered_subject = subject - rendered_body = body - error = str(exc) - samples.append({ - "recipient": { - "name": user.get_full_name() or user.username, - "email": user.email, - "username": user.username, - "project": project.title if project else "", - "allocation": (f"{allocation.pk} — {allocation.get_parent_resource}" - if allocation else ""), - }, - "subject": rendered_subject, - "body": rendered_body, - "error": error, - }) - - return JsonResponse({ - "samples": samples, - "total_emails": total, - "scope": scope, - }) - - -@staff_required -@require_POST -def validate_view(request): - """ - Pre-flight validation — called from compose JS before Send is enabled. - POST payload: - subject, body (str) - filters (JSON-encoded dict with projects/allocations/resources/departments/statuses/roles) - extra_context (JSON-encoded dict {var_key: user_value}) - """ - def _json(field, default): - raw = request.POST.get(field, "") - if not raw: - return default - try: - return json.loads(raw) - except (ValueError, TypeError): - return default - - subject = request.POST.get("subject", "") - body = request.POST.get("body", "") - filters = _json("filters", {}) or {} - extra_context = _json("extra_context", {}) or {} - dedupe_users = _json("dedupe_users", []) or [] - # Normalize every filter key to a list (validate_campaign expects lists) - for k in ("projects","allocations","resources","departments","statuses","roles"): - v = filters.get(k) - if v is None: - filters[k] = [] - elif not isinstance(v, list): - filters[k] = [v] - - result = validate_campaign(subject, body, filters, extra_context, - dedupe_users=dedupe_users) - return JsonResponse(result) - - -@staff_required -def resend_compose(request, pk): - """ - Show a review-and-resend page pre-filled from an existing campaign. - POST creates a new campaign and dispatches it. - """ - source = get_object_or_404(NotificationCampaign, pk=pk) - snapshot = source.filters_snapshot or {} - - if request.method == "POST": - subject = request.POST.get("subject", "").strip() - body = request.POST.get("body", "").strip() - sender = request.POST.get("sender", "") - reply_to = request.POST.get("reply_to", "") - - if not subject or not body: - messages.error(request, "Subject and body are required.") - return redirect("notifications:resend-compose", pk=pk) - - campaign = NotificationCampaign.objects.create( - template=source.template, - subject=subject, - body=body, - sender=sender, - reply_to=reply_to, - status=NotificationCampaign.STATUS_QUEUED, - filters_snapshot=snapshot, - extra_context=source.extra_context or {}, - recipient_count=0, - created_by=request.user, - ) - _dispatch_send(campaign.pk) - messages.info(request, "Resend queued — sending in progress.") - return redirect("notifications:campaign-detail", pk=campaign.pk) - - return render(request, "resend_compose.html", { - "source": source, - "snapshot": snapshot, - "snapshot_json": json.dumps(snapshot), - "senders": SenderConfig.objects.all(), - }) \ No newline at end of file diff --git a/coldfront_notifications/views/__init__.py b/coldfront_notifications/views/__init__.py new file mode 100644 index 0000000..a95ed93 --- /dev/null +++ b/coldfront_notifications/views/__init__.py @@ -0,0 +1,17 @@ +# Re-export all views so urls.py can continue using `from . import views`. +from .dashboard import dashboard # noqa: F401 +from .campaigns import ( # noqa: F401 + campaign_list, campaign_detail, campaign_progress, + resend_failed, resend_compose, +) +from .compose import ( # noqa: F401 + compose, filter_options, recipient_count_view, + preview_render_view, validate_view, _dispatch_send, +) +from .templates import ( # noqa: F401 + template_list, template_form, template_delete, template_json, +) +from .settings import ( # noqa: F401 + variable_list, variable_form, variable_delete, variables_view, + settings_view, sender_form, sender_delete, +) diff --git a/coldfront_notifications/views/campaigns.py b/coldfront_notifications/views/campaigns.py new file mode 100644 index 0000000..0c89c0d --- /dev/null +++ b/coldfront_notifications/views/campaigns.py @@ -0,0 +1,116 @@ +import json +import logging + +from django.contrib import messages +from django.contrib.admin.views.decorators import staff_member_required as staff_required +from django.http import JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.http import require_POST + +from ..models import NotificationCampaign, NotificationLog, SenderConfig +from .compose import _dispatch_send + +logger = logging.getLogger(__name__) + + +@staff_required +def campaign_list(request): + status_filter = request.GET.get("status", "") + qs = NotificationCampaign.objects.select_related("template") + if status_filter: + qs = qs.filter(status=status_filter) + return render(request, "campaign_list.html", { + "campaigns": qs, + "status_filter": status_filter, + }) + + +@staff_required +def campaign_detail(request, pk): + campaign = get_object_or_404(NotificationCampaign, pk=pk) + logs = campaign.logs.all() + return render(request, "campaign_detail.html", { + "campaign": campaign, + "recipient_logs": logs, + }) + + +@staff_required +def campaign_progress(request, pk): + """Lightweight JSON endpoint for AJAX progress polling.""" + campaign = get_object_or_404(NotificationCampaign, pk=pk) + return JsonResponse({ + "status": campaign.status, + "status_display": campaign.get_status_display(), + "recipient_count": campaign.recipient_count, + "delivered_count": campaign.delivered_count, + "failed_count": campaign.failed_count, + "delivery_pct": campaign.delivery_pct, + }) + + +@staff_required +@require_POST +def resend_failed(request, pk): + campaign = get_object_or_404(NotificationCampaign, pk=pk) + failed_emails = list( + campaign.logs.filter(status=NotificationLog.STATUS_FAILED) + .values_list("email", flat=True) + ) + if not failed_emails: + messages.info(request, "No failed recipients to resend.") + return redirect("notifications:campaign-detail", pk=pk) + + campaign.logs.filter(status=NotificationLog.STATUS_FAILED).update( + status=NotificationLog.STATUS_QUEUED + ) + campaign.status = NotificationCampaign.STATUS_QUEUED + campaign.save(update_fields=["status"]) + + _dispatch_send(campaign.pk) + messages.info(request, "Resend queued — delivery in progress.") + + return redirect("notifications:campaign-detail", pk=pk) + + +@staff_required +def resend_compose(request, pk): + """ + Show a review-and-resend page pre-filled from an existing campaign. + POST creates a new campaign and dispatches it. + """ + source = get_object_or_404(NotificationCampaign, pk=pk) + snapshot = source.filters_snapshot or {} + + if request.method == "POST": + subject = request.POST.get("subject", "").strip() + body = request.POST.get("body", "").strip() + sender = request.POST.get("sender", "") + reply_to = request.POST.get("reply_to", "") + + if not subject or not body: + messages.error(request, "Subject and body are required.") + return redirect("notifications:resend-compose", pk=pk) + + campaign = NotificationCampaign.objects.create( + template=source.template, + subject=subject, + body=body, + sender=sender, + reply_to=reply_to, + status=NotificationCampaign.STATUS_QUEUED, + filters_snapshot=snapshot, + extra_context=source.extra_context or {}, + recipient_count=0, + created_by=request.user, + ) + _dispatch_send(campaign.pk) + messages.info(request, "Resend queued — sending in progress.") + return redirect("notifications:campaign-detail", pk=campaign.pk) + + return render(request, "resend_compose.html", { + "source": source, + "snapshot": snapshot, + "snapshot_json": json.dumps(snapshot), + "senders": SenderConfig.objects.all(), + }) diff --git a/coldfront_notifications/views/compose.py b/coldfront_notifications/views/compose.py new file mode 100644 index 0000000..1b19df2 --- /dev/null +++ b/coldfront_notifications/views/compose.py @@ -0,0 +1,459 @@ +""" +Compose notification views: compose form, filter cascade, recipient preview, +email preview render, and pre-flight validation. +""" +import json +import logging + +from django.contrib import messages +from django.contrib.admin.views.decorators import staff_member_required as staff_required +from django.http import JsonResponse +from django.shortcuts import redirect, render +from django.views.decorators.http import require_POST + +from ..models import NotificationCampaign, NotificationTemplate, NotificationVariable +from ..utils import recipient_count +from ..validators import validate_campaign +from .filters import get_filter_context, compute_filter_options + +logger = logging.getLogger(__name__) + + +def _dispatch_send(campaign_pk: int): + """ + Queue the send task on Celery. Falls back to a background thread when + the broker is unreachable so the HTTP response returns immediately. + """ + from ..tasks import send_notification_campaign + try: + send_notification_campaign.delay(campaign_pk) + except Exception as exc: + import threading + logger.warning("Celery broker unreachable (%s) — sending in background thread", exc) + + def _run(): + from django import db + try: + db.close_old_connections() + send_notification_campaign(campaign_pk) + except Exception as thread_exc: + logger.error("Background send thread crashed for campaign %s: %s", + campaign_pk, thread_exc, exc_info=True) + + t = threading.Thread(target=_run, daemon=True) + t.start() + + +@staff_required +def compose(request): + templates = NotificationTemplate.objects.filter(is_deleted=False) + ctx = get_filter_context() + + templates_json = json.dumps([ + { + "id": t.pk, + "slug": t.slug, + "name": t.name, + "subject": t.subject, + "body": t.body, + "variables": t.variables, + } + for t in templates + ]) + + variables_json = json.dumps([ + { + "key": v.key, + "label": v.label, + "description": v.description, + "example": v.example, + "source": v.source, + "input_widget": v.input_widget, + "is_required": v.is_required, + } + for v in NotificationVariable.objects.filter(is_deleted=False) + ]) + + if request.method == "POST": + action = request.POST.get("action", "send") + + def _filter(field): + raw = request.POST.get(field, "") + if not raw: + return [] + try: + val = json.loads(raw) + return val if isinstance(val, list) else [val] + except (ValueError, TypeError): + return request.POST.getlist(field) + + filters = { + "projects": _filter("filter_projects"), + "allocations": _filter("filter_allocations"), + "resources": _filter("filter_resources"), + "departments": _filter("filter_departments"), + "statuses": _filter("filter_statuses"), + "roles": _filter("filter_roles"), + } + extra = [ + line.strip() + for line in request.POST.get("extra_recipients", "").splitlines() + if line.strip() + ] + + subject = request.POST.get("subject", "").strip() + body = request.POST.get("body", "").strip() + sender = request.POST.get("sender", "") + reply_to = request.POST.get("reply_to", "") + tmpl_id = request.POST.get("template_id") + + try: + dedupe_users = json.loads(request.POST.get("dedupe_users", "[]") or "[]") + if not isinstance(dedupe_users, list): + dedupe_users = [] + except (ValueError, TypeError): + dedupe_users = [] + + if not subject or not body: + messages.error(request, "Subject and body are required.") + ctx.update({ + "templates": templates, + "templates_json": templates_json, + }) + return render(request, "compose.html", ctx) + + if action == "send": + v = validate_campaign(subject, body, filters, {}, + dedupe_users=dedupe_users) + if v["errors"] or v["missing_tokens"]: + messages.error( + request, + f"Cannot send: {len(v['errors'])} resolution error(s), " + f"{len(v['missing_tokens'])} unknown token(s).", + ) + ctx.update({ + "validation": v, + "templates": templates, + "templates_json": templates_json, + }) + return render(request, "compose.html", ctx) + + filters["extra_recipients"] = extra + filters["dedupe_users"] = dedupe_users + campaign = NotificationCampaign.objects.create( + template_id=tmpl_id or None, + subject=subject, + body=body, + sender=sender, + reply_to=reply_to, + status=(NotificationCampaign.STATUS_DRAFT + if action == "draft" + else NotificationCampaign.STATUS_QUEUED), + filters_snapshot=filters, + extra_context={}, + recipient_count=0, + created_by=request.user, + ) + + if action == "send": + _dispatch_send(campaign.pk) + messages.info(request, "Notification queued — sending in progress. This page refreshes automatically.") + return redirect("notifications:campaign-detail", pk=campaign.pk) + + messages.success(request, f"Draft \"{subject[:50]}\" saved.") + return redirect("notifications:campaign-list") + + ctx.update({ + "templates": templates, + "templates_json": templates_json, + "variables_json": variables_json, + "filter_summaries_json": json.dumps(ctx.get("filter_summaries", {})), + }) + return render(request, "compose.html", ctx) + + +@staff_required +@require_POST +def filter_options(request): + """ + Event-driven filter re computation. + + The frontend sends an event name identifying which filter changed and the + current state of all selections. The backend computes the full + {options, selected, summary} state for every filter. + """ + try: + payload = json.loads(request.body) + except (ValueError, TypeError): + return JsonResponse({"error": "Invalid JSON"}, status=400) + + event = payload.get("event", "") + valid_events = {"PROJECT_UPDATED", "DEPARTMENT_UPDATED", + "ALLOCATION_UPDATED", "RESOURCE_UPDATED", + "ALLOCATION_STATUS_UPDATED", "ROLE_UPDATED"} + if event not in valid_events: + return JsonResponse({"error": f"Unknown event: {event}"}, status=400) + + selections = payload.get("selections", {}) + return JsonResponse(compute_filter_options(selections)) + + +@staff_required +@require_POST +def recipient_count_view(request): + filters = { + "projects": request.POST.getlist("projects"), + "allocations": request.POST.getlist("allocations"), + "resources": request.POST.getlist("resources"), + "departments": request.POST.getlist("departments"), + "statuses": request.POST.getlist("alloc_status"), + "roles": request.POST.getlist("roles"), + } + preview = request.POST.get("preview") == "true" + subject = request.POST.get("subject", "") + body = request.POST.get("body", "") + + if not preview: + return JsonResponse({"count": recipient_count(filters)}) + + try: + page = max(1, int(request.POST.get("page", 1))) + except (ValueError, TypeError): + page = 1 + from ..conf import PREVIEW_PAGE_SIZE + try: + page_size = max(10, min(100, int(request.POST.get("page_size", PREVIEW_PAGE_SIZE)))) + except (ValueError, TypeError): + page_size = PREVIEW_PAGE_SIZE + + from ..utils import enumerate_recipients, _has_alloc_filters + from ..validators import _extract_tokens, _required_scope + from collections import Counter + + tokens = _extract_tokens(subject, body) + used_vars = list(NotificationVariable.objects.filter(key__in=tokens)) + token_scope = _required_scope(used_vars) + + if _has_alloc_filters(filters): + scope = "allocation" + else: + scope = max(token_scope, "project", key=["user", "project", "allocation"].index) + + from coldfront.core.project.models import ProjectUser + + user_info = {} + user_counts = Counter() + all_rows = [] + total = 0 + + for user, project, allocation in enumerate_recipients(filters, scope): + u = user.username + user_counts[u] += 1 + if u not in user_info: + user_info[u] = { + "email": user.email, + "full_name": user.get_full_name() or user.username, + } + all_rows.append((user, project, allocation)) + total += 1 + + total_pages = max(1, (total + page_size - 1) // page_size) + page = min(page, total_pages) + want_start = (page - 1) * page_size + want_end = want_start + page_size + + page_rows = [] + for user, project, allocation in all_rows[want_start:want_end]: + page_rows.append({ + "username": user.username, + "full_name": user.get_full_name() or user.username, + "email": user.email, + "user_pk": user.pk, + "project_pk": project.pk if project else None, + "project_title": project.title if project else "", + "allocation": ( + f"{allocation.pk} — {allocation.get_parent_resource}" + if allocation else "" + ), + }) + + if page_rows: + pairs = [(r["user_pk"], r["project_pk"]) for r in page_rows if r["project_pk"]] + if pairs: + from django.db.models import Q + q = Q() + for uid, pid in pairs: + q |= Q(user_id=uid, project_id=pid) + role_map = {} + for pu in ProjectUser.objects.filter(q, status__name="Active").select_related("role"): + role_map[(pu.user_id, pu.project_id)] = pu.role.name if pu.role else "" + else: + role_map = {} + for r in page_rows: + r["role"] = role_map.get((r["user_pk"], r["project_pk"]), "") + r["project"] = r.pop("project_title") + del r["user_pk"] + del r["project_pk"] + + multi_users = sorted([ + {"username": u, "email": user_info[u]["email"], + "full_name": user_info[u]["full_name"], "count": c} + for u, c in user_counts.items() if c > 1 + ], key=lambda x: -x["count"]) + + return JsonResponse({ + "count": total, + "user_count": len(user_info), + "recipients": page_rows, + "multi_users": multi_users, + "scope": scope, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + }) + + +@staff_required +@require_POST +def preview_render_view(request): + """ + Render the subject+body for up to N sample recipient tuples so the admin + can see substitution in action before sending. + """ + from ..utils import enumerate_recipients_deduped + from ..validators import _extract_tokens, _required_scope + from ..resolvers import MissingValue + from ..tasks import _build_values, _render + + def _json(field, default): + raw = request.POST.get(field, "") + if not raw: + return default + try: + return json.loads(raw) + except (ValueError, TypeError): + return default + + subject = request.POST.get("subject", "") + body = request.POST.get("body", "") + filters = _json("filters", {}) or {} + dedupe_users = _json("dedupe_users", []) or [] + try: + from ..conf import PREVIEW_RENDER_LIMIT + limit = max(1, min(10, int(request.POST.get("limit", PREVIEW_RENDER_LIMIT)))) + except (ValueError, TypeError): + limit = 3 + + for k in ("projects", "allocations", "resources", "departments", "statuses", "roles"): + filters.setdefault(k, []) + if not isinstance(filters[k], list): + filters[k] = [filters[k]] + + tokens = _extract_tokens(subject, body) + vars_by_key = {v.key: v for v in NotificationVariable.objects.filter(key__in=tokens)} + scope = _required_scope(list(vars_by_key.values())) + + samples = [] + total = 0 + for user, project, allocation in enumerate_recipients_deduped(filters, scope, dedupe_users): + total += 1 + if len(samples) >= limit: + continue + ctx = {"user": user, "project": project, "allocation": allocation} + try: + values = _build_values(tokens, vars_by_key, ctx) + rendered_subject = _render(subject, values) + rendered_body = _render(body, values) + error = None + except MissingValue as exc: + rendered_subject = subject + rendered_body = body + error = str(exc) + samples.append({ + "recipient": { + "name": user.get_full_name() or user.username, + "email": user.email, + "username": user.username, + "project": project.title if project else "", + "allocation": (f"{allocation.pk} — {allocation.get_parent_resource}" + if allocation else ""), + }, + "subject": rendered_subject, + "body": rendered_body, + "error": error, + }) + + return JsonResponse({ + "samples": samples, + "total_emails": total, + "scope": scope, + }) + + +@staff_required +@require_POST +def validate_view(request): + """Pre-flight validation — called from compose JS before Send is enabled.""" + def _json(field, default): + raw = request.POST.get(field, "") + if not raw: + return default + try: + return json.loads(raw) + except (ValueError, TypeError): + return default + + subject = request.POST.get("subject", "") + body = request.POST.get("body", "") + filters = _json("filters", {}) or {} + extra_context = _json("extra_context", {}) or {} + dedupe_users = _json("dedupe_users", []) or [] + for k in ("projects", "allocations", "resources", "departments", "statuses", "roles"): + v = filters.get(k) + if v is None: + filters[k] = [] + elif not isinstance(v, list): + filters[k] = [v] + + from ..utils import _has_alloc_filters + from ..validators import _extract_tokens, _required_scope + tokens = _extract_tokens(subject, body) + used_vars = list(NotificationVariable.objects.filter(key__in=tokens)) + token_scope = _required_scope(used_vars) + if _has_alloc_filters(filters): + elevated_scope = "allocation" + else: + elevated_scope = max(token_scope, "project", + key=["user", "project", "allocation"].index) + + result = validate_campaign(subject, body, filters, extra_context, + dedupe_users=dedupe_users, + scope_override=elevated_scope) + + from coldfront.core.project.models import Project + from coldfront.core.resource.models import Resource + + active = [] + if filters.get("projects"): + names = list(Project.objects.filter( + pk__in=filters["projects"] + ).values_list("title", flat=True)) + if names: + active.append({"label": "Project", "values": names}) + if filters.get("departments"): + active.append({"label": "Department", "values": filters["departments"]}) + if filters.get("allocations"): + active.append({"label": "Allocation", "values": [str(pk) for pk in filters["allocations"]]}) + if filters.get("resources"): + names = list(Resource.objects.filter( + pk__in=filters["resources"] + ).values_list("name", flat=True)) + if names: + active.append({"label": "Resource", "values": names}) + if filters.get("statuses"): + active.append({"label": "Allocation Status", "values": filters["statuses"]}) + if filters.get("roles"): + active.append({"label": "User Role", "values": filters["roles"]}) + result["active_filters"] = active + + return JsonResponse(result) diff --git a/coldfront_notifications/views/dashboard.py b/coldfront_notifications/views/dashboard.py new file mode 100644 index 0000000..b395417 --- /dev/null +++ b/coldfront_notifications/views/dashboard.py @@ -0,0 +1,26 @@ +from django.contrib.admin.views.decorators import staff_member_required as staff_required +from django.shortcuts import render +from django.utils import timezone + +from ..models import NotificationCampaign, NotificationLog + + +@staff_required +def dashboard(request): + from datetime import timedelta + thirty_days_ago = timezone.now() - timedelta(days=30) + + recent = NotificationCampaign.objects.select_related("template")[:10] + stats = { + "total_sent": NotificationLog.objects.filter(status="delivered").count(), + "total_failed_30": NotificationCampaign.objects.filter( + status=NotificationCampaign.STATUS_FAILED, + created_at__gte=thirty_days_ago, + ).count(), + "sending_now": NotificationCampaign.objects.filter(status="sending").count(), + "total_campaigns": NotificationCampaign.objects.count(), + } + return render(request, "dashboard.html", { + "recent_campaigns": recent, + **stats, + }) diff --git a/coldfront_notifications/views/filters.py b/coldfront_notifications/views/filters.py new file mode 100644 index 0000000..1965811 --- /dev/null +++ b/coldfront_notifications/views/filters.py @@ -0,0 +1,320 @@ +""" +Filter computation engine for the compose page. + +Provides: + - build_filter_summaries() — summary dicts for tooltip/detail display + - get_filter_context() — initial filter data for page load + - compute_filter_options() — full cross-filter computation for AJAX cascade +""" +from collections import Counter + +from ..models import SenderConfig + + +def build_filter_summaries(projects_qs=None, allocations_qs=None, + resources_qs=None, dept_names=None, + status_names=None, role_names=None, + active_filters=None): + """ + Build a summary dict for each filter's current option set. + + Each summary has: count (int), breakdown (str), filtered_by (str or ""). + The frontend renders these into a styled tooltip. + + active_filters: dict of filter labels -> list of selected display values, + e.g. {"Allocation Status": ["Denied"], "Project": ["Alpha"]}. + Empty or None means no filters applied. + """ + af = active_filters or {} + parts = {} + + def _filtered_by(exclude_label): + """Format which OTHER filters are active (exclude our own label).""" + lines = [] + for label, vals in af.items(): + if label == exclude_label or not vals: + continue + lines.append(f"{label}: {', '.join(str(v) for v in vals)}") + return " | ".join(lines) + + def _summary(label, total, breakdown, exclude_label): + return { + "count": total, + "breakdown": breakdown, + "filtered_by": _filtered_by(exclude_label), + } + + # Projects — group by project status + if projects_qs is not None: + total = projects_qs.count() + if total: + counts = Counter(projects_qs.values_list("status__name", flat=True)) + breakdown = ", ".join(f"{c} {n}" for n, c in counts.most_common()) + else: + breakdown = "" + parts["projects"] = _summary("Project", total, breakdown, "Project") + + # Allocations — group by allocation status + if allocations_qs is not None: + total = allocations_qs.distinct().count() + if total: + counts = Counter(allocations_qs.distinct().values_list("status__name", flat=True)) + breakdown = ", ".join(f"{c} {n}" for n, c in counts.most_common()) + else: + breakdown = "" + parts["allocations"] = _summary("Allocation", total, breakdown, "Allocation") + + # Resources — group by resource type + if resources_qs is not None: + total = resources_qs.count() + if total: + counts = Counter(resources_qs.values_list("resource_type__name", flat=True)) + breakdown = ", ".join(f"{c} {n}" for n, c in counts.most_common()) + else: + breakdown = "" + parts["resources"] = _summary("Resource", total, breakdown, "Resource") + + # Departments + if dept_names is not None: + names = sorted(set(dept_names)) + total = len(names) + breakdown = ", ".join(names[:8]) + if total > 8: + breakdown += f" (+{total - 8} more)" + parts["departments"] = _summary("Department", total, breakdown, "Department") + + # Statuses + if status_names is not None: + names = sorted(set(n for n in status_names if n)) + total = len(names) + parts["statuses"] = _summary("Allocation Status", total, ", ".join(names), "Allocation Status") + + # Roles + if role_names is not None: + names = sorted(set(n for n in role_names if n)) + total = len(names) + parts["roles"] = _summary("User Role", total, ", ".join(names), "User Role") + + return parts + + +def get_filter_context(): + """Return the initial filter context for the compose page template.""" + from coldfront.core.project.models import Project, ProjectUserRoleChoice + from coldfront.core.allocation.models import Allocation, AllocationStatusChoice + from coldfront.core.resource.models import Resource + from ifxuser.models import Organization + + projects_qs = Project.objects.order_by("title") + allocations_qs = Allocation.objects.select_related("project").order_by("project__title", "pk") + resources_qs = Resource.objects.order_by("name") + dept_names = sorted(set( + Organization.objects.filter(rank="department").values_list("name", flat=True) + )) + status_names = list(AllocationStatusChoice.objects.values_list("name", flat=True).order_by("name")) + role_names = list(ProjectUserRoleChoice.objects.values_list("name", flat=True).order_by("name")) + + summaries = build_filter_summaries( + projects_qs=projects_qs, allocations_qs=allocations_qs, + resources_qs=resources_qs, dept_names=dept_names, + status_names=status_names, role_names=role_names, + ) + + return { + "projects": projects_qs, + "allocations": allocations_qs, + "resources": resources_qs, + "departments": dept_names, + "statuses": status_names, + "roles": role_names, + "senders": SenderConfig.objects.all(), + "reply_tos": SenderConfig.objects.all(), + "filter_summaries": summaries, + } + + +def compute_filter_options(selections): + """ + Compute all filter options from the given selections. + + Each filter excludes its own selection (no self-narrowing) but considers + every other active selection. Returns a dict ready for JsonResponse. + """ + from coldfront.core.project.models import Project, ProjectUser, ProjectUserRoleChoice + from coldfront.core.allocation.models import Allocation, AllocationStatusChoice + from coldfront.core.resource.models import Resource + from ifxuser.models import Organization + + sel_projects = [int(x) for x in selections.get("projects", []) if x] + sel_allocations = [int(x) for x in selections.get("allocations", []) if x] + sel_resources = [int(x) for x in selections.get("resources", []) if x] + sel_departments = [str(x) for x in selections.get("departments", []) if x] + sel_statuses = [str(x) for x in selections.get("statuses", []) if x] + sel_roles = [str(x) for x in selections.get("roles", []) if x] + + # ── Helpers ────────────────────────────────────────────────────── + def _project_options(qs): + return [{"id": p.pk, "label": p.title} for p in qs.order_by("title")] + + def _allocation_options(qs): + return [ + {"id": a.pk, "label": f"{a.project.title} — {a.get_parent_resource or ''}"} + for a in qs.select_related("project").distinct().order_by("project__title", "pk") + ] + + def _resource_options(qs): + return [{"id": r.pk, "label": r.name} for r in qs.order_by("name")] + + def _department_options(names): + return [{"id": n, "label": n} for n in sorted(set(names))] + + def _string_options(names): + return [{"id": n, "label": n} for n in sorted(set(n for n in names if n))] + + def _prune(options, selected): + valid = {o["id"] for o in options} + return [s for s in selected if s in valid] + + # ── Full (unfiltered) sets — for "no narrowing" detection ──────── + all_projects_qs = Project.objects.all() + all_allocations_qs = Allocation.objects.select_related("project").all() + all_resources_qs = Resource.objects.all() + all_dept_names = list( + Organization.objects.filter(rank="department") + .values_list("name", flat=True).distinct() + ) + all_status_names = list( + AllocationStatusChoice.objects.values_list("name", flat=True) + ) + all_role_names = list( + ProjectUserRoleChoice.objects.values_list("name", flat=True) + ) + + # ── Tier 1: project <-> department (mutual peers) ──────────────── + proj_opts_qs = all_projects_qs + if sel_departments: + proj_opts_qs = proj_opts_qs.filter( + projectorganization__organization__rank="department", + projectorganization__organization__name__in=sel_departments, + ) + projects_opts = _project_options(proj_opts_qs) + + dept_scope_qs = all_projects_qs + if sel_projects: + dept_scope_qs = dept_scope_qs.filter(pk__in=sel_projects) + dept_names = list(Organization.objects.filter( + rank="department", + projectorganization__project__in=dept_scope_qs, + ).values_list("name", flat=True).distinct()) + departments_opts = _department_options(dept_names) + + # Full project scope (both selections applied) for Tier 2. + project_scope = all_projects_qs + if sel_projects: + project_scope = project_scope.filter(pk__in=sel_projects) + if sel_departments: + project_scope = project_scope.filter( + projectorganization__organization__rank="department", + projectorganization__organization__name__in=sel_departments, + ) + alloc_base = Allocation.objects.filter(project__in=project_scope) + + # ── Tier 2: allocation, resource, status — each excludes itself ── + alloc_opts_qs = alloc_base + if sel_statuses: + alloc_opts_qs = alloc_opts_qs.filter(status__name__in=sel_statuses) + if sel_resources: + alloc_opts_qs = alloc_opts_qs.filter(resources__pk__in=sel_resources) + allocations_opts = _allocation_options(alloc_opts_qs) + + res_scope = alloc_base + if sel_statuses: + res_scope = res_scope.filter(status__name__in=sel_statuses) + if sel_allocations: + res_scope = res_scope.filter(pk__in=sel_allocations) + res_opts_qs = Resource.objects.filter( + pk__in=res_scope.values_list("resources__pk", flat=True).distinct() + ) + resources_opts = _resource_options(res_opts_qs) + + # Status is independent of allocation selection to avoid circular pruning. + stat_scope = alloc_base + if sel_resources: + stat_scope = stat_scope.filter(resources__pk__in=sel_resources) + status_names = list(stat_scope.values_list("status__name", flat=True).distinct()) + statuses_opts = _string_options(status_names) + + # Roles: from project scope. + role_pks = ProjectUser.objects.filter( + project__in=project_scope + ).values_list("role__pk", flat=True).distinct() + role_names = list( + ProjectUserRoleChoice.objects.filter(pk__in=role_pks) + .values_list("name", flat=True) + ) + roles_opts = _string_options(role_names) + + # ── Prune selections ───────────────────────────────────────────── + pruned = { + "projects": _prune(projects_opts, sel_projects), + "allocations": _prune(allocations_opts, sel_allocations), + "departments": _prune(departments_opts, sel_departments), + "resources": _prune(resources_opts, sel_resources), + "statuses": _prune(statuses_opts, sel_statuses), + "roles": _prune(roles_opts, sel_roles), + } + + # ── Active-filter labels from pruned selections ────────────────── + active_filters = {} + if pruned["projects"]: + names = list(all_projects_qs.filter(pk__in=pruned["projects"]).values_list("title", flat=True)) + if names: + active_filters["Project"] = names + if pruned["allocations"]: + active_filters["Allocation"] = [str(pk) for pk in pruned["allocations"]] + if pruned["departments"]: + active_filters["Department"] = pruned["departments"] + if pruned["resources"]: + names = list(all_resources_qs.filter(pk__in=pruned["resources"]).values_list("name", flat=True)) + if names: + active_filters["Resource"] = names + if pruned["statuses"]: + active_filters["Allocation Status"] = pruned["statuses"] + if pruned["roles"]: + active_filters["User Role"] = pruned["roles"] + + # ── Summaries ──────────────────────────────────────────────────── + summaries = build_filter_summaries( + projects_qs=proj_opts_qs, allocations_qs=alloc_opts_qs, + resources_qs=res_opts_qs, dept_names=dept_names, + status_names=status_names, role_names=role_names, + active_filters=active_filters, + ) + + full_counts = { + "projects": all_projects_qs.count(), + "allocations": all_allocations_qs.distinct().count(), + "resources": all_resources_qs.count(), + "departments": len(set(all_dept_names)), + "statuses": len(set(n for n in all_status_names if n)), + "roles": len(set(n for n in all_role_names if n)), + } + for key, s in summaries.items(): + if isinstance(s, dict) and s.get("count", 0) == full_counts.get(key, -1): + s["filtered_by"] = "" + + def _pack(options, pruned_sel, key): + return { + "options": options, + "selected": pruned_sel, + "summary": summaries.get(key, {}), + } + + return { + "projects": _pack(projects_opts, pruned["projects"], "projects"), + "allocations": _pack(allocations_opts, pruned["allocations"], "allocations"), + "departments": _pack(departments_opts, pruned["departments"], "departments"), + "resources": _pack(resources_opts, pruned["resources"], "resources"), + "statuses": _pack(statuses_opts, pruned["statuses"], "statuses"), + "roles": _pack(roles_opts, pruned["roles"], "roles"), + } diff --git a/coldfront_notifications/views/settings.py b/coldfront_notifications/views/settings.py new file mode 100644 index 0000000..381e343 --- /dev/null +++ b/coldfront_notifications/views/settings.py @@ -0,0 +1,107 @@ +from django.contrib import messages +from django.contrib.admin.views.decorators import staff_member_required as staff_required +from django.http import JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.http import require_POST + +from ..forms import NotificationVariableForm, SenderConfigForm +from ..models import NotificationTemplate, NotificationVariable, SenderConfig + + +@staff_required +def variable_list(request): + return render(request, "variable_list.html", { + "variables": NotificationVariable.objects.filter(is_deleted=False), + }) + + +@staff_required +def variable_form(request, pk=None): + instance = get_object_or_404(NotificationVariable, pk=pk) if pk else None + if request.method == "POST": + form = NotificationVariableForm(request.POST, instance=instance) + if form.is_valid(): + obj = form.save() + action_word = "updated" if instance else "created" + messages.success(request, f'Variable "{{{{{obj.key}}}}}" {action_word}.') + return redirect("notifications:variable-list") + else: + form = NotificationVariableForm(instance=instance) + + from ..resolvers import QUERY_CHOICES + return render(request, "variable_form.html", { + "form": form, + "variable": instance, + "query_choices": QUERY_CHOICES, + }) + + +@staff_required +def variable_delete(request, pk): + instance = get_object_or_404(NotificationVariable, pk=pk) + affected = [ + t for t in NotificationTemplate.objects.filter(is_deleted=False) + if instance.key in t.variables + ] + if request.method == "POST": + instance.is_deleted = True + instance.save(update_fields=["is_deleted"]) + messages.success(request, f'Variable "{{{{{instance.key}}}}}" deleted.') + return redirect("notifications:variable-list") + return render(request, "variable_delete_confirm.html", { + "variable": instance, + "affected_templates": affected, + }) + + +@staff_required +def variables_view(request): + """Return the full catalog of NotificationVariables as JSON.""" + return JsonResponse({ + "variables": [ + { + "key": v.key, + "label": v.label, + "description": v.description, + "example": v.example, + "source": v.source, + "input_widget": v.input_widget, + "is_required": v.is_required, + } + for v in NotificationVariable.objects.filter(is_deleted=False) + ] + }) + + +@staff_required +def settings_view(request): + senders = SenderConfig.objects.all() + return render(request, "settings.html", {"senders": senders}) + + +@staff_required +def sender_form(request, pk=None): + instance = get_object_or_404(SenderConfig, pk=pk) if pk else None + if request.method == "POST": + form = SenderConfigForm(request.POST, instance=instance) + if form.is_valid(): + obj = form.save() + action_word = "updated" if instance else "added" + messages.success(request, f'Address "{obj.email}" {action_word}.') + return redirect("notifications:settings") + else: + form = SenderConfigForm(instance=instance) + return render(request, "sender_form.html", { + "form": form, + "sender": instance, + }) + + +@staff_required +@require_POST +def sender_delete(request, pk): + obj = get_object_or_404(SenderConfig, pk=pk) + email = obj.email + obj.delete() + messages.success(request, f'Address "{email}" removed.') + return redirect("notifications:settings") diff --git a/coldfront_notifications/views/templates.py b/coldfront_notifications/views/templates.py new file mode 100644 index 0000000..c953762 --- /dev/null +++ b/coldfront_notifications/views/templates.py @@ -0,0 +1,80 @@ +import json + +from django.contrib import messages +from django.contrib.admin.views.decorators import staff_member_required as staff_required +from django.http import JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.text import slugify + +from ..forms import NotificationTemplateForm +from ..models import NotificationCampaign, NotificationTemplate, NotificationVariable + + +@staff_required +def template_list(request): + return render(request, "template_list.html", { + "templates": NotificationTemplate.objects.filter(is_deleted=False), + }) + + +@staff_required +def template_form(request, pk=None): + instance = get_object_or_404(NotificationTemplate, pk=pk) if pk else None + if request.method == "POST": + form = NotificationTemplateForm(request.POST, instance=instance) + if form.is_valid(): + obj = form.save(commit=False) + if not obj.slug: + obj.slug = slugify(obj.name) + obj.save() + action_word = "updated" if instance else "created" + messages.success(request, f'Template "{obj.name}" {action_word}.') + return redirect("notifications:template-list") + else: + form = NotificationTemplateForm(instance=instance) + + variables_json = json.dumps([ + { + "key": v.key, + "label": v.label, + "description": v.description, + "example": v.example, + "source": v.source, + } + for v in NotificationVariable.objects.filter(is_deleted=False) + ]) + + return render(request, "template_form.html", { + "form": form, + "template": instance, + "variables_json": variables_json, + }) + + +@staff_required +def template_delete(request, pk): + instance = get_object_or_404(NotificationTemplate, pk=pk) + notification_count = NotificationCampaign.objects.filter(template=instance).count() + if request.method == "POST": + instance.is_deleted = True + instance.save(update_fields=["is_deleted"]) + messages.success(request, f'Template "{instance.name}" deleted.') + return redirect("notifications:template-list") + return render(request, "template_delete_confirm.html", { + "template": instance, + "notification_count": notification_count, + }) + + +@staff_required +def template_json(request, pk): + """Return a single template's live content for the compose-page sidebar.""" + t = get_object_or_404(NotificationTemplate, pk=pk) + return JsonResponse({ + "id": t.pk, + "slug": t.slug, + "name": t.name, + "subject": t.subject, + "body": t.body, + "variables": t.variables, + }) diff --git a/static/coldfront_notifications/css/compose.css b/static/coldfront_notifications/css/compose.css deleted file mode 100644 index 9b02095..0000000 --- a/static/coldfront_notifications/css/compose.css +++ /dev/null @@ -1,164 +0,0 @@ -.flbl { font-weight: 600; font-size: .82rem; margin-bottom: 4px; display: block; color: #343a40; } - -/* recipients: row 1 — filters */ -.recip-filters { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 12px; - align-items: end; -} - -/* recipients: row 2 — count pill + buttons */ -.recip-actions { - display: flex; align-items: center; gap: 10px; - margin-top: 14px; padding-top: 12px; - border-top: 1px solid #eef0f3; -} -.recip-count-pill { - background: #fdf5f6; border: 1px solid #e8b4bb; - border-radius: 5px; padding: 5px 14px; text-align: center; -} -.recip-count-num { font-size: 1.3rem; font-weight: 700; font-family: monospace; color: #A51C30; line-height: 1; } -.recip-count-lbl { font-size: .68rem; color: #6c757d; margin-top: 2px; } -.bcc-badge { - display: inline-flex; align-items: center; gap: 5px; font-size: .72rem; - background: #e8f4fd; border: 1px solid #bee5fb; color: #0c5460; - border-radius: 4px; padding: 3px 8px; white-space: nowrap; -} - -/* two col layout */ -.compose-layout { display: flex; gap: 16px; align-items: flex-start; } -.tmpl-col { width: 200px; flex-shrink: 0; position: sticky; top: 16px; } -.compose-col { flex: 1; min-width: 0; } - -/* template search */ -.tmpl-search-wrap { padding: 6px 8px; border-bottom: 1px solid #e9ecef; } -.tmpl-search-input { - width: 100%; border: 1px solid #ced4da; border-radius: 3px; - padding: 4px 8px; font-size: .76rem; outline: none; -} -.tmpl-search-input:focus { border-color: #A51C30; } - -/* template list */ -.tmpl-list { max-height: 520px; overflow-y: auto; } -.tmpl-list::-webkit-scrollbar { width: 3px; } -.tmpl-list::-webkit-scrollbar-thumb { background: #ced4da; border-radius: 2px; } -.tmpl-item { - padding: 10px 12px; border-bottom: 1px solid #f0f2f5; - cursor: pointer; transition: background .12s; border-left: 3px solid transparent; -} -.tmpl-item:last-child { border-bottom: none; } -.tmpl-item:hover { background: #f8f9fa; } -.tmpl-item.active { background: #fdf0f2; border-left-color: #A51C30; } -.tmpl-item.active .tmpl-name { color: #A51C30; } -.tmpl-name { font-weight: 600; font-size: .8rem; margin-bottom: 2px; } -.tmpl-slug { font-family: monospace; font-size: .65rem; color: #6c757d; margin-bottom: 4px; } -.tmpl-vars { display: flex; flex-wrap: wrap; gap: 2px; } -.tmpl-var-sm { - font-family: monospace; font-size: .62rem; background: #eef2f9; color: #3d5080; - border: 1px solid #c8d4ec; padding: 1px 4px; border-radius: 2px; -} - -/* vars + body */ -.vars-body-wrap { - display: flex; border: 1px solid #ced4da; border-radius: 4px; overflow: hidden; -} -.vars-col { - width: 155px; flex-shrink: 0; border-right: 1px solid #dee2e6; - background: #f8f9fa; display: flex; flex-direction: column; -} -.vars-col-hdr { - padding: 6px 9px; font-size: .67rem; font-weight: 700; text-transform: uppercase; - letter-spacing: .07em; color: #6c757d; background: #f0f2f5; border-bottom: 1px solid #dee2e6; -} -.vars-col-search { padding: 6px 7px; border-bottom: 1px solid #dee2e6; } -.vars-col-search input { - width: 100%; border: 1px solid #ced4da; border-radius: 3px; - padding: 3px 6px; font-size: .74rem; outline: none; background: #fff; -} -.vars-col-search input:focus { border-color: #A51C30; } -.vars-col-list { - overflow-y: auto; padding: 5px 6px; display: flex; flex-direction: column; - gap: 3px; max-height: 340px; flex: 1; -} -.vars-col-list::-webkit-scrollbar { width: 3px; } -.vars-col-list::-webkit-scrollbar-thumb { background: #ced4da; border-radius: 2px; } -.var-btn { - font-family: monospace; font-size: .71rem; background: #fff; color: #3d5080; - border: 1px solid #c8d4ec; padding: 4px 7px; border-radius: 3px; cursor: pointer; - transition: background .1s; text-align: left; - display: block; width: 100%; -} -.var-btn:hover { background: #d5e0f5; color: #1a2d5a; border-color: #a0b8e0; } -.vars-empty { font-size: .74rem; color: #adb5bd; padding: 6px 4px; } - -.body-col-inner { flex: 1; min-width: 0; display: flex; } -#id_body { - flex: 1; border: none; border-radius: 0; outline: none; - font-family: 'SFMono-Regular', Consolas, monospace; - font-size: .82rem; line-height: 1.65; padding: 10px; - resize: none; min-height: 340px; width: 100%; -} -#id_body:focus { box-shadow: none; } - -/* send bar */ -.send-warn { - display: flex; gap: 9px; align-items: flex-start; - background: #fff8e1; border: 1px solid #ffe082; border-radius: 4px; - padding: 9px 12px; font-size: .82rem; margin-bottom: 12px; -} -.send-warn i { color: #e65100; margin-top: 1px; flex-shrink: 0; } - -/* recipient modal — top bar with search */ -.recip-topbar { - display: flex; justify-content: space-between; align-items: center; - padding: 8px 12px; background: #f8f9fa; border-bottom: 1px solid #dee2e6; - font-size: .78rem; color: #6c757d; gap: 12px; -} -.recip-search { width: 220px !important; font-size: .78rem !important; flex-shrink: 0; } - -/* recipient modal — fixed-height scrollable table */ -.recip-table-wrap { - height: 530px; overflow-y: auto; -} -.recip-table-wrap .table { margin-bottom: 0; } -.recip-table-wrap thead th { - position: sticky; top: 0; z-index: 1; background: #e9ecef; -} - -/* recipient toolbar — always visible below the scroll area */ -.recip-toolbar { - display: flex; align-items: center; justify-content: space-between; - padding: 8px 16px; border-top: 1px solid #dee2e6; background: #f8f9fa; - border-radius: 0 0 .3rem .3rem; -} -.recip-toolbar-left { font-size: .78rem; color: #6c757d; flex-shrink: 0; } -.recip-toolbar-center { display: flex; align-items: center; gap: 4px; } -.recip-toolbar-right { display: flex; align-items: center; flex-shrink: 0; } -.recip-page-info { - font-size: .78rem; margin: 0 6px; white-space: nowrap; min-width: 100px; text-align: center; -} -.recip-page-size-label { - font-size: .78rem; font-weight: 400; color: #6c757d; - 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; - font-size: .78rem; height: auto !important; -} - -/* select2 overrides */ -.select2-container { width: 100% !important; } -.select2-container--default .select2-selection--multiple { - border-color: #ced4da; border-radius: 4px; min-height: 36px; - max-height: 90px; overflow-y: auto; -} -.select2-container--default.select2-container--focus .select2-selection--multiple { border-color: #A51C30; box-shadow: 0 0 0 .15rem rgba(165,28,48,.13); } -.select2-container--default .select2-results__option--highlighted[aria-selected] { background: #A51C30 !important; } -.select2-container--default .select2-selection__choice { - background: #A51C30 !important; border-color: #8a1728 !important; color: #fff !important; - border-radius: 3px; font-size: .75rem; - max-width: 100%; overflow: hidden; text-overflow: ellipsis; word-break: break-word; -} -.select2-container--default .select2-selection__choice__remove { color: rgba(255,255,255,.7) !important; } -.select2-container--default .select2-selection__choice__remove:hover { color: #fff !important; } diff --git a/static/coldfront_notifications/css/template_form.css b/static/coldfront_notifications/css/template_form.css deleted file mode 100644 index de474fd..0000000 --- a/static/coldfront_notifications/css/template_form.css +++ /dev/null @@ -1,44 +0,0 @@ -.flbl { font-weight: 600; font-size: .82rem; margin-bottom: 4px; display: block; color: #343a40; } - -.vars-body-wrap { - display: flex; border: 1px solid #ced4da; border-radius: 4px; overflow: hidden; -} -.vars-col { - width: 155px; flex-shrink: 0; border-right: 1px solid #dee2e6; - background: #f8f9fa; display: flex; flex-direction: column; -} -.vars-col-hdr { - padding: 6px 9px; font-size: .67rem; font-weight: 700; text-transform: uppercase; - letter-spacing: .07em; color: #6c757d; background: #f0f2f5; - border-bottom: 1px solid #dee2e6; white-space: nowrap; -} -.vars-col-search { padding: 6px 7px; border-bottom: 1px solid #dee2e6; } -.vars-col-search input { - width: 100%; border: 1px solid #ced4da; border-radius: 3px; - padding: 3px 6px; font-size: .74rem; outline: none; background: #fff; -} -.vars-col-search input:focus { border-color: #A51C30; } -.vars-col-list { - overflow-y: auto; padding: 5px 6px; - display: flex; flex-direction: column; gap: 3px; - max-height: 380px; flex: 1; -} -.vars-col-list::-webkit-scrollbar { width: 3px; } -.vars-col-list::-webkit-scrollbar-thumb { background: #ced4da; border-radius: 2px; } -.var-btn { - font-family: monospace; font-size: .71rem; background: #fff; color: #3d5080; - border: 1px solid #c8d4ec; padding: 4px 7px; border-radius: 3px; cursor: pointer; - transition: background .1s; text-align: left; - display: block; width: 100%; -} -.var-btn:hover { background: #d5e0f5; color: #1a2d5a; border-color: #a0b8e0; } -.vars-empty { font-size: .74rem; color: #adb5bd; padding: 6px 4px; } - -.body-col-inner { flex: 1; min-width: 0; display: flex; } -#id_body { - flex: 1; border: none; border-radius: 0; outline: none; - font-family: 'SFMono-Regular', Consolas, monospace; - font-size: .82rem; line-height: 1.65; padding: 10px; - resize: none; min-height: 380px; width: 100%; -} -#id_body:focus { box-shadow: none; } diff --git a/static/coldfront_notifications/css/variable_form.css b/static/coldfront_notifications/css/variable_form.css deleted file mode 100644 index 7fc5507..0000000 --- a/static/coldfront_notifications/css/variable_form.css +++ /dev/null @@ -1,16 +0,0 @@ -.flbl { font-weight: 600; font-size: .82rem; margin-bottom: 4px; display: block; color: #343a40; } -.radio-card { - border: 1px solid #ced4da; border-radius: 5px; padding: 10px 12px; - cursor: pointer; transition: border-color .1s, background .1s; - margin-bottom: 0; -} -.radio-card input[type=radio] { margin-right: 8px; } -.radio-card:hover { background: #f8f9fa; } -.radio-card.active { border-color: #A51C30; background: #fdf5f6; } -.conditional { display: none; } -.conditional.show { display: block; } -.example-box { - background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; - padding: 10px 14px; font-size: .8rem; -} -.example-box code { font-size: .78rem; } diff --git a/static/coldfront_notifications/js/campaign_detail.js b/static/coldfront_notifications/js/campaign_detail.js deleted file mode 100644 index 2ff440c..0000000 --- a/static/coldfront_notifications/js/campaign_detail.js +++ /dev/null @@ -1,67 +0,0 @@ -// ── campaign_detail.js ────────────────────────────────────────── -// Expects globals set inline by the Django template: -// CAMPAIGN_STATUS - string ('queued', 'sending', 'sent', etc.) -// CAMPAIGN_PROGRESS_URL - URL for the progress JSON endpoint - -var STATUS_CLASSES = { - sent: 'badge-success', - sending: 'badge-primary', - partial: 'badge-warning', - queued: 'badge-info', - draft: 'badge-secondary', - failed: 'badge-danger' -}; - -function updateProgress(data) { - CAMPAIGN_STATUS = data.status; - $('#statusBadge') - .text(data.status_display) - .attr('class', 'badge ' + (STATUS_CLASSES[data.status] || 'badge-secondary')); - $('#totalCount').text(data.recipient_count); - $('#deliveredCount').text(data.delivered_count); - $('#failedCount').text(data.failed_count); - if (data.failed_count > 0) { - $('#failedCount').css('color', '#dc3545'); - } - $('#progressBar').css('width', data.delivery_pct + '%'); - $('#progressPct').text(data.delivery_pct + '% delivery rate'); - if (data.recipient_count > 0) { - $('#progressWrap').show(); - } -} - -function pollProgress() { - if (CAMPAIGN_STATUS !== 'sending' && CAMPAIGN_STATUS !== 'queued') return; - $.getJSON(CAMPAIGN_PROGRESS_URL, function(data) { - updateProgress(data); - if (data.status === 'sending' || data.status === 'queued') { - setTimeout(pollProgress, 3000); - } else { - // Final state reached — reload to get the full log table. - location.reload(); - } - }).fail(function() { - setTimeout(pollProgress, 5000); - }); -} - -$(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]}] }); - } - // Start polling if campaign is in-flight. - if (CAMPAIGN_STATUS === 'sending' || CAMPAIGN_STATUS === 'queued') { - setTimeout(pollProgress, 3000); - } -}); - -$(document).on('click', '.view-email-btn', function() { - $('#em-from').text($(this).attr('data-from')); - $('#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')); - $('#emailViewModal').modal('show'); -}); diff --git a/static/coldfront_notifications/js/compose.js b/static/coldfront_notifications/js/compose.js deleted file mode 100644 index d6ab45f..0000000 --- a/static/coldfront_notifications/js/compose.js +++ /dev/null @@ -1,624 +0,0 @@ -// ── compose.js ────────────────────────────────────────────────── -// Expects the following globals set inline by the Django template: -// TEMPLATES - array of template objects -// VARIABLES - array of variable objects -// URLS.previewRender - URL for preview-render endpoint -// URLS.filterOptions - URL for filter-options endpoint -// URLS.validate - URL for validate endpoint -// URLS.recipientCount - URL for recipient-count endpoint - -// Index VARIABLES by key for O(1) lookup -var VAR_BY_KEY = {}; -VARIABLES.forEach(function(v) { VAR_BY_KEY[v.key] = v; }); - -// ── Render chip list ───────────────────────────────────────────── -function renderVars(vars, filter) { - var $list = $('#varsList').empty(); - var filtered = filter - ? vars.filter(function(v) { return v.toLowerCase().indexOf(filter.toLowerCase()) !== -1; }) - : vars; - if (!filtered.length) { - $list.append('
    No matches
    '); - return; - } - filtered.forEach(function(v) { - var token = '{{' + v + '}}'; - var def = VAR_BY_KEY[v] || {}; - var example = def.example || ''; - var badge = def.source === 'manual' ? ' [manual]' : ''; - var $btn = $('') - .attr('data-var', token) - .attr('title', (def.description || def.label || '') + (example ? ' — ' + example : '')) - .html( - '' + token + badge + '' + - '' + example + '' - ); - $list.append($btn); - }); -} - -// ── Extract tokens from subject + body ─────────────────────────── -var TOKEN_RE = /\{\{(\w+)\}\}/g; -function extractTokens() { - var text = ($('#id_subject').val() || '') + '\n' + ($('#id_body').val() || ''); - var seen = {}; - var out = []; - var m; - while ((m = TOKEN_RE.exec(text))) { - if (!seen[m[1]]) { seen[m[1]] = true; out.push(m[1]); } - } - return out; -} - -// ── Load template into compose fields ──────────────────────────── -// Always fetch fresh content from the server — avoids stale data when -// an admin edits a template in another tab and returns to compose. -var ACTIVE_VARS = []; -function loadTemplate($el) { - var id = parseInt($el.attr('data-id') || 0); - var slug = $el.attr('data-slug') || ''; - $('#active-slug').text(slug); - $('#hidden_template_id').val(id); - $('#varSearch').val(''); - $('#validationPanel').hide(); - $('#sendBtn').prop('disabled', true); - $('#sendWarn').hide(); - setDedupeUsers([]); - if (!id) { ACTIVE_VARS = []; renderVars([], ''); return; } - $.getJSON('/notifications/templates/' + id + '/json/', function(t) { - $('#id_subject').val(t.subject || ''); - $('#id_body').val(t.body || ''); - ACTIVE_VARS = t.variables || []; - renderVars(ACTIVE_VARS, ''); - if (HAS_VALIDATED) runValidation(); - }).fail(function() { - ACTIVE_VARS = []; - renderVars([], ''); - }); -} - -// ── Template click ─────────────────────────────────────────────── -$(document).on('click', '.tmpl-item', function() { - $('.tmpl-item').removeClass('active'); - $(this).addClass('active'); - loadTemplate($(this)); -}); - -// ── Template search ───────────────────────────────────────────── -$(document).on('input', '#tmplSearch', function() { - var q = $(this).val().toLowerCase(); - $('.tmpl-item').each(function() { - var text = $(this).text().toLowerCase(); - $(this).toggle(!q || text.indexOf(q) !== -1); - }); -}); - -// ── Variable search ────────────────────────────────────────────── -$(document).on('input', '#varSearch', function() { - renderVars(ACTIVE_VARS, $(this).val()); -}); - -// ── 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(); -}); - -// ── Preview modal ──────────────────────────────────────────────── -// Samples returned from /preview-render/; indexed by select option value. -var PREVIEW_SAMPLES = []; - -function renderPreviewSample(idx) { - var s = PREVIEW_SAMPLES[idx]; - if (!s) { - // "Template" — no recipient, raw tokens - $('#prev-to').text('—'); - $('#prev-subject').text($('#id_subject').val() || '—'); - $('#prev-body').text($('#id_body').val() || '—'); - $('#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 || '—'); - if (s.error) { - $('#prev-error').text('Resolution error: ' + s.error).show(); - } else { - $('#prev-error').hide(); - } - var ctx = []; - if (s.recipient.project) ctx.push('project: ' + s.recipient.project); - if (s.recipient.allocation) ctx.push('allocation: ' + s.recipient.allocation); - $('#prev-footer').text(ctx.join(' · ') || 'recipient-only context'); -} - -$(document).on('click', '#previewBtn', function() { - $('#prev-from').text($('#id_sender').val()); - $('#prev-replyto').text($('#id_reply_to').val()); - // Show the raw template immediately while samples load - PREVIEW_SAMPLES = []; - $('#prev-recipient-picker').html('' - + ''); - renderPreviewSample(null); - $('#prev-scope-badge').text(''); - $('#previewModal').modal('show'); - - $.ajax({ - url: URLS.previewRender, - type: 'POST', - data: { - csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), - subject: $('#id_subject').val() || '', - body: $('#id_body').val() || '', - filters: JSON.stringify(collectFilters()), - dedupe_users: JSON.stringify(getDedupeUsers()), - }, - success: function(data) { - PREVIEW_SAMPLES = data.samples || []; - var opts = ['']; - PREVIEW_SAMPLES.forEach(function(s, i) { - var lbl = (s.recipient.name || s.recipient.username) + ' <' + s.recipient.email + '>'; - if (s.recipient.project) lbl += ' · ' + s.recipient.project; - opts.push(''); - }); - if (!PREVIEW_SAMPLES.length) { - opts.push(''); - } - $('#prev-recipient-picker').html(opts.join('')); - if (data.scope) $('#prev-scope-badge').text('scope: ' + data.scope + ' · ' + (data.total_emails || 0) + ' total'); - - // Auto-select the first real sample if available - if (PREVIEW_SAMPLES.length) { - $('#prev-recipient-picker').val('0'); - renderPreviewSample(0); - } else { - renderPreviewSample(null); - } - }, - error: function() { - $('#prev-recipient-picker').html(''); - $('#prev-footer').text('Preview failed to load — showing raw template'); - }, - }); -}); - -$(document).on('change', '#prev-recipient-picker', function() { - var v = $(this).val(); - renderPreviewSample(v === '' ? null : parseInt(v, 10)); -}); - -// ── Recipient filters stale on change ──────────────────────────── -$(document).on('change', '.s2', function() { - $('#recipNum').text('?'); - $('#recipLbl').text('press Validate'); - $('#sendBtn').prop('disabled', true); - $('#previewRecipBtn').prop('disabled', true); - $('#sendWarn').hide(); - // Changing filters may change the user pool — dedupe choices are user-scoped, - // so reset them to avoid stale selections. - setDedupeUsers([]); -}); - -// ── Cascading filter options ───────────────────────────────────── -// Rebuild a '; - } else { - cbCell = ''; - } - html += '' - + cbCell - + '' + (r.full_name||'') + (isMulti && first ? ' multi' : '') + '' - + '' + (r.email||'') + '' - + '' + (r.role||'') + '' - + '' + (r.project||'') + '' - + '' + (r.allocation||'') + '' - + ''; - }); - }); - $('#recipRows').html(html || 'No recipients matched.'); - - // Effective email count = sum of group length, capped at 1 when deduped - var effective = 0; - order.forEach(function(u) { - effective += dedupeSet[u] ? 1 : byUser[u].length; - }); - var scopeLbl = PREVIEW_SCOPE ? 'scope: ' + PREVIEW_SCOPE : ''; - $('#recipModalCount').text( - effective + ' emails → ' + order.length + ' users' - + (effective !== PREVIEW_ROWS.length ? ' (was ' + PREVIEW_ROWS.length + ' before dedupe)' : '') - ); - if (PREVIEW_SCOPE) { $('#recipModalScope').text(scopeLbl).show(); } else { $('#recipModalScope').hide(); } - -} - -function getPageSize() { - return parseInt($('#recipPageSize').val(), 10) || 10; -} - -function updatePaginationControls() { - var atFirst = PREVIEW_PAGE <= 1; - var atLast = PREVIEW_PAGE >= PREVIEW_TOTAL_PAGES; - $('#recipPageInfo').text('Page ' + PREVIEW_PAGE + ' of ' + PREVIEW_TOTAL_PAGES); - $('#recipFirstBtn').prop('disabled', atFirst); - $('#recipPrevBtn').prop('disabled', atFirst); - $('#recipNextBtn').prop('disabled', atLast); - $('#recipLastBtn').prop('disabled', atLast); -} - -function fetchPreviewPage(page) { - PREVIEW_PAGE = page || 1; - $('#recipSearch').val(''); - $('#recipRows').html('Loading…'); - - $.ajax({ - url: URLS.recipientCount, - type: 'POST', - data: { - csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), - projects: $('#f_project').val() || [], - allocations: $('#f_allocation').val() || [], - departments: $('#f_dept').val() || [], - resources: $('#f_resource').val() || [], - alloc_status: $('#f_status').val() || [], - roles: $('#f_role').val() || [], - subject: $('#id_subject').val() || '', - body: $('#id_body').val() || '', - dedupe_users: JSON.stringify(getDedupeUsers()), - preview: 'true', - page: PREVIEW_PAGE, - page_size: getPageSize(), - }, - traditional: true, - success: function(data) { - PREVIEW_ROWS = data.recipients || []; - PREVIEW_SCOPE = data.scope || null; - PREVIEW_TOTAL = data.count || 0; - PREVIEW_TOTAL_PAGES = data.total_pages || 1; - PREVIEW_PAGE = data.page || 1; - ALL_MULTI_USERS = (data.multi_users || []).map(function(m) { return m.username; }); - renderPreviewRows(); - // Show dedupe-all row if any multi-email users exist across all pages - if (ALL_MULTI_USERS.length) { - $('#dedupeAllRow').show(); - var allDeduped = ALL_MULTI_USERS.every(function(u) { - return getDedupeUsers().indexOf(u) !== -1; - }); - $('#dedupeAllCb').prop('checked', allDeduped); - } else { - $('#dedupeAllRow').hide(); - } - updatePaginationControls(); - }, - error: function() { - $('#recipRows').html('Failed to load.'); - } - }); -} - -$(document).on('click', '#previewRecipBtn', function() { - $('#recipModal').modal('show'); - fetchPreviewPage(1); -}); -$(document).on('click', '#recipFirstBtn', function() { - if (PREVIEW_PAGE > 1) fetchPreviewPage(1); -}); -$(document).on('click', '#recipPrevBtn', function() { - if (PREVIEW_PAGE > 1) fetchPreviewPage(PREVIEW_PAGE - 1); -}); -$(document).on('click', '#recipNextBtn', function() { - if (PREVIEW_PAGE < PREVIEW_TOTAL_PAGES) fetchPreviewPage(PREVIEW_PAGE + 1); -}); -$(document).on('click', '#recipLastBtn', function() { - if (PREVIEW_PAGE < PREVIEW_TOTAL_PAGES) fetchPreviewPage(PREVIEW_TOTAL_PAGES); -}); -$(document).on('change', '#recipPageSize', function() { - fetchPreviewPage(1); -}); - -// Client-side search: filter visible rows in the current page -$(document).on('input', '#recipSearch', function() { - var q = $(this).val().toLowerCase(); - $('#recipRows tr').each(function() { - var text = $(this).text().toLowerCase(); - $(this).toggle(!q || text.indexOf(q) !== -1); - }); -}); - -// All multi-email usernames across all pages (from server response). -var ALL_MULTI_USERS = []; - -// Bulk dedupe: toggle ALL multi-email users across all pages. -$(document).on('change', '#dedupeAllCb', function() { - if ($(this).is(':checked')) { - setDedupeUsers(ALL_MULTI_USERS.slice()); - } else { - setDedupeUsers([]); - } - renderPreviewRows(); - runValidation(); -}); - -// Per-user dedupe toggle inside the modal. -$(document).on('change', '.dedupe-cb', function() { - var username = $(this).attr('data-username'); - var cur = getDedupeUsers(); - var idx = cur.indexOf(username); - if ($(this).is(':checked')) { - if (idx === -1) cur.push(username); - } else { - if (idx !== -1) cur.splice(idx, 1); - } - setDedupeUsers(cur); - renderPreviewRows(); - runValidation(); -}); - - -// ── Serialize filter hidden fields before submit ───────────────── -$('#notifForm').on('submit', function() { - $('#h_projects').val(JSON.stringify($('#f_project').val() || [])); - $('#h_allocations').val(JSON.stringify($('#f_allocation').val() || [])); - $('#h_depts').val(JSON.stringify($('#f_dept').val() || [])); - $('#h_resources').val(JSON.stringify($('#f_resource').val() || [])); - $('#h_statuses').val(JSON.stringify($('#f_status').val() || [])); - $('#h_roles').val(JSON.stringify($('#f_role').val() || [])); -}); - -// ── Select2 init ───────────────────────────────────────────────── -$(document).ready(function() { - $('.s2').select2({ - allowClear: true, - width: '100%', - placeholder: function() { return $(this).data('placeholder'); } - }); - - // Load first template - var $first = $('.tmpl-item').first(); - if ($first.length) loadTemplate($first); -}); - -// ── pageshow: reset validation state + refresh active template ──── -// When a user edits a template in another tab and comes back, bfcache -// restores the DOM with the OLD textarea content and any stale errors. -// Force-hide the panel and re-fetch the active template so the textarea -// reflects the latest body before the next Validate click. -$(window).on('pageshow', function() { - $('#validationPanel').hide(); - $('#validationOK').hide(); - $('#validationBad').hide(); - $('#sendWarn').hide(); - $('#sendBtn').prop('disabled', true); - $('#recipNum').text('?'); - $('#recipLbl').text('press Validate'); - $('#emailCount').text('—'); - $('#multiBadge').hide(); - $('#multiWarn').hide(); - setDedupeUsers([]); - HAS_VALIDATED = false; - var $active = $('.tmpl-item.active'); - if ($active.length) loadTemplate($active); -}); diff --git a/static/coldfront_notifications/js/datatables_init.js b/static/coldfront_notifications/js/datatables_init.js deleted file mode 100644 index 269b49b..0000000 --- a/static/coldfront_notifications/js/datatables_init.js +++ /dev/null @@ -1,23 +0,0 @@ -// ── datatables_init.js ────────────────────────────────────────── -// Shared DataTable initializer for list pages. -// Expects the following global set inline by the Django template: -// DT_CONFIG - object with: { tableId, order, nonOrderableTargets, paging, searching, info } -// All fields except tableId are optional. - -$(function() { - if (typeof DT_CONFIG === 'undefined') return; - - var opts = { - order: DT_CONFIG.order || [[0, 'asc']] - }; - - if (DT_CONFIG.nonOrderableTargets) { - opts.columnDefs = [{ orderable: false, targets: DT_CONFIG.nonOrderableTargets }]; - } - - if (DT_CONFIG.paging === false) opts.paging = false; - if (DT_CONFIG.searching === false) opts.searching = false; - if (DT_CONFIG.info === false) opts.info = false; - - $('#' + DT_CONFIG.tableId).DataTable(opts); -}); diff --git a/static/coldfront_notifications/js/template_form.js b/static/coldfront_notifications/js/template_form.js deleted file mode 100644 index f394522..0000000 --- a/static/coldfront_notifications/js/template_form.js +++ /dev/null @@ -1,73 +0,0 @@ -// ── template_form.js ──────────────────────────────────────────── -// Expects the following global set inline by the Django template: -// ALL_VARS - array of variable objects -// IS_EDIT - boolean, true when editing an existing template - -function renderVars(filter) { - var $list = $('#varsList').empty(); - var q = (filter || '').toLowerCase(); - var filtered = q - ? ALL_VARS.filter(function(v) { - return (v.key || '').toLowerCase().indexOf(q) !== -1 - || (v.label || '').toLowerCase().indexOf(q) !== -1; }) - : ALL_VARS; - if (!filtered.length) { - $list.append('
    No matches
    '); - return; - } - filtered.forEach(function(v) { - var token = '{{' + v.key + '}}'; - var tag = v.source === 'manual' ? ' [manual]' : ''; - var $btn = $('') - .attr('data-var', token) - .attr('title', (v.description || v.label || '') + (v.example ? ' — ' + v.example : '')) - .html( - '' + token + tag + '' + - '' + (v.example || '') + '' - ); - $list.append($btn); - }); -} - -function renderVarRef() { - var $body = $('#varRefBody').empty(); - if (!ALL_VARS.length) { - $body.append('No variables defined.'); - return; - } - ALL_VARS.forEach(function(v) { - var badgeClass = v.source === 'manual' ? 'badge-warning' : 'badge-success'; - var badgeText = v.source === 'manual' ? 'Manual' : 'Query'; - $body.append( - '' - + '{{' + v.key + '}}' - + '' + badgeText + '' - + '' + (v.example || '—') + '' - + '' - ); - }); -} - -$(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(); -}); - -$(document).on('input', '#varSearch', function() { - renderVars($(this).val()); -}); - -$(document).ready(function() { renderVars(''); renderVarRef(); }); - -if (!IS_EDIT) { - $(document).on('input', '#id_name', function() { - var slug = $(this).val().toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); - $('#id_slug').val(slug); - }); -} diff --git a/static/coldfront_notifications/js/variable_form.js b/static/coldfront_notifications/js/variable_form.js deleted file mode 100644 index ad78cd6..0000000 --- a/static/coldfront_notifications/js/variable_form.js +++ /dev/null @@ -1,90 +0,0 @@ -// ── variable_form.js ──────────────────────────────────────────── -// No server-rendered globals required. - -// Example values per resolver_key -var QUERY_EXAMPLES = { - "user.username": "jdoe", - "user.email": "jdoe@example.com", - "user.full_name": "John Doe", - "user.first_name": "John", - "user.last_name": "Doe", - "user.ifxid": "IFX000123", - "project.title": "astro-gpu-2025", - "project.description": "(project description)", - "project.status.name": "Active", - "project.field_of_science.description": "Physics", - "project.parent_project.title": "FASRC", - "project.pi.full_name": "Dr. Jane Smith", - "project.pi.email": "pi@example.com", - "project.pi.username": "jsmith", - "project.department.name": "Astronomy", - "projectuser.role.name": "PI", - "allocation.id": "412", - "allocation.description": "(allocation description)", - "allocation.justification": "(justification text)", - "allocation.quantity": "1", - "allocation.start_date": "2025-01-01", - "allocation.end_date": "2025-12-31", - "allocation.status.name": "Active", - "resource.name": "Cannon Cluster", - "resource.description": "(resource description)", - "resource.resource_type.name": "Cluster", - "resource.parent_resource.name": "Cannon Cluster", - "resource.names_joined": "Cannon Cluster, Holyoke GPU" -}; - -function syncSource() { - var val = $('input[name=source]:checked').val(); - $('#card-manual, #card-query').removeClass('active'); - if (val === 'manual') { - $('#card-manual').addClass('active'); - $('#query-config').removeClass('show'); - $('#manual-config').addClass('show'); - syncValueWidget(); - } else if (val === 'query') { - $('#card-query').addClass('active'); - $('#query-config').addClass('show'); - $('#manual-config').removeClass('show'); - updateQueryExample(); - } else { - $('#query-config').removeClass('show'); - $('#manual-config').removeClass('show'); - } -} - -// Swap the Value input's type based on input_widget selection so the admin -// gets a proper date/url/textarea control while entering the stored value. -function syncValueWidget() { - var widget = $('#id_input_widget').val() || 'text'; - var $cur = $('#id_value'); - if (!$cur.length) return; - var current = $cur.val(); - var name = $cur.attr('name') || 'value'; - var $new; - if (widget === 'textarea') { - $new = $(''); - } else if (widget === 'date') { - $new = $(''); - } else if (widget === 'datetime') { - $new = $(''); - } else if (widget === 'url') { - $new = $(''); - } else { - $new = $(''); - } - $new.attr({ id: 'id_value', name: name, class: 'form-control' }).val(current); - $cur.replaceWith($new); -} - -function updateQueryExample() { - var k = $('#id_resolver_key').val(); - if (!k) { $('#query-example').hide(); return; } - var ex = QUERY_EXAMPLES[k] || '(depends on recipient)'; - $('#query-example-text').html('' + k + ' → ' + ex + ''); - $('#query-example').show(); -} - -$(document).on('change', 'input[name=source]', syncSource); -$(document).on('change', '#id_resolver_key', updateQueryExample); -$(document).on('change', '#id_input_widget', syncValueWidget); -$(document).ready(syncSource); From 522bd5ad69185303c537a44851f01f98928db304 Mon Sep 17 00:00:00 2001 From: Antonio Rodriguez Date: Tue, 12 May 2026 14:28:35 +0200 Subject: [PATCH 02/10] Optimize recipient preview memory, reduce filter query count, fix BCC recipient_count_view (compose.py): - Replace all_rows list with two-pass streaming: pass 1 counts + collects user_info, pass 2 streams to the requested page slice and breaks early. Memory is now O(page_size) instead of O(total_recipients). compute_filter_options (filters.py): - Use COUNT() queries instead of materializing full querysets for no-narrowing detection - Option helpers use values_list projections instead of full model hydration - Role query uses single reverse join instead of two-step PK lookup - Active-filter labels looked up from already-computed option dicts instead of re-querying the DB BCC fix (tasks.py): - Extra recipients are BCC'd on the first email only, not every email UI (compose.html): - Remove "via BCC" from send warning text --- coldfront_notifications/tasks.py | 2 +- .../templates/compose.html | 2 +- coldfront_notifications/views/compose.py | 37 +++++---- coldfront_notifications/views/filters.py | 80 +++++++++---------- 4 files changed, 62 insertions(+), 59 deletions(-) diff --git a/coldfront_notifications/tasks.py b/coldfront_notifications/tasks.py index 8888183..6827ca3 100644 --- a/coldfront_notifications/tasks.py +++ b/coldfront_notifications/tasks.py @@ -210,7 +210,7 @@ def send_notification_campaign(campaign_pk: int): body=body, from_email=campaign.sender, to=[user.email], - bcc=bcc_pool or [], + bcc=bcc_pool if i == 0 else [], reply_to=[campaign.reply_to] if campaign.reply_to else [], ) diff --git a/coldfront_notifications/templates/compose.html b/coldfront_notifications/templates/compose.html index 3af143d..7d9b94c 100644 --- a/coldfront_notifications/templates/compose.html +++ b/coldfront_notifications/templates/compose.html @@ -273,7 +273,7 @@

    diff --git a/coldfront_notifications/views/compose.py b/coldfront_notifications/views/compose.py index 1b19df2..2dc459f 100644 --- a/coldfront_notifications/views/compose.py +++ b/coldfront_notifications/views/compose.py @@ -241,9 +241,10 @@ def recipient_count_view(request): from coldfront.core.project.models import ProjectUser + # Two-pass streaming to avoid loading all rows into memory. + # Pass 1: count total, collect user_counts + user_info (lightweight). user_info = {} user_counts = Counter() - all_rows = [] total = 0 for user, project, allocation in enumerate_recipients(filters, scope): @@ -254,28 +255,34 @@ def recipient_count_view(request): "email": user.email, "full_name": user.get_full_name() or user.username, } - all_rows.append((user, project, allocation)) total += 1 + # Clamp page BEFORE computing the slice window. total_pages = max(1, (total + page_size - 1) // page_size) page = min(page, total_pages) want_start = (page - 1) * page_size want_end = want_start + page_size + # Pass 2: stream again, collect only the page slice. page_rows = [] - for user, project, allocation in all_rows[want_start:want_end]: - page_rows.append({ - "username": user.username, - "full_name": user.get_full_name() or user.username, - "email": user.email, - "user_pk": user.pk, - "project_pk": project.pk if project else None, - "project_title": project.title if project else "", - "allocation": ( - f"{allocation.pk} — {allocation.get_parent_resource}" - if allocation else "" - ), - }) + idx = 0 + for user, project, allocation in enumerate_recipients(filters, scope): + if idx >= want_end: + break + if idx >= want_start: + page_rows.append({ + "username": user.username, + "full_name": user.get_full_name() or user.username, + "email": user.email, + "user_pk": user.pk, + "project_pk": project.pk if project else None, + "project_title": project.title if project else "", + "allocation": ( + f"{allocation.pk} — {allocation.get_parent_resource}" + if allocation else "" + ), + }) + idx += 1 if page_rows: pairs = [(r["user_pk"], r["project_pk"]) for r in page_rows if r["project_pk"]] diff --git a/coldfront_notifications/views/filters.py b/coldfront_notifications/views/filters.py index 1965811..2cd2014 100644 --- a/coldfront_notifications/views/filters.py +++ b/coldfront_notifications/views/filters.py @@ -140,7 +140,7 @@ def compute_filter_options(selections): Each filter excludes its own selection (no self-narrowing) but considers every other active selection. Returns a dict ready for JsonResponse. """ - from coldfront.core.project.models import Project, ProjectUser, ProjectUserRoleChoice + from coldfront.core.project.models import Project, ProjectUserRoleChoice from coldfront.core.allocation.models import Allocation, AllocationStatusChoice from coldfront.core.resource.models import Resource from ifxuser.models import Organization @@ -154,16 +154,18 @@ def compute_filter_options(selections): # ── Helpers ────────────────────────────────────────────────────── def _project_options(qs): - return [{"id": p.pk, "label": p.title} for p in qs.order_by("title")] + return [{"id": pk, "label": title} + for pk, title in qs.order_by("title").values_list("pk", "title")] def _allocation_options(qs): - return [ - {"id": a.pk, "label": f"{a.project.title} — {a.get_parent_resource or ''}"} - for a in qs.select_related("project").distinct().order_by("project__title", "pk") - ] + return [{"id": pk, "label": f"{proj} — {res or ''}"} + for pk, proj, res in qs.distinct() + .order_by("project__title", "pk") + .values_list("pk", "project__title", "resources__name")] def _resource_options(qs): - return [{"id": r.pk, "label": r.name} for r in qs.order_by("name")] + return [{"id": pk, "label": name} + for pk, name in qs.order_by("name").values_list("pk", "name")] def _department_options(names): return [{"id": n, "label": n} for n in sorted(set(names))] @@ -175,23 +177,20 @@ def _prune(options, selected): valid = {o["id"] for o in options} return [s for s in selected if s in valid] - # ── Full (unfiltered) sets — for "no narrowing" detection ──────── - all_projects_qs = Project.objects.all() - all_allocations_qs = Allocation.objects.select_related("project").all() - all_resources_qs = Resource.objects.all() - all_dept_names = list( - Organization.objects.filter(rank="department") - .values_list("name", flat=True).distinct() - ) - all_status_names = list( - AllocationStatusChoice.objects.values_list("name", flat=True) - ) - all_role_names = list( - ProjectUserRoleChoice.objects.values_list("name", flat=True) - ) + # ── Full counts — small lookup tables, fetched once for + # "no narrowing" detection. No full queryset materialized. ───── + full_counts = { + "projects": Project.objects.count(), + "allocations": Allocation.objects.distinct().count(), + "resources": Resource.objects.count(), + "departments": (Organization.objects.filter(rank="department") + .values("name").distinct().count()), + "statuses": AllocationStatusChoice.objects.count(), + "roles": ProjectUserRoleChoice.objects.count(), + } # ── Tier 1: project <-> department (mutual peers) ──────────────── - proj_opts_qs = all_projects_qs + proj_opts_qs = Project.objects.all() if sel_departments: proj_opts_qs = proj_opts_qs.filter( projectorganization__organization__rank="department", @@ -199,7 +198,7 @@ def _prune(options, selected): ) projects_opts = _project_options(proj_opts_qs) - dept_scope_qs = all_projects_qs + dept_scope_qs = Project.objects.all() if sel_projects: dept_scope_qs = dept_scope_qs.filter(pk__in=sel_projects) dept_names = list(Organization.objects.filter( @@ -209,7 +208,7 @@ def _prune(options, selected): departments_opts = _department_options(dept_names) # Full project scope (both selections applied) for Tier 2. - project_scope = all_projects_qs + project_scope = Project.objects.all() if sel_projects: project_scope = project_scope.filter(pk__in=sel_projects) if sel_departments: @@ -244,13 +243,11 @@ def _prune(options, selected): status_names = list(stat_scope.values_list("status__name", flat=True).distinct()) statuses_opts = _string_options(status_names) - # Roles: from project scope. - role_pks = ProjectUser.objects.filter( - project__in=project_scope - ).values_list("role__pk", flat=True).distinct() + # Roles: from project scope (single query with join). role_names = list( - ProjectUserRoleChoice.objects.filter(pk__in=role_pks) - .values_list("name", flat=True) + ProjectUserRoleChoice.objects.filter( + projectuser__project__in=project_scope + ).values_list("name", flat=True).distinct() ) roles_opts = _string_options(role_names) @@ -264,18 +261,24 @@ def _prune(options, selected): "roles": _prune(roles_opts, sel_roles), } - # ── Active-filter labels from pruned selections ────────────────── + # ── Active-filter labels from already-computed options ──────────── + # Look up labels from the options lists instead of re-querying the DB. + proj_labels = {o["id"]: o["label"] for o in projects_opts} + res_labels = {o["id"]: o["label"] for o in resources_opts} + alloc_labels = {o["id"]: o["label"] for o in allocations_opts} + active_filters = {} if pruned["projects"]: - names = list(all_projects_qs.filter(pk__in=pruned["projects"]).values_list("title", flat=True)) + names = [proj_labels[pk] for pk in pruned["projects"] if pk in proj_labels] if names: active_filters["Project"] = names if pruned["allocations"]: - active_filters["Allocation"] = [str(pk) for pk in pruned["allocations"]] + names = [alloc_labels.get(pk, str(pk)) for pk in pruned["allocations"]] + active_filters["Allocation"] = names if pruned["departments"]: active_filters["Department"] = pruned["departments"] if pruned["resources"]: - names = list(all_resources_qs.filter(pk__in=pruned["resources"]).values_list("name", flat=True)) + names = [res_labels[pk] for pk in pruned["resources"] if pk in res_labels] if names: active_filters["Resource"] = names if pruned["statuses"]: @@ -291,14 +294,7 @@ def _prune(options, selected): active_filters=active_filters, ) - full_counts = { - "projects": all_projects_qs.count(), - "allocations": all_allocations_qs.distinct().count(), - "resources": all_resources_qs.count(), - "departments": len(set(all_dept_names)), - "statuses": len(set(n for n in all_status_names if n)), - "roles": len(set(n for n in all_role_names if n)), - } + # Clear "filtered_by" when the option count matches the full set. for key, s in summaries.items(): if isinstance(s, dict) and s.get("count", 0) == full_counts.get(key, -1): s["filtered_by"] = "" From 5e49c43667b0f749c1421c752317af6fe0812a4a Mon Sep 17 00:00:00 2001 From: Antonio Rodriguez Date: Tue, 12 May 2026 15:38:54 +0200 Subject: [PATCH 03/10] Fix M2M duplicate in allocation options, reorder filters by tier - Fix _allocation_options producing duplicates from M2M values_list on resources. Reverted to select_related + get_parent_resource. - Reorder filter dropdowns by tier: Project, Department, Resource, Allocation Status, Allocation, Role. - Add spacing between Details button and inline summary chips. --- .../coldfront_notifications/css/compose.css | 3 ++- .../templates/compose.html | 20 +++++++++---------- coldfront_notifications/views/filters.py | 9 +++++---- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/coldfront_notifications/static/coldfront_notifications/css/compose.css b/coldfront_notifications/static/coldfront_notifications/css/compose.css index e8c20d7..70dbcf9 100644 --- a/coldfront_notifications/static/coldfront_notifications/css/compose.css +++ b/coldfront_notifications/static/coldfront_notifications/css/compose.css @@ -11,7 +11,7 @@ /* recipients: row 2 — actions | inline summary | count pill */ .recip-actions { display: flex; align-items: center; gap: 10px; - margin-top: 14px; padding-top: 12px; + margin-top: 20px; padding-top: 14px; border-top: 1px solid #eef0f3; } .recip-actions-left { @@ -21,6 +21,7 @@ flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; font-size: .75rem; color: #6c757d; display: flex; align-items: center; gap: 4px; + margin-left: 14px; } .recip-inline-summary .ris-chip { display: inline-block; background: #f4f6f9; border: 1px solid #dee2e8; diff --git a/coldfront_notifications/templates/compose.html b/coldfront_notifications/templates/compose.html index 7d9b94c..6f06786 100644 --- a/coldfront_notifications/templates/compose.html +++ b/coldfront_notifications/templates/compose.html @@ -27,7 +27,7 @@

    - +
    @@ -38,15 +38,6 @@

    -
    - - -
    -
    +
    + + +
    +
    disabled state on trigger. - $(sel).trigger('change.select2'); - }); +function clearAllFilters() { + var keys = ['departments', 'projects', 'resources', 'statuses', 'allocations', 'roles']; + for (var i = 0; i < keys.length; i++) { + var state = FilterStore.state[keys[i]]; + if (state) { + state.selected = []; + state.autoSelected = []; + state.autoSource = ''; + state.dismissed = []; + state.visible = state.all; + } + if (FILTERS[keys[i]]) { + FILTERS[keys[i]].render(); + } + } + _invalidateValidation(); + _updateSummaries(); } -function applyFilterState(data) { - // data: { projects: {options, selected, summary}, allocations: {…}, … } - // Rebuild each - {% for p in projects %} - - {% endfor %} - + +
    - - + +
    - - + +
    - - + +
    - - + +
    - - + +
    - +
    -
    -
    —
    recipients
    @@ -108,6 +82,12 @@

    + + +
    + + +
    @@ -160,6 +140,7 @@

    {% csrf_token %} + @@ -277,15 +258,21 @@

    This action cannot be undone. -
    - - +
    + +
    + + + +
    @@ -414,28 +401,6 @@

    - - {% endblock %} @@ -444,20 +409,20 @@ // Server-rendered data (needs Django context) var TEMPLATES = {{ templates_json|safe }}; var VARIABLES = {{ variables_json|safe }}; -// Django URL endpoints for AJAX calls +var FILTER_DATA = {{ filter_data_json|safe }}; +var DRAFT_DATA = {{ draft_json|safe }}; var URLS = { - previewRender: '{% url "notifications:preview-render" %}', - filterOptions: '{% url "notifications:filter-options" %}', - validate: '{% url "notifications:validate" %}', - recipientCount: '{% url "notifications:recipient-count" %}', - templateJson: '{% url "notifications:template-json" pk=0 %}'.replace('/0/', '/{id}/') + previewRender: '{% url "notifications:api-preview-render" %}', + validate: '{% url "notifications:api-validate" %}', + recipientCount: '{% url "notifications:api-recipient-count" %}', + templateJson: '{% url "notifications:template-json" pk=0 %}'.replace('/0/', '/{id}/'), + draftSave: '{% url "notifications:api-draft-save" %}' }; -var FILTER_SUMMARIES = {{ filter_summaries_json|safe }}; - + {% endblock %} \ No newline at end of file diff --git a/coldfront_notifications/templates/dashboard.html b/coldfront_notifications/templates/coldfront_notifications/dashboard.html similarity index 100% rename from coldfront_notifications/templates/dashboard.html rename to coldfront_notifications/templates/coldfront_notifications/dashboard.html diff --git a/coldfront_notifications/templates/resend_compose.html b/coldfront_notifications/templates/coldfront_notifications/resend_compose.html similarity index 99% rename from coldfront_notifications/templates/resend_compose.html rename to coldfront_notifications/templates/coldfront_notifications/resend_compose.html index 25bbe73..dd21cd2 100644 --- a/coldfront_notifications/templates/resend_compose.html +++ b/coldfront_notifications/templates/coldfront_notifications/resend_compose.html @@ -271,9 +271,9 @@