diff --git a/coldfront_notifications/campaign_sender.py b/coldfront_notifications/campaign_sender.py
index b653a67..85d00e9 100644
--- a/coldfront_notifications/campaign_sender.py
+++ b/coldfront_notifications/campaign_sender.py
@@ -154,9 +154,11 @@ def send(self):
scope = determine_scope(list(variables_by_key.values()))
snapshot = campaign.filters_snapshot or {}
dedupe_users = snapshot.get("dedupe_users") or []
+ dedupe_selections = snapshot.get("dedupe_selections") or {}
resolved_emails = self._resolve_all_recipients(
- tokens, variables_by_key, snapshot, scope, dedupe_users,
+ tokens, variables_by_key, snapshot, scope,
+ dedupe_users, dedupe_selections,
)
if resolved_emails is None:
return # _fail already called
@@ -175,7 +177,8 @@ def send(self):
self._deliver_emails(resolved_emails, snapshot)
- def _resolve_all_recipients(self, tokens, variables_by_key, snapshot, scope, dedupe_users):
+ def _resolve_all_recipients(self, tokens, variables_by_key, snapshot, scope,
+ dedupe_users, dedupe_selections):
"""Resolve template variables for every recipient.
Returns a list of (user, project, allocation, rendered_subject, rendered_body)
@@ -184,7 +187,9 @@ def _resolve_all_recipients(self, tokens, variables_by_key, snapshot, scope, ded
resolver = RecipientResolver(snapshot)
resolved = []
- for user, project, allocation in resolver.enumerate_deduped(scope, dedupe_users):
+ for user, project, allocation in resolver.enumerate_deduped(
+ scope, dedupe_users, dedupe_selections=dedupe_selections,
+ ):
context = {"user": user, "project": project, "allocation": allocation}
try:
values = self.renderer.build_values(tokens, variables_by_key, context)
diff --git a/coldfront_notifications/filters.py b/coldfront_notifications/filters.py
index 3b9e3b0..1bfccc7 100644
--- a/coldfront_notifications/filters.py
+++ b/coldfront_notifications/filters.py
@@ -15,11 +15,11 @@
from typing import Any
from django.contrib.auth import get_user_model
+from django.db.models import Case, IntegerField, Value, When
from coldfront.core.allocation.models import (
Allocation,
AllocationStatusChoice,
- AllocationUser,
)
from coldfront.core.department.models import Department
from coldfront.core.project.models import Project, ProjectUser, ProjectUserRoleChoice
@@ -265,6 +265,14 @@ def _apply(self, project_users, role_names):
PROJECT_FILTERS = ("departments", "projects", "roles")
ALLOCATION_FILTERS = ("allocations", "resources", "statuses")
+PI_PRIORITY_ANNOTATION = {
+ "role_priority": Case(
+ When(role__name="PI", then=Value(0)),
+ default=Value(1),
+ output_field=IntegerField(),
+ )
+}
+
# FilterDataBuilder
@@ -288,11 +296,20 @@ class RecipientResolver:
Accepts the filters dict from the compose POST and produces User
querysets or (user, project, allocation) tuples for email rendering.
+
+ Supports two modes (via ``selection_mode`` key in filters):
+ - ``"filters"`` (default): narrow recipients through filter cascade
+ - ``"direct"``: hand-pick users by PK via ``direct_user_pks``
"""
def __init__(self, filters: dict):
self.filters = filters
+ def _is_direct_mode(self) -> bool:
+ return self.filters.get("selection_mode") == "direct"
+
+ # ── filter-mode helpers ─────────────────────────────────────────
+
def _apply_project_filters(self, project_users):
for name in PROJECT_FILTERS:
values = self.filters.get(name) or []
@@ -311,23 +328,31 @@ def _apply_allocation_filters(self):
def _has_allocation_filters(self):
return any(self.filters.get(name) for name in ALLOCATION_FILTERS)
+ def _matching_project_ids(self):
+ """Return project IDs from allocations that match the allocation filters."""
+ return (
+ self._apply_allocation_filters()
+ .values_list("project_id", flat=True)
+ .distinct()
+ )
+
+ # ── public API ──────────────────────────────────────────────────
+
def queryset(self):
"""Return a deduplicated User queryset matching the filters."""
+ if self._is_direct_mode():
+ pks = self.filters.get("direct_user_pks") or []
+ return User.objects.filter(pk__in=pks)
+
project_users = self._apply_project_filters(
ProjectUser.objects.select_related("user", "project")
.filter(status__name="Active"),
)
if self._has_allocation_filters():
- allocation_user_pks = (
- AllocationUser.objects
- .filter(
- allocation__in=self._apply_allocation_filters(),
- status__name="Active",
- )
- .values_list("user__pk", flat=True)
+ project_users = project_users.filter(
+ project_id__in=self._matching_project_ids()
)
- project_users = project_users.filter(user__pk__in=allocation_user_pks)
user_pks = project_users.values_list("user__pk", flat=True).distinct()
return User.objects.filter(pk__in=user_pks)
@@ -346,6 +371,10 @@ def enumerate(self, scope: str):
if scope not in ("user", "project", "allocation"):
raise ValueError(f"Unknown scope {scope!r}")
+ if self._is_direct_mode():
+ yield from self._enumerate_direct(scope)
+ return
+
if scope == "user":
for user in self.queryset().iterator():
yield (user, None, None)
@@ -357,55 +386,112 @@ def enumerate(self, scope: str):
"user", "project", "project__pi", "project__status", "role",
)
.filter(status__name="Active"),
- ).order_by("user_id", "project_id", "pk")
+ ).annotate(**PI_PRIORITY_ANNOTATION).order_by(
+ "user_id", "role_priority", "project_id", "pk",
+ )
if scope == "project":
if self._has_allocation_filters():
- allocation_user_pks = (
- AllocationUser.objects
- .filter(
- allocation__in=self._apply_allocation_filters(),
- status__name="Active",
- )
- .values_list("user__pk", flat=True)
- .distinct()
+ project_users = project_users.filter(
+ project_id__in=self._matching_project_ids()
)
- project_users = project_users.filter(user__pk__in=allocation_user_pks)
for project_user in project_users.iterator():
yield (project_user.user, project_user.project, None)
return
# scope == "allocation"
- matched_allocations = self._apply_allocation_filters()
- allocation_users = (
- AllocationUser.objects
+ matched_allocations = (
+ self._apply_allocation_filters()
+ .select_related("status", "project")
+ .order_by("project_id", "pk")
+ )
+
+ allocations_by_project = defaultdict(list)
+ for allocation in matched_allocations.iterator():
+ allocations_by_project[allocation.project_id].append(allocation)
+
+ for project_user in project_users.iterator():
+ for allocation in allocations_by_project.get(project_user.project_id, []):
+ yield (project_user.user, project_user.project, allocation)
+
+ # ── direct-mode enumeration ─────────────────────────────────────
+
+ def _enumerate_direct(self, scope: str):
+ """Yield tuples for directly-selected users, expanding to their
+ projects/allocations when the template scope demands it."""
+ pks = self.filters.get("direct_user_pks") or []
+ if not pks:
+ return
+
+ if scope == "user":
+ for user in User.objects.filter(pk__in=pks).iterator():
+ yield (user, None, None)
+ return
+
+ # Expand to active project memberships
+ project_users = (
+ ProjectUser.objects
.select_related(
- "allocation", "allocation__status", "allocation__project",
+ "user", "project", "project__pi", "project__status", "role",
)
- .filter(allocation__in=matched_allocations, status__name="Active")
- .order_by("user_id", "allocation_id", "pk")
+ .filter(user__pk__in=pks, status__name="Active")
+ .annotate(**PI_PRIORITY_ANNOTATION)
+ .order_by("user_id", "role_priority", "project_id", "pk")
+ )
+
+ if scope == "project":
+ for project_user in project_users.iterator():
+ yield (project_user.user, project_user.project, None)
+ return
+
+ # scope == "allocation" — expand to allocations on the user's projects
+ project_id_subquery = project_users.values_list("project_id", flat=True).distinct()
+ allocations = (
+ Allocation.objects
+ .filter(project_id__in=project_id_subquery)
+ .select_related("status", "project")
+ .order_by("project_id", "pk")
)
- allocations_by_user_project = defaultdict(list)
- for allocation_user in allocation_users.iterator():
- key = (allocation_user.user_id, allocation_user.allocation.project_id)
- allocations_by_user_project[key].append(allocation_user.allocation)
+ allocations_by_project = defaultdict(list)
+ for allocation in allocations.iterator():
+ allocations_by_project[allocation.project_id].append(allocation)
for project_user in project_users.iterator():
- key = (project_user.user_id, project_user.project_id)
- for allocation in allocations_by_user_project.get(key, []):
+ for allocation in allocations_by_project.get(project_user.project_id, []):
yield (project_user.user, project_user.project, allocation)
- def enumerate_deduped(self, scope: str, dedupe_users):
- """Wraps enumerate with per-user deduplication."""
- dedupe = set(dedupe_users or [])
- if not dedupe:
+ def enumerate_deduped(self, scope: str, dedupe_users=None,
+ dedupe_selections=None):
+ """Wraps enumerate with per-user deduplication.
+
+ dedupe_users: list of usernames — keep first tuple only (legacy).
+ dedupe_selections: dict {username: [project_pk, ...]} — keep only
+ tuples whose project_pk is in the list. Takes precedence over
+ dedupe_users for usernames present in both.
+ """
+ selections = dedupe_selections or {}
+ legacy_dedupe = set(dedupe_users or []) - set(selections.keys())
+
+ if not legacy_dedupe and not selections:
yield from self.enumerate(scope)
return
- seen = set()
+
+ seen_legacy = set()
for user, project, allocation in self.enumerate(scope):
- if user.username in dedupe:
- if user.pk in seen:
+ username = user.username
+
+ if username in selections:
+ keep_pks = selections[username]
+ if project and project.pk in keep_pks:
+ yield (user, project, allocation)
+ elif not project:
+ yield (user, project, allocation)
+ continue
+
+ if username in legacy_dedupe:
+ if user.pk in seen_legacy:
continue
- seen.add(user.pk)
+ seen_legacy.add(user.pk)
+
yield (user, project, allocation)
diff --git a/coldfront_notifications/models.py b/coldfront_notifications/models.py
index 594eaf1..fef709b 100644
--- a/coldfront_notifications/models.py
+++ b/coldfront_notifications/models.py
@@ -199,6 +199,10 @@ def delivery_pct(self):
@property
def filter_summary(self):
"""Return list of human-readable filter badge strings."""
+ if self.filters_snapshot.get("selection_mode") == "direct":
+ pks = self.filters_snapshot.get("direct_user_pks", [])
+ return [f"Direct selection: {len(pks)} user(s)"]
+
labels = {
"projects": "Project",
"allocations": "Allocation",
diff --git a/coldfront_notifications/notification_validator.py b/coldfront_notifications/notification_validator.py
index 4a7b5af..dc7737f 100644
--- a/coldfront_notifications/notification_validator.py
+++ b/coldfront_notifications/notification_validator.py
@@ -30,11 +30,13 @@ class NotificationValidator:
"""
def __init__(self, subject: str, body: str, filters: dict,
- dedupe_users=None, scope_override=None):
+ dedupe_users=None, dedupe_selections=None,
+ scope_override=None):
self.subject = subject
self.body = body
self.filters = filters
self.dedupe_users = dedupe_users
+ self.dedupe_selections = dedupe_selections
self.scope_override = scope_override
def validate(self) -> dict:
@@ -54,7 +56,10 @@ def validate(self) -> dict:
emails_per_user = Counter()
resolver = RecipientResolver(self.filters)
- for user, project, allocation in resolver.enumerate_deduped(scope, self.dedupe_users):
+ for user, project, allocation in resolver.enumerate_deduped(
+ scope, self.dedupe_users,
+ dedupe_selections=self.dedupe_selections,
+ ):
unique_user_pks.add(user.pk)
emails_per_user[user.username] += 1
context = {"user": user, "project": project, "allocation": allocation}
diff --git a/coldfront_notifications/static/coldfront_notifications/css/compose.css b/coldfront_notifications/static/coldfront_notifications/css/compose.css
index cbda566..74993f2 100644
--- a/coldfront_notifications/static/coldfront_notifications/css/compose.css
+++ b/coldfront_notifications/static/coldfront_notifications/css/compose.css
@@ -179,6 +179,47 @@
font-size: .78rem; height: auto !important;
}
+/* mode toggle */
+.recip-mode-toggle .btn-group .btn {
+ font-size: .78rem; padding: 4px 12px;
+}
+.recip-mode-toggle .btn-group .btn.active {
+ background: #A51C30; border-color: #A51C30; color: #fff;
+}
+
+/* direct user selection */
+.direct-search-wrap { position: relative; }
+.direct-search-results {
+ position: absolute; z-index: 100; width: 100%;
+ max-height: 260px; overflow-y: auto;
+ background: #fff; border: 1px solid #ced4da; border-top: none;
+ border-radius: 0 0 4px 4px; box-shadow: 0 4px 12px rgba(0,0,0,.1);
+}
+.dsr-item {
+ padding: 6px 10px; cursor: pointer; border-bottom: 1px solid #f0f2f5;
+ display: flex; flex-direction: column;
+}
+.dsr-item:last-child { border-bottom: none; }
+.dsr-item:hover { background: #fdf0f2; }
+.dsr-name { font-size: .82rem; font-weight: 600; color: #343a40; }
+.dsr-detail { font-size: .72rem; color: #6c757d; font-family: monospace; }
+.dsr-empty { padding: 10px; font-size: .8rem; color: #adb5bd; text-align: center; }
+
+.direct-user-chips {
+ display: flex; flex-wrap: wrap; gap: 5px; min-height: 20px;
+}
+.user-chip {
+ display: inline-flex; align-items: center; gap: 4px;
+ background: #A51C30; color: #fff; border-radius: 12px;
+ padding: 3px 10px; font-size: .75rem; font-family: monospace;
+ cursor: default;
+}
+.user-chip-remove {
+ cursor: pointer; font-size: .6rem; opacity: .7;
+ transition: opacity .15s;
+}
+.user-chip-remove:hover { opacity: 1; }
+
/* select2 overrides */
.select2-container { width: 100% !important; }
.select2-container--default .select2-selection--multiple {
diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_direct_select.js b/coldfront_notifications/static/coldfront_notifications/js/compose_direct_select.js
new file mode 100644
index 0000000..fbf4ea3
--- /dev/null
+++ b/coldfront_notifications/static/coldfront_notifications/js/compose_direct_select.js
@@ -0,0 +1,316 @@
+// compose_direct_select.js — Direct user selection mode.
+//
+// Provides a search autocomplete, chip display, and bulk-paste
+// resolution for selecting individual users as recipients.
+//
+// Globals used: URLS, _invalidateValidation (from compose_filters.js)
+// Globals exported: SELECTION_MODE, SELECTED_USERS, switchSelectionMode
+
+var SELECTION_MODE = 'filters';
+var SELECTED_USERS = {}; // pk → {pk, username, email, full_name}
+
+var _searchDebounce = null;
+var _searchCache = null; // cache for the initial (empty-query) results
+
+
+// ── Shared accessors (used by compose_filters, compose_preview, etc.) ──
+
+function getSelectionMode() {
+ return SELECTION_MODE;
+}
+
+function getDirectUserPks() {
+ return Object.keys(SELECTED_USERS).map(Number);
+}
+
+
+// ── Mode switching ─────────────────────────────────────────────────
+
+function switchSelectionMode(mode) {
+ SELECTION_MODE = mode;
+ $('#h_selection_mode').val(mode);
+
+ if (mode === 'direct') {
+ $('#filterModePanel').hide();
+ $('#directModePanel').show();
+ $('#modeFilters').removeClass('active');
+ $('#modeDirect').addClass('active');
+ $('#clearFiltersBtn').hide();
+ // Pre-populate search results on first switch
+ if (!_searchCache) {
+ _fetchUsers('', function(results) {
+ _searchCache = results;
+ _renderSearchResults(results);
+ $('#directSearchResults').show();
+ });
+ }
+ } else {
+ $('#directModePanel').hide();
+ $('#filterModePanel').show();
+ $('#modeDirect').removeClass('active');
+ $('#modeFilters').addClass('active');
+ }
+ _invalidateValidation();
+}
+
+$(document).on('click', '#modeFilters', function() {
+ if (SELECTION_MODE !== 'filters') switchSelectionMode('filters');
+});
+$(document).on('click', '#modeDirect', function() {
+ if (SELECTION_MODE !== 'direct') switchSelectionMode('direct');
+});
+
+
+// ── User search autocomplete ───────────────────────────────────────
+
+function _fetchUsers(query, callback) {
+ $.ajax({
+ url: URLS.userSearch,
+ type: 'GET',
+ data: { q: query, limit: 100 },
+ success: function(data) {
+ callback(data.results || []);
+ },
+ error: function() {
+ callback([]);
+ }
+ });
+}
+
+function _renderSearchResults(results) {
+ var $container = $('#directSearchResults');
+ if (!results.length) {
+ $container.html('
No users found
');
+ $container.show();
+ return;
+ }
+
+ var html = '';
+ for (var i = 0; i < results.length; i++) {
+ var u = results[i];
+ if (SELECTED_USERS[u.pk]) continue; // skip already-selected
+ var escaped_name = $('').text(u.full_name).html();
+ var escaped_user = $('').text(u.username).html();
+ var escaped_email = $('').text(u.email).html();
+ html += ''
+ + '' + escaped_name + ' '
+ + '' + escaped_user + ' · ' + escaped_email + ' '
+ + '
';
+ }
+ $container.html(html || 'All matching users already selected
');
+ $container.show();
+}
+
+$(document).on('input', '#directUserSearch', function() {
+ var q = $(this).val().trim();
+ clearTimeout(_searchDebounce);
+
+ if (!q) {
+ // Show cached initial results
+ if (_searchCache) {
+ _renderSearchResults(_searchCache);
+ } else {
+ _fetchUsers('', function(results) {
+ _searchCache = results;
+ _renderSearchResults(results);
+ });
+ }
+ return;
+ }
+
+ _searchDebounce = setTimeout(function() {
+ _fetchUsers(q, function(results) {
+ _renderSearchResults(results);
+ });
+ }, 300);
+});
+
+$(document).on('focus', '#directUserSearch', function() {
+ var q = $(this).val().trim();
+ if (!q && _searchCache) {
+ _renderSearchResults(_searchCache);
+ } else if (!q) {
+ _fetchUsers('', function(results) {
+ _searchCache = results;
+ _renderSearchResults(results);
+ });
+ } else {
+ // Re-show existing results
+ $('#directSearchResults').show();
+ }
+});
+
+// Close dropdown when clicking outside
+$(document).on('mousedown', function(e) {
+ if (!$(e.target).closest('.direct-search-wrap').length) {
+ $('#directSearchResults').hide();
+ }
+});
+
+// Select a user from search results
+$(document).on('click', '.dsr-item', function() {
+ var pk = parseInt($(this).data('pk'), 10);
+ if (SELECTED_USERS[pk]) return;
+
+ SELECTED_USERS[pk] = {
+ pk: pk,
+ username: $(this).data('username'),
+ email: $(this).data('email'),
+ full_name: $(this).data('fullname')
+ };
+
+ $(this).remove();
+ _renderChips();
+ _syncHiddenField();
+ _invalidateValidation();
+ $('#directUserSearch').val('').focus();
+});
+
+
+// ── Chip rendering ─────────────────────────────────────────────────
+
+function _renderChips() {
+ var pks = Object.keys(SELECTED_USERS);
+ var html = '';
+ for (var i = 0; i < pks.length; i++) {
+ var u = SELECTED_USERS[pks[i]];
+ var escaped = $('').text(u.username).html();
+ html += ''
+ + escaped
+ + ' '
+ + ' ';
+ }
+ $('#directUserChips').html(html);
+ $('#directUserCount').text(pks.length ? pks.length + ' user(s) selected' : '');
+}
+
+$(document).on('click', '.user-chip-remove', function(e) {
+ e.stopPropagation();
+ var pk = parseInt($(this).closest('.user-chip').data('pk'), 10);
+ delete SELECTED_USERS[pk];
+ _renderChips();
+ _syncHiddenField();
+ _invalidateValidation();
+});
+
+
+// ── Bulk paste ─────────────────────────────────────────────────────
+
+$(document).on('click', '#directBulkResolveBtn', function() {
+ var raw = $('#directBulkPaste').val().trim();
+ if (!raw) return;
+
+ var $btn = $(this);
+ $btn.prop('disabled', true).html(' Resolving…');
+
+ $.ajax({
+ url: URLS.userBulkResolve,
+ type: 'POST',
+ data: {
+ csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(),
+ identifiers: raw
+ },
+ success: function(data) {
+ var added = 0;
+ var found = data.found || [];
+ for (var i = 0; i < found.length; i++) {
+ if (!SELECTED_USERS[found[i].pk]) {
+ SELECTED_USERS[found[i].pk] = found[i];
+ added++;
+ }
+ }
+ _renderChips();
+ _syncHiddenField();
+ _invalidateValidation();
+ _searchCache = null; // invalidate cache since selections changed
+
+ var feedbackHtml = '';
+ if (added) {
+ feedbackHtml += '' + added + ' user(s) added. ';
+ }
+ var notFound = data.not_found || [];
+ if (notFound.length) {
+ feedbackHtml += ' ' + notFound.length + ' not found: '
+ + notFound.map(function(id) { return '' + $('').text(id).html() + ' '; }).join(', ')
+ + ' ';
+ }
+ $('#directBulkFeedback').html(feedbackHtml);
+ if (!notFound.length) {
+ $('#directBulkPaste').val('');
+ }
+ },
+ error: function() {
+ $('#directBulkFeedback').html('Request failed — please try again. ');
+ },
+ complete: function() {
+ $btn.prop('disabled', false).html(' Resolve');
+ }
+ });
+});
+
+
+// ── Hidden field sync ──────────────────────────────────────────────
+
+function _syncHiddenField() {
+ $('#h_direct_user_pks').val(JSON.stringify(getDirectUserPks()));
+}
+
+
+// ── Restore direct mode from draft ─────────────────────────────────
+
+function restoreDirectMode(userPks) {
+ if (!userPks || !userPks.length) return;
+
+ // Fetch full details for the stored PKs via bulk resolve.
+ // We send PKs as "identifiers" — the search endpoint won't match by PK,
+ // so instead fetch all and match client-side, with a fallback request
+ // for any PKs not found in the initial batch.
+ var remaining = userPks.slice();
+
+ function _resolveBySearch(query, callback) {
+ $.ajax({
+ url: URLS.userSearch,
+ type: 'GET',
+ data: { q: query, limit: 200 },
+ success: function(data) { callback(data.results || []); },
+ error: function() { callback([]); }
+ });
+ }
+
+ _resolveBySearch('', function(results) {
+ var byPk = {};
+ for (var i = 0; i < results.length; i++) byPk[results[i].pk] = results[i];
+
+ var unresolved = [];
+ for (var j = 0; j < remaining.length; j++) {
+ var pk = remaining[j];
+ if (byPk[pk]) {
+ SELECTED_USERS[pk] = byPk[pk];
+ } else {
+ unresolved.push(pk);
+ }
+ }
+
+ // For any PKs not in the first batch, fetch individually
+ if (unresolved.length) {
+ var done = 0;
+ for (var k = 0; k < unresolved.length; k++) {
+ (function(upk) {
+ _resolveBySearch(String(upk), function(hits) {
+ for (var h = 0; h < hits.length; h++) {
+ if (hits[h].pk === upk) { SELECTED_USERS[upk] = hits[h]; break; }
+ }
+ done++;
+ if (done === unresolved.length) { _renderChips(); _syncHiddenField(); }
+ });
+ })(unresolved[k]);
+ }
+ }
+
+ _renderChips();
+ _syncHiddenField();
+ });
+}
diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_draft.js b/coldfront_notifications/static/coldfront_notifications/js/compose_draft.js
index 0bee2fc..bd00d6a 100644
--- a/coldfront_notifications/static/coldfront_notifications/js/compose_draft.js
+++ b/coldfront_notifications/static/coldfront_notifications/js/compose_draft.js
@@ -27,6 +27,10 @@ function _markClean(timestamp) {
}
function _collectDraftData() {
+ var filters = collectFilters();
+ // Include dedupe state in the filters snapshot so drafts restore it.
+ filters.dedupe_users = getDedupeUsers();
+ filters.dedupe_selections = getDedupeSelections();
return {
csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(),
draft_pk: DRAFT_PK || '',
@@ -35,7 +39,7 @@ function _collectDraftData() {
sender: $('#id_sender').val() || '',
reply_to: $('#id_reply_to').val() || '',
template_id: $('#hidden_template_id').val() || '',
- filters: JSON.stringify(collectFilters()),
+ filters: JSON.stringify(filters),
extra_context: JSON.stringify({})
};
}
@@ -109,24 +113,33 @@ function restoreDraft(draftData) {
// Wait for Select2 and FilterStore to be initialized
setTimeout(function() {
- for (var filterName in filterMap) {
- var values = filters[filterName] || [];
- if (!values.length) continue;
-
- var state = FilterStore.state[filterName];
- if (!state) continue;
-
- // Coerce types to match the option IDs
- var firstOption = state.all.length ? state.all[0] : null;
- var useInt = firstOption && typeof firstOption.id === 'number';
- if (useInt) {
- values = values.map(function(value) { return parseInt(value, 10); });
+ // Check if draft used direct mode
+ if (filters.selection_mode === 'direct' && typeof switchSelectionMode === 'function') {
+ switchSelectionMode('direct');
+ var directPks = filters.direct_user_pks || [];
+ if (directPks.length && typeof restoreDirectMode === 'function') {
+ restoreDirectMode(directPks);
}
-
- state.selected = values;
- if (FILTERS[filterName]) {
- FILTERS[filterName].render();
- FILTERS[filterName].dispatchChanged();
+ } else {
+ for (var filterName in filterMap) {
+ var values = filters[filterName] || [];
+ if (!values.length) continue;
+
+ var state = FilterStore.state[filterName];
+ if (!state) continue;
+
+ // Coerce types to match the option IDs
+ var firstOption = state.all.length ? state.all[0] : null;
+ var useInt = firstOption && typeof firstOption.id === 'number';
+ if (useInt) {
+ values = values.map(function(value) { return parseInt(value, 10); });
+ }
+
+ state.selected = values;
+ if (FILTERS[filterName]) {
+ FILTERS[filterName].render();
+ FILTERS[filterName].dispatchChanged();
+ }
}
}
@@ -135,10 +148,13 @@ function restoreDraft(draftData) {
$('textarea[name="extra_recipients"]').val(filters.extra_recipients.join('\n'));
}
- // Restore dedupe users
+ // Restore dedupe users and selections
if (filters.dedupe_users && filters.dedupe_users.length) {
$('#h_dedupe_users').val(JSON.stringify(filters.dedupe_users));
}
+ if (filters.dedupe_selections && typeof filters.dedupe_selections === 'object') {
+ setDedupeSelections(filters.dedupe_selections);
+ }
// Highlight the matching template in the sidebar
if (draftData.template_id) {
diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_filters.js b/coldfront_notifications/static/coldfront_notifications/js/compose_filters.js
index 78305d5..999d0d5 100644
--- a/coldfront_notifications/static/coldfront_notifications/js/compose_filters.js
+++ b/coldfront_notifications/static/coldfront_notifications/js/compose_filters.js
@@ -18,6 +18,8 @@
// Globals used: FILTER_DATA (from Django template)
// Globals exported: collectFilters, getDedupeUsers, setDedupeUsers
+var FILTER_KEYS = ['departments', 'projects', 'resources', 'statuses', 'allocations', 'roles'];
+
var FilterStore = {
state: {},
listeners: {},
@@ -275,12 +277,18 @@ var FILTERS = {
// Shared helpers
function collectFilters() {
- var filters = {};
- var keys = ['departments', 'projects', 'resources', 'statuses', 'allocations', 'roles'];
- for (var i = 0; i < keys.length; i++) {
- var key = keys[i];
- var state = FilterStore.state[key];
- filters[key] = (state && state.selected.length) ? state.selected.map(String) : [];
+ var mode = typeof getSelectionMode === 'function' ? getSelectionMode() : 'filters';
+ var filters = { selection_mode: mode };
+
+ if (mode === 'direct') {
+ filters.direct_user_pks = typeof getDirectUserPks === 'function' ? getDirectUserPks() : [];
+ for (var i = 0; i < FILTER_KEYS.length; i++) filters[FILTER_KEYS[i]] = [];
+ } else {
+ for (var i = 0; i < FILTER_KEYS.length; i++) {
+ var key = FILTER_KEYS[i];
+ var state = FilterStore.state[key];
+ filters[key] = (state && state.selected.length) ? state.selected.map(String) : [];
+ }
}
return filters;
}
@@ -296,6 +304,17 @@ function setDedupeUsers(list) {
$('#h_dedupe_users').val(JSON.stringify(list || []));
}
+function getDedupeSelections() {
+ try {
+ var v = JSON.parse($('#h_dedupe_selections').val() || '{}');
+ return (typeof v === 'object' && !Array.isArray(v)) ? v : {};
+ } catch (e) { return {}; }
+}
+
+function setDedupeSelections(obj) {
+ $('#h_dedupe_selections').val(JSON.stringify(obj || {}));
+}
+
function _invalidateValidation() {
$('#recipNum').text('?');
@@ -303,7 +322,9 @@ function _invalidateValidation() {
$('#sendBtn').prop('disabled', true);
$('#previewRecipBtn').prop('disabled', true);
$('#sendWarn').hide();
+ $('#directScopeWarn').hide();
setDedupeUsers([]);
+ setDedupeSelections({});
if (typeof _setValidateButtonState === 'function') _setValidateButtonState('default');
if (typeof _hideTopValidationResult === 'function') _hideTopValidationResult();
}
@@ -538,7 +559,7 @@ function _getBottomUpContext(name, state) {
function _renderFilterDetails() {
var $container = $('#filterDetails').empty();
- var keys = ['departments', 'projects', 'resources', 'statuses', 'allocations', 'roles'];
+ var keys = FILTER_KEYS;
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
@@ -577,7 +598,7 @@ function _renderFilterDetails() {
function updateFilterSummaries() { _updateSummaries(); }
function clearAllFilters() {
- var keys = ['departments', 'projects', 'resources', 'statuses', 'allocations', 'roles'];
+ var keys = FILTER_KEYS;
for (var i = 0; i < keys.length; i++) {
var state = FilterStore.state[keys[i]];
if (state) {
@@ -591,7 +612,7 @@ function clearAllFilters() {
}
function _updateClearButton() {
- var keys = ['departments', 'projects', 'resources', 'statuses', 'allocations', 'roles'];
+ var keys = FILTER_KEYS;
var hasAny = false;
for (var i = 0; i < keys.length; i++) {
var state = FilterStore.state[keys[i]];
@@ -608,7 +629,7 @@ $(document).on('click', '#clearFiltersBtn', clearAllFilters);
$(document).ready(function() {
if (typeof FILTER_DATA === 'undefined') return;
- var keys = ['departments', 'projects', 'resources', 'statuses', 'allocations', 'roles'];
+ var keys = FILTER_KEYS;
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
if (FILTERS[k] && FILTER_DATA[k]) FILTERS[k].init(FILTER_DATA[k]);
diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_init.js b/coldfront_notifications/static/coldfront_notifications/js/compose_init.js
index 957a8ab..39f7d02 100644
--- a/coldfront_notifications/static/coldfront_notifications/js/compose_init.js
+++ b/coldfront_notifications/static/coldfront_notifications/js/compose_init.js
@@ -9,12 +9,19 @@
// ── Serialize filter hidden fields before submit ─────────────────
$('#notifForm').on('submit', function() {
if (typeof tinymce !== 'undefined') tinymce.triggerSave();
- $('#h_projects').val(JSON.stringify($('#f_project').val() || []));
- $('#h_allocations').val(JSON.stringify($('#f_allocation').val() || []));
- $('#h_depts').val(JSON.stringify($('#f_dept').val() || []));
- $('#h_resources').val(JSON.stringify($('#f_resource').val() || []));
- $('#h_statuses').val(JSON.stringify($('#f_status').val() || []));
- $('#h_roles').val(JSON.stringify($('#f_role').val() || []));
+ var mode = typeof getSelectionMode === 'function' ? getSelectionMode() : 'filters';
+ $('#h_selection_mode').val(mode);
+ if (mode === 'direct') {
+ var pks = typeof getDirectUserPks === 'function' ? getDirectUserPks() : [];
+ $('#h_direct_user_pks').val(JSON.stringify(pks));
+ } else {
+ $('#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() || []));
+ }
if (typeof DRAFT_PK !== 'undefined' && DRAFT_PK) {
$('#hidden_draft_pk').val(DRAFT_PK);
}
diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_preview.js b/coldfront_notifications/static/coldfront_notifications/js/compose_preview.js
index aa22aaa..adac8ab 100644
--- a/coldfront_notifications/static/coldfront_notifications/js/compose_preview.js
+++ b/coldfront_notifications/static/coldfront_notifications/js/compose_preview.js
@@ -61,6 +61,7 @@ $(document).on('click', '#previewBtn', function() {
body: getBodyContent() || '',
filters: JSON.stringify(collectFilters()),
dedupe_users: JSON.stringify(getDedupeUsers()),
+ dedupe_selections: JSON.stringify(getDedupeSelections()),
},
success: function(data) {
PREVIEW_SAMPLES = data.samples || [];
@@ -107,7 +108,8 @@ var PREVIEW_TOTAL_PAGES = 1;
var PREVIEW_TOTAL = 0;
var PREVIEW_USER_COUNT = 0;
var ALL_MULTI_USERS = [];
-var ALL_MULTI_COUNTS = {}; // username → email count (for dedup math)
+var ALL_MULTI_COUNTS = {}; // username → email count (for dedup math)
+var ALL_MULTI_FIRST_PKS = {}; // username → first (PI-priority) project_pk
function renderPreviewRows() {
// Group rows by username preserving order.
@@ -118,8 +120,10 @@ function renderPreviewRows() {
byUser[u].push(r);
});
- var dedupeSet = {};
- getDedupeUsers().forEach(function(u) { dedupeSet[u] = true; });
+ var selections = getDedupeSelections();
+ var dedupeUsers = getDedupeUsers();
+ var legacySet = {};
+ dedupeUsers.forEach(function(u) { legacySet[u] = true; });
// Build a set from the server's cross-page multi-user list for O(1) lookup.
var multiSet = {};
@@ -129,23 +133,49 @@ function renderPreviewRows() {
order.forEach(function(u) {
var group = byUser[u];
var isMulti = !!multiSet[u];
- var isDeduped = !!dedupeSet[u];
+ var userSelections = selections[u] || null;
+ // User is in legacy dedupe (first-tuple-only) if in dedupe_users
+ // but NOT in dedupe_selections.
+ var isLegacyDeduped = !!legacySet[u] && userSelections === null;
+ // User has active selections → some rows are excluded.
+ var hasExclusions = userSelections !== null || isLegacyDeduped;
+
group.forEach(function(r, idx) {
var first = idx === 0;
- var muted = (isDeduped && !first);
- if (isDeduped && group.length === 1 && isMulti) muted = true;
+ var projectPk = r.project_pk || '';
+
+ // A row is "kept" if:
+ // - no exclusions active, OR
+ // - legacy dedupe: only the first row is kept, OR
+ // - selections: row's project_pk is in the kept list.
+ var isKept;
+ if (!hasExclusions) {
+ isKept = true;
+ } else if (isLegacyDeduped) {
+ isKept = first;
+ } else {
+ isKept = userSelections.indexOf(projectPk) !== -1;
+ }
+
+ var muted = hasExclusions && !isKept;
var styleAttr = muted
? ' style="color:#aab0b7;text-decoration:line-through;font-style:italic;"'
: '';
+
+ // Every row of a multi-email user gets a checkbox.
var cbCell = '';
- if (first && isMulti) {
- cbCell = ' ';
+ if (isMulti) {
+ cbCell = ''
+ + ' '
+ + ' ';
} else {
cbCell = ' ';
}
+
html += ''
+ cbCell
+ '' + (r.full_name||'') + (isMulti && first ? ' multi ' : '') + ' '
@@ -158,17 +188,18 @@ function renderPreviewRows() {
});
$('#recipRows').html(html || ' No recipients matched. ');
- // Compute effective email count: deduped users count as 1 email each.
- var dedupeList = getDedupeUsers();
- var dedupeSet = {};
- dedupeList.forEach(function(u) { dedupeSet[u] = true; });
-
- // For each multi-user that's deduped, subtract (count - 1) from total.
+ // Compute effective email count.
var effective = PREVIEW_TOTAL;
- if (dedupeList.length && PREVIEW_TOTAL > 0) {
+ if (PREVIEW_TOTAL > 0) {
ALL_MULTI_USERS.forEach(function(u) {
- if (dedupeSet[u] && ALL_MULTI_COUNTS[u]) {
- effective -= (ALL_MULTI_COUNTS[u] - 1);
+ var count = ALL_MULTI_COUNTS[u] || 0;
+ if (count <= 1) return;
+ if (selections[u]) {
+ // Per-user selections: keep only the selected project rows.
+ effective -= (count - selections[u].length);
+ } else if (legacySet[u]) {
+ // Legacy dedupe: keep only one email per user.
+ effective -= (count - 1);
}
});
}
@@ -197,27 +228,39 @@ function updatePaginationControls() {
function fetchPreviewPage(page) {
PREVIEW_PAGE = page || 1;
- $('#recipSearch').val('');
$('#recipRows').html(' Loading… ');
+ var mode = typeof getSelectionMode === 'function' ? getSelectionMode() : 'filters';
+ var searchQuery = ($('#recipSearch').val() || '').trim();
+ var postData = {
+ csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(),
+ subject: $('#id_subject').val() || '',
+ body: getBodyContent() || '',
+ dedupe_users: JSON.stringify(getDedupeUsers()),
+ preview: 'true',
+ page: PREVIEW_PAGE,
+ page_size: getPageSize(),
+ selection_mode: mode,
+ search: searchQuery
+ };
+
+ if (mode === 'direct') {
+ postData.direct_user_pks = JSON.stringify(
+ typeof getDirectUserPks === 'function' ? getDirectUserPks() : []
+ );
+ } else {
+ postData.projects = $('#f_project').val() || [];
+ postData.allocations = $('#f_allocation').val() || [];
+ postData.departments = $('#f_dept').val() || [];
+ postData.resources = $('#f_resource').val() || [];
+ postData.alloc_status = $('#f_status').val() || [];
+ postData.roles = $('#f_role').val() || [];
+ }
+
$.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: getBodyContent() || '',
- dedupe_users: JSON.stringify(getDedupeUsers()),
- preview: 'true',
- page: PREVIEW_PAGE,
- page_size: getPageSize(),
- },
+ data: postData,
traditional: true,
success: function(data) {
PREVIEW_ROWS = data.recipients || [];
@@ -228,13 +271,21 @@ function fetchPreviewPage(page) {
PREVIEW_PAGE = data.page || 1;
ALL_MULTI_USERS = (data.multi_users || []).map(function(m) { return m.username; });
ALL_MULTI_COUNTS = {};
- (data.multi_users || []).forEach(function(m) { ALL_MULTI_COUNTS[m.username] = m.count; });
+ ALL_MULTI_FIRST_PKS = {};
+ (data.multi_users || []).forEach(function(m) {
+ ALL_MULTI_COUNTS[m.username] = m.count;
+ if (m.first_project_pk != null) ALL_MULTI_FIRST_PKS[m.username] = m.first_project_pk;
+ });
renderPreviewRows();
// Show dedupe-all row if any multi-email users exist across all pages
if (ALL_MULTI_USERS.length) {
$('#dedupeAllRow').show();
+ var sel = getDedupeSelections();
+ var dedupeList = getDedupeUsers();
+ var dedupeSet = {};
+ dedupeList.forEach(function(u) { dedupeSet[u] = true; });
var allDeduped = ALL_MULTI_USERS.every(function(u) {
- return getDedupeUsers().indexOf(u) !== -1;
+ return !!sel[u] || !!dedupeSet[u];
});
$('#dedupeAllCb').prop('checked', allDeduped);
} else {
@@ -268,37 +319,87 @@ $(document).on('change', '#recipPageSize', function() {
fetchPreviewPage(1);
});
-// Client-side search: filter visible rows in the current page
+// Server-side search across all recipients (debounced).
+var _searchTimer = null;
$(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);
- });
+ clearTimeout(_searchTimer);
+ _searchTimer = setTimeout(function() {
+ fetchPreviewPage(1);
+ }, 300);
});
-// Bulk dedupe: toggle ALL multi-email users across all pages.
+// Bulk dedupe: keep only the first (PI-priority) row for every multi-email user.
+// Uses the legacy dedupe_users path which keeps the first tuple only,
+// regardless of scope. Per-row checkboxes move individual users into
+// dedupe_selections for fine-grained control.
$(document).on('change', '#dedupeAllCb', function() {
if ($(this).is(':checked')) {
- setDedupeUsers(ALL_MULTI_USERS.slice());
+ var dedupeList = ALL_MULTI_USERS.slice();
+ setDedupeUsers(dedupeList);
+ // Clear per-user selections so the legacy first-tuple path is used.
+ setDedupeSelections({});
} else {
setDedupeUsers([]);
+ setDedupeSelections({});
}
renderPreviewRows();
runValidation();
});
-// Per-user dedupe toggle inside the modal.
-$(document).on('change', '.dedupe-cb', function() {
+// Per-row include/exclude checkbox for multi-email users.
+// When a user was in legacy dedupe (first-tuple-only via dedupe_users),
+// any per-row change moves them to dedupe_selections for fine-grained control.
+$(document).on('change', '.dedupe-row-cb', function() {
var username = $(this).attr('data-username');
- var cur = getDedupeUsers();
- var idx = cur.indexOf(username);
+ var projectPk = $(this).attr('data-project-pk');
+ // Coerce to number if numeric (project PKs are integers)
+ if (projectPk && !isNaN(projectPk)) projectPk = parseInt(projectPk, 10);
+
+ var sel = getDedupeSelections();
+ var dedupeUsers = getDedupeUsers();
+
+ // If user was in legacy dedupe but not yet in selections, transition them:
+ // seed selections with the first (PI-priority) project_pk so the behavior
+ // starts from the same state as "dedupe all" showed.
+ var isLegacy = dedupeUsers.indexOf(username) !== -1 && !sel[username];
+ if (isLegacy) {
+ var firstPk = ALL_MULTI_FIRST_PKS[username];
+ sel[username] = firstPk != null ? [firstPk] : [];
+ }
+
if ($(this).is(':checked')) {
- if (idx === -1) cur.push(username);
+ // Row re-included.
+ if (sel[username]) {
+ if (sel[username].indexOf(projectPk) === -1) {
+ sel[username].push(projectPk);
+ }
+ // If all rows for this user are now checked, remove from dedupe entirely.
+ var totalCount = ALL_MULTI_COUNTS[username] || 0;
+ if (sel[username].length >= totalCount) {
+ delete sel[username];
+ var idx = dedupeUsers.indexOf(username);
+ if (idx !== -1) dedupeUsers.splice(idx, 1);
+ }
+ }
} else {
- if (idx !== -1) cur.splice(idx, 1);
+ // Row excluded — need to build selection list of kept rows.
+ if (!sel[username]) {
+ // First exclusion for this user: collect all project_pks on this page,
+ // then remove the unchecked one.
+ var allPks = [];
+ PREVIEW_ROWS.forEach(function(r) {
+ if (r.username === username && r.project_pk) allPks.push(r.project_pk);
+ });
+ sel[username] = allPks.filter(function(pk) { return pk !== projectPk; });
+ } else {
+ sel[username] = sel[username].filter(function(pk) { return pk !== projectPk; });
+ }
+ // Ensure user is in the dedupe list so the backend filters.
+ if (dedupeUsers.indexOf(username) === -1) dedupeUsers.push(username);
}
- setDedupeUsers(cur);
+
+ setDedupeSelections(sel);
+ setDedupeUsers(dedupeUsers);
renderPreviewRows();
runValidation();
});
diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_validate.js b/coldfront_notifications/static/coldfront_notifications/js/compose_validate.js
index c0bac51..1efa711 100644
--- a/coldfront_notifications/static/coldfront_notifications/js/compose_validate.js
+++ b/coldfront_notifications/static/coldfront_notifications/js/compose_validate.js
@@ -59,7 +59,10 @@ function runValidation() {
var $btn = $('#recalcBtn');
$btn.prop('disabled', true).html(' ');
_setValidateButtonState('default');
- _hideTopValidationResult();
+ _showTopValidationResult(
+ ' Validating…',
+ false
+ );
$.ajax({
url: URLS.validate,
@@ -70,6 +73,7 @@ function runValidation() {
body: getBodyContent() || '',
filters: JSON.stringify(collectFilters()),
dedupe_users: JSON.stringify(getDedupeUsers()),
+ dedupe_selections: JSON.stringify(getDedupeSelections()),
},
success: function(data) {
var userCount = data.user_count || 0;
@@ -83,7 +87,10 @@ function runValidation() {
// Filter summary bar
var activeFilters = data.active_filters || [];
+ var selMode = data.selection_mode || 'filters';
if (activeFilters.length) {
+ var icon = selMode === 'direct' ? 'fa-user-check' : 'fa-filter';
+ var label = selMode === 'direct' ? 'Recipients:' : 'Filters:';
var chips = activeFilters.map(function(filter) {
var values = filter.values.map(function(value) {
return '' + $('').text(value).html() + ' ';
@@ -91,7 +98,7 @@ function runValidation() {
return '' + $('').text(filter.label).html() + ': ' + values + ' ';
}).join('');
$('#filterSummaryBar')
- .html(' Filters: ' + chips + '
')
+ .html(' ' + label + ' ' + chips + '
')
.show();
} else {
$('#filterSummaryBar')
@@ -117,6 +124,27 @@ function runValidation() {
$('#multiWarn').hide();
}
+ // Direct mode: warn if scope excluded some selected users
+ var scope = data.scope || 'user';
+ if (selMode === 'direct' && data.direct_selected_count && data.direct_selected_count > userCount) {
+ var excluded = data.direct_selected_count - userCount;
+ var scopeLabels = {
+ project: 'active project memberships',
+ allocation: 'active allocations'
+ };
+ var scopeLabel = scopeLabels[scope] || scope + ' context';
+ $('#directScopeWarn')
+ .html(
+ ' ' +
+ '' + excluded + ' of ' + data.direct_selected_count + ' selected user(s) ' +
+ 'will not receive an email because they have no ' + scopeLabel + '. ' +
+ 'The template uses ' + scope + '-scoped variables that require this context.'
+ )
+ .show();
+ } else {
+ $('#directScopeWarn').hide();
+ }
+
$('#previewRecipBtn').prop('disabled', userCount === 0);
HAS_VALIDATED = true;
diff --git a/coldfront_notifications/static/coldfront_notifications/js/tinymce_init.js b/coldfront_notifications/static/coldfront_notifications/js/tinymce_init.js
index 4b87985..42d05a6 100644
--- a/coldfront_notifications/static/coldfront_notifications/js/tinymce_init.js
+++ b/coldfront_notifications/static/coldfront_notifications/js/tinymce_init.js
@@ -11,11 +11,18 @@ function getBodyContent() {
return editor ? editor.getContent() : ($('#id_body').val() || '');
}
+// Pending content to load once TinyMCE is ready (handles race condition
+// when setBodyContent is called before the editor finishes initializing).
+var _pendingBodyContent = null;
+var _tinymceReady = false;
+
function setBodyContent(html) {
var editor = tinymce.get('id_body');
- if (editor) {
+ if (editor && _tinymceReady) {
editor.setContent(html || '');
} else {
+ // Editor not ready yet — stash the content for the init callback.
+ _pendingBodyContent = html || '';
$('#id_body').val(html || '');
}
}
@@ -38,6 +45,15 @@ $(document).ready(function() {
editor.on('change keyup', function() {
editor.save();
});
+ editor.on('init', function() {
+ _tinymceReady = true;
+ // If setBodyContent was called before the editor was ready,
+ // apply the stashed content now.
+ if (_pendingBodyContent !== null) {
+ editor.setContent(_pendingBodyContent);
+ _pendingBodyContent = null;
+ }
+ });
}
});
});
diff --git a/coldfront_notifications/template_variable_value_resolver.py b/coldfront_notifications/template_variable_value_resolver.py
index 027033e..d689049 100644
--- a/coldfront_notifications/template_variable_value_resolver.py
+++ b/coldfront_notifications/template_variable_value_resolver.py
@@ -245,7 +245,10 @@ def resolve_path(self, context):
return format_value(context.allocation.path or None)
def resolve_quota(self, context):
- return format_value(context.allocation.get_attribute("Storage Quota (TB)"))
+ unit_label = context.primary_resource.quantity_label
+ if not unit_label:
+ raise MissingValue("resource has no quantity_label")
+ return format_value(context.allocation.get_attribute(f"Storage Quota ({unit_label})"))
def resolve_usage(self, context):
return format_value(context.allocation.usage)
diff --git a/coldfront_notifications/templates/coldfront_notifications/_recipient_preview_modal.html b/coldfront_notifications/templates/coldfront_notifications/_recipient_preview_modal.html
index fb24dd7..cb8085f 100644
--- a/coldfront_notifications/templates/coldfront_notifications/_recipient_preview_modal.html
+++ b/coldfront_notifications/templates/coldfront_notifications/_recipient_preview_modal.html
@@ -20,7 +20,7 @@ Recipient Preview
- {% if show_dedupe %}Dedupe {% endif %}
+ {% if show_dedupe %}Include {% endif %}
Name Email Role Project Allocation
@@ -28,10 +28,10 @@ Recipient Preview
-
+
- Dedupe all — send at most one email per user
+ Dedupe all — keep only PI/first row per user
diff --git a/coldfront_notifications/templates/coldfront_notifications/compose.html b/coldfront_notifications/templates/coldfront_notifications/compose.html
index 4ee502c..0f2d69a 100644
--- a/coldfront_notifications/templates/coldfront_notifications/compose.html
+++ b/coldfront_notifications/templates/coldfront_notifications/compose.html
@@ -27,36 +27,78 @@
-
-
-
- Department
-
-
-
-
Project
-
+
+
+
+
+ Filter Recipients
+
+
+ Select Users
+
+
-
- Resource
-
-
+
+
+
+
+ Department
+
+
+
+
+ Project
+
+
+
+
+ Resource
+
+
-
-
Allocation Status
-
+
+ Allocation Status
+
+
+
+
+ Allocation
+
+
+
+
+ User Role
+
+
+
-
-
Allocation
-
+
+
+
-
-
User Role
-
+
+
+
+
+
+
Bulk Add (paste usernames or emails, one per line)
+
+
+
+ Resolve
+
+
+
@@ -149,6 +191,9 @@
+
+
+
@@ -283,7 +329,7 @@
{% include "coldfront_notifications/_email_preview_modal.html" %}
-{% include "coldfront_notifications/_recipient_preview_modal.html" with show_dedupe=True info_text="Users with multiple emails show a dedupe checkbox — tick it to send them only their first email." %}
+{% include "coldfront_notifications/_recipient_preview_modal.html" with show_dedupe=True info_text="Users with multiple emails have per-row checkboxes — uncheck rows to exclude them. Dedupe all keeps only the PI/first row per user." %}
{% endblock %}
@@ -296,16 +342,19 @@
var FILTER_DATA = {{ filter_data_json|safe }};
var DRAFT_DATA = {{ draft_json|safe }};
var URLS = {
- 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" %}'
+ 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" %}',
+ userSearch: '{% url "notifications:api-user-search" %}',
+ userBulkResolve: '{% url "notifications:api-user-bulk-resolve" %}'
};
+
diff --git a/coldfront_notifications/tests/test_filters.py b/coldfront_notifications/tests/test_filters.py
index 754023f..6542354 100644
--- a/coldfront_notifications/tests/test_filters.py
+++ b/coldfront_notifications/tests/test_filters.py
@@ -363,5 +363,167 @@ def test_role_narrows_nothing(self):
self.assertEqual(RoleFilter().narrowed_by(), [])
+class TestRecipientResolverDirectMode(unittest.TestCase):
+ """RecipientResolver in direct user selection mode."""
+
+ def test_is_direct_mode_true(self):
+ resolver = RecipientResolver({"selection_mode": "direct", "direct_user_pks": [1]})
+ self.assertTrue(resolver._is_direct_mode())
+
+ def test_is_direct_mode_false_when_filters(self):
+ resolver = RecipientResolver({"selection_mode": "filters"})
+ self.assertFalse(resolver._is_direct_mode())
+
+ def test_is_direct_mode_false_when_absent(self):
+ resolver = RecipientResolver({"projects": [1]})
+ self.assertFalse(resolver._is_direct_mode())
+
+ @patch("coldfront_notifications.filters.User")
+ def test_queryset_direct_mode(self, MockUser):
+ resolver = RecipientResolver({"selection_mode": "direct", "direct_user_pks": [10, 20]})
+ resolver.queryset()
+ MockUser.objects.filter.assert_called_once_with(pk__in=[10, 20])
+
+ @patch("coldfront_notifications.filters.User")
+ def test_queryset_direct_mode_empty(self, MockUser):
+ resolver = RecipientResolver({"selection_mode": "direct", "direct_user_pks": []})
+ resolver.queryset()
+ MockUser.objects.filter.assert_called_once_with(pk__in=[])
+
+ @patch("coldfront_notifications.filters.User")
+ def test_count_direct_mode(self, MockUser):
+ MockUser.objects.filter.return_value.count.return_value = 3
+ resolver = RecipientResolver({"selection_mode": "direct", "direct_user_pks": [1, 2, 3]})
+ self.assertEqual(resolver.count(), 3)
+
+ @patch("coldfront_notifications.filters.User")
+ def test_enumerate_user_scope_direct(self, MockUser):
+ user1 = MagicMock(pk=1, username="alice")
+ user2 = MagicMock(pk=2, username="bob")
+ MockUser.objects.filter.return_value.iterator.return_value = iter([user1, user2])
+
+ resolver = RecipientResolver({"selection_mode": "direct", "direct_user_pks": [1, 2]})
+ tuples = list(resolver.enumerate("user"))
+ self.assertEqual(len(tuples), 2)
+ self.assertEqual(tuples[0], (user1, None, None))
+ self.assertEqual(tuples[1], (user2, None, None))
+
+ @patch("coldfront_notifications.filters.ProjectUser")
+ def test_enumerate_project_scope_direct(self, MockPU):
+ user1 = MagicMock(pk=1)
+ proj1 = MagicMock(pk=10)
+ proj2 = MagicMock(pk=20)
+ pu1 = MagicMock(user=user1, project=proj1)
+ pu2 = MagicMock(user=user1, project=proj2)
+
+ (MockPU.objects.select_related.return_value
+ .filter.return_value
+ .annotate.return_value
+ .order_by.return_value
+ .iterator.return_value) = iter([pu1, pu2])
+
+ resolver = RecipientResolver({"selection_mode": "direct", "direct_user_pks": [1]})
+ tuples = list(resolver.enumerate("project"))
+ self.assertEqual(len(tuples), 2)
+ self.assertEqual(tuples[0], (user1, proj1, None))
+ self.assertEqual(tuples[1], (user1, proj2, None))
+
+ def test_enumerate_invalid_scope_raises(self):
+ resolver = RecipientResolver({"selection_mode": "direct", "direct_user_pks": [1]})
+ with self.assertRaises(ValueError):
+ list(resolver.enumerate("invalid"))
+
+ @patch("coldfront_notifications.filters.User")
+ def test_enumerate_direct_empty_pks(self, MockUser):
+ resolver = RecipientResolver({"selection_mode": "direct", "direct_user_pks": []})
+ tuples = list(resolver.enumerate("user"))
+ self.assertEqual(tuples, [])
+
+ @patch("coldfront_notifications.filters.User")
+ def test_enumerate_deduped_direct(self, MockUser):
+ user1 = MagicMock(pk=1, username="alice")
+ MockUser.objects.filter.return_value.iterator.return_value = iter([user1])
+
+ resolver = RecipientResolver({"selection_mode": "direct", "direct_user_pks": [1]})
+ tuples = list(resolver.enumerate_deduped("user", ["alice"]))
+ self.assertEqual(len(tuples), 1)
+ self.assertEqual(tuples[0], (user1, None, None))
+
+ @patch("coldfront_notifications.filters.Allocation")
+ @patch("coldfront_notifications.filters.ProjectUser")
+ def test_enumerate_allocation_scope_direct(self, MockPU, MockAllocation):
+ """Allocation scope expands to (user, project, allocation) tuples
+ using ProjectUser membership, not AllocationUser."""
+ user1 = MagicMock(pk=1)
+ proj1 = MagicMock(pk=10)
+ alloc1 = MagicMock(pk=100, project_id=10)
+ alloc2 = MagicMock(pk=101, project_id=10)
+
+ pu1 = MagicMock(user=user1, user_id=1, project=proj1, project_id=10)
+
+ pu_qs = (MockPU.objects.select_related.return_value
+ .filter.return_value
+ .annotate.return_value
+ .order_by.return_value)
+ pu_qs.values_list.return_value.distinct.return_value = [10]
+ pu_qs.iterator.return_value = iter([pu1])
+
+ (MockAllocation.objects
+ .filter.return_value
+ .select_related.return_value
+ .order_by.return_value
+ .iterator.return_value) = iter([alloc1, alloc2])
+
+ resolver = RecipientResolver({"selection_mode": "direct", "direct_user_pks": [1]})
+ tuples = list(resolver.enumerate("allocation"))
+ self.assertEqual(len(tuples), 2)
+ self.assertEqual(tuples[0], (user1, proj1, alloc1))
+ self.assertEqual(tuples[1], (user1, proj1, alloc2))
+
+ @patch("coldfront_notifications.filters.User")
+ def test_filter_mode_queryset_unchanged(self, MockUser):
+ """Filter mode (no selection_mode key) still works as before."""
+ resolver = RecipientResolver({"projects": [1]})
+ self.assertFalse(resolver._is_direct_mode())
+
+ def test_direct_mode_ignores_filter_keys(self):
+ """When in direct mode, filter keys are irrelevant."""
+ resolver = RecipientResolver({
+ "selection_mode": "direct",
+ "direct_user_pks": [1, 2],
+ "projects": [99],
+ "roles": ["PI"],
+ })
+ self.assertTrue(resolver._is_direct_mode())
+
+
+class TestFilterSummaryDirectMode(unittest.TestCase):
+ """NotificationCampaign.filter_summary handles direct mode."""
+
+ def test_direct_mode_summary(self):
+ from coldfront_notifications.models import NotificationCampaign
+ campaign = NotificationCampaign()
+ campaign.filters_snapshot = {
+ "selection_mode": "direct",
+ "direct_user_pks": [1, 2, 3],
+ }
+ self.assertEqual(campaign.filter_summary, ["Direct selection: 3 user(s)"])
+
+ def test_filter_mode_summary_unchanged(self):
+ from coldfront_notifications.models import NotificationCampaign
+ campaign = NotificationCampaign()
+ campaign.filters_snapshot = {
+ "projects": ["Alpha", "Beta"],
+ "departments": [],
+ }
+ self.assertEqual(campaign.filter_summary, ["Project: Alpha, Beta"])
+
+ def test_empty_filters_summary(self):
+ from coldfront_notifications.models import NotificationCampaign
+ campaign = NotificationCampaign()
+ campaign.filters_snapshot = {}
+ self.assertEqual(campaign.filter_summary, [])
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/coldfront_notifications/tests/test_integration.py b/coldfront_notifications/tests/test_integration.py
index 1c8925d..57b6d04 100644
--- a/coldfront_notifications/tests/test_integration.py
+++ b/coldfront_notifications/tests/test_integration.py
@@ -263,7 +263,7 @@ def test_department_filter(self):
def test_allocation_status_filter(self):
qs = RecipientResolver({"statuses": ["Active"]}).queryset()
user_pks = set(qs.values_list("pk", flat=True))
- # All users have active AllocationUser records
+ # Both projects have Active allocations → all ProjectUsers included
self.assertIn(self.user1.pk, user_pks)
self.assertIn(self.user2.pk, user_pks)
self.assertIn(self.user3.pk, user_pks)
@@ -294,9 +294,12 @@ def test_scope_project_returns_per_project_user(self):
self.assertIsNotNone(project)
self.assertIsNone(allocation)
- def test_scope_allocation_returns_per_allocation_user(self):
+ def test_scope_allocation_returns_per_project_user_allocation(self):
results = list(RecipientResolver({}).enumerate("allocation"))
- # alloc1: user1, user2; alloc2: user1, user3 → 4 tuples
+ # proj1 has alloc1, proj2 has alloc2
+ # proj1 users: user1 (PI), user2 (User) → 2 tuples with alloc1
+ # proj2 users: user1 (PI), user3 (Manager) → 2 tuples with alloc2
+ # Total: 4 tuples (all ProjectUsers × their project's allocations)
self.assertEqual(len(results), 4)
for user, project, allocation in results:
self.assertIsNotNone(project)
diff --git a/coldfront_notifications/tests/test_resolvers.py b/coldfront_notifications/tests/test_resolvers.py
index e24d7b8..e98d8e8 100644
--- a/coldfront_notifications/tests/test_resolvers.py
+++ b/coldfront_notifications/tests/test_resolvers.py
@@ -262,11 +262,22 @@ def test_allocation_path_no_allocation_raises(self):
def test_allocation_quota(self):
allocation = MagicMock()
+ resource = allocation.get_parent_resource
+ resource.quantity_label = "TB"
allocation.get_attribute.return_value = 10.5
context = self._make_context(allocation=allocation)
self.assertEqual(resolver_registry.resolve("allocation.quota", context), "10.5")
allocation.get_attribute.assert_called_once_with("Storage Quota (TB)")
+ def test_allocation_quota_tib(self):
+ allocation = MagicMock()
+ resource = allocation.get_parent_resource
+ resource.quantity_label = "TiB"
+ allocation.get_attribute.return_value = 5.0
+ context = self._make_context(allocation=allocation)
+ self.assertEqual(resolver_registry.resolve("allocation.quota", context), "5.0")
+ allocation.get_attribute.assert_called_once_with("Storage Quota (TiB)")
+
def test_allocation_quota_none_raises(self):
allocation = MagicMock()
allocation.get_attribute.return_value = None
diff --git a/coldfront_notifications/tests/test_validators.py b/coldfront_notifications/tests/test_validators.py
index 876dadb..a8f626c 100644
--- a/coldfront_notifications/tests/test_validators.py
+++ b/coldfront_notifications/tests/test_validators.py
@@ -247,5 +247,116 @@ def test_scope_propagated_correctly(self):
self.assertEqual(result["scope"], "allocation")
+class TestNotificationValidatorDirectMode(unittest.TestCase):
+ """NotificationValidator with direct user selection."""
+
+ PATCH_RESOLVER = "coldfront_notifications.notification_validator.RecipientResolver"
+ PATCH_RESOLVE = "coldfront_notifications.notification_validator.resolver_registry.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):
+ variable = MagicMock()
+ variable.key = key
+ variable.source = source
+ variable.Source.QUERY = "query"
+ variable.Source.MANUAL = "manual"
+ variable.resolver_key = resolver_key
+ variable.value = value
+ variable.is_required = is_required
+ return variable
+
+ def _make_user(self, pk, username, email):
+ user = MagicMock()
+ user.pk = pk
+ user.username = username
+ user.email = email
+ user.get_full_name.return_value = username
+ return user
+
+ def _patch_recipient_resolver(self, tuples):
+ mock_resolver_instance = MagicMock()
+ mock_resolver_instance.enumerate_deduped.return_value = iter(tuples)
+ patcher = patch(
+ self.PATCH_RESOLVER,
+ return_value=mock_resolver_instance,
+ )
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_direct_mode_user_scope_no_tokens(self):
+ """With no template variables, scope stays 'user' — one email per user."""
+ self._patch_nv_objects([])
+
+ user1 = self._make_user(1, "alice", "alice@test.com")
+ user2 = self._make_user(2, "bob", "bob@test.com")
+ self._patch_recipient_resolver([
+ (user1, None, None),
+ (user2, None, None),
+ ])
+
+ filters = {"selection_mode": "direct", "direct_user_pks": [1, 2]}
+ result = NotificationValidator("Hello", "body", filters).validate()
+
+ self.assertEqual(result["scope"], "user")
+ self.assertEqual(result["user_count"], 2)
+ self.assertEqual(result["email_count"], 2)
+ self.assertEqual(result["errors"], [])
+
+ def test_direct_mode_user_scope_with_user_vars(self):
+ """User-scoped variables keep scope at 'user'."""
+ variable = self._make_var("name", resolver_key="user.full_name")
+ self._patch_nv_objects([variable])
+
+ user1 = self._make_user(1, "alice", "alice@test.com")
+ self._patch_recipient_resolver([(user1, None, None)])
+
+ filters = {"selection_mode": "direct", "direct_user_pks": [1]}
+ with patch(self.PATCH_RESOLVE, return_value="Alice"):
+ result = NotificationValidator("Hi {{name}}", "body", filters).validate()
+
+ self.assertEqual(result["scope"], "user")
+ self.assertEqual(result["user_count"], 1)
+ self.assertEqual(result["email_count"], 1)
+
+ def test_direct_mode_project_scope_with_project_vars(self):
+ """Project-scoped variables elevate scope to 'project'."""
+ variable = self._make_var("ptitle", resolver_key="project.title")
+ self._patch_nv_objects([variable])
+
+ user1 = self._make_user(1, "alice", "alice@test.com")
+ proj1 = MagicMock(title="Alpha", pk=10)
+ proj2 = MagicMock(title="Beta", pk=20)
+ self._patch_recipient_resolver([
+ (user1, proj1, None),
+ (user1, proj2, None),
+ ])
+
+ filters = {"selection_mode": "direct", "direct_user_pks": [1]}
+ with patch(self.PATCH_RESOLVE, return_value="Title"):
+ result = NotificationValidator("Re: {{ptitle}}", "body", filters).validate()
+
+ self.assertEqual(result["scope"], "project")
+ self.assertEqual(result["user_count"], 1)
+ self.assertEqual(result["email_count"], 2)
+
+ def test_direct_mode_passes_filters_to_resolver(self):
+ """The filters dict (with selection_mode) is passed to RecipientResolver."""
+ self._patch_nv_objects([])
+
+ filters = {"selection_mode": "direct", "direct_user_pks": [1, 2, 3]}
+ with patch(self.PATCH_RESOLVER) as MockResolver:
+ MockResolver.return_value.enumerate_deduped.return_value = iter([])
+ NotificationValidator("subj", "body", filters).validate()
+ MockResolver.assert_called_once_with(filters)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/coldfront_notifications/urls.py b/coldfront_notifications/urls.py
index 4056d35..8f25b7c 100644
--- a/coldfront_notifications/urls.py
+++ b/coldfront_notifications/urls.py
@@ -147,4 +147,14 @@
views.DraftSaveView.as_view(),
name="api-draft-save",
),
+ path(
+ "api/user-search/",
+ views.UserSearchView.as_view(),
+ name="api-user-search",
+ ),
+ path(
+ "api/user-bulk-resolve/",
+ views.UserBulkResolveView.as_view(),
+ name="api-user-bulk-resolve",
+ ),
]
diff --git a/coldfront_notifications/views/__init__.py b/coldfront_notifications/views/__init__.py
index 5bc11b0..df74d43 100644
--- a/coldfront_notifications/views/__init__.py
+++ b/coldfront_notifications/views/__init__.py
@@ -8,6 +8,7 @@
ComposeView, RecipientCountView,
PreviewRenderView, ValidateView,
DraftSaveView,
+ UserSearchView, UserBulkResolveView,
)
from .helpers import dispatch_send # noqa: F401
from .templates import ( # noqa: F401
diff --git a/coldfront_notifications/views/compose.py b/coldfront_notifications/views/compose.py
index c2e0b9d..e793d12 100644
--- a/coldfront_notifications/views/compose.py
+++ b/coldfront_notifications/views/compose.py
@@ -9,10 +9,12 @@
import json
import logging
+import re
from collections import Counter
from typing import Any
from django.contrib import messages
+from django.contrib.auth import get_user_model
from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import redirect
@@ -35,8 +37,57 @@
from ..template_variable_value_resolver import MissingValue
from .helpers import StaffRequiredMixin, dispatch_send
+User = get_user_model()
logger = logging.getLogger(__name__)
+SCOPE_ORDER = ["user", "project", "allocation"]
+
+
+def _parse_json(raw: str, default: Any = None) -> Any:
+ """Safely parse a JSON string, returning *default* on failure."""
+ if not raw:
+ return default
+ try:
+ return json.loads(raw)
+ except (ValueError, TypeError):
+ return default
+
+
+def _normalize_filter_keys(filters: dict):
+ """Ensure all standard filter keys are present as lists."""
+ for key in ("projects", "allocations", "resources", "departments", "statuses", "roles"):
+ filters.setdefault(key, [])
+ if not isinstance(filters[key], list):
+ filters[key] = [filters[key]]
+
+
+def _serialize_user(user) -> dict:
+ """Build the standard user-info dict used by JSON API responses."""
+ return {
+ "pk": user.pk,
+ "username": user.username,
+ "email": user.email,
+ "full_name": user.get_full_name() or user.username,
+ }
+
+
+def resolve_scope(subject: str, body: str, filters: dict) -> str:
+ """Determine the enumeration scope for a notification.
+
+ In filter mode the scope is elevated to at least "project" so previews
+ always show project context. In direct mode the scope is determined
+ purely by the template variables — elevating would exclude users
+ without project/allocation memberships.
+ """
+ tokens = extract_tokens(subject, body)
+ used_variables = list(NotificationVariable.objects.filter(key__in=tokens))
+ token_scope = determine_scope(used_variables)
+
+ if filters.get("selection_mode") == "direct":
+ return token_scope
+
+ return max(token_scope, "project", key=SCOPE_ORDER.index)
+
class ComposeView(StaffRequiredMixin, TemplateView):
"""Main compose page: GET renders the form, POST handles send/draft."""
@@ -123,9 +174,14 @@ def post(self, request, *args: Any, **kwargs: Any):
template_id = request.POST.get("template_id")
draft_pk = request.POST.get("draft_pk")
- dedupe_users = self._parse_json(request.POST.get("dedupe_users", "[]"), [])
+ dedupe_users = _parse_json(request.POST.get("dedupe_users", "[]"), [])
if not isinstance(dedupe_users, list):
dedupe_users = []
+ dedupe_selections = _parse_json(
+ request.POST.get("dedupe_selections", "{}"), {},
+ )
+ if not isinstance(dedupe_selections, dict):
+ dedupe_selections = {}
if not subject or not body:
messages.error(request, "Subject and body are required.")
@@ -135,6 +191,7 @@ def post(self, request, *args: Any, **kwargs: Any):
validation_result = NotificationValidator(
subject, body, filters,
dedupe_users=dedupe_users,
+ dedupe_selections=dedupe_selections,
).validate()
if validation_result["errors"] or validation_result["missing_tokens"]:
messages.error(
@@ -148,6 +205,7 @@ def post(self, request, *args: Any, **kwargs: Any):
filters["extra_recipients"] = extra_recipients
filters["dedupe_users"] = dedupe_users
+ filters["dedupe_selections"] = dedupe_selections
if draft_pk:
campaign = self._update_draft(draft_pk, request.user, action, {
@@ -216,7 +274,9 @@ def _update_draft(draft_pk, user, action, fields):
@staticmethod
def _parse_filters_from_post(request) -> dict:
- return {
+ selection_mode = request.POST.get("selection_mode", "filters")
+ result = {
+ "selection_mode": selection_mode,
"projects": ComposeView._parse_filter_list(request.POST.get("filter_projects", "")),
"allocations": ComposeView._parse_filter_list(request.POST.get("filter_allocations", "")),
"resources": ComposeView._parse_filter_list(request.POST.get("filter_resources", "")),
@@ -224,6 +284,10 @@ def _parse_filters_from_post(request) -> dict:
"statuses": ComposeView._parse_filter_list(request.POST.get("filter_statuses", "")),
"roles": ComposeView._parse_filter_list(request.POST.get("filter_roles", "")),
}
+ if selection_mode == "direct":
+ raw_pks = request.POST.get("direct_user_pks", "[]")
+ result["direct_user_pks"] = _parse_json(raw_pks, [])
+ return result
@staticmethod
def _parse_filter_list(raw: str) -> list:
@@ -235,28 +299,31 @@ def _parse_filter_list(raw: str) -> list:
except (ValueError, TypeError):
return [raw]
- @staticmethod
- def _parse_json(raw: str, default: Any = None) -> Any:
- if not raw:
- return default
- try:
- return json.loads(raw)
- except (ValueError, TypeError):
- return default
-
class RecipientCountView(StaffRequiredMixin, View):
"""Return recipient count or paginated preview as JSON."""
def post(self, request, *args: Any, **kwargs: Any) -> JsonResponse:
- 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"),
- }
+ selection_mode = request.POST.get("selection_mode", "filters")
+ if selection_mode == "direct":
+ raw_pks = request.POST.get("direct_user_pks", "[]")
+ try:
+ direct_pks = json.loads(raw_pks) if isinstance(raw_pks, str) else raw_pks
+ except (ValueError, TypeError):
+ direct_pks = []
+ filters = {
+ "selection_mode": "direct",
+ "direct_user_pks": direct_pks,
+ }
+ else:
+ 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"),
+ }
is_preview = request.POST.get("preview") == "true"
subject = request.POST.get("subject", "")
body = request.POST.get("body", "")
@@ -268,14 +335,19 @@ def post(self, request, *args: Any, **kwargs: Any) -> JsonResponse:
def _build_paginated_preview(self, request, filters, subject, body) -> JsonResponse:
page, page_size = self._parse_pagination(request)
+ search_query = request.POST.get("search", "").strip().lower()
scope = self._determine_preview_scope(filters, subject, body)
resolver = RecipientResolver(filters)
- user_info, emails_per_user, total_count = self._collect_user_stats(resolver, scope)
+ user_info, emails_per_user, total_count = self._collect_user_stats(
+ resolver, scope, search_query,
+ )
total_pages = max(1, (total_count + page_size - 1) // page_size)
page = min(page, total_pages)
- page_rows = self._collect_page_rows(resolver, scope, page, page_size)
+ page_rows = self._collect_page_rows(
+ resolver, scope, page, page_size, search_query,
+ )
self._enrich_with_roles(page_rows)
multi_email_users = self._build_multi_email_list(emails_per_user, user_info)
@@ -304,40 +376,61 @@ def _parse_pagination(request) -> tuple[int, int]:
@staticmethod
def _determine_preview_scope(filters, subject, body) -> str:
- tokens = extract_tokens(subject, body)
- used_variables = list(NotificationVariable.objects.filter(key__in=tokens))
- token_scope = determine_scope(used_variables)
+ return resolve_scope(subject, body, filters)
- return max(token_scope, "project", key=["user", "project", "allocation"].index)
+ @staticmethod
+ def _row_matches_search(user, project, allocation, query: str) -> bool:
+ """Check if a recipient tuple matches a search query."""
+ if not query:
+ return True
+ full_name = (user.get_full_name() or user.username).lower()
+ fields = (
+ user.username.lower(),
+ user.email.lower(),
+ full_name,
+ (project.title.lower() if project else ""),
+ )
+ return any(query in field for field in fields)
@staticmethod
- def _collect_user_stats(resolver, scope) -> tuple[dict, Counter, int]:
+ def _collect_user_stats(resolver, scope, search_query="") -> tuple[dict, Counter, int]:
user_info = {}
emails_per_user = Counter()
total_count = 0
for user, project, allocation in resolver.enumerate(scope):
+ if not RecipientCountView._row_matches_search(
+ user, project, allocation, search_query,
+ ):
+ continue
username = user.username
emails_per_user[username] += 1
if username not in user_info:
user_info[username] = {
"email": user.email,
"full_name": user.get_full_name() or user.username,
+ "first_project_pk": project.pk if project else None,
}
total_count += 1
return user_info, emails_per_user, total_count
@staticmethod
- def _collect_page_rows(resolver, scope, page, page_size) -> list[dict]:
+ def _collect_page_rows(resolver, scope, page, page_size,
+ search_query="") -> list[dict]:
start_index = (page - 1) * page_size
end_index = start_index + page_size
page_rows = []
+ matched = 0
- for index, (user, project, allocation) in enumerate(resolver.enumerate(scope)):
- if index >= end_index:
+ for user, project, allocation in resolver.enumerate(scope):
+ if not RecipientCountView._row_matches_search(
+ user, project, allocation, search_query,
+ ):
+ continue
+ if matched >= end_index:
break
- if index >= start_index:
+ if matched >= start_index:
page_rows.append({
"username": user.username,
"full_name": user.get_full_name() or user.username,
@@ -345,12 +438,14 @@ def _collect_page_rows(resolver, scope, page, page_size) -> list[dict]:
"user_pk": user.pk,
"project_pk": project.pk if project else None,
"project_title": project.title if project else "",
+ "allocation_pk": allocation.pk if allocation else None,
"allocation": (
f"{allocation.pk} \u2014 {allocation.get_parent_resource}"
if allocation
else ""
),
})
+ matched += 1
return page_rows
@@ -379,7 +474,6 @@ def _enrich_with_roles(page_rows: list[dict]):
row["role"] = role_lookup.get((row["user_pk"], row["project_pk"]), "")
row["project"] = row.pop("project_title")
del row["user_pk"]
- del row["project_pk"]
@staticmethod
def _build_multi_email_list(emails_per_user, user_info) -> list[dict]:
@@ -390,6 +484,7 @@ def _build_multi_email_list(emails_per_user, user_info) -> list[dict]:
"email": user_info[username]["email"],
"full_name": user_info[username]["full_name"],
"count": count,
+ "first_project_pk": user_info[username].get("first_project_pk"),
}
for username, count in emails_per_user.items()
if count > 1
@@ -404,15 +499,18 @@ class PreviewRenderView(StaffRequiredMixin, View):
def post(self, request, *args: Any, **kwargs: Any) -> JsonResponse:
subject = request.POST.get("subject", "")
body = request.POST.get("body", "")
- filters = self._parse_json(request.POST.get("filters", ""), {}) or {}
- dedupe_users = self._parse_json(request.POST.get("dedupe_users", ""), []) or []
+ filters = _parse_json(request.POST.get("filters", ""), {}) or {}
+ dedupe_users = _parse_json(request.POST.get("dedupe_users", ""), []) or []
+ dedupe_selections = _parse_json(
+ request.POST.get("dedupe_selections", ""), {},
+ ) or {}
try:
limit = max(1, min(10, int(request.POST.get("limit", PREVIEW_RENDER_LIMIT))))
except (ValueError, TypeError):
limit = 3
- self._normalize_filter_keys(filters)
+ _normalize_filter_keys(filters)
tokens = extract_tokens(subject, body)
variables_by_key = {
@@ -425,7 +523,7 @@ def post(self, request, *args: Any, **kwargs: Any) -> JsonResponse:
samples = []
total_count = 0
for user, project, allocation in RecipientResolver(filters).enumerate_deduped(
- scope, dedupe_users,
+ scope, dedupe_users, dedupe_selections=dedupe_selections,
):
total_count += 1
if len(samples) >= limit:
@@ -464,22 +562,6 @@ def post(self, request, *args: Any, **kwargs: Any) -> JsonResponse:
"scope": scope,
})
- @staticmethod
- def _parse_json(raw: str, default: Any = None) -> Any:
- if not raw:
- return default
- try:
- return json.loads(raw)
- except (ValueError, TypeError):
- return default
-
- @staticmethod
- def _normalize_filter_keys(filters: dict):
- for key in ("projects", "allocations", "resources", "departments", "statuses", "roles"):
- filters.setdefault(key, [])
- if not isinstance(filters[key], list):
- filters[key] = [filters[key]]
-
class ValidateView(StaffRequiredMixin, View):
"""Pre-flight validation — called from compose JS before Send."""
@@ -487,35 +569,56 @@ class ValidateView(StaffRequiredMixin, View):
def post(self, request, *args: Any, **kwargs: Any) -> JsonResponse:
subject = request.POST.get("subject", "")
body = request.POST.get("body", "")
- filters = PreviewRenderView._parse_json(request.POST.get("filters", ""), {}) or {}
- dedupe_users = PreviewRenderView._parse_json(request.POST.get("dedupe_users", ""), []) or []
-
- PreviewRenderView._normalize_filter_keys(filters)
-
- tokens = extract_tokens(subject, body)
- used_variables = list(NotificationVariable.objects.filter(key__in=tokens))
- token_scope = determine_scope(used_variables)
-
- elevated_scope = max(
- token_scope,
- "project",
- key=["user", "project", "allocation"].index,
+ filters = _parse_json(request.POST.get("filters", ""), {}) or {}
+ dedupe_users = _parse_json(request.POST.get("dedupe_users", ""), []) or []
+ dedupe_selections = _parse_json(
+ request.POST.get("dedupe_selections", ""), {},
+ ) or {}
+
+ _normalize_filter_keys(filters)
+
+ logger.debug(
+ "ValidateView: selection_mode=%s, direct_user_pks=%s",
+ filters.get("selection_mode"),
+ len(filters.get("direct_user_pks", [])) if filters.get("selection_mode") == "direct" else "N/A",
)
+ scope = resolve_scope(subject, body, filters)
+
result = NotificationValidator(
subject,
body,
filters,
dedupe_users=dedupe_users,
- scope_override=elevated_scope,
+ dedupe_selections=dedupe_selections,
+ scope_override=scope,
).validate()
result["active_filters"] = self._build_active_filters(filters)
+ result["selection_mode"] = filters.get("selection_mode", "filters")
+
+ # In direct mode, tell the frontend how many users were selected
+ # vs. how many matched the scope so it can show a warning.
+ if filters.get("selection_mode") == "direct":
+ selected_pks = filters.get("direct_user_pks") or []
+ result["direct_selected_count"] = len(selected_pks)
+
return JsonResponse(result)
@staticmethod
def _build_active_filters(filters: dict) -> list[dict]:
"""Build human-readable active filter labels."""
+ if filters.get("selection_mode") == "direct":
+ pks = filters.get("direct_user_pks") or []
+ usernames = list(
+ User.objects.filter(pk__in=pks)
+ .order_by("username")
+ .values_list("username", flat=True)
+ )
+ if usernames:
+ return [{"label": "Direct Selection", "values": usernames}]
+ return [{"label": "Direct Selection", "values": [f"{len(pks)} user(s)"]}]
+
active = []
if filters.get("projects"):
@@ -563,6 +666,70 @@ def _build_active_filters(filters: dict) -> list[dict]:
return active
+class UserSearchView(StaffRequiredMixin, View):
+ """API endpoint for user autocomplete search."""
+
+ def get(self, request, *args: Any, **kwargs: Any) -> JsonResponse:
+ q = request.GET.get("q", "").strip()
+ try:
+ limit = min(int(request.GET.get("limit", 100)), 200)
+ except (ValueError, TypeError):
+ limit = 100
+
+ users = User.objects.filter(is_active=True)
+ if q:
+ users = users.filter(
+ Q(username__icontains=q)
+ | Q(email__icontains=q)
+ | Q(first_name__icontains=q)
+ | Q(last_name__icontains=q)
+ | Q(full_name__icontains=q)
+ )
+ users = users.order_by("username")[:limit]
+ return JsonResponse({"results": [_serialize_user(u) for u in users]})
+
+
+class UserBulkResolveView(StaffRequiredMixin, View):
+ """Resolve pasted usernames/emails to user objects."""
+
+ def post(self, request, *args: Any, **kwargs: Any) -> JsonResponse:
+ raw = request.POST.get("identifiers", "")
+ # Split on newlines, commas, semicolons, tabs, and spaces to
+ # support CSV paste, spreadsheet paste, and plain lists.
+ # (Usernames and emails never contain spaces, so this is safe.)
+ identifiers = [
+ item.strip()
+ for item in re.split(r"[\n,;\t ]+", raw)
+ if item.strip()
+ ]
+ if not identifiers:
+ return JsonResponse({"found": [], "not_found": []})
+
+ # Batch-resolve: two queries instead of one per identifier.
+ lowered = [ident.lower() for ident in identifiers]
+ users_by_username = {
+ u.username.lower(): u
+ for u in User.objects.filter(username__iregex=r"^(" + "|".join(re.escape(i) for i in lowered) + r")$", is_active=True)
+ }
+ users_by_email = {
+ u.email.lower(): u
+ for u in User.objects.filter(email__iregex=r"^(" + "|".join(re.escape(i) for i in lowered) + r")$", is_active=True)
+ }
+
+ found = []
+ seen_pks = set()
+ not_found = []
+ for identifier in identifiers:
+ user = users_by_username.get(identifier.lower()) or users_by_email.get(identifier.lower())
+ if user and user.pk not in seen_pks:
+ seen_pks.add(user.pk)
+ found.append(_serialize_user(user))
+ elif not user:
+ not_found.append(identifier)
+
+ return JsonResponse({"found": found, "not_found": not_found})
+
+
class DraftSaveView(StaffRequiredMixin, View):
"""AJAX endpoint to create or update a draft campaign."""
@@ -574,8 +741,8 @@ def post(self, request, *args: Any, **kwargs: Any) -> JsonResponse:
reply_to = request.POST.get("reply_to", "")
template_id = request.POST.get("template_id") or None
- filters = PreviewRenderView._parse_json(request.POST.get("filters", ""), {}) or {}
- extra_context = PreviewRenderView._parse_json(request.POST.get("extra_context", ""), {}) or {}
+ filters = _parse_json(request.POST.get("filters", ""), {}) or {}
+ extra_context = _parse_json(request.POST.get("extra_context", ""), {}) or {}
draft_fields = {
"subject": subject,