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 @@ - {% if show_dedupe %}{% endif %} + {% if show_dedupe %}{% endif %} @@ -28,10 +28,10 @@ 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 @@

- -
-
- - -
-
- - + +
+
+ +
+
-
- - -
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
-
- - +
+ + +
+ +
+ + +
+ +
+ + +
+
-
- - + +

DedupeIncludeNameEmailRoleProjectAllocation