Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions coldfront_notifications/campaign_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down
164 changes: 125 additions & 39 deletions coldfront_notifications/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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 []
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
4 changes: 4 additions & 0 deletions coldfront_notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 7 additions & 2 deletions coldfront_notifications/notification_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading