diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cb297e3..dd42e05 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,14 +25,18 @@ jobs: run: | pip install --upgrade pip pip install "Django>=4.2,<5" coverage - env: - PYTHONPATH: . - name: Run unit tests run: | - coverage run -m unittest coldfront_notifications.tests.test_unit -v + coverage run -m unittest \ + coldfront_notifications.tests.test_resolvers \ + coldfront_notifications.tests.test_validators \ + coldfront_notifications.tests.test_tasks_unit \ + coldfront_notifications.tests.test_filters \ + coldfront_notifications.tests.test_conf \ + -v coverage report --show-missing --fail-under=80 \ - --include="coldfront_notifications/resolvers.py,coldfront_notifications/validators.py,coldfront_notifications/conf.py,coldfront_notifications/__init__.py" + --include="coldfront_notifications/template_variable_value_resolver.py,coldfront_notifications/notification_validator.py,coldfront_notifications/campaign_sender.py,coldfront_notifications/conf.py,coldfront_notifications/__init__.py" env: PYTHONPATH: . @@ -86,27 +90,10 @@ jobs: - name: Check package run: twine check dist/* - - name: Verify wheel contents + - name: Verify wheel installs run: | pip install dist/*.whl python -c " - import django - from django.conf import settings - settings.configure( - INSTALLED_APPS=[ - 'django.contrib.contenttypes', - 'django.contrib.auth', - 'django.contrib.admin', - 'django.contrib.sessions', - 'django.contrib.messages', - 'coldfront_notifications', - ], - DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}}, - DEFAULT_AUTO_FIELD='django.db.models.BigAutoField', - AUTH_USER_MODEL='auth.User', - ROOT_URLCONF='coldfront_notifications.urls', - ) - django.setup() - from coldfront_notifications import urls, models, views, resolvers, validators, tasks, conf - print('All modules importable') + import coldfront_notifications + print(f'Installed: coldfront_notifications {coldfront_notifications.__version__}') " diff --git a/.gitignore b/.gitignore index 0f03392..08da9a7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ __pycache__/ dist/ build/ *.whl - +*bak # Virtual environments .venv/ venv/ diff --git a/FILTERS.md b/FILTERS.md new file mode 100644 index 0000000..2012da6 --- /dev/null +++ b/FILTERS.md @@ -0,0 +1,133 @@ +# Compose Notification — Filter System + +## Overview + +The compose page has 6 recipient filters. All filter data is loaded once on +page load via `FilterDataBuilder`. The frontend `FilterStore` handles cascade +narrowing entirely client-side — zero AJAX round-trips for filter changes. + +Two cascade directions: +- **Top-down (narrow):** selecting a department narrows projects, resources, + and allocations via the visible set. +- **Bottom-up (select):** selecting a role auto-selects matching departments. + Manual selections take priority over auto-selections. + +## Filters + +| Filter | DOM ID | Value type | Class | +|-------------------|-----------------|------------------|----------------------| +| Department | `#f_dept` | Dept PKs (int) | `DepartmentFilter` | +| Project | `#f_project` | Project PKs | `ProjectFilter` | +| Resource | `#f_resource` | Resource PKs | `ResourceFilter` | +| Allocation Status | `#f_status` | Status names | `StatusFilter` | +| Allocation | `#f_allocation` | Allocation PKs | `AllocationFilter` | +| User Role | `#f_role` | Role names | `RoleFilter` | + +## Database Relationships + +``` +Department (Organization, org_tree='Research Computing Storage Billing') + └── Lab (OrgRelation, child__rank='lab') + └── Project (ProjectOrganization) + ├── Allocation + │ ├── Resource (M2M via Allocation.resources) + │ └── Status (FK to AllocationStatusChoice) + └── ProjectUser + ├── User + └── Role (FK to ProjectUserRoleChoice) +``` + +Key: Department → Project traversal goes through OrgRelation and +ProjectOrganization (not a direct FK). The `DepartmentFilter._apply()` method +and `DepartmentFilter.initial_options()` both use this join chain. + +## Filter Cascade + +``` +Top-down narrowing: + + Department → Projects → Resources + ↑ ↓ + └── Resource narrows Projects too + → Allocations ← Statuses + +Bottom-up context (detail cards only, no dropdown changes): + + Allocation → shows Dept, Project, Resource, Status + Project → shows Department + Role → shows Departments and project count + Resource → shows Departments and project count + Status → shows matching allocation count +``` + +## State Model + +Each filter in the `FilterStore` has: +- `all` — full server dataset, never changes after page load +- `visible` — subset of `all` after upstream narrowing +- `selected` — user's manual picks (subset of visible) +- `autoSelected` — set by bottom-up propagation (role → departments) +- `dismissed` — auto-selected items the user explicitly removed +- `autoSource` — human-readable description of what triggered auto-select + +Downstream filters read `getEffectiveIds()` which returns: +1. `selected` if any exist, else +2. `visible` IDs if narrowed from `all`, else +3. `null` (no constraint) + +## Backend Architecture + +All filter logic lives in `filters.py`: + +- **`BaseFilter`** — abstract class with `initial_options()` and `_apply()` +- **6 filter classes** — each owns both its UI options and queryset filtering +- **`FilterDataBuilder`** — assembles all filter data into JSON for page load +- **`RecipientResolver`** — resolves selected filters into actual recipients + at send time + +## Frontend Architecture + +JS modules loaded in order: + +1. **`compose_filters.js`** — FilterStore, FilterWidget, narrowing functions, + bottom-up propagation, filter details cards, clear filters +2. **`compose_validate.js`** — validation AJAX, send-bar UI +3. **`compose_templates.js`** — template list/search/load, variable chips +4. **`compose_preview.js`** — email preview and recipient preview modals +5. **`compose_draft.js`** — draft auto-save, resume, dirty tracking +6. **`compose_init.js`** — Select2 init, form submit serialization (loads last) + +## Filter Data Format + +Each filter's options from `FilterDataBuilder.build()`: + +```json +{ + "departments": { + "options": [ + {"id": 1880, "label": "Chemistry and Chemical Biology", "project_ids": [55, 94, ...]} + ], + "narrowed_by": [] + }, + "projects": { + "options": [{"id": 1, "label": "alpha_lab"}], + "narrowed_by": ["departments"] + }, + "resources": { + "options": [{"id": 38, "label": "FASRC Cluster", "project_ids": [2, 3, ...]}], + "narrowed_by": ["projects"] + }, + "statuses": { + "options": [{"id": "Active", "label": "Active"}], + "narrowed_by": [] + }, + "allocations": { + "options": [{"id": 100, "label": "alpha_lab — Storage", "project_id": 1, "status": "Active", "resource_ids": [38]}], + "narrowed_by": ["projects", "resources", "statuses"] + }, + "roles": { + "options": [{"id": "PI", "label": "PI", "project_ids": [1, 2, ...]}], + "narrowed_by": [] + } +} +``` diff --git a/FUNCTIONAL_TEST_PROTOCOL.md b/FUNCTIONAL_TEST_PROTOCOL.md new file mode 100644 index 0000000..cd461bb --- /dev/null +++ b/FUNCTIONAL_TEST_PROTOCOL.md @@ -0,0 +1,319 @@ +# Functional Test Protocol — Compose Filters + +## Data Model + +``` +Department (18 available) + └── Labs (via OrgRelation, child rank='lab') + └── Projects (via ProjectOrganization) — 1,121 total + ├── Allocations — 2,574 total + │ ├── Resources — 1,912 total (M2M) + │ └── Status (Active, Pending Deactivation, Denied, etc.) + └── ProjectUser + ├── User — 5,539 active + └── Role (PI, General Manager, User, Storage Manager, Access Manager) +``` + +Selecting a department narrows projects to only those connected via +the department's labs. Resources, allocations, and statuses narrow +based on visible projects. Roles can auto-select departments bottom-up. + +--- + +## Test Scenarios + +### Scenario 1 — Chemistry + General Manager (Sarah's use case) + +> "I want to email all the General Managers that belong to the Chemistry +> Department, to let them know about an upcoming change to their software." + +**Data chain:** +- Department: Chemistry and Chemical Biology (pk=1880) +- Labs: 7 (aspuru_lab, kahne_lab, liau_lab, lieber_lab, shair_lab, gordon_lab, zheng_lab) +- Projects: 7 +- General Managers across those projects: 6 + +**Steps:** +1. Open Compose Notification +2. Select **Department** → "Chemistry and Chemical Biology" +3. Verify: Projects dropdown narrows to **7 projects** +4. Verify: Resources and Allocations narrow accordingly +5. Verify: Detail card shows "Departments: 7 (of 1200) · 1 selected" +6. Select **User Role** → "General Manager" +7. Verify: Detail card shows role selected +8. Click **Validate** +9. Verify: **5 users** reported, button turns green + +**Expected result:** 5 recipients + +**Evidence:** [ ] Screenshot / [ ] Video + +--- + +### Scenario 2 — bos-isilon/tier1 + PI (Sarah's use case) + +> "I need to let all the PIs that have data on bos-isilon/tier1 resource +> that the storage will be down for an upgrade." + +**Data chain:** +- Resource: bos-isilon/tier1 — linked to 113 projects +- PIs across those projects: 110 (project scope) +- PIs with AllocationUser records on those allocations: 88 (allocation scope) + +**Steps:** +1. Open Compose Notification +2. Select **Resource** → "bos-isilon/tier1" +3. Verify: Allocations narrow to only bos-isilon allocations +4. Select **User Role** → "PI" +5. Verify: Departments do NOT auto-select (resource already picked) +6. Click **Validate** +7. Verify result depends on template: + - **No template / project-scoped template:** 110 users + - **Allocation-scoped template** (uses `{{allocation_path}}`, etc.): **88 users → 109 emails** + +**Expected result:** 110 users (project scope) or 88 users / 109 emails (allocation scope) + +**Note:** 22 PIs are on bos-isilon projects but don't have AllocationUser +records, so they disappear when the template requires allocation-level data. + +**Evidence:** [ ] Screenshot / [ ] Video + +--- + +### Scenario 3 — Economics + Pending Deactivation (Sarah's use case) + +> "I'm doing some data cleanup work with the Economics department and I +> need to know what folders are pending deactivation." + +**Data chain:** +- Department: Economics (pk=1883) +- Projects: 13 +- Pending Deactivation allocations: 3 (manually set for testing) +- Users on those allocations: 2 + +**Steps:** +1. Open Compose Notification +2. Select **Department** → "Economics" +3. Verify: Projects narrow to **13 projects** +4. Select **Allocation Status** → "Pending Deactivation" +5. Verify: Allocations narrow to only Pending Deactivation +6. Verify: Detail card shows "Filtered by: Project, Alloc Status" +7. Click **Validate** +8. Verify: **2 users** reported + +**Expected result:** 2 recipients + +**Evidence:** [ ] Screenshot / [ ] Video + +--- + +### Scenario 4 — No filters (all active users) + +**Data chain:** +- All active ProjectUser records → all distinct users + +**Steps:** +1. Open Compose Notification +2. Do NOT select any filters +3. Verify: All dropdowns show full counts (18 depts, 1200 projects, etc.) +4. Verify: No detail cards shown (nothing active) +5. Click **Validate** +6. Verify: **5,539 users** reported + +**Expected result:** 5,539 recipients + +**Evidence:** [ ] Screenshot / [ ] Video + +--- + +### Scenario 5 — Single department only (Astronomy) + +**Data chain:** +- Department: Astronomy (pk=1878) +- Labs: 9 (Alyssa Goodman, Charlie Conroy, Doug Finkbeiner, etc.) +- Projects: 9 + +**Steps:** +1. Open Compose Notification +2. Select **Department** → "Astronomy" +3. Verify: Projects narrow to **9 projects** +4. Verify: Resources narrow to only those linked to Astronomy allocations +5. Verify: Detail card shows "Departments: 9 (of 1200) · 1 selected" +6. Verify: Detail card shows "Filtered by: Department" on projects +7. Click **Validate** +8. Verify: **147 users** reported + +**Expected result:** 147 recipients + +**Evidence:** [ ] Screenshot / [ ] Video + +--- + +### Scenario 6 — Bottom-up: Role auto-selects departments + +**Data chain:** +- Role: Storage Manager — present in 57 projects across multiple departments + +**Steps:** +1. Open Compose Notification +2. Select **User Role** → "Storage Manager" +3. Verify: Departments are **auto-selected** (blue pills appear in dropdown) +4. Verify: Detail card shows "Auto-selected by User Role: Storage Manager" +5. Verify: Projects narrow based on the auto-selected departments +6. Click **Validate** +7. Verify: **66 users** reported +8. **Deselect** the role +9. Verify: Auto-selected departments clear +10. Verify: All filters reset to full counts + +**Expected result:** 66 recipients; auto-selection clears on role deselect + +**Evidence:** [ ] Screenshot / [ ] Video + +--- + +### Scenario 7 — Resource + Status combo (active isilon users) + +**Data chain:** +- Resource: bos-isilon/tier1 → 113 projects +- Status: Active → intersection with isilon allocations + +**Steps:** +1. Open Compose Notification +2. Select **Resource** → "bos-isilon/tier1" +3. Verify: Allocations narrow +4. Select **Allocation Status** → "Active" +5. Verify: Allocations narrow further (only Active + isilon) +6. Verify: Detail card shows allocation breakdown with Active count +7. Click **Validate** +8. Verify: **548 users** reported + +**Expected result:** 548 recipients + +**Evidence:** [ ] Screenshot / [ ] Video + +--- + +### Scenario 7b — Resource narrows Projects (team feedback fix) + +> "I filtered by resource for nesetape/tier3 and it showed 74 allocations. +> But the project dropdown hadn't removed the projects that did not contain +> that resource." + +**Data chain:** +- Resource selected → its `project_ids` constrain the projects dropdown +- Projects narrow → allocations narrow (cascade continues downstream) + +**Steps:** +1. Open Compose Notification +2. Verify: Projects dropdown shows full count (1200) +3. Select **Resource** → pick any resource (e.g. "bos-isilon/tier1") +4. Verify: **Projects dropdown narrows** to only projects with that resource +5. Verify: Allocations narrow accordingly +6. Verify: Detail card on Projects shows "Filtered by: Resource" +7. **Deselect** the resource +8. Verify: Projects return to full count (1200) + +**Expected result:** Projects narrow when resource is selected + +**Evidence:** [ ] Screenshot / [ ] Video + +--- + +### Scenario 8 — Multiple departments + +**Data chain:** +- Chemistry (7 projects) + Earth and Planetary Sciences (7 projects) +- Combined: 14 projects (no overlap) + +**Steps:** +1. Open Compose Notification +2. Select **Department** → "Chemistry and Chemical Biology" +3. Verify: Projects narrow to 7 +4. Add **Department** → "Earth and Planetary Sciences" +5. Verify: Projects expand to **14** (union of both) +6. Verify: Resources and Allocations reflect the combined scope +7. Click **Validate** +8. Verify: **145 users** reported +9. **Remove** "Earth and Planetary Sciences" +10. Verify: Projects narrow back to 7 + +**Expected result:** 145 recipients with both; narrowing works on remove + +**Evidence:** [ ] Screenshot / [ ] Video + +--- + +### Scenario 9 — FASRC Cluster + PI (cluster notification) + +**Data chain:** +- Resource: FASRC Cluster — linked to most projects +- PIs on FASRC Cluster: 127 + +**Steps:** +1. Open Compose Notification +2. Select **Resource** → "FASRC Cluster" +3. Select **User Role** → "PI" +4. Verify: Role triggers bottom-up auto-select on departments +5. Verify: But since resource was selected first, departments should NOT auto-select (manual resource selection takes priority in the cascade) +6. Click **Validate** +7. Verify: **127 users** reported + +**Expected result:** 127 recipients + +**Evidence:** [ ] Screenshot / [ ] Video + +--- + +### Scenario 10 — Three-filter combo (SEAS + Active + PI) + +**Data chain:** +- Department: School of Engineering and Applied Sciences (pk=1956) → 80 projects +- Status: Active → narrows allocations +- Role: PI → narrows to PIs only +- Intersection: 54 users + +**Steps:** +1. Open Compose Notification +2. Select **Department** → "School of Engineering and Applied Sciences" +3. Verify: Projects narrow to **80** +4. Select **Allocation Status** → "Active" +5. Verify: Allocations narrow to Active only within SEAS +6. Select **User Role** → "PI" +7. Verify: No bottom-up auto-select (department already manually selected) +8. Verify: Detail cards show all three filters active +9. Click **Validate** +10. Verify: **54 users** reported + +**Expected result:** 54 recipients + +**Evidence:** [ ] Screenshot / [ ] Video + +--- + +## Cascade Verification Checklist + +For each scenario, verify the following cascade behavior: + +| Check | How to verify | +|---|---| +| Top-down narrowing | Select a department → projects dropdown count decreases | +| Resource narrowing from projects | Select a department → resources dropdown count decreases | +| Allocation narrowing | Select department + status → allocations show fewer options | +| Bottom-up auto-select | Select a role with no dept selected → departments auto-populate | +| Manual overrides auto | Select a dept first, then a role → dept stays, no auto-select | +| Clear Filters | Click "Clear Filters" → all dropdowns reset to full counts | +| Detail cards update | Every filter change → detail cards reflect current state | +| Validate button state | Green on success, red on error, gray on change | +| Validation at top | Error/success message appears under filters AND at send bar | +| Draft save | Click Save Draft → appears in campaign list with Draft badge | + +--- + +## Test Environment + +- **URL:** http://127.0.0.1:2444/notifications/compose/ +- **Login:** antonio / antonio (superuser) +- **Database:** MySQL in Docker (mysql-custom), database cf-contractor +- **Data:** cf_contractor_dump.sql + test modifications (Pending Deactivation on Economics, extra General Managers on Chemistry) +- **Email:** MailHog at http://127.0.0.1:8025 (catches all sent emails) diff --git a/README.md b/README.md index 2eb6fbd..17a6d09 100644 --- a/README.md +++ b/README.md @@ -5,23 +5,25 @@ A [ColdFront](https://coldfront.readthedocs.io/) plugin for sending bulk email n ## Features ### Compose & Send -- **Cascading recipient filters** — filter by Project, Department, Allocation, Resource, Allocation Status, and User Role. Each filter narrows the downstream options in real time. +- **Cascading recipient filters** — filter by Department, Project, Resource, Allocation Status, Allocation, and User Role. All filter data loads once on page load; cross-filter narrowing happens entirely client-side with zero AJAX round-trips. See [FILTERS.md](FILTERS.md) for the full architecture. +- **Bidirectional cascade** — top-down (department narrows projects, resources, allocations) and bottom-up (selecting a role auto-selects matching departments). - **Template-based composition** — pick a saved template or write a one-off. Templates support `{{variable}}` placeholders that resolve per recipient. - **Live email preview** — preview the rendered email for sample recipients before sending. A dropdown lets you switch between recipients to see how variable substitution looks for each. - **Per-user dedupe control** — users matched on multiple projects/allocations can receive one email per context or be deduped to one. Controlled per-user in the recipient preview with a "Dedupe all" option. - **Pre-flight validation** — checks every `{{token}}` resolves for every recipient before enabling Send. Unknown tokens, empty values, and resolution errors are surfaced with clear messages. +- **Draft auto-save** — compose progress is auto-saved every 30 seconds. Drafts are visible in the notifications list and can be resumed at any time. Navigate away without losing work. - **Per-email delivery** — each recipient gets an individually addressed email with their specific variable substitution. No bulk BCC. ### Template Variables -- **Query variables** — auto-resolved per recipient from ColdFront data. 28 built-in resolver paths covering Recipient, Project, PI, Department, Role, Allocation, and Resource fields. +- **Query variables** — auto-resolved per recipient from ColdFront data. Built-in resolver paths covering Recipient, Project, PI, Department, Role, Allocation, and Resource fields. - **Manual variables** — fixed values defined once at creation time and reused across all notifications (e.g. a maintenance date, a renewal URL). - **Full CRUD UI** — create, edit, soft-delete variables from the plugin's own interface. Query variables pick from a whitelisted dropdown; manual variables store a typed value. ### Notifications - **Dashboard** — stat cards (Total Delivered, Sending Now, Failed 30d, Total Notifications) plus a recent notifications table. - **Notification detail** — live delivery progress (AJAX polling), per-recipient log with status badges, and a view icon showing the exact rendered email as delivered. +- **Notification list** — status filter tabs (All, Sent, Partial, Failed, Draft) with Edit action on drafts. - **Resend failed** — one-click resend to failed recipients. -- **Soft delete** — deleted templates and variables remain visible (crossed out) on past notification details. ### Settings - **Sender & Reply-To management** — configure available From/Reply-To addresses with default flags. @@ -66,6 +68,14 @@ INSTALLED_APPS += ["coldfront_notifications"] EXTRA_APPS_URLS = [ ("notifications/", "coldfront_notifications.urls", "notifications"), ] + +# Templates and static files (adjust the path to your install location) +TEMPLATES[0]['DIRS'] += [ + '/usr/src/app/coldfront_notifications/coldfront_notifications/templates', +] +STATICFILES_DIRS += [ + '/usr/src/app/coldfront_notifications/coldfront_notifications/static', +] ``` If your ColdFront instance doesn't already wire `EXTRA_APPS_URLS`, add this to your main `urls.py`: @@ -110,7 +120,7 @@ with open('coldfront_notifications/coldfront_notifications/fixtures/variables.js " ``` -Or create your own variables from the UI: **Notifications → Template Variables → New Variable**. +Or create your own variables from the UI: **Notifications > Template Variables > New Variable**. ### Configure sender addresses @@ -120,7 +130,7 @@ If your ColdFront instance has `EMAIL_SENDER`, `EMAIL_TICKET_SYSTEM_ADDRESS`, or python manage.py seed_notification_senders ``` -Safe to re-run — skips addresses that already exist. You can also manage addresses manually at **Notifications → Settings**. +Safe to re-run — skips addresses that already exist. You can also manage addresses manually at **Notifications > Settings**. --- @@ -187,13 +197,30 @@ No code changes — the SMTP path is identical. --- +## Module Structure + +| Module | Purpose | +|---|---| +| `models.py` | NotificationCampaign, NotificationLog, NotificationTemplate, NotificationVariable, SenderConfig | +| `filters.py` | FilterDataBuilder, RecipientResolver, 6 filter classes | +| `template_variable_value_resolver.py` | ResolverRegistry, resolver groups, ResolverContext | +| `notification_validator.py` | NotificationValidator, extract_tokens, determine_scope | +| `campaign_sender.py` | CampaignSender, TemplateRenderer, SmtpDelivery, Celery task | +| `views/compose.py` | ComposeView, RecipientCountView, PreviewRenderView, ValidateView, DraftSaveView | +| `views/campaigns.py` | CampaignListView, CampaignDetailView, ResendFailedView, ResendComposeView | +| `views/templates.py` | TemplateListView, TemplateFormView, TemplateDeleteView, TemplateJsonView | +| `views/settings.py` | VariableListView, VariableFormView, SettingsView, SenderFormView | +| `views/helpers.py` | StaffRequiredMixin, dispatch_send | + +--- + ## Navigation | Menu item | Path | Description | |---|---|---| | Dashboard | `/notifications/` | Stats + recent notifications | -| Notifications | `/notifications/campaigns/` | Full list with status filter | -| Compose Notification | `/notifications/compose/` | Build and send | +| Notifications | `/notifications/campaigns/` | Full list with status filter (incl. Draft) | +| Compose Notification | `/notifications/compose/` | Build and send (or `?draft=` to resume) | | Templates | `/notifications/templates/` | Create/edit/delete templates | | Template Variables | `/notifications/variables/` | Create/edit/delete `{{token}}` definitions | | Settings | `/notifications/settings/` | Sender/reply-to addresses | diff --git a/coldfront_notifications/admin.py b/coldfront_notifications/admin.py index 9d25100..070609d 100644 --- a/coldfront_notifications/admin.py +++ b/coldfront_notifications/admin.py @@ -8,19 +8,19 @@ NotificationVariable, SenderConfig, ) -from .resolvers import QUERY_CHOICES +from .template_variable_value_resolver import registry as resolver_registry class NotificationVariableForm(forms.ModelForm): """ - Force resolver_key into a whitelisted dropdown built from QUERY_CHOICES. + Force resolver_key into a whitelisted dropdown built from resolver_registry.choices. When source=manual, the field is hidden/cleared. """ resolver_key = forms.ChoiceField( required=False, choices=( [("", "— choose a query path —")] - + [(k, f"{group}: {label}") for (k, label, group) in QUERY_CHOICES] + + [(k, f"{group}: {label}") for (k, label, group) in resolver_registry.choices] ), help_text="Only used when Source = Query.", ) @@ -35,9 +35,9 @@ class Meta: def clean(self): cleaned = super().clean() - if cleaned.get("source") == NotificationVariable.SOURCE_MANUAL: + if cleaned.get("source") == NotificationVariable.Source.MANUAL: cleaned["resolver_key"] = "" - elif cleaned.get("source") == NotificationVariable.SOURCE_QUERY: + elif cleaned.get("source") == NotificationVariable.Source.QUERY: if not cleaned.get("resolver_key"): raise forms.ValidationError( "Pick a query source when Source = Query." diff --git a/coldfront_notifications/campaign_sender.py b/coldfront_notifications/campaign_sender.py new file mode 100644 index 0000000..abf70eb --- /dev/null +++ b/coldfront_notifications/campaign_sender.py @@ -0,0 +1,312 @@ +""" +Celery task and sender logic for notification campaigns. + +CampaignSender resolves template variables for each recipient, renders +the subject/body, and sends emails in batches over a single SMTP connection +with retry on transient errors. +""" +from __future__ import annotations + +import logging +import re +import smtplib +import time + +from django.conf import settings +from django.core.mail import EmailMessage, get_connection +from django.utils import timezone + +from .conf import BATCH_SIZE, BATCH_DELAY, MAX_RETRIES, RETRY_DELAY +from .filters import RecipientResolver +from .models import NotificationCampaign, NotificationLog, NotificationVariable +from .notification_validator import extract_tokens, determine_scope +from .template_variable_value_resolver import MissingValue, registry as resolver_registry + +logger = logging.getLogger(__name__) + +try: + from celery import shared_task +except ImportError: + def shared_task(fn): + return fn + +TOKEN_PATTERN = re.compile(r"\{\{(\w+)\}\}") + + +class TemplateRenderer: + """Renders {{token}} placeholders in subject/body strings.""" + + def render(self, template_string: str, values: dict) -> str: + def replace_token(match): + token = match.group(1) + return values[token] if token in values else match.group(0) + return TOKEN_PATTERN.sub(replace_token, template_string or "") + + def build_values(self, tokens: list[str], variables_by_key: dict, context: dict) -> dict: + """Resolve all token values for a single recipient context.""" + values = {} + for token in tokens: + variable = variables_by_key.get(token) + if variable is None: + raise MissingValue(f"unknown token {token!r}") + + if variable.source == NotificationVariable.Source.MANUAL: + stored_value = (variable.value or "").strip() + if not stored_value and variable.is_required: + raise MissingValue(f"manual variable {token!r} has no stored value") + values[token] = variable.value or "" + else: + try: + values[token] = resolver_registry.resolve(variable.resolver_key, context) + except MissingValue: + raise MissingValue( + f"cannot resolve {variable.resolver_key} for {context['user'].email}" + ) + return values + + +class SmtpDelivery: + """Handles SMTP delivery with retry logic for transient errors.""" + + def __init__(self, max_retries: int = MAX_RETRIES, retry_delay: float = RETRY_DELAY): + self.max_retries = max_retries + self.retry_delay = retry_delay + + def is_transient_error(self, exception) -> bool: + if isinstance(exception, smtplib.SMTPResponseException): + return 400 <= exception.smtp_code < 500 + if isinstance(exception, smtplib.SMTPServerDisconnected): + return True + if isinstance(exception, ConnectionError): + return True + return False + + def send_with_retry(self, message: EmailMessage, connection) -> Exception | None: + """Send a message with retry on transient errors. + + Returns None on success or the exception on permanent failure. + """ + last_exception = None + for attempt in range(self.max_retries + 1): + try: + message.connection = connection + message.send() + return None + except Exception as exception: + last_exception = exception + if not self.is_transient_error(exception) or attempt == self.max_retries: + return exception + + delay = self.retry_delay * (2 ** attempt) + logger.warning( + "Transient SMTP error (attempt %d/%d), retrying in %.1fs: %s", + attempt + 1, self.max_retries + 1, delay, exception, + ) + time.sleep(delay) + self._reopen_connection(connection) + return last_exception + + def _reopen_connection(self, connection): + try: + connection.close() + except Exception: + pass + try: + connection.open() + except Exception as reopen_exception: + logger.error("Failed to re-open SMTP connection: %s", reopen_exception) + raise + + +class CampaignSender: + """Orchestrates sending a notification campaign. + + Phase 1: Resolve all template variables for every recipient. + Phase 2: Send emails in batches over a single SMTP connection. + """ + + def __init__(self, campaign: NotificationCampaign): + self.campaign = campaign + self.renderer = TemplateRenderer() + self.delivery = SmtpDelivery() + + def send(self): + """Execute the full send pipeline.""" + campaign = self.campaign + + campaign.status = NotificationCampaign.Status.SENDING + campaign.sent_at = timezone.now() + campaign.save(update_fields=["status", "sent_at"]) + campaign.logs.all().delete() + + tokens = extract_tokens(campaign.subject, campaign.body) + variables_by_key = { + variable.key: variable + for variable in NotificationVariable.objects.filter(key__in=tokens) + } + + unknown_tokens = [token for token in tokens if token not in variables_by_key] + if unknown_tokens: + self._fail(f"Unknown tokens: {', '.join(unknown_tokens)}") + return + + scope = determine_scope(list(variables_by_key.values())) + snapshot = campaign.filters_snapshot or {} + dedupe_users = snapshot.get("dedupe_users") or [] + + resolved_emails = self._resolve_all_recipients( + tokens, variables_by_key, snapshot, scope, dedupe_users, + ) + if resolved_emails is None: + return # _fail already called + + if not resolved_emails: + self._fail("No recipients matched the filters.") + return + + campaign.recipient_count = len(resolved_emails) + campaign.save(update_fields=["recipient_count"]) + + logger.info( + "Campaign %s: %d emails to send (batch_size=%d, delay=%.1fs)", + campaign.pk, len(resolved_emails), BATCH_SIZE, BATCH_DELAY, + ) + + self._deliver_emails(resolved_emails, snapshot) + + def _resolve_all_recipients(self, tokens, variables_by_key, snapshot, scope, dedupe_users): + """Resolve template variables for every recipient. + + Returns a list of (user, project, allocation, rendered_subject, rendered_body) + tuples, or None if a resolution error occurred. + """ + resolver = RecipientResolver(snapshot) + resolved = [] + + for user, project, allocation in resolver.enumerate_deduped(scope, dedupe_users): + context = {"user": user, "project": project, "allocation": allocation} + try: + values = self.renderer.build_values(tokens, variables_by_key, context) + except MissingValue as error: + self._fail(f"Resolution error: {error}") + return None + + resolved.append(( + user, project, allocation, + self.renderer.render(self.campaign.subject, values), + self.renderer.render(self.campaign.body, values), + )) + + return resolved + + def _deliver_emails(self, resolved_emails, snapshot): + """Send resolved emails in batches over a single SMTP connection.""" + campaign = self.campaign + bcc_recipients = list(set( + address.strip() + for address in snapshot.get("extra_recipients", []) + if address.strip() + )) + + delivered_count = 0 + failed_count = 0 + + connection = get_connection() + try: + connection.open() + except Exception as exception: + self._fail(f"Cannot open SMTP connection: {exception}") + return + + try: + for index, (user, project, allocation, rendered_subject, rendered_body) in enumerate(resolved_emails): + log_entry = NotificationLog.objects.create( + campaign=campaign, + email=user.email, + status=NotificationLog.Status.QUEUED, + rendered_subject=rendered_subject, + rendered_body=rendered_body, + project_id=project.pk if project else None, + allocation_id=allocation.pk if allocation else None, + ) + + to = [user.email] + bcc = bcc_recipients if index == 0 else [] + if settings.DEBUG: + dev_list = getattr(settings, "EMAIL_DEVELOPMENT_EMAIL_LIST", []) + if dev_list: + to = list(dev_list) + if bcc: + bcc = list(dev_list) + + message = EmailMessage( + subject=rendered_subject, + body=rendered_body, + from_email=campaign.sender, + to=to, + bcc=bcc, + reply_to=[campaign.reply_to] if campaign.reply_to else [], + ) + + send_error = self.delivery.send_with_retry(message, connection) + if send_error is None: + log_entry.status = NotificationLog.Status.DELIVERED + delivered_count += 1 + else: + log_entry.status = NotificationLog.Status.FAILED + log_entry.notes = str(send_error)[:500] + failed_count += 1 + + log_entry.save(update_fields=["status", "notes"]) + + if (index + 1) % BATCH_SIZE == 0: + campaign.delivered_count = delivered_count + campaign.failed_count = failed_count + campaign.save(update_fields=["delivered_count", "failed_count"]) + + if index + 1 < len(resolved_emails): + logger.info( + "Campaign %s: batch pause after %d/%d emails", + campaign.pk, index + 1, len(resolved_emails), + ) + time.sleep(BATCH_DELAY) + finally: + try: + connection.close() + except Exception: + pass + + campaign.status = ( + NotificationCampaign.Status.SENT if failed_count == 0 + else NotificationCampaign.Status.PARTIAL if delivered_count > 0 + else NotificationCampaign.Status.FAILED + ) + campaign.delivered_count = delivered_count + campaign.failed_count = failed_count + campaign.recipient_count = delivered_count + failed_count + campaign.completed_at = timezone.now() + campaign.save(update_fields=[ + "status", "delivered_count", "failed_count", + "recipient_count", "completed_at", + ]) + + logger.info( + "Campaign %s complete: %d delivered, %d failed (of %d total)", + campaign.pk, delivered_count, failed_count, delivered_count + failed_count, + ) + + def _fail(self, reason: str): + self.campaign.status = NotificationCampaign.Status.FAILED + self.campaign.completed_at = timezone.now() + self.campaign.save(update_fields=["status", "completed_at"]) + logger.error("Campaign %s FAILED: %s", self.campaign.pk, reason) + + +@shared_task +def send_notification_campaign(campaign_pk: int): + try: + campaign = NotificationCampaign.objects.get(pk=campaign_pk) + except NotificationCampaign.DoesNotExist: + logger.error("Campaign %s not found", campaign_pk) + return + CampaignSender(campaign).send() diff --git a/coldfront_notifications/conf.py b/coldfront_notifications/conf.py index 8d5ab14..2bd0a36 100644 --- a/coldfront_notifications/conf.py +++ b/coldfront_notifications/conf.py @@ -11,14 +11,11 @@ """ from django.conf import settings -# ── Email dispatch ──────────────────────────────────────────────── BATCH_SIZE = getattr(settings, "NOTIFICATION_BATCH_SIZE", 50) BATCH_DELAY = getattr(settings, "NOTIFICATION_BATCH_DELAY", 2.0) # seconds MAX_RETRIES = getattr(settings, "NOTIFICATION_MAX_RETRIES", 3) RETRY_DELAY = getattr(settings, "NOTIFICATION_RETRY_DELAY", 1.0) # seconds, doubles each retry -# ── Recipient preview ──────────────────────────────────────────── PREVIEW_PAGE_SIZE = getattr(settings, "NOTIFICATION_PREVIEW_PAGE_SIZE", 50) -# ── Preview render (email preview modal) ───────────────────────── PREVIEW_RENDER_LIMIT = getattr(settings, "NOTIFICATION_PREVIEW_RENDER_LIMIT", 10) diff --git a/coldfront_notifications/filters.py b/coldfront_notifications/filters.py new file mode 100644 index 0000000..3b9e3b0 --- /dev/null +++ b/coldfront_notifications/filters.py @@ -0,0 +1,411 @@ +""" +Filter engine for the compose page. + +Each filter class owns: +- ``initial_options()`` — UI dropdown data for the frontend +- ``_apply(qs, values)`` — queryset narrowing for recipient resolution + +``FilterDataBuilder`` assembles all filter data into a single JSON-ready +payload that the frontend uses for client-side cross-filter cascading. +""" +from __future__ import annotations + +import json +from collections import defaultdict +from typing import Any + +from django.contrib.auth import get_user_model + +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 +from ifxuser.models import OrgRelation + +User = get_user_model() + + +class BaseFilter: + """Abstract interface for all compose-page filters.""" + + def initial_options(self) -> list[dict[str, Any]]: + raise NotImplementedError + + def narrowed_by(self) -> list[str]: + return [] + + def to_dict(self) -> dict[str, Any]: + return { + "options": self.initial_options(), + "narrowed_by": self.narrowed_by(), + } + + def apply(self, queryset, values): + if not values: + return queryset + return self._apply(queryset, values) + + def _apply(self, queryset, values): + raise NotImplementedError + + +# Concrete filters + +class DepartmentFilter(BaseFilter): + """Departments linked to projects via OrgRelation → ProjectOrganization.""" + + def initial_options(self) -> list[dict[str, Any]]: + departments = list( + Department.objects.order_by("name") + .values_list("pk", "name") + ) + if not departments: + return [] + + department_ids = {pk for pk, _ in departments} + + department_projects = ( + OrgRelation.objects + .filter( + parent_id__in=department_ids, + child__rank="lab", + child__projectorganization__isnull=False, + ) + .values_list( + "parent_id", + "child__projectorganization__project_id", + ) + .distinct() + ) + + project_ids_by_department: dict[int, set[int]] = defaultdict(set) + for department_pk, project_id in department_projects: + project_ids_by_department[department_pk].add(project_id) + + return [ + { + "id": pk, + "label": name, + "project_ids": sorted(project_ids_by_department.get(pk, set())), + } + for pk, name in departments + ] + + def _apply(self, project_users, department_pks): + project_ids = ( + OrgRelation.objects + .filter( + parent_id__in=department_pks, + child__rank="lab", + child__projectorganization__isnull=False, + ) + .values_list("child__projectorganization__project_id", flat=True) + .distinct() + ) + return project_users.filter(project_id__in=project_ids) + + +class ProjectFilter(BaseFilter): + """All projects, narrowed by department selections.""" + + def initial_options(self) -> list[dict[str, Any]]: + return [ + {"id": pk, "label": title} + for pk, title in ( + Project.objects.order_by("title").values_list("pk", "title") + ) + ] + + def narrowed_by(self) -> list[str]: + return ["departments"] + + def _apply(self, project_users, project_pks): + return project_users.filter(project__pk__in=project_pks) + + +class ResourceFilter(BaseFilter): + """Resources linked to projects through allocations.""" + + def initial_options(self) -> list[dict[str, Any]]: + resource_allocations = ( + Allocation.objects + .values_list("resources__pk", "resources__name", "project_id") + .distinct() + ) + + resources_by_pk: dict[int, dict[str, Any]] = {} + for resource_pk, resource_name, project_id in resource_allocations: + if resource_pk is None: + continue + if resource_pk not in resources_by_pk: + resources_by_pk[resource_pk] = { + "id": resource_pk, + "label": resource_name, + "project_ids": set(), + } + if project_id is not None: + resources_by_pk[resource_pk]["project_ids"].add(project_id) + + options = sorted(resources_by_pk.values(), key=lambda r: r["label"]) + for option in options: + option["project_ids"] = sorted(option["project_ids"]) + + return options + + def narrowed_by(self) -> list[str]: + return ["projects"] + + def _apply(self, allocations, resource_pks): + return allocations.filter(resources__pk__in=resource_pks) + + +class StatusFilter(BaseFilter): + """Allocation status choices.""" + + def initial_options(self) -> list[dict[str, Any]]: + return [ + {"id": name, "label": name} + for name in ( + AllocationStatusChoice.objects + .order_by("name") + .values_list("name", flat=True) + ) + ] + + def _apply(self, allocations, status_names): + return allocations.filter(status__name__in=status_names) + + +class AllocationFilter(BaseFilter): + """Allocations with FK columns for cross-filtering.""" + + def initial_options(self) -> list[dict[str, Any]]: + allocations = ( + Allocation.objects + .select_related("project", "status") + .prefetch_related("resources") + .order_by("project__title", "pk") + ) + + options = [] + for allocation in allocations: + parent_resource = allocation.get_parent_resource + status_name = allocation.status.name if allocation.status else "" + label = f"{allocation.project.title} \u2014 {parent_resource or ''} [{status_name}] (#{allocation.pk})" + options.append({ + "id": allocation.pk, + "label": label, + "project_id": allocation.project_id, + "status": allocation.status.name if allocation.status else "", + "resource_ids": sorted( + allocation.resources.values_list("pk", flat=True) + ), + }) + + return options + + def narrowed_by(self) -> list[str]: + return ["projects", "resources", "statuses"] + + def _apply(self, allocations, allocation_pks): + return allocations.filter(pk__in=allocation_pks) + + +class RoleFilter(BaseFilter): + """Project user role choices with project_ids for bottom-up selection.""" + + def initial_options(self) -> list[dict[str, Any]]: + roles = list( + ProjectUserRoleChoice.objects + .order_by("name") + .values_list("name", flat=True) + ) + if not roles: + return [] + + role_projects = ( + ProjectUser.objects + .filter(status__name="Active") + .values_list("role__name", "project_id") + .distinct() + ) + + project_ids_by_role: dict[str, set[int]] = defaultdict(set) + for role_name, project_id in role_projects: + if role_name and project_id is not None: + project_ids_by_role[role_name].add(project_id) + + return [ + { + "id": name, + "label": name, + "project_ids": sorted(project_ids_by_role.get(name, set())), + } + for name in roles + ] + + def _apply(self, project_users, role_names): + return project_users.filter(role__name__in=role_names) + + +# Registry + +FILTER_REGISTRY: dict[str, type[BaseFilter]] = { + "departments": DepartmentFilter, + "projects": ProjectFilter, + "resources": ResourceFilter, + "statuses": StatusFilter, + "allocations": AllocationFilter, + "roles": RoleFilter, +} + +PROJECT_FILTERS = ("departments", "projects", "roles") +ALLOCATION_FILTERS = ("allocations", "resources", "statuses") + + +# FilterDataBuilder + +class FilterDataBuilder: + """Assembles all filter data into a single JSON-ready payload.""" + + def build(self) -> dict[str, dict[str, Any]]: + return { + name: cls().to_dict() + for name, cls in FILTER_REGISTRY.items() + } + + def build_json(self) -> str: + return json.dumps(self.build()) + + +# RecipientResolver + +class RecipientResolver: + """Resolves the selected filter values into actual recipients at send time. + + Accepts the filters dict from the compose POST and produces User + querysets or (user, project, allocation) tuples for email rendering. + """ + + def __init__(self, filters: dict): + self.filters = filters + + def _apply_project_filters(self, project_users): + for name in PROJECT_FILTERS: + values = self.filters.get(name) or [] + if values: + project_users = FILTER_REGISTRY[name]().apply(project_users, values) + return project_users + + def _apply_allocation_filters(self): + allocations = Allocation.objects.all() + for name in ALLOCATION_FILTERS: + values = self.filters.get(name) or [] + if values: + allocations = FILTER_REGISTRY[name]().apply(allocations, values) + return allocations + + def _has_allocation_filters(self): + return any(self.filters.get(name) for name in ALLOCATION_FILTERS) + + def queryset(self): + """Return a deduplicated User queryset matching the filters.""" + 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(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) + + def count(self) -> int: + return self.queryset().count() + + def enumerate(self, scope: str): + """Yield (user, project, allocation) tuples. + + scope: + "allocation" — one tuple per (user, project, allocation) match + "project" — one tuple per (user, project); allocation=None + "user" — one tuple per distinct user; project=None, allocation=None + """ + if scope not in ("user", "project", "allocation"): + raise ValueError(f"Unknown scope {scope!r}") + + if scope == "user": + for user in self.queryset().iterator(): + yield (user, None, None) + return + + project_users = self._apply_project_filters( + ProjectUser.objects + .select_related( + "user", "project", "project__pi", "project__status", "role", + ) + .filter(status__name="Active"), + ).order_by("user_id", "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(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 + .select_related( + "allocation", "allocation__status", "allocation__project", + ) + .filter(allocation__in=matched_allocations, status__name="Active") + .order_by("user_id", "allocation_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) + + 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, []): + 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: + yield from self.enumerate(scope) + return + seen = set() + for user, project, allocation in self.enumerate(scope): + if user.username in dedupe: + if user.pk in seen: + continue + seen.add(user.pk) + yield (user, project, allocation) diff --git a/coldfront_notifications/forms.py b/coldfront_notifications/forms.py index b2e2ae3..a44a2e4 100644 --- a/coldfront_notifications/forms.py +++ b/coldfront_notifications/forms.py @@ -1,18 +1,18 @@ from django import forms from django.utils.text import slugify from .models import NotificationTemplate, NotificationVariable, SenderConfig -from .resolvers import QUERY_CHOICES +from .template_variable_value_resolver import registry as resolver_registry class NotificationVariableForm(forms.ModelForm): """Plugin-side form (Bootstrap-themed). Restricts resolver_key to the - whitelisted QUERY_CHOICES so admins can't invent arbitrary ORM paths.""" + whitelisted resolver_registry.choices so admins can't invent arbitrary ORM paths.""" resolver_key = forms.ChoiceField( required=False, choices=( [("", "— choose a query source —")] - + [(k, f"{group}: {label}") for (k, label, group) in QUERY_CHOICES] + + [(k, f"{group}: {label}") for (k, label, group) in resolver_registry.choices] ), widget=forms.Select(attrs={"class": "form-control"}), ) @@ -38,12 +38,12 @@ class Meta: def clean(self): cleaned = super().clean() source = cleaned.get("source") - if source == NotificationVariable.SOURCE_MANUAL: + if source == NotificationVariable.Source.MANUAL: cleaned["resolver_key"] = "" required = cleaned.get("is_required", True) if required and not (cleaned.get("value") or "").strip(): self.add_error("value", "Required when source is Manual.") - elif source == NotificationVariable.SOURCE_QUERY: + elif source == NotificationVariable.Source.QUERY: cleaned["value"] = "" if not cleaned.get("resolver_key"): self.add_error("resolver_key", "Required when source is Query.") diff --git a/coldfront_notifications/models.py b/coldfront_notifications/models.py index a92ca8c..594eaf1 100644 --- a/coldfront_notifications/models.py +++ b/coldfront_notifications/models.py @@ -1,7 +1,11 @@ +import re + from django.core.exceptions import ValidationError from django.db import models from django.contrib.auth import get_user_model +from .template_variable_value_resolver import registry as resolver_registry + User = get_user_model() @@ -14,50 +18,42 @@ class NotificationVariable(models.Model): source=manual → admin types a value at compose time; stored on NotificationCampaign.extra_context. """ - SOURCE_QUERY = "query" - SOURCE_MANUAL = "manual" - SOURCE_CHOICES = [ - (SOURCE_QUERY, "Query (auto-resolved from data)"), - (SOURCE_MANUAL, "Manual (admin enters per-campaign)"), - ] - - WIDGET_TEXT = "text" - WIDGET_TEXTAREA = "textarea" - WIDGET_DATE = "date" - WIDGET_DATETIME = "datetime" - WIDGET_URL = "url" - WIDGET_CHOICES = [ - (WIDGET_TEXT, "Text"), - (WIDGET_TEXTAREA, "Multi-line text"), - (WIDGET_DATE, "Date"), - (WIDGET_DATETIME, "Date & Time"), - (WIDGET_URL, "URL"), - ] - - key = models.SlugField( + + class Source(models.TextChoices): + QUERY = "query", "Query (auto-resolved from data)" + MANUAL = "manual", "Manual (admin enters per-campaign)" + + class Widget(models.TextChoices): + TEXT = "text", "Text" + TEXTAREA = "textarea", "Multi-line text" + DATE = "date", "Date" + DATETIME = "datetime", "Date & Time" + URL = "url", "URL" + + key = models.SlugField( max_length=60, unique=True, help_text="Token used in {{key}} placeholders (letters, digits, _)", ) - label = models.CharField(max_length=120) - description = models.CharField(max_length=255, blank=True) - example = models.CharField(max_length=255, blank=True) - source = models.CharField(max_length=10, choices=SOURCE_CHOICES) + label = models.CharField(max_length=120) + description = models.CharField(max_length=255, blank=True) + example = models.CharField(max_length=255, blank=True) + source = models.CharField(max_length=10, choices=Source.choices) resolver_key = models.CharField( max_length=80, blank=True, help_text="Required when source=query; must match a registered resolver", ) input_widget = models.CharField( - max_length=10, choices=WIDGET_CHOICES, default=WIDGET_TEXT, + max_length=10, choices=Widget.choices, default=Widget.TEXT, help_text="Input type for the Value field when source=manual", ) - value = models.TextField( + value = models.TextField( blank=True, default="", help_text="Fixed value used for every campaign (manual variables only).", ) - is_required = models.BooleanField(default=True) - is_deleted = models.BooleanField(default=False) - created = models.DateTimeField(auto_now_add=True) - modified = models.DateTimeField(auto_now=True) + is_required = models.BooleanField(default=True) + is_deleted = models.BooleanField(default=False) + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) class Meta: ordering = ["key"] @@ -66,11 +62,10 @@ def __str__(self): return f"{{{{{self.key}}}}} ({self.get_source_display()})" def clean(self): - from .resolvers import QUERY_RESOLVERS - if self.source == self.SOURCE_QUERY: + if self.source == self.Source.QUERY: if not self.resolver_key: raise ValidationError({"resolver_key": "Required when source is Query."}) - if self.resolver_key not in QUERY_RESOLVERS: + if self.resolver_key not in resolver_registry.resolvers: raise ValidationError({ "resolver_key": f"'{self.resolver_key}' is not a registered resolver.", @@ -87,10 +82,9 @@ def clean(self): @property def required_scope(self): """Which context element the resolver depends on ('user', 'project', 'allocation', or None).""" - from .resolvers import QUERY_SCOPES - if self.source == self.SOURCE_MANUAL: + if self.source == self.Source.MANUAL: return None - return QUERY_SCOPES.get(self.resolver_key) + return resolver_registry.scopes.get(self.resolver_key) class SenderConfig(models.Model): @@ -121,7 +115,6 @@ def __str__(self): @property def variables(self): """Extract {{variable}} placeholders from subject + body.""" - import re pattern = re.compile(r"\{\{(\w+)\}\}") found = pattern.findall(self.subject) + pattern.findall(self.body) # deduplicate preserving order @@ -137,20 +130,14 @@ class Meta: class NotificationCampaign(models.Model): - STATUS_QUEUED = "queued" - STATUS_SENDING = "sending" - STATUS_SENT = "sent" - STATUS_PARTIAL = "partial" - STATUS_FAILED = "failed" - STATUS_DRAFT = "draft" - STATUS_CHOICES = [ - (STATUS_QUEUED, "Queued"), - (STATUS_SENDING, "Sending"), - (STATUS_SENT, "Sent"), - (STATUS_PARTIAL, "Partial"), - (STATUS_FAILED, "Failed"), - (STATUS_DRAFT, "Draft"), - ] + + class Status(models.TextChoices): + QUEUED = "queued", "Queued" + SENDING = "sending", "Sending" + SENT = "sent", "Sent" + PARTIAL = "partial", "Partial" + FAILED = "failed", "Failed" + DRAFT = "draft", "Draft" template = models.ForeignKey( NotificationTemplate, @@ -162,29 +149,33 @@ class NotificationCampaign(models.Model): body = models.TextField() sender = models.EmailField() reply_to = models.EmailField(blank=True) - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_DRAFT) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT) # filters snapshot (JSON) filters_snapshot = models.JSONField(default=dict, blank=True) # Admin-supplied values for source=manual variables ({"maint_date": "...", ...}) - extra_context = models.JSONField(default=dict, blank=True) + extra_context = models.JSONField(default=dict, blank=True) recipient_count = models.PositiveIntegerField(default=0) delivered_count = models.PositiveIntegerField(default=0) - failed_count = models.PositiveIntegerField(default=0) + failed_count = models.PositiveIntegerField(default=0) created_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True, related_name="notification_campaigns", ) - sent_at = models.DateTimeField(null=True, blank=True) + sent_at = models.DateTimeField(null=True, blank=True) completed_at = models.DateTimeField(null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return self.subject + @property + def display_subject(self): + return self.subject or "No subject (draft)" + @property def duration(self): if not self.sent_at or not self.completed_at: @@ -228,28 +219,24 @@ class Meta: class NotificationLog(models.Model): - STATUS_QUEUED = "queued" - STATUS_DELIVERED = "delivered" - STATUS_BOUNCED = "bounced" - STATUS_FAILED = "failed" - STATUS_CHOICES = [ - (STATUS_QUEUED, "Queued"), - (STATUS_DELIVERED, "Delivered"), - (STATUS_BOUNCED, "Bounced"), - (STATUS_FAILED, "Failed"), - ] - - campaign = models.ForeignKey( + + class Status(models.TextChoices): + QUEUED = "queued", "Queued" + DELIVERED = "delivered", "Delivered" + BOUNCED = "bounced", "Bounced" + FAILED = "failed", "Failed" + + campaign = models.ForeignKey( NotificationCampaign, on_delete=models.CASCADE, related_name="logs", ) - email = models.EmailField() - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_QUEUED) + email = models.EmailField() + status = models.CharField(max_length=20, choices=Status.choices, default=Status.QUEUED) timestamp = models.DateTimeField(auto_now_add=True) notes = models.TextField(blank=True) rendered_subject = models.TextField(blank=True, default="") - rendered_body = models.TextField(blank=True, default="") + rendered_body = models.TextField(blank=True, default="") # Per-email context (one log row = one sent email) project_id = models.IntegerField(null=True, blank=True, db_index=True) allocation_id = models.IntegerField(null=True, blank=True, db_index=True) diff --git a/coldfront_notifications/notification_validator.py b/coldfront_notifications/notification_validator.py new file mode 100644 index 0000000..4a7b5af --- /dev/null +++ b/coldfront_notifications/notification_validator.py @@ -0,0 +1,174 @@ +""" +Pre-flight validation for notification campaigns. + +NotificationValidator checks that every {{token}} in the subject and body +can be resolved for every recipient. It produces a validation result with +errors, counts, and scope information. + +A campaign is sendable when errors == [] and missing_tokens == []. +""" +from __future__ import annotations + +import re +from collections import Counter + +from .filters import RecipientResolver +from .models import NotificationVariable +from .template_variable_value_resolver import MissingValue, registry as resolver_registry + +TOKEN_PATTERN = re.compile(r"\{\{(\w+)\}\}") + +SCOPE_RANK = {"user": 0, "project": 1, "allocation": 2} +SCOPE_BY_RANK = {0: "user", 1: "project", 2: "allocation"} + + +class NotificationValidator: + """Validates a notification campaign before sending. + + Extracts tokens from subject/body, resolves each against every + recipient tuple, and collects errors for unresolvable variables. + """ + + def __init__(self, subject: str, body: str, filters: dict, + dedupe_users=None, scope_override=None): + self.subject = subject + self.body = body + self.filters = filters + self.dedupe_users = dedupe_users + self.scope_override = scope_override + + def validate(self) -> dict: + """Run validation and return the result dict.""" + tokens = self._extract_tokens() + variables_by_key = self._load_variables(tokens) + missing_tokens = [ + token for token in tokens if token not in variables_by_key + ] + matched_variables = [ + variables_by_key[token] for token in tokens if token in variables_by_key + ] + scope = self.scope_override or self._determine_scope(matched_variables) + + resolution_errors = [] + unique_user_pks = set() + emails_per_user = Counter() + + resolver = RecipientResolver(self.filters) + for user, project, allocation in resolver.enumerate_deduped(scope, self.dedupe_users): + unique_user_pks.add(user.pk) + emails_per_user[user.username] += 1 + context = {"user": user, "project": project, "allocation": allocation} + + for token in tokens: + variable = variables_by_key.get(token) + if variable is None: + continue + + error = self._validate_variable(variable, context) + if error: + resolution_errors.append({ + "email": user.email, + "username": user.username, + "full_name": user.get_full_name() or user.username, + "token": token, + "reason": error, + "project": project.title if project else "", + "project_pk": project.pk if project else None, + "allocation_pk": allocation.pk if allocation else None, + "allocation_label": ( + f"{allocation.pk} — {allocation.get_parent_resource}" + if allocation else "" + ), + }) + + multi_email_users = sorted( + [ + {"username": username, "count": count} + for username, count in emails_per_user.items() + if count > 1 + ], + key=lambda entry: -entry["count"], + ) + + return { + "errors": resolution_errors, + "user_count": len(unique_user_pks), + "email_count": sum(emails_per_user.values()), + "multi_emails": multi_email_users, + "missing_tokens": missing_tokens, + "scope": scope, + } + + def _extract_tokens(self) -> list[str]: + """Return unique tokens from subject + body in order of first occurrence.""" + seen = set() + tokens = [] + for text in (self.subject, self.body): + for match in TOKEN_PATTERN.finditer(text or ""): + token = match.group(1) + if token not in seen: + seen.add(token) + tokens.append(token) + return tokens + + def _load_variables(self, tokens: list[str]) -> dict: + """Load NotificationVariable objects for the given tokens.""" + return { + variable.key: variable + for variable in NotificationVariable.objects.filter(key__in=tokens) + } + + def _determine_scope(self, variables) -> str: + """Return the widest scope required by the given variables.""" + highest_rank = 0 + for variable in variables: + if variable.source != NotificationVariable.Source.QUERY: + continue + variable_scope = resolver_registry.scopes.get(variable.resolver_key) + rank = SCOPE_RANK.get(variable_scope, 0) + if rank > highest_rank: + highest_rank = rank + return SCOPE_BY_RANK[highest_rank] + + def _validate_variable(self, variable, context: dict) -> str | None: + """Validate a single variable against a recipient context. + + Returns an error message string, or None if valid. + """ + try: + if variable.source == NotificationVariable.Source.MANUAL: + if variable.is_required and not (variable.value or "").strip(): + raise MissingValue() + else: + resolver_registry.resolve(variable.resolver_key, context) + return None + except MissingValue: + if variable.source == NotificationVariable.Source.MANUAL: + return f"manual variable {variable.key!r} has no stored value" + return f"cannot resolve {variable.resolver_key} for this recipient" + + +def extract_tokens(subject: str, body: str) -> list[str]: + """Extract unique {{token}} names from subject and body.""" + seen = set() + tokens = [] + for text in (subject, body): + for match in TOKEN_PATTERN.finditer(text or ""): + token = match.group(1) + if token not in seen: + seen.add(token) + tokens.append(token) + return tokens + + +def determine_scope(variables) -> str: + """Return the widest scope required by the given variables.""" + highest_rank = 0 + for variable in variables: + if variable.source != NotificationVariable.Source.QUERY: + continue + variable_scope = resolver_registry.scopes.get(variable.resolver_key) + rank = SCOPE_RANK.get(variable_scope, 0) + if rank > highest_rank: + highest_rank = rank + return SCOPE_BY_RANK[highest_rank] diff --git a/coldfront_notifications/resolvers.py b/coldfront_notifications/resolvers.py deleted file mode 100644 index 87956f7..0000000 --- a/coldfront_notifications/resolvers.py +++ /dev/null @@ -1,191 +0,0 @@ -""" -Query resolvers for NotificationVariable(source='query'). - -The admin UI shows QUERY_CHOICES as a dropdown. The chosen value is stored -as NotificationVariable.resolver_key and dispatches here at send time. - -Each resolver takes a context dict: - {"user": IfxUser, "project": Project|None, "allocation": Allocation|None} -and returns a str, or raises MissingValue if the value can't be produced. -""" - - -class MissingValue(Exception): - """Raised by a resolver when it cannot produce a non-empty value.""" - - -def _fmt(value): - if value is None: - raise MissingValue() - s = str(value).strip() - if not s: - raise MissingValue() - return s - - -def _need(ctx, key): - v = ctx.get(key) - if v is None: - raise MissingValue() - return v - - -def _primary_resource(allocation): - """Returns the parent resource of an allocation, or None.""" - if allocation is None: - return None - return allocation.get_parent_resource # @property - - -def _dept_name(project): - from ifxuser.models import Organization - return Organization.objects.filter( - projectorganization__project=project, - rank="department", - ).values_list("name", flat=True).first() - - -def _project_role(user, project): - from coldfront.core.project.models import ProjectUser - pu = ( - ProjectUser.objects.filter( - user=user, project=project, status__name="Active", - ) - .select_related("role") - .first() - ) - return pu.role.name if pu and pu.role else None - - -# ───────────────────────────────────────────────────────────────────────── -# Resolver registry. -# Each entry: resolver_key → callable(ctx) -> str -# ───────────────────────────────────────────────────────────────────────── - -QUERY_RESOLVERS = { - # Recipient (user) - "user.username": lambda c: _fmt(_need(c, "user").username), - "user.email": lambda c: _fmt(_need(c, "user").email), - "user.full_name": lambda c: _fmt(_need(c, "user").get_full_name() or _need(c, "user").username), - "user.first_name": lambda c: _fmt(_need(c, "user").first_name), - "user.last_name": lambda c: _fmt(_need(c, "user").last_name), - "user.ifxid": lambda c: _fmt(getattr(_need(c, "user"), "ifxid", None)), - - # Project - "project.title": lambda c: _fmt(_need(c, "project").title), - "project.description": lambda c: _fmt(_need(c, "project").description), - "project.status.name": lambda c: _fmt(_need(c, "project").status.name), - "project.field_of_science.description": lambda c: _fmt( - _need(c, "project").field_of_science.description if _need(c, "project").field_of_science_id else None - ), - "project.parent_project.title": lambda c: _fmt( - _need(c, "project").parent_project.title if _need(c, "project").parent_project_id else None - ), - - # PI (of project) - "project.pi.full_name": lambda c: _fmt( - _need(c, "project").pi.get_full_name() or _need(c, "project").pi.username if _need(c, "project").pi_id else None - ), - "project.pi.email": lambda c: _fmt(_need(c, "project").pi.email if _need(c, "project").pi_id else None), - "project.pi.username": lambda c: _fmt(_need(c, "project").pi.username if _need(c, "project").pi_id else None), - - # Department (via ProjectOrganization) - "project.department.name": lambda c: _fmt(_dept_name(_need(c, "project"))), - - # User's role on the project - "projectuser.role.name": lambda c: _fmt(_project_role(_need(c, "user"), _need(c, "project"))), - - # Allocation - "allocation.id": lambda c: _fmt(_need(c, "allocation").pk), - "allocation.description": lambda c: _fmt(_need(c, "allocation").description), - "allocation.justification": lambda c: _fmt(_need(c, "allocation").justification), - "allocation.quantity": lambda c: _fmt(_need(c, "allocation").quantity), - "allocation.start_date": lambda c: _fmt(_need(c, "allocation").start_date), - "allocation.end_date": lambda c: _fmt(_need(c, "allocation").end_date), - "allocation.status.name": lambda c: _fmt(_need(c, "allocation").status.name), - - # Resource (primary on matched allocation) - "resource.name": lambda c: _fmt(getattr(_primary_resource(_need(c, "allocation")), "name", None)), - "resource.description": lambda c: _fmt(getattr(_primary_resource(_need(c, "allocation")), "description", None)), - "resource.resource_type.name": lambda c: _fmt( - (_primary_resource(_need(c, "allocation")).resource_type.name - if _primary_resource(_need(c, "allocation")) and _primary_resource(_need(c, "allocation")).resource_type_id - else None) - ), - "resource.parent_resource.name": lambda c: _fmt( - (_primary_resource(_need(c, "allocation")).parent_resource.name - if _primary_resource(_need(c, "allocation")) and _primary_resource(_need(c, "allocation")).parent_resource_id - else None) - ), - "resource.names_joined": lambda c: _fmt( - ", ".join(r.name for r in _need(c, "allocation").resources.all()) or None - ), -} - - -# Human-readable labels for the admin dropdown, grouped. -# Order preserved; groups keyed for UI grouping. -QUERY_CHOICES = [ - # (resolver_key, label, group) - ("user.username", "User · Username", "Recipient"), - ("user.email", "User · Email", "Recipient"), - ("user.full_name", "User · Full Name", "Recipient"), - ("user.first_name", "User · First Name", "Recipient"), - ("user.last_name", "User · Last Name", "Recipient"), - ("user.ifxid", "User · IFX ID", "Recipient"), - - ("project.title", "Project · Title", "Project"), - ("project.description", "Project · Description", "Project"), - ("project.status.name", "Project · Status", "Project"), - ("project.field_of_science.description", "Project · Field of Science", "Project"), - ("project.parent_project.title", "Project · Parent Title", "Project"), - - ("project.pi.full_name", "PI · Full Name", "PI"), - ("project.pi.email", "PI · Email", "PI"), - ("project.pi.username", "PI · Username", "PI"), - - ("project.department.name", "Department · Name", "Department"), - - ("projectuser.role.name", "Role · Name", "Role"), - - ("allocation.id", "Allocation · ID", "Allocation"), - ("allocation.description", "Allocation · Description", "Allocation"), - ("allocation.justification", "Allocation · Justification", "Allocation"), - ("allocation.quantity", "Allocation · Quantity", "Allocation"), - ("allocation.start_date", "Allocation · Start Date", "Allocation"), - ("allocation.end_date", "Allocation · End Date", "Allocation"), - ("allocation.status.name", "Allocation · Status", "Allocation"), - - ("resource.name", "Resource · Name (primary)", "Resource"), - ("resource.description", "Resource · Description", "Resource"), - ("resource.resource_type.name", "Resource · Type", "Resource"), - ("resource.parent_resource.name","Resource · Parent Name", "Resource"), - ("resource.names_joined", "Resource · All Names", "Resource"), -] - - -# Each resolver_key's required scope — used to decide whether to iterate -# AllocationUsers vs ProjectUsers vs just distinct users during enumeration. -def _scope_for(key): - if key.startswith("allocation.") or key.startswith("resource."): - return "allocation" - if key.startswith("project.") or key.startswith("projectuser."): - return "project" - return "user" - - -QUERY_SCOPES = {k: _scope_for(k) for k, _label, _group in QUERY_CHOICES} - - -def resolve(resolver_key: str, ctx: dict) -> str: - """Dispatch a resolver_key on a context. Raises MissingValue on failure.""" - fn = QUERY_RESOLVERS.get(resolver_key) - if fn is None: - raise MissingValue() - try: - return fn(ctx) - except MissingValue: - raise - except Exception: - # Any attribute traversal failure collapses to a missing value. - raise MissingValue() diff --git a/coldfront_notifications/static/coldfront_notifications/css/compose.css b/coldfront_notifications/static/coldfront_notifications/css/compose.css index 9b02095..dc5fe0c 100644 --- a/coldfront_notifications/static/coldfront_notifications/css/compose.css +++ b/coldfront_notifications/static/coldfront_notifications/css/compose.css @@ -1,5 +1,6 @@ .flbl { font-weight: 600; font-size: .82rem; margin-bottom: 4px; display: block; color: #343a40; } + /* recipients: row 1 — filters */ .recip-filters { display: grid; @@ -8,16 +9,47 @@ align-items: end; } -/* recipients: row 2 — count pill + buttons */ +/* recipients: row 2 — actions + count pill */ .recip-actions { - display: flex; align-items: center; gap: 10px; - margin-top: 14px; padding-top: 12px; + display: flex; align-items: center; justify-content: space-between; + margin-top: 20px; padding-top: 14px; border-top: 1px solid #eef0f3; } +.recip-actions-left { + display: flex; gap: 6px; flex-shrink: 0; +} .recip-count-pill { + flex-shrink: 0; background: #fdf5f6; border: 1px solid #e8b4bb; border-radius: 5px; padding: 5px 14px; text-align: center; } + +/* recipients: row 3 — filter breakdown details */ +.recip-details { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 8px; + margin-top: 14px; +} +.recip-details:empty { display: none; } +.recip-detail-card { + background: #f8f9fb; border: 1px solid #e2e6ea; + border-radius: 4px; padding: 8px 10px; font-size: .75rem; color: #495057; +} +.recip-detail-card.narrowed { + border-left: 3px solid #A51C30; +} +.recip-detail-title { + font-weight: 700; font-size: .78rem; color: #343a40; + margin-bottom: 3px; +} +.recip-detail-title .count { color: #A51C30; } +.recip-detail-breakdown { + font-size: .72rem; color: #495057; line-height: 1.5; +} +.recip-detail-filtered-by { + font-size: .68rem; color: #888; margin-top: 3px; font-style: italic; +} .recip-count-num { font-size: 1.3rem; font-weight: 700; font-family: monospace; color: #A51C30; line-height: 1; } .recip-count-lbl { font-size: .68rem; color: #6c757d; margin-top: 2px; } .bcc-badge { @@ -162,3 +194,36 @@ } .select2-container--default .select2-selection__choice__remove { color: rgba(255,255,255,.7) !important; } .select2-container--default .select2-selection__choice__remove:hover { color: #fff !important; } + +/* filter summary bar (shown after Validate) */ +.filter-summary-bar { + background: #f4f6f9; border: 1px solid #dee2e8; border-radius: 5px; + padding: 8px 14px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; +} +.filter-summary-bar .fs-label { + font-weight: 600; color: #6c757d; font-size: .75rem; margin-right: 4px; +} +.filter-summary-bar .fs-chip { + display: inline-block; background: #fff; border: 1px solid #ced4da; + border-radius: 3px; padding: 2px 8px; font-size: .75rem; color: #495057; +} +.filter-summary-bar .fs-chip strong { color: #A51C30; font-weight: 600; } +.filter-summary-bar .fs-none { + color: #adb5bd; font-style: italic; font-size: .75rem; +} + +/* filter info icon */ +.filter-info { + font-size: .68rem; cursor: help; margin-left: 3px; opacity: .5; + transition: opacity .15s; +} +.filter-info:hover { opacity: 1; } + +/* jQuery UI tooltip override for filter summaries */ +.filter-summary-tip { + background: #333 !important; color: #fff !important; + border: none !important; border-radius: 5px !important; + padding: 8px 12px !important; font-size: .78rem !important; + max-width: 340px !important; line-height: 1.5 !important; + box-shadow: 0 2px 8px rgba(0,0,0,.25) !important; +} diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose.js b/coldfront_notifications/static/coldfront_notifications/js/compose.js deleted file mode 100644 index d6ab45f..0000000 --- a/coldfront_notifications/static/coldfront_notifications/js/compose.js +++ /dev/null @@ -1,624 +0,0 @@ -// ── compose.js ────────────────────────────────────────────────── -// Expects the following globals set inline by the Django template: -// TEMPLATES - array of template objects -// VARIABLES - array of variable objects -// URLS.previewRender - URL for preview-render endpoint -// URLS.filterOptions - URL for filter-options endpoint -// URLS.validate - URL for validate endpoint -// URLS.recipientCount - URL for recipient-count endpoint - -// Index VARIABLES by key for O(1) lookup -var VAR_BY_KEY = {}; -VARIABLES.forEach(function(v) { VAR_BY_KEY[v.key] = v; }); - -// ── Render chip list ───────────────────────────────────────────── -function renderVars(vars, filter) { - var $list = $('#varsList').empty(); - var filtered = filter - ? vars.filter(function(v) { return v.toLowerCase().indexOf(filter.toLowerCase()) !== -1; }) - : vars; - if (!filtered.length) { - $list.append('
No matches
'); - return; - } - filtered.forEach(function(v) { - var token = '{{' + v + '}}'; - var def = VAR_BY_KEY[v] || {}; - var example = def.example || ''; - var badge = def.source === 'manual' ? ' [manual]' : ''; - var $btn = $('') - .attr('data-var', token) - .attr('title', (def.description || def.label || '') + (example ? ' — ' + example : '')) - .html( - '' + token + badge + '' + - '' + example + '' - ); - $list.append($btn); - }); -} - -// ── Extract tokens from subject + body ─────────────────────────── -var TOKEN_RE = /\{\{(\w+)\}\}/g; -function extractTokens() { - var text = ($('#id_subject').val() || '') + '\n' + ($('#id_body').val() || ''); - var seen = {}; - var out = []; - var m; - while ((m = TOKEN_RE.exec(text))) { - if (!seen[m[1]]) { seen[m[1]] = true; out.push(m[1]); } - } - return out; -} - -// ── Load template into compose fields ──────────────────────────── -// Always fetch fresh content from the server — avoids stale data when -// an admin edits a template in another tab and returns to compose. -var ACTIVE_VARS = []; -function loadTemplate($el) { - var id = parseInt($el.attr('data-id') || 0); - var slug = $el.attr('data-slug') || ''; - $('#active-slug').text(slug); - $('#hidden_template_id').val(id); - $('#varSearch').val(''); - $('#validationPanel').hide(); - $('#sendBtn').prop('disabled', true); - $('#sendWarn').hide(); - setDedupeUsers([]); - if (!id) { ACTIVE_VARS = []; renderVars([], ''); return; } - $.getJSON('/notifications/templates/' + id + '/json/', function(t) { - $('#id_subject').val(t.subject || ''); - $('#id_body').val(t.body || ''); - ACTIVE_VARS = t.variables || []; - renderVars(ACTIVE_VARS, ''); - if (HAS_VALIDATED) runValidation(); - }).fail(function() { - ACTIVE_VARS = []; - renderVars([], ''); - }); -} - -// ── Template click ─────────────────────────────────────────────── -$(document).on('click', '.tmpl-item', function() { - $('.tmpl-item').removeClass('active'); - $(this).addClass('active'); - loadTemplate($(this)); -}); - -// ── Template search ───────────────────────────────────────────── -$(document).on('input', '#tmplSearch', function() { - var q = $(this).val().toLowerCase(); - $('.tmpl-item').each(function() { - var text = $(this).text().toLowerCase(); - $(this).toggle(!q || text.indexOf(q) !== -1); - }); -}); - -// ── Variable search ────────────────────────────────────────────── -$(document).on('input', '#varSearch', function() { - renderVars(ACTIVE_VARS, $(this).val()); -}); - -// ── Variable insert at cursor ──────────────────────────────────── -$(document).on('click', '.var-btn', function() { - var ta = document.getElementById('id_body'); - var token = $(this).attr('data-var'); - var s = ta.selectionStart, e = ta.selectionEnd; - ta.value = ta.value.substring(0, s) + token + ta.value.substring(e); - ta.selectionStart = ta.selectionEnd = s + token.length; - ta.focus(); -}); - -// ── Preview modal ──────────────────────────────────────────────── -// Samples returned from /preview-render/; indexed by select option value. -var PREVIEW_SAMPLES = []; - -function renderPreviewSample(idx) { - var s = PREVIEW_SAMPLES[idx]; - if (!s) { - // "Template" — no recipient, raw tokens - $('#prev-to').text('—'); - $('#prev-subject').text($('#id_subject').val() || '—'); - $('#prev-body').text($('#id_body').val() || '—'); - $('#prev-error').hide(); - $('#prev-footer').text('Showing template with literal placeholders'); - return; - } - $('#prev-to').text((s.recipient.name || s.recipient.username) + ' <' + s.recipient.email + '>'); - $('#prev-subject').text(s.subject || '—'); - $('#prev-body').text(s.body || '—'); - if (s.error) { - $('#prev-error').text('Resolution error: ' + s.error).show(); - } else { - $('#prev-error').hide(); - } - var ctx = []; - if (s.recipient.project) ctx.push('project: ' + s.recipient.project); - if (s.recipient.allocation) ctx.push('allocation: ' + s.recipient.allocation); - $('#prev-footer').text(ctx.join(' · ') || 'recipient-only context'); -} - -$(document).on('click', '#previewBtn', function() { - $('#prev-from').text($('#id_sender').val()); - $('#prev-replyto').text($('#id_reply_to').val()); - // Show the raw template immediately while samples load - PREVIEW_SAMPLES = []; - $('#prev-recipient-picker').html('' - + ''); - renderPreviewSample(null); - $('#prev-scope-badge').text(''); - $('#previewModal').modal('show'); - - $.ajax({ - url: URLS.previewRender, - type: 'POST', - data: { - csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), - subject: $('#id_subject').val() || '', - body: $('#id_body').val() || '', - filters: JSON.stringify(collectFilters()), - dedupe_users: JSON.stringify(getDedupeUsers()), - }, - success: function(data) { - PREVIEW_SAMPLES = data.samples || []; - var opts = ['']; - PREVIEW_SAMPLES.forEach(function(s, i) { - var lbl = (s.recipient.name || s.recipient.username) + ' <' + s.recipient.email + '>'; - if (s.recipient.project) lbl += ' · ' + s.recipient.project; - opts.push(''); - }); - if (!PREVIEW_SAMPLES.length) { - opts.push(''); - } - $('#prev-recipient-picker').html(opts.join('')); - if (data.scope) $('#prev-scope-badge').text('scope: ' + data.scope + ' · ' + (data.total_emails || 0) + ' total'); - - // Auto-select the first real sample if available - if (PREVIEW_SAMPLES.length) { - $('#prev-recipient-picker').val('0'); - renderPreviewSample(0); - } else { - renderPreviewSample(null); - } - }, - error: function() { - $('#prev-recipient-picker').html(''); - $('#prev-footer').text('Preview failed to load — showing raw template'); - }, - }); -}); - -$(document).on('change', '#prev-recipient-picker', function() { - var v = $(this).val(); - renderPreviewSample(v === '' ? null : parseInt(v, 10)); -}); - -// ── Recipient filters stale on change ──────────────────────────── -$(document).on('change', '.s2', function() { - $('#recipNum').text('?'); - $('#recipLbl').text('press Validate'); - $('#sendBtn').prop('disabled', true); - $('#previewRecipBtn').prop('disabled', true); - $('#sendWarn').hide(); - // Changing filters may change the user pool — dedupe choices are user-scoped, - // so reset them to avoid stale selections. - setDedupeUsers([]); -}); - -// ── Cascading filter options ───────────────────────────────────── -// Rebuild a '; - } else { - cbCell = ''; - } - html += '' - + cbCell - + '' + (r.full_name||'') + (isMulti && first ? ' multi' : '') + '' - + '' + (r.email||'') + '' - + '' + (r.role||'') + '' - + '' + (r.project||'') + '' - + '' + (r.allocation||'') + '' - + ''; - }); - }); - $('#recipRows').html(html || 'No recipients matched.'); - - // Effective email count = sum of group length, capped at 1 when deduped - var effective = 0; - order.forEach(function(u) { - effective += dedupeSet[u] ? 1 : byUser[u].length; - }); - var scopeLbl = PREVIEW_SCOPE ? 'scope: ' + PREVIEW_SCOPE : ''; - $('#recipModalCount').text( - effective + ' emails → ' + order.length + ' users' - + (effective !== PREVIEW_ROWS.length ? ' (was ' + PREVIEW_ROWS.length + ' before dedupe)' : '') - ); - if (PREVIEW_SCOPE) { $('#recipModalScope').text(scopeLbl).show(); } else { $('#recipModalScope').hide(); } - -} - -function getPageSize() { - return parseInt($('#recipPageSize').val(), 10) || 10; -} - -function updatePaginationControls() { - var atFirst = PREVIEW_PAGE <= 1; - var atLast = PREVIEW_PAGE >= PREVIEW_TOTAL_PAGES; - $('#recipPageInfo').text('Page ' + PREVIEW_PAGE + ' of ' + PREVIEW_TOTAL_PAGES); - $('#recipFirstBtn').prop('disabled', atFirst); - $('#recipPrevBtn').prop('disabled', atFirst); - $('#recipNextBtn').prop('disabled', atLast); - $('#recipLastBtn').prop('disabled', atLast); -} - -function fetchPreviewPage(page) { - PREVIEW_PAGE = page || 1; - $('#recipSearch').val(''); - $('#recipRows').html('Loading…'); - - $.ajax({ - url: URLS.recipientCount, - type: 'POST', - data: { - csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), - projects: $('#f_project').val() || [], - allocations: $('#f_allocation').val() || [], - departments: $('#f_dept').val() || [], - resources: $('#f_resource').val() || [], - alloc_status: $('#f_status').val() || [], - roles: $('#f_role').val() || [], - subject: $('#id_subject').val() || '', - body: $('#id_body').val() || '', - dedupe_users: JSON.stringify(getDedupeUsers()), - preview: 'true', - page: PREVIEW_PAGE, - page_size: getPageSize(), - }, - traditional: true, - success: function(data) { - PREVIEW_ROWS = data.recipients || []; - PREVIEW_SCOPE = data.scope || null; - PREVIEW_TOTAL = data.count || 0; - PREVIEW_TOTAL_PAGES = data.total_pages || 1; - PREVIEW_PAGE = data.page || 1; - ALL_MULTI_USERS = (data.multi_users || []).map(function(m) { return m.username; }); - renderPreviewRows(); - // Show dedupe-all row if any multi-email users exist across all pages - if (ALL_MULTI_USERS.length) { - $('#dedupeAllRow').show(); - var allDeduped = ALL_MULTI_USERS.every(function(u) { - return getDedupeUsers().indexOf(u) !== -1; - }); - $('#dedupeAllCb').prop('checked', allDeduped); - } else { - $('#dedupeAllRow').hide(); - } - updatePaginationControls(); - }, - error: function() { - $('#recipRows').html('Failed to load.'); - } - }); -} - -$(document).on('click', '#previewRecipBtn', function() { - $('#recipModal').modal('show'); - fetchPreviewPage(1); -}); -$(document).on('click', '#recipFirstBtn', function() { - if (PREVIEW_PAGE > 1) fetchPreviewPage(1); -}); -$(document).on('click', '#recipPrevBtn', function() { - if (PREVIEW_PAGE > 1) fetchPreviewPage(PREVIEW_PAGE - 1); -}); -$(document).on('click', '#recipNextBtn', function() { - if (PREVIEW_PAGE < PREVIEW_TOTAL_PAGES) fetchPreviewPage(PREVIEW_PAGE + 1); -}); -$(document).on('click', '#recipLastBtn', function() { - if (PREVIEW_PAGE < PREVIEW_TOTAL_PAGES) fetchPreviewPage(PREVIEW_TOTAL_PAGES); -}); -$(document).on('change', '#recipPageSize', function() { - fetchPreviewPage(1); -}); - -// Client-side search: filter visible rows in the current page -$(document).on('input', '#recipSearch', function() { - var q = $(this).val().toLowerCase(); - $('#recipRows tr').each(function() { - var text = $(this).text().toLowerCase(); - $(this).toggle(!q || text.indexOf(q) !== -1); - }); -}); - -// All multi-email usernames across all pages (from server response). -var ALL_MULTI_USERS = []; - -// Bulk dedupe: toggle ALL multi-email users across all pages. -$(document).on('change', '#dedupeAllCb', function() { - if ($(this).is(':checked')) { - setDedupeUsers(ALL_MULTI_USERS.slice()); - } else { - setDedupeUsers([]); - } - renderPreviewRows(); - runValidation(); -}); - -// Per-user dedupe toggle inside the modal. -$(document).on('change', '.dedupe-cb', function() { - var username = $(this).attr('data-username'); - var cur = getDedupeUsers(); - var idx = cur.indexOf(username); - if ($(this).is(':checked')) { - if (idx === -1) cur.push(username); - } else { - if (idx !== -1) cur.splice(idx, 1); - } - setDedupeUsers(cur); - renderPreviewRows(); - runValidation(); -}); - - -// ── Serialize filter hidden fields before submit ───────────────── -$('#notifForm').on('submit', function() { - $('#h_projects').val(JSON.stringify($('#f_project').val() || [])); - $('#h_allocations').val(JSON.stringify($('#f_allocation').val() || [])); - $('#h_depts').val(JSON.stringify($('#f_dept').val() || [])); - $('#h_resources').val(JSON.stringify($('#f_resource').val() || [])); - $('#h_statuses').val(JSON.stringify($('#f_status').val() || [])); - $('#h_roles').val(JSON.stringify($('#f_role').val() || [])); -}); - -// ── Select2 init ───────────────────────────────────────────────── -$(document).ready(function() { - $('.s2').select2({ - allowClear: true, - width: '100%', - placeholder: function() { return $(this).data('placeholder'); } - }); - - // Load first template - var $first = $('.tmpl-item').first(); - if ($first.length) loadTemplate($first); -}); - -// ── pageshow: reset validation state + refresh active template ──── -// When a user edits a template in another tab and comes back, bfcache -// restores the DOM with the OLD textarea content and any stale errors. -// Force-hide the panel and re-fetch the active template so the textarea -// reflects the latest body before the next Validate click. -$(window).on('pageshow', function() { - $('#validationPanel').hide(); - $('#validationOK').hide(); - $('#validationBad').hide(); - $('#sendWarn').hide(); - $('#sendBtn').prop('disabled', true); - $('#recipNum').text('?'); - $('#recipLbl').text('press Validate'); - $('#emailCount').text('—'); - $('#multiBadge').hide(); - $('#multiWarn').hide(); - setDedupeUsers([]); - HAS_VALIDATED = false; - var $active = $('.tmpl-item.active'); - if ($active.length) loadTemplate($active); -}); diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_draft.js b/coldfront_notifications/static/coldfront_notifications/js/compose_draft.js new file mode 100644 index 0000000..4c84a3c --- /dev/null +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_draft.js @@ -0,0 +1,190 @@ +// compose_draft.js — Draft auto-save, resume, and dirty tracking. +// +// Loaded after compose_filters.js and before compose_init.js. +// +// Globals used: DRAFT_DATA, URLS, FILTER_DATA, FILTERS, FilterStore, +// collectFilters (from compose_filters.js) +// Globals exported: DRAFT_PK, isDraftDirty, restoreDraft + +var DRAFT_PK = null; +var _draftDirty = false; +var _autoSaveInterval = null; +var AUTO_SAVE_DELAY_MS = 30000; + +function isDraftDirty() { + return _draftDirty; +} + +function _markDirty() { + _draftDirty = true; + $('#draftStatus').text('Unsaved changes').css('color', '#856404'); +} + +function _markClean(timestamp) { + _draftDirty = false; + var time = timestamp || new Date().toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); + $('#draftStatus').text('Draft saved at ' + time).css('color', '#28a745'); +} + +function _collectDraftData() { + return { + csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), + draft_pk: DRAFT_PK || '', + subject: $('#id_subject').val() || '', + body: $('#id_body').val() || '', + sender: $('#id_sender').val() || '', + reply_to: $('#id_reply_to').val() || '', + template_id: $('#hidden_template_id').val() || '', + filters: JSON.stringify(collectFilters()), + extra_context: JSON.stringify({}) + }; +} + +function saveDraft(callback) { + var data = _collectDraftData(); + + $('#saveDraftBtn').prop('disabled', true).html('Saving...'); + + $.ajax({ + url: URLS.draftSave, + type: 'POST', + data: data, + success: function(response) { + DRAFT_PK = response.pk; + $('#hidden_draft_pk').val(DRAFT_PK); + _markClean(); + if (callback) callback(true); + }, + error: function() { + $('#draftStatus').text('Save failed').css('color', '#dc3545'); + if (callback) callback(false); + }, + complete: function() { + $('#saveDraftBtn').prop('disabled', false).html('Save Draft'); + } + }); +} + +function _autoSave() { + if (_draftDirty) { + saveDraft(); + } +} + +function _startAutoSave() { + if (_autoSaveInterval) return; + _autoSaveInterval = setInterval(_autoSave, AUTO_SAVE_DELAY_MS); +} + +function _stopAutoSave() { + if (_autoSaveInterval) { + clearInterval(_autoSaveInterval); + _autoSaveInterval = null; + } +} + +function restoreDraft(draftData) { + if (!draftData) return; + + DRAFT_PK = draftData.pk; + $('#hidden_draft_pk').val(DRAFT_PK); + + // Restore form fields + if (draftData.subject) $('#id_subject').val(draftData.subject); + if (draftData.body) $('#id_body').val(draftData.body); + if (draftData.sender) $('#id_sender').val(draftData.sender); + if (draftData.reply_to) $('#id_reply_to').val(draftData.reply_to); + if (draftData.template_id) $('#hidden_template_id').val(draftData.template_id); + + // Restore filter selections + var filters = draftData.filters || {}; + var filterMap = { + departments: '#f_dept', + projects: '#f_project', + resources: '#f_resource', + statuses: '#f_status', + allocations: '#f_allocation', + roles: '#f_role' + }; + + // 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); }); + } + + state.selected = values; + if (FILTERS[filterName]) { + FILTERS[filterName].render(); + FILTERS[filterName].dispatchChanged(); + } + } + + // Restore extra recipients + if (filters.extra_recipients && filters.extra_recipients.length) { + $('textarea[name="extra_recipients"]').val(filters.extra_recipients.join('\n')); + } + + // Restore dedupe users + if (filters.dedupe_users && filters.dedupe_users.length) { + $('#h_dedupe_users').val(JSON.stringify(filters.dedupe_users)); + } + + // Highlight the matching template in the sidebar + if (draftData.template_id) { + var $templateItem = $('.tmpl-item[data-id="' + draftData.template_id + '"]'); + if ($templateItem.length) { + $templateItem.addClass('active'); + $('#active-slug').text($templateItem.data('slug')); + } + } + + _markClean(); + _updateSummaries(); + }, 200); +} + +// Dirty tracking — mark dirty on any form input change +$(document).on('input change', '#id_subject, #id_body, #id_sender, #id_reply_to, textarea[name="extra_recipients"]', _markDirty); +$(document).on('change', '.s2', function() { + // Only mark dirty from user-driven changes (not programmatic) + if (!$(this).data('restoring')) { + _markDirty(); + } +}); + +// Save Draft button +$(document).on('click', '#saveDraftBtn', function() { + saveDraft(); +}); + +// Beforeunload warning +$(window).on('beforeunload', function() { + if (_draftDirty) { + return 'You have unsaved changes. Leave anyway?'; + } +}); + +// Clear beforeunload on form submit (send) +$('#notifForm').on('submit', function() { + _draftDirty = false; + _stopAutoSave(); +}); + +// Initialize on document ready +$(document).ready(function() { + if (typeof DRAFT_DATA !== 'undefined' && DRAFT_DATA) { + restoreDraft(DRAFT_DATA); + } + _startAutoSave(); +}); diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_filters.js b/coldfront_notifications/static/coldfront_notifications/js/compose_filters.js new file mode 100644 index 0000000..78305d5 --- /dev/null +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_filters.js @@ -0,0 +1,618 @@ +// compose_filters.js — Client-side state-driven cascading filter system. +// +// All filter data is loaded once on page load (FILTER_DATA from Django). +// Cross-filter narrowing happens entirely in the browser — zero AJAX. +// +// State model: each filter has {all, visible, selected}. +// - "all" = full server dataset, never changes +// - "visible" = subset of all after upstream (top-down) narrowing +// - "selected" = user's manual picks +// +// Cascade directions: +// - Top-down (narrow): department → projects → resources → allocations. +// Resource also narrows projects (bidirectional with cycle guard). +// - Bottom-up (inform): selecting a child filter shows context in the +// detail cards (e.g. which departments/projects an allocation belongs to) +// without modifying upstream dropdowns (except resource → projects) +// +// Globals used: FILTER_DATA (from Django template) +// Globals exported: collectFilters, getDedupeUsers, setDedupeUsers + +var FilterStore = { + state: {}, + listeners: {}, + _dispatching: {}, + + register: function(widget, events) { + for (var i = 0; i < events.length; i++) { + var evt = events[i]; + if (!this.listeners[evt]) this.listeners[evt] = []; + this.listeners[evt].push(widget); + } + }, + + dispatch: function(eventName) { + if (this._dispatching[eventName]) return; + this._dispatching[eventName] = true; + try { + var chain = this.listeners[eventName] || []; + for (var i = 0; i < chain.length; i++) { + chain[i].narrow(); + chain[i].dispatchChanged(); + } + } finally { + this._dispatching[eventName] = false; + } + }, + + getSelected: function(name) { + var s = this.state[name]; + return s ? s.selected : []; + }, + + getEffectiveIds: function(name) { + var s = this.state[name]; + if (!s) return null; + if (s.selected.length) return s.selected; + if (s.visible.length < s.all.length) { + return s.visible.map(function(v) { return v.id; }); + } + return null; + } +}; + + +function FilterWidget(config) { + this.name = config.name; + this.selector = config.selector; + this.emits = config.emits; + this.narrowedBy = config.narrowedBy || []; + this.narrowFn = config.narrowFn || null; + this._isRendering = false; +} + +FilterWidget.prototype.init = function(data) { + FilterStore.state[this.name] = { + all: data.options || [], + visible: data.options || [], + selected: [] + }; + FilterStore.register(this, this.narrowedBy); + this.render(); + this._bindChange(); +}; + +FilterWidget.prototype.narrow = function() { + var state = FilterStore.state[this.name]; + if (!this.narrowFn) { + state.visible = state.all; + return; + } + + var visible = []; + for (var i = 0; i < state.all.length; i++) { + if (this.narrowFn(state.all[i], FilterStore)) { + visible.push(state.all[i]); + } + } + state.visible = visible; + + var visibleIds = _toLookup(visible.map(function(v) { return v.id; })); + state.selected = state.selected.filter(function(id) { + return visibleIds[id] === true; + }); + + this.render(); +}; + +FilterWidget.prototype.render = function() { + this._isRendering = true; + var state = FilterStore.state[this.name]; + var $sel = $(this.selector); + var html = ''; + + for (var i = 0; i < state.visible.length; i++) { + var opt = state.visible[i]; + var escaped = $('
').text(opt.label).html(); + html += ''; + } + + $sel.html(html); + $sel.val(state.selected.map(String)); + $sel.trigger('change.select2'); + this._isRendering = false; +}; + +FilterWidget.prototype.dispatchChanged = function() { + if (this.emits) FilterStore.dispatch(this.emits); +}; + +FilterWidget.prototype._bindChange = function() { + var self = this; + $(document).on('change', this.selector, function() { + if (self._isRendering) return; + + var raw = $(self.selector).val() || []; + var state = FilterStore.state[self.name]; + var firstOpt = state.all.length ? state.all[0] : null; + var useInt = firstOpt && typeof firstOpt.id === 'number'; + state.selected = useInt + ? raw.map(function(v) { return parseInt(v, 10); }) + : raw; + + _invalidateValidation(); + self.dispatchChanged(); + _updateSummaries(); + }); +}; + + +// Top-down narrowing functions + +function narrowProjects(row, store) { + var passedDepartment = true; + var passedResource = true; + + // Department filter: check if this project belongs to a selected department + var selectedDepts = store.getSelected('departments'); + if (selectedDepts.length) { + passedDepartment = false; + var deptState = store.state.departments; + for (var i = 0; i < deptState.all.length; i++) { + var dept = deptState.all[i]; + if (selectedDepts.indexOf(dept.id) === -1) continue; + if (dept.project_ids && dept.project_ids.indexOf(row.id) !== -1) { + passedDepartment = true; + break; + } + } + } + + // Resource filter: check if this project has at least one selected resource + var selectedResources = store.getSelected('resources'); + if (selectedResources.length) { + passedResource = false; + var resState = store.state.resources; + for (var i = 0; i < resState.all.length; i++) { + var resource = resState.all[i]; + if (selectedResources.indexOf(resource.id) === -1) continue; + if (resource.project_ids && resource.project_ids.indexOf(row.id) !== -1) { + passedResource = true; + break; + } + } + } + + return passedDepartment && passedResource; +} + +function narrowResources(row, store) { + var projectIds = store.getEffectiveIds('projects'); + if (!projectIds) return true; + var lookup = _toLookup(projectIds); + var pids = row.project_ids || []; + for (var i = 0; i < pids.length; i++) { + if (lookup[pids[i]]) return true; + } + return false; +} + +function narrowAllocations(row, store) { + var projectIds = store.getEffectiveIds('projects'); + if (projectIds && !_toLookup(projectIds)[row.project_id]) return false; + + var selResources = store.getSelected('resources'); + if (selResources.length) { + var rids = row.resource_ids || []; + var found = false; + for (var i = 0; i < rids.length; i++) { + if (selResources.indexOf(rids[i]) !== -1) { found = true; break; } + } + if (!found) return false; + } + + var selStatuses = store.getSelected('statuses'); + if (selStatuses.length && selStatuses.indexOf(row.status) === -1) return false; + + return true; +} + +function _toLookup(arr) { + var m = {}; + for (var i = 0; i < arr.length; i++) m[arr[i]] = true; + return m; +} + + +// Filter instances + +var FILTERS = { + departments: new FilterWidget({ + name: 'departments', + selector: '#f_dept', + emits: 'DEPARTMENT_CHANGED', + narrowedBy: [], + narrowFn: null + }), + projects: new FilterWidget({ + name: 'projects', + selector: '#f_project', + emits: 'PROJECT_CHANGED', + narrowedBy: ['DEPARTMENT_CHANGED', 'RESOURCE_CHANGED'], + narrowFn: narrowProjects + }), + resources: new FilterWidget({ + name: 'resources', + selector: '#f_resource', + emits: 'RESOURCE_CHANGED', + narrowedBy: ['PROJECT_CHANGED'], + narrowFn: narrowResources + }), + statuses: new FilterWidget({ + name: 'statuses', + selector: '#f_status', + emits: 'STATUS_CHANGED', + narrowedBy: [], + narrowFn: null + }), + allocations: new FilterWidget({ + name: 'allocations', + selector: '#f_allocation', + emits: 'ALLOCATION_CHANGED', + narrowedBy: ['PROJECT_CHANGED', 'RESOURCE_CHANGED', 'STATUS_CHANGED'], + narrowFn: narrowAllocations + }), + roles: new FilterWidget({ + name: 'roles', + selector: '#f_role', + emits: 'ROLE_CHANGED', + narrowedBy: [], + narrowFn: null + }) +}; + + +// 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) : []; + } + return filters; +} + +function getDedupeUsers() { + try { + var v = JSON.parse($('#h_dedupe_users').val() || '[]'); + return Array.isArray(v) ? v : []; + } catch (e) { return []; } +} + +function setDedupeUsers(list) { + $('#h_dedupe_users').val(JSON.stringify(list || [])); +} + + +function _invalidateValidation() { + $('#recipNum').text('?'); + $('#recipLbl').text('press Validate'); + $('#sendBtn').prop('disabled', true); + $('#previewRecipBtn').prop('disabled', true); + $('#sendWarn').hide(); + setDedupeUsers([]); + if (typeof _setValidateButtonState === 'function') _setValidateButtonState('default'); + if (typeof _hideTopValidationResult === 'function') _hideTopValidationResult(); +} + + +// Filter details — inline breakdown cards with bottom-up context + +var FILTER_LABELS = { + departments: 'Departments', + projects: 'Projects', + resources: 'Resources', + statuses: 'Alloc Status', + allocations: 'Allocations', + roles: 'Roles' +}; + +function _updateSummaries() { + _renderFilterDetails(); + _updateClearButton(); +} + +function _getBreakdown(name, state) { + if (name === 'allocations') { + var byStatus = {}; + for (var i = 0; i < state.visible.length; i++) { + var s = state.visible[i].status || 'Unknown'; + byStatus[s] = (byStatus[s] || 0) + 1; + } + var parts = []; + for (var key in byStatus) parts.push(byStatus[key] + ' ' + key); + return parts.join(', '); + } + + if (state.selected.length && state.selected.length <= 5) { + var labels = []; + var selLookup = _toLookup(state.selected); + for (var i = 0; i < state.all.length; i++) { + if (selLookup[state.all[i].id]) labels.push(state.all[i].label); + } + return labels.join(', '); + } + + return ''; +} + +function _getTopDownInfo(name) { + var sources = { + projects: ['Department', 'Resource'], + resources: ['Project'], + allocations: ['Project', 'Resource', 'Alloc Status'] + }; + var deps = sources[name]; + if (!deps) return ''; + + var active = []; + if (deps.indexOf('Department') !== -1 && FilterStore.getSelected('departments').length) { + active.push('Department'); + } + if (deps.indexOf('Project') !== -1 && FilterStore.getEffectiveIds('projects')) { + active.push('Project'); + } + if (deps.indexOf('Resource') !== -1 && FilterStore.getSelected('resources').length) { + active.push('Resource'); + } + if (deps.indexOf('Alloc Status') !== -1 && FilterStore.getSelected('statuses').length) { + active.push('Alloc Status'); + } + return active.length ? 'Filtered by: ' + active.join(', ') : ''; +} + +function _getBottomUpContext(name, state) { + if (!state.selected.length) return ''; + + if (name === 'allocations') { + var deptNames = {}; + var projectNames = {}; + var resourceNames = {}; + var statuses = {}; + var deptState = FilterStore.state.departments; + var projState = FilterStore.state.projects; + var resState = FilterStore.state.resources; + + // Build resource ID → label lookup + var resourceLabelById = {}; + if (resState) { + for (var r = 0; r < resState.all.length; r++) { + resourceLabelById[resState.all[r].id] = resState.all[r].label; + } + } + + for (var i = 0; i < state.selected.length; i++) { + var alloc = null; + for (var j = 0; j < state.all.length; j++) { + if (state.all[j].id === state.selected[i]) { alloc = state.all[j]; break; } + } + if (!alloc) continue; + + if (alloc.status) statuses[alloc.status] = true; + + // Resolve resource names + var rids = alloc.resource_ids || []; + for (var r = 0; r < rids.length; r++) { + var resLabel = resourceLabelById[rids[r]]; + if (resLabel) resourceNames[resLabel] = true; + } + + // Find project label + if (projState) { + for (var j = 0; j < projState.all.length; j++) { + if (projState.all[j].id === alloc.project_id) { + projectNames[projState.all[j].label] = true; + break; + } + } + } + + // Find department for this project + if (deptState) { + for (var j = 0; j < deptState.all.length; j++) { + var dept = deptState.all[j]; + if (dept.project_ids && dept.project_ids.indexOf(alloc.project_id) !== -1) { + deptNames[dept.label] = true; + } + } + } + } + + var parts = []; + var deptList = Object.keys(deptNames); + if (deptList.length) parts.push('Dept: ' + (deptList.length <= 3 ? deptList.join(', ') : deptList.length + ' departments')); + var projList = Object.keys(projectNames); + if (projList.length) parts.push('Project: ' + (projList.length <= 3 ? projList.join(', ') : projList.length + ' projects')); + var resList = Object.keys(resourceNames); + if (resList.length) parts.push('Resource: ' + (resList.length <= 3 ? resList.join(', ') : resList.length + ' resources')); + var statusList = Object.keys(statuses); + if (statusList.length) parts.push('Status: ' + statusList.join(', ')); + return parts.length ? 'Context: ' + parts.join(' · ') : ''; + } + + if (name === 'projects') { + var deptState = FilterStore.state.departments; + if (!deptState) return ''; + + var deptNames = {}; + var selLookup = _toLookup(state.selected); + for (var i = 0; i < deptState.all.length; i++) { + var dept = deptState.all[i]; + var deptPids = dept.project_ids || []; + for (var j = 0; j < deptPids.length; j++) { + if (selLookup[deptPids[j]]) { + deptNames[dept.label] = true; + break; + } + } + } + + var deptList = Object.keys(deptNames); + if (!deptList.length) return ''; + return 'Dept: ' + (deptList.length <= 3 ? deptList.join(', ') : deptList.length + ' departments'); + } + + if (name === 'roles') { + var roleState = FilterStore.state.roles; + var deptState = FilterStore.state.departments; + if (!roleState || !deptState) return ''; + + var roleProjectIds = {}; + for (var i = 0; i < roleState.all.length; i++) { + var role = roleState.all[i]; + if (state.selected.indexOf(role.id) === -1) continue; + var pids = role.project_ids || []; + for (var j = 0; j < pids.length; j++) roleProjectIds[pids[j]] = true; + } + + var matchedDepts = []; + for (var i = 0; i < deptState.all.length; i++) { + var dept = deptState.all[i]; + var deptPids = dept.project_ids || []; + for (var j = 0; j < deptPids.length; j++) { + if (roleProjectIds[deptPids[j]]) { matchedDepts.push(dept.label); break; } + } + } + + var projectCount = Object.keys(roleProjectIds).length; + var parts = []; + if (matchedDepts.length) parts.push(matchedDepts.length <= 3 ? matchedDepts.join(', ') : matchedDepts.length + ' departments'); + if (projectCount) parts.push(projectCount + ' projects'); + return parts.length ? 'Present in: ' + parts.join(' across ') : ''; + } + + if (name === 'resources') { + var resState = FilterStore.state.resources; + var deptState = FilterStore.state.departments; + if (!resState || !deptState) return ''; + + var resProjectIds = {}; + for (var i = 0; i < resState.all.length; i++) { + var res = resState.all[i]; + if (state.selected.indexOf(res.id) === -1) continue; + var pids = res.project_ids || []; + for (var j = 0; j < pids.length; j++) resProjectIds[pids[j]] = true; + } + + var matchedDepts = []; + for (var i = 0; i < deptState.all.length; i++) { + var dept = deptState.all[i]; + var deptPids = dept.project_ids || []; + for (var j = 0; j < deptPids.length; j++) { + if (resProjectIds[deptPids[j]]) { matchedDepts.push(dept.label); break; } + } + } + + var projectCount = Object.keys(resProjectIds).length; + var parts = []; + if (matchedDepts.length) parts.push(matchedDepts.length <= 3 ? matchedDepts.join(', ') : matchedDepts.length + ' departments'); + if (projectCount) parts.push(projectCount + ' projects'); + return parts.length ? 'Spans: ' + parts.join(' across ') : ''; + } + + if (name === 'statuses') { + var allocState = FilterStore.state.allocations; + if (!allocState) return ''; + var matchCount = 0; + for (var i = 0; i < allocState.visible.length; i++) { + if (state.selected.indexOf(allocState.visible[i].status) !== -1) matchCount++; + } + return matchCount ? matchCount + ' matching allocations' : ''; + } + + return ''; +} + +function _renderFilterDetails() { + var $container = $('#filterDetails').empty(); + var keys = ['departments', 'projects', 'resources', 'statuses', 'allocations', 'roles']; + + for (var i = 0; i < keys.length; i++) { + var k = keys[i]; + var state = FilterStore.state[k]; + if (!state) continue; + + var label = FILTER_LABELS[k] || k; + var isNarrowed = state.visible.length < state.all.length; + var hasSelections = state.selected.length > 0; + + if (!isNarrowed && !hasSelections) continue; + + var countText = '' + state.visible.length + ''; + if (isNarrowed) { + countText += ' (of ' + state.all.length + ')'; + } + if (hasSelections) { + countText += ' · ' + state.selected.length + ' selected'; + } + + var breakdown = _getBreakdown(k, state); + var topDownInfo = _getTopDownInfo(k); + var bottomUpContext = _getBottomUpContext(k, state); + + var html = '
'; + html += '
' + label + ': ' + countText + '
'; + if (breakdown) html += '
' + breakdown + '
'; + if (bottomUpContext) html += '
' + bottomUpContext + '
'; + if (topDownInfo) html += '
' + topDownInfo + '
'; + html += '
'; + + $container.append(html); + } +} + +function updateFilterSummaries() { _updateSummaries(); } + +function clearAllFilters() { + var keys = ['departments', 'projects', 'resources', 'statuses', 'allocations', 'roles']; + for (var i = 0; i < keys.length; i++) { + var state = FilterStore.state[keys[i]]; + if (state) { + state.selected = []; + state.visible = state.all; + } + if (FILTERS[keys[i]]) FILTERS[keys[i]].render(); + } + _invalidateValidation(); + _updateSummaries(); +} + +function _updateClearButton() { + var keys = ['departments', 'projects', 'resources', 'statuses', 'allocations', 'roles']; + var hasAny = false; + for (var i = 0; i < keys.length; i++) { + var state = FilterStore.state[keys[i]]; + if (state && state.selected.length) { hasAny = true; break; } + } + $('#clearFiltersBtn').toggle(hasAny); +} + +$(document).on('click', '#clearFiltersBtn', clearAllFilters); + + +// Initialization + +$(document).ready(function() { + if (typeof FILTER_DATA === 'undefined') return; + + var keys = ['departments', 'projects', 'resources', 'statuses', 'allocations', 'roles']; + for (var i = 0; i < keys.length; i++) { + var k = keys[i]; + if (FILTERS[k] && FILTER_DATA[k]) FILTERS[k].init(FILTER_DATA[k]); + } + + _updateSummaries(); +}); diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_init.js b/coldfront_notifications/static/coldfront_notifications/js/compose_init.js new file mode 100644 index 0000000..d120283 --- /dev/null +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_init.js @@ -0,0 +1,51 @@ +// ── compose_init.js ───────────────────────────────────────────── +// Select2 initialization, form submit serialization, pageshow reset. +// Must be loaded LAST (after all other compose_*.js files). +// +// Globals used: loadTemplate (from compose_templates.js), +// setDedupeUsers (from compose_filters.js), +// HAS_VALIDATED (from compose_validate.js) + +// ── Serialize filter hidden fields before submit ───────────────── +$('#notifForm').on('submit', function() { + $('#h_projects').val(JSON.stringify($('#f_project').val() || [])); + $('#h_allocations').val(JSON.stringify($('#f_allocation').val() || [])); + $('#h_depts').val(JSON.stringify($('#f_dept').val() || [])); + $('#h_resources').val(JSON.stringify($('#f_resource').val() || [])); + $('#h_statuses').val(JSON.stringify($('#f_status').val() || [])); + $('#h_roles').val(JSON.stringify($('#f_role').val() || [])); + if (typeof DRAFT_PK !== 'undefined' && DRAFT_PK) { + $('#hidden_draft_pk').val(DRAFT_PK); + } +}); + +// ── Select2 init ───────────────────────────────────────────────── +$(document).ready(function() { + $('.s2').select2({ + allowClear: true, + width: '100%', + placeholder: function() { return $(this).data('placeholder'); } + }); +}); + +// ── pageshow: reset validation state + refresh active template ──── +// When a user edits a template in another tab and comes back, bfcache +// restores the DOM with the OLD textarea content and any stale errors. +// Force-hide the panel and re-fetch the active template so the textarea +// reflects the latest body before the next Validate click. +$(window).on('pageshow', function() { + $('#validationPanel').hide(); + $('#validationOK').hide(); + $('#validationBad').hide(); + $('#sendWarn').hide(); + $('#sendBtn').prop('disabled', true); + $('#recipNum').text('?'); + $('#recipLbl').text('press Validate'); + $('#emailCount').text('—'); + $('#multiBadge').hide(); + $('#multiWarn').hide(); + setDedupeUsers([]); + HAS_VALIDATED = false; + var $active = $('.tmpl-item.active'); + if ($active.length) loadTemplate($active); +}); diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_preview.js b/coldfront_notifications/static/coldfront_notifications/js/compose_preview.js new file mode 100644 index 0000000..e0557a8 --- /dev/null +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_preview.js @@ -0,0 +1,304 @@ +// ── compose_preview.js ────────────────────────────────────────── +// Email preview modal and recipient preview modal (with dedupe + pagination). +// +// Globals used: URLS (from Django template), +// collectFilters, getDedupeUsers, setDedupeUsers (from compose_filters.js), +// runValidation (from compose_validate.js) + +// Blur focused element before modals hide to avoid aria-hidden warning. +$('#previewModal, #recipModal').on('hide.bs.modal', function() { + document.activeElement && document.activeElement.blur(); +}); + +// ═══════════════════════════════════════════════════════════════════ +// EMAIL PREVIEW MODAL +// ═══════════════════════════════════════════════════════════════════ + +var PREVIEW_SAMPLES = []; + +function renderPreviewSample(idx) { + var s = PREVIEW_SAMPLES[idx]; + if (!s) { + // "Template" — no recipient, raw tokens + $('#prev-to').text('—'); + $('#prev-subject').text($('#id_subject').val() || '—'); + $('#prev-body').text($('#id_body').val() || '—'); + $('#prev-error').hide(); + $('#prev-footer').text('Showing template with literal placeholders'); + return; + } + $('#prev-to').text((s.recipient.name || s.recipient.username) + ' <' + s.recipient.email + '>'); + $('#prev-subject').text(s.subject || '—'); + $('#prev-body').text(s.body || '—'); + if (s.error) { + $('#prev-error').text('Resolution error: ' + s.error).show(); + } else { + $('#prev-error').hide(); + } + var ctx = []; + if (s.recipient.project) ctx.push('project: ' + s.recipient.project); + if (s.recipient.allocation) ctx.push('allocation: ' + s.recipient.allocation); + $('#prev-footer').text(ctx.join(' · ') || 'recipient-only context'); +} + +$(document).on('click', '#previewBtn', function() { + $('#prev-from').text($('#id_sender').val()); + $('#prev-replyto').text($('#id_reply_to').val()); + // Show the raw template immediately while samples load + PREVIEW_SAMPLES = []; + $('#prev-recipient-picker').html('' + + ''); + renderPreviewSample(null); + $('#prev-scope-badge').text(''); + $('#previewModal').modal('show'); + + $.ajax({ + url: URLS.previewRender, + type: 'POST', + data: { + csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), + subject: $('#id_subject').val() || '', + body: $('#id_body').val() || '', + filters: JSON.stringify(collectFilters()), + dedupe_users: JSON.stringify(getDedupeUsers()), + }, + success: function(data) { + PREVIEW_SAMPLES = data.samples || []; + var opts = ['']; + PREVIEW_SAMPLES.forEach(function(s, i) { + var lbl = (s.recipient.name || s.recipient.username) + ' <' + s.recipient.email + '>'; + if (s.recipient.project) lbl += ' · ' + s.recipient.project; + opts.push(''); + }); + if (!PREVIEW_SAMPLES.length) { + opts.push(''); + } + $('#prev-recipient-picker').html(opts.join('')); + if (data.scope) $('#prev-scope-badge').text('scope: ' + data.scope + ' · ' + (data.total_emails || 0) + ' total'); + + // Auto-select the first real sample if available + if (PREVIEW_SAMPLES.length) { + $('#prev-recipient-picker').val('0'); + renderPreviewSample(0); + } else { + renderPreviewSample(null); + } + }, + error: function() { + $('#prev-recipient-picker').html(''); + $('#prev-footer').text('Preview failed to load — showing raw template'); + }, + }); +}); + +$(document).on('change', '#prev-recipient-picker', function() { + var v = $(this).val(); + renderPreviewSample(v === '' ? null : parseInt(v, 10)); +}); + +// ═══════════════════════════════════════════════════════════════════ +// RECIPIENT PREVIEW MODAL (with dedupe + pagination) +// ═══════════════════════════════════════════════════════════════════ + +var PREVIEW_ROWS = []; +var PREVIEW_SCOPE = null; +var PREVIEW_PAGE = 1; +var PREVIEW_TOTAL_PAGES = 1; +var PREVIEW_TOTAL = 0; +var PREVIEW_USER_COUNT = 0; +var ALL_MULTI_USERS = []; +var ALL_MULTI_COUNTS = {}; // username → email count (for dedup math) + +function renderPreviewRows() { + // Group rows by username preserving order. + var order = [], byUser = {}; + PREVIEW_ROWS.forEach(function(r) { + var u = r.username; + if (!(u in byUser)) { order.push(u); byUser[u] = []; } + byUser[u].push(r); + }); + + var dedupeSet = {}; + getDedupeUsers().forEach(function(u) { dedupeSet[u] = true; }); + + // Build a set from the server's cross-page multi-user list for O(1) lookup. + var multiSet = {}; + ALL_MULTI_USERS.forEach(function(u) { multiSet[u] = true; }); + + var html = ''; + order.forEach(function(u) { + var group = byUser[u]; + var isMulti = !!multiSet[u]; + var isDeduped = !!dedupeSet[u]; + group.forEach(function(r, idx) { + var first = idx === 0; + var muted = (isDeduped && !first); + if (isDeduped && group.length === 1 && isMulti) muted = true; + var styleAttr = muted + ? ' style="color:#aab0b7;text-decoration:line-through;font-style:italic;"' + : ''; + var cbCell = ''; + if (first && isMulti) { + cbCell = ''; + } else { + cbCell = ''; + } + html += '' + + cbCell + + '' + (r.full_name||'') + (isMulti && first ? ' multi' : '') + '' + + '' + (r.email||'') + '' + + '' + (r.role||'') + '' + + '' + (r.project||'') + '' + + '' + (r.allocation||'') + '' + + ''; + }); + }); + $('#recipRows').html(html || 'No recipients matched.'); + + // 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. + var effective = PREVIEW_TOTAL; + if (dedupeList.length && PREVIEW_TOTAL > 0) { + ALL_MULTI_USERS.forEach(function(u) { + if (dedupeSet[u] && ALL_MULTI_COUNTS[u]) { + effective -= (ALL_MULTI_COUNTS[u] - 1); + } + }); + } + + var countText = effective + ' emails, ' + PREVIEW_USER_COUNT + ' users'; + if (effective !== PREVIEW_TOTAL) { + countText += ' (was ' + PREVIEW_TOTAL + ' before dedupe)'; + } + $('#recipModalCount').text(countText); + $('#recipModalScope').hide(); +} + +function getPageSize() { + return parseInt($('#recipPageSize').val(), 10) || 10; +} + +function updatePaginationControls() { + var atFirst = PREVIEW_PAGE <= 1; + var atLast = PREVIEW_PAGE >= PREVIEW_TOTAL_PAGES; + $('#recipPageInfo').text('Page ' + PREVIEW_PAGE + ' of ' + PREVIEW_TOTAL_PAGES); + $('#recipFirstBtn').prop('disabled', atFirst); + $('#recipPrevBtn').prop('disabled', atFirst); + $('#recipNextBtn').prop('disabled', atLast); + $('#recipLastBtn').prop('disabled', atLast); +} + +function fetchPreviewPage(page) { + PREVIEW_PAGE = page || 1; + $('#recipSearch').val(''); + $('#recipRows').html('Loading…'); + + $.ajax({ + url: URLS.recipientCount, + type: 'POST', + data: { + csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), + projects: $('#f_project').val() || [], + allocations: $('#f_allocation').val() || [], + departments: $('#f_dept').val() || [], + resources: $('#f_resource').val() || [], + alloc_status: $('#f_status').val() || [], + roles: $('#f_role').val() || [], + subject: $('#id_subject').val() || '', + body: $('#id_body').val() || '', + dedupe_users: JSON.stringify(getDedupeUsers()), + preview: 'true', + page: PREVIEW_PAGE, + page_size: getPageSize(), + }, + traditional: true, + success: function(data) { + PREVIEW_ROWS = data.recipients || []; + PREVIEW_SCOPE = data.scope || null; + PREVIEW_TOTAL = data.count || 0; + PREVIEW_USER_COUNT = data.user_count || 0; + PREVIEW_TOTAL_PAGES = data.total_pages || 1; + PREVIEW_PAGE = data.page || 1; + ALL_MULTI_USERS = (data.multi_users || []).map(function(m) { return m.username; }); + ALL_MULTI_COUNTS = {}; + (data.multi_users || []).forEach(function(m) { ALL_MULTI_COUNTS[m.username] = m.count; }); + renderPreviewRows(); + // Show dedupe-all row if any multi-email users exist across all pages + if (ALL_MULTI_USERS.length) { + $('#dedupeAllRow').show(); + var allDeduped = ALL_MULTI_USERS.every(function(u) { + return getDedupeUsers().indexOf(u) !== -1; + }); + $('#dedupeAllCb').prop('checked', allDeduped); + } else { + $('#dedupeAllRow').hide(); + } + updatePaginationControls(); + }, + error: function() { + $('#recipRows').html('Failed to load.'); + } + }); +} + +$(document).on('click', '#previewRecipBtn', function() { + $('#recipModal').modal('show'); + fetchPreviewPage(1); +}); +$(document).on('click', '#recipFirstBtn', function() { + if (PREVIEW_PAGE > 1) fetchPreviewPage(1); +}); +$(document).on('click', '#recipPrevBtn', function() { + if (PREVIEW_PAGE > 1) fetchPreviewPage(PREVIEW_PAGE - 1); +}); +$(document).on('click', '#recipNextBtn', function() { + if (PREVIEW_PAGE < PREVIEW_TOTAL_PAGES) fetchPreviewPage(PREVIEW_PAGE + 1); +}); +$(document).on('click', '#recipLastBtn', function() { + if (PREVIEW_PAGE < PREVIEW_TOTAL_PAGES) fetchPreviewPage(PREVIEW_TOTAL_PAGES); +}); +$(document).on('change', '#recipPageSize', function() { + fetchPreviewPage(1); +}); + +// Client-side search: filter visible rows in the current page +$(document).on('input', '#recipSearch', function() { + var q = $(this).val().toLowerCase(); + $('#recipRows tr').each(function() { + var text = $(this).text().toLowerCase(); + $(this).toggle(!q || text.indexOf(q) !== -1); + }); +}); + +// Bulk dedupe: toggle ALL multi-email users across all pages. +$(document).on('change', '#dedupeAllCb', function() { + if ($(this).is(':checked')) { + setDedupeUsers(ALL_MULTI_USERS.slice()); + } else { + setDedupeUsers([]); + } + renderPreviewRows(); + runValidation(); +}); + +// Per-user dedupe toggle inside the modal. +$(document).on('change', '.dedupe-cb', function() { + var username = $(this).attr('data-username'); + var cur = getDedupeUsers(); + var idx = cur.indexOf(username); + if ($(this).is(':checked')) { + if (idx === -1) cur.push(username); + } else { + if (idx !== -1) cur.splice(idx, 1); + } + setDedupeUsers(cur); + renderPreviewRows(); + runValidation(); +}); diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_templates.js b/coldfront_notifications/static/coldfront_notifications/js/compose_templates.js new file mode 100644 index 0000000..fcbb648 --- /dev/null +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_templates.js @@ -0,0 +1,108 @@ +// ── compose_templates.js ──────────────────────────────────────── +// Template list, search, loading, variable chips, and variable insert. +// +// Globals used: VARIABLES, VAR_BY_KEY, URLS (from Django template), +// HAS_VALIDATED, runValidation (from compose_validate.js), +// setDedupeUsers (from compose_filters.js) + +// Index VARIABLES by key for O(1) lookup +var VAR_BY_KEY = {}; +VARIABLES.forEach(function(v) { VAR_BY_KEY[v.key] = v; }); + +// ── Render chip list ───────────────────────────────────────────── +var ACTIVE_VARS = []; + +function renderVars(vars, filter) { + var $list = $('#varsList').empty(); + var filtered = filter + ? vars.filter(function(v) { return v.toLowerCase().indexOf(filter.toLowerCase()) !== -1; }) + : vars; + if (!filtered.length) { + $list.append('
No matches
'); + return; + } + filtered.forEach(function(v) { + var token = '{{' + v + '}}'; + var def = VAR_BY_KEY[v] || {}; + var example = def.example || ''; + var badge = def.source === 'manual' ? ' [manual]' : ''; + var $btn = $('') + .attr('data-var', token) + .attr('title', (def.description || def.label || '') + (example ? ' — ' + example : '')) + .html( + '' + token + badge + '' + + '' + example + '' + ); + $list.append($btn); + }); +} + +// ── Extract tokens from subject + body ─────────────────────────── +var TOKEN_RE = /\{\{(\w+)\}\}/g; +function extractTokens() { + var text = ($('#id_subject').val() || '') + '\n' + ($('#id_body').val() || ''); + var seen = {}; + var out = []; + var m; + while ((m = TOKEN_RE.exec(text))) { + if (!seen[m[1]]) { seen[m[1]] = true; out.push(m[1]); } + } + return out; +} + +// ── Load template into compose fields ──────────────────────────── +// Always fetch fresh content from the server — avoids stale data when +// an admin edits a template in another tab and returns to compose. +function loadTemplate($el) { + var id = parseInt($el.attr('data-id') || 0); + var slug = $el.attr('data-slug') || ''; + $('#active-slug').text(slug); + $('#hidden_template_id').val(id); + $('#varSearch').val(''); + $('#validationPanel').hide(); + $('#sendBtn').prop('disabled', true); + $('#sendWarn').hide(); + setDedupeUsers([]); + if (!id) { ACTIVE_VARS = []; renderVars([], ''); return; } + $.getJSON(URLS.templateJson.replace('{id}', id), function(t) { + $('#id_subject').val(t.subject || ''); + $('#id_body').val(t.body || ''); + ACTIVE_VARS = t.variables || []; + renderVars(ACTIVE_VARS, ''); + if (HAS_VALIDATED) runValidation(); + }).fail(function() { + ACTIVE_VARS = []; + renderVars([], ''); + }); +} + +// ── Template click ─────────────────────────────────────────────── +$(document).on('click', '.tmpl-item', function() { + $('.tmpl-item').removeClass('active'); + $(this).addClass('active'); + loadTemplate($(this)); +}); + +// ── Template search ───────────────────────────────────────────── +$(document).on('input', '#tmplSearch', function() { + var q = $(this).val().toLowerCase(); + $('.tmpl-item').each(function() { + var text = $(this).text().toLowerCase(); + $(this).toggle(!q || text.indexOf(q) !== -1); + }); +}); + +// ── Variable search ────────────────────────────────────────────── +$(document).on('input', '#varSearch', function() { + renderVars(ACTIVE_VARS, $(this).val()); +}); + +// ── Variable insert at cursor ──────────────────────────────────── +$(document).on('click', '.var-btn', function() { + var ta = document.getElementById('id_body'); + var token = $(this).attr('data-var'); + var s = ta.selectionStart, e = ta.selectionEnd; + ta.value = ta.value.substring(0, s) + token + ta.value.substring(e); + ta.selectionStart = ta.selectionEnd = s + token.length; + ta.focus(); +}); diff --git a/coldfront_notifications/static/coldfront_notifications/js/compose_validate.js b/coldfront_notifications/static/coldfront_notifications/js/compose_validate.js new file mode 100644 index 0000000..48f1947 --- /dev/null +++ b/coldfront_notifications/static/coldfront_notifications/js/compose_validate.js @@ -0,0 +1,194 @@ +// compose_validate.js — Validation AJAX, send-bar UI, and input invalidation. +// +// Globals used: URLS (from Django template), +// collectFilters, getDedupeUsers (from compose_filters.js) +// Globals exported: HAS_VALIDATED, runValidation, +// _setValidateButtonState, _hideTopValidationResult + +var HAS_VALIDATED = false; + +function _setValidateButtonState(state) { + var classMap = { + default: 'btn btn-outline-secondary btn-sm', + success: 'btn btn-outline-success btn-sm', + error: 'btn btn-outline-danger btn-sm' + }; + var className = classMap[state] || classMap.default; + $('#recalcBtn').attr('class', className); + // Bottom validate button always stays outline-secondary +} + +function _showTopValidationResult(html, isError) { + var alertClass = isError ? 'alert-danger' : 'alert-success'; + $('#topValidationResult') + .html('
' + html + '
') + .show(); +} + +function _hideTopValidationResult() { + $('#topValidationResult').hide().empty(); +} + +function _buildErrorRow(error) { + var parts = ['' + error.email + '']; + if (error.project) { + parts.push('project ' + $('').text(error.project).html() + ''); + } + if (error.allocation_label) { + parts.push('allocation ' + $('').text(error.allocation_label).html() + ''); + } + parts.push('{{' + error.token + '}} — ' + error.reason); + return '
  • ' + parts.join(' · ') + '
  • '; +} + +function _buildTopErrorMessage(errorTitle, errors) { + var html = '' + errorTitle + ''; + html += '
      '; + var limit = Math.min(errors.length, 20); + for (var i = 0; i < limit; i++) { + html += _buildErrorRow(errors[i]); + } + if (errors.length > 20) { + html += '
    • … and ' + (errors.length - 20) + ' more
    • '; + } + html += '
    '; + return html; +} + +function runValidation() { + var $btn = $('#recalcBtn'); + $btn.prop('disabled', true).html(''); + _setValidateButtonState('default'); + _hideTopValidationResult(); + + $.ajax({ + url: URLS.validate, + type: 'POST', + data: { + csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), + subject: $('#id_subject').val() || '', + body: $('#id_body').val() || '', + filters: JSON.stringify(collectFilters()), + dedupe_users: JSON.stringify(getDedupeUsers()), + }, + success: function(data) { + var userCount = data.user_count || 0; + var emailCount = data.email_count || 0; + $('#recipNum').text(userCount); + $('#recipLbl').text('users'); + $('#emailCount').text(emailCount); + $('#warnCount').text(emailCount); + $('#sendCount').text(emailCount); + $('#hidden_recip_count').val(emailCount); + + // Filter summary bar + var activeFilters = data.active_filters || []; + if (activeFilters.length) { + var chips = activeFilters.map(function(filter) { + var values = filter.values.map(function(value) { + return '' + $('').text(value).html() + ''; + }).join(', '); + return '' + $('').text(filter.label).html() + ': ' + values + ''; + }).join(''); + $('#filterSummaryBar') + .html('
    Filters:' + chips + '
    ') + .show(); + } else { + $('#filterSummaryBar') + .html('
    Filters:No filters applied — targeting all active users
    ') + .show(); + } + + // Multi-email users + if (data.multi_emails && data.multi_emails.length) { + $('#multiCount').text(data.multi_emails.length); + $('#multiBadge').show(); + var multiLines = data.multi_emails.map(function(entry) { + return '
  • ' + entry.username + ' — ' + entry.count + ' emails
  • '; + }).join(''); + $('#multiList').html(multiLines); + $('#multiWarnText').text( + data.multi_emails.length + ' user(s) will receive multiple emails ' + + '(different project/allocation context each)' + ); + $('#multiWarn').show(); + } else { + $('#multiBadge').hide(); + $('#multiWarn').hide(); + } + + $('#previewRecipBtn').prop('disabled', userCount === 0); + HAS_VALIDATED = true; + + // Errors and missing tokens + $('#validationPanel').show(); + var errors = data.errors || []; + var missingTokens = data.missing_tokens || []; + + if (errors.length || missingTokens.length) { + _setValidateButtonState('error'); + + var titleParts = []; + if (missingTokens.length) titleParts.push(missingTokens.length + ' unknown token(s): ' + missingTokens.join(', ')); + if (errors.length) titleParts.push(errors.length + ' resolution error(s)'); + var errorTitle = titleParts.join('; '); + + $('#validationBadTitle').text('Blocking — ' + errorTitle); + var $errorList = $('#validationErrors').empty(); + errors.slice(0, 30).forEach(function(error) { + $errorList.append(_buildErrorRow(error)); + }); + if (errors.length > 30) { + $errorList.append('
  • … and ' + (errors.length - 30) + ' more
  • '); + } + $('#validationBad').show(); + $('#validationOK').hide(); + $('#sendBtn').prop('disabled', true); + $('#sendWarn').hide(); + + _showTopValidationResult(_buildTopErrorMessage(errorTitle, errors), true); + + } else { + _setValidateButtonState('success'); + + var successMessage = '' + + userCount + ' users → ' + emailCount + ' emails. All variables resolve.'; + + $('#validationOK').html(successMessage).show(); + $('#validationBad').hide(); + $('#sendBtn').prop('disabled', emailCount === 0); + if (emailCount > 0) $('#sendWarn').show(); + + _showTopValidationResult(successMessage, false); + } + }, + error: function() { + $('#recipNum').text('—'); + $('#recipLbl').text('Error — try again'); + _setValidateButtonState('error'); + _showTopValidationResult( + 'Validation request failed. Please try again.', + true + ); + }, + complete: function() { + // Restore button text but keep the colored class + $btn.prop('disabled', false).html('Validate'); + } + }); +} + +$(document).on('click', '#recalcBtn, #recalcBtn2', function() { runValidation(); }); + +$(document).on('click', '#multiShowBtn', function() { + $('#multiList').toggle(); +}); + +// Body/subject input changes invalidate previous validation. +$(document).on('input', '#id_body, #id_subject', function() { + $('#validationPanel').hide(); + $('#sendBtn').prop('disabled', true); + $('#sendWarn').hide(); + _setValidateButtonState('default'); + _hideTopValidationResult(); +}); diff --git a/coldfront_notifications/tasks.py b/coldfront_notifications/tasks.py deleted file mode 100644 index 8888183..0000000 --- a/coldfront_notifications/tasks.py +++ /dev/null @@ -1,276 +0,0 @@ -""" -Celery tasks for async email dispatch. - -Sends one email per (user, project, allocation) tuple (scope permitting). -Hard-fails the campaign if any token cannot be resolved for any recipient. - -Batch handling: - - Reuses a single SMTP connection across all emails - - Sends in batches with a configurable delay between them - - Retries transient SMTP errors (4xx) with exponential backoff - -Settings (in Django settings / local_settings.py): - NOTIFICATION_BATCH_SIZE = 50 # emails per batch - NOTIFICATION_BATCH_DELAY = 2.0 # seconds between batches - NOTIFICATION_MAX_RETRIES = 3 # retries per email on transient failure - NOTIFICATION_RETRY_DELAY = 1.0 # initial retry delay (doubles each retry) -""" -import logging -import re -import time -import smtplib - -from django.core.mail import EmailMessage, get_connection -from django.utils import timezone - -from .conf import BATCH_SIZE, BATCH_DELAY, MAX_RETRIES, RETRY_DELAY - -logger = logging.getLogger(__name__) - -try: - from celery import shared_task -except ImportError: - def shared_task(fn): - return fn - - -TOKEN_RE = re.compile(r"\{\{(\w+)\}\}") - - -def _render(template_str: str, values: dict) -> str: - def repl(m): - k = m.group(1) - return values[k] if k in values else m.group(0) - return TOKEN_RE.sub(repl, template_str or "") - - -def _build_values(tokens, vars_by_key, ctx): - from .models import NotificationVariable - from .resolvers import MissingValue, resolve - - out = {} - for t in tokens: - v = vars_by_key.get(t) - if v is None: - raise MissingValue(f"unknown token {t!r}") - if v.source == NotificationVariable.SOURCE_MANUAL: - val = (v.value or "").strip() - if not val and v.is_required: - raise MissingValue(f"manual variable {t!r} has no stored value") - out[t] = v.value or "" - else: - try: - out[t] = resolve(v.resolver_key, ctx) - except MissingValue: - raise MissingValue( - f"cannot resolve {v.resolver_key} for {ctx['user'].email}" - ) - return out - - -def _is_transient(exc): - """Return True if the SMTP error is transient (4xx) and worth retrying.""" - if isinstance(exc, smtplib.SMTPResponseException): - return 400 <= exc.smtp_code < 500 - if isinstance(exc, smtplib.SMTPServerDisconnected): - return True - if isinstance(exc, ConnectionError): - return True - return False - - -def _send_with_retry(msg, connection, max_retries=MAX_RETRIES, base_delay=RETRY_DELAY): - """ - Send an EmailMessage with retry on transient SMTP errors. - Re-opens the connection on disconnect. - Returns None on success or the exception on permanent failure. - """ - last_exc = None - for attempt in range(max_retries + 1): - try: - msg.connection = connection - msg.send() - return None - except Exception as exc: - last_exc = exc - if not _is_transient(exc) or attempt == max_retries: - return exc - delay = base_delay * (2 ** attempt) - logger.warning( - "Transient SMTP error (attempt %d/%d), retrying in %.1fs: %s", - attempt + 1, max_retries + 1, delay, exc, - ) - time.sleep(delay) - # Re-open connection if it was dropped - try: - connection.close() - except Exception: - pass - try: - connection.open() - except Exception as reopen_exc: - logger.error("Failed to re-open SMTP connection: %s", reopen_exc) - return reopen_exc - return last_exc - - -@shared_task -def send_notification_campaign(campaign_pk: int): - from .models import NotificationCampaign, NotificationLog, NotificationVariable - from .resolvers import MissingValue - from .utils import enumerate_recipients_deduped - from .validators import _extract_tokens, _required_scope - - try: - campaign = NotificationCampaign.objects.get(pk=campaign_pk) - except NotificationCampaign.DoesNotExist: - logger.error("Campaign %s not found", campaign_pk) - return - - campaign.status = NotificationCampaign.STATUS_SENDING - campaign.sent_at = timezone.now() - campaign.save(update_fields=["status", "sent_at"]) - - campaign.logs.all().delete() - - tokens = _extract_tokens(campaign.subject, campaign.body) - vars_by_key = {v.key: v for v in NotificationVariable.objects.filter(key__in=tokens)} - - unknown = [t for t in tokens if t not in vars_by_key] - if unknown: - _fail_campaign(campaign, f"Unknown tokens: {', '.join(unknown)}") - return - - scope = _required_scope(list(vars_by_key.values())) - snapshot = campaign.filters_snapshot or {} - dedupe_users = snapshot.get("dedupe_users") or [] - - # --- Phase 1: resolve all variables (streaming, no full list in memory) --- - # We resolve per-tuple and yield. Hard-fail on first resolution error. - tuples = enumerate_recipients_deduped(snapshot, scope, dedupe_users) - bcc_pool = list(set( - a.strip() for a in snapshot.get("extra_recipients", []) if a.strip() - )) - - # Pre-check: collect resolved emails into a generator that hard-fails. - # We need to know the total count upfront for progress, so we do a - # two-pass approach: resolve all first, then send in batches. - resolved = [] - for user, project, allocation in tuples: - ctx = {"user": user, "project": project, "allocation": allocation} - try: - values = _build_values(tokens, vars_by_key, ctx) - except MissingValue as exc: - _fail_campaign(campaign, f"Resolution error: {exc}") - return - resolved.append(( - user, project, allocation, - _render(campaign.subject, values), - _render(campaign.body, values), - )) - - if not resolved: - _fail_campaign(campaign, "No recipients matched the filters.") - return - - total = len(resolved) - campaign.recipient_count = total - campaign.save(update_fields=["recipient_count"]) - - logger.info( - "Campaign %s: %d emails to send (batch_size=%d, delay=%.1fs)", - campaign_pk, total, BATCH_SIZE, BATCH_DELAY, - ) - - # --- Phase 2: send in batches over a single SMTP connection --- - delivered = 0 - failed = 0 - - connection = get_connection() - try: - connection.open() - except Exception as exc: - _fail_campaign(campaign, f"Cannot open SMTP connection: {exc}") - return - - try: - for i, (user, project, allocation, subj, body) in enumerate(resolved): - log = NotificationLog.objects.create( - campaign=campaign, - email=user.email, - status=NotificationLog.STATUS_QUEUED, - rendered_subject=subj, - rendered_body=body, - project_id=project.pk if project else None, - allocation_id=allocation.pk if allocation else None, - ) - - msg = EmailMessage( - subject=subj, - body=body, - from_email=campaign.sender, - to=[user.email], - bcc=bcc_pool or [], - reply_to=[campaign.reply_to] if campaign.reply_to else [], - ) - - exc = _send_with_retry(msg, connection) - if exc is None: - log.status = NotificationLog.STATUS_DELIVERED - log.save(update_fields=["status"]) - delivered += 1 - else: - log.status = NotificationLog.STATUS_FAILED - log.notes = str(exc) - log.save(update_fields=["status", "notes"]) - failed += 1 - logger.error( - "Send failed for campaign %s recipient %s: %s", - campaign_pk, user.email, exc, - ) - - # Batch boundary: flush counts to DB so the detail page - # shows live progress, then pause to avoid SMTP rate limits. - seq = i + 1 - if seq % BATCH_SIZE == 0: - campaign.delivered_count = delivered - campaign.failed_count = failed - campaign.save(update_fields=["delivered_count", "failed_count"]) - if seq < total: - logger.info( - "Campaign %s: batch %d done (%d/%d), sleeping %.1fs", - campaign_pk, seq // BATCH_SIZE, seq, total, BATCH_DELAY, - ) - time.sleep(BATCH_DELAY) - - finally: - try: - connection.close() - except Exception: - pass - - campaign.delivered_count = delivered - campaign.failed_count = failed - campaign.recipient_count = delivered + failed - campaign.status = ( - NotificationCampaign.STATUS_SENT if failed == 0 - else NotificationCampaign.STATUS_PARTIAL if delivered > 0 - else NotificationCampaign.STATUS_FAILED - ) - campaign.completed_at = timezone.now() - campaign.save(update_fields=[ - "delivered_count", "failed_count", "recipient_count", - "status", "completed_at", - ]) - logger.info( - "Campaign %s complete: %d delivered, %d failed (of %d total)", - campaign_pk, delivered, failed, total, - ) - - -def _fail_campaign(campaign, reason: str): - from .models import NotificationCampaign - campaign.status = NotificationCampaign.STATUS_FAILED - campaign.completed_at = timezone.now() - campaign.save(update_fields=["status", "completed_at"]) - logger.error("Campaign %s FAILED: %s", campaign.pk, reason) diff --git a/coldfront_notifications/template_variable_value_resolver.py b/coldfront_notifications/template_variable_value_resolver.py new file mode 100644 index 0000000..027033e --- /dev/null +++ b/coldfront_notifications/template_variable_value_resolver.py @@ -0,0 +1,327 @@ +""" +Query resolvers for NotificationVariable(source='query'). + +Each resolver group is a class that registers its resolver keys, labels, +scope, and resolve methods. The ResolverRegistry collects all groups and +provides the public API: resolve(), choices, scopes, and key validation. + +Context dict passed to each resolver: + {"user": IfxUser, "project": Project|None, "allocation": Allocation|None} +""" +from coldfront.core.project.models import ProjectUser +from ifxuser.models import Organization + + +class MissingValue(Exception): + """Raised when a resolver cannot produce a non-empty value.""" + + +class ResolverContext: + """Wraps the raw context dict with typed accessors that raise MissingValue.""" + + def __init__(self, context: dict): + self._context = context + + @property + def user(self): + user = self._context.get("user") + if user is None: + raise MissingValue("user is required") + return user + + @property + def project(self): + project = self._context.get("project") + if project is None: + raise MissingValue("project is required") + return project + + @property + def allocation(self): + allocation = self._context.get("allocation") + if allocation is None: + raise MissingValue("allocation is required") + return allocation + + @property + def primary_resource(self): + resource = self.allocation.get_parent_resource + if resource is None: + raise MissingValue("allocation has no parent resource") + return resource + + +def format_value(value): + """Convert a value to a non-empty stripped string, or raise MissingValue.""" + if value is None: + raise MissingValue() + formatted = str(value).strip() + if not formatted: + raise MissingValue() + return formatted + + +class BaseResolverGroup: + """Each subclass defines resolvers for a scope (user, project, allocation). + + Subclasses populate ``resolvers`` — a list of (key, label, method_name) + tuples — and implement the corresponding methods. + """ + scope = None + group_label = None + resolvers = [] + + def get_registry_entries(self): + """Return list of (key, label, group, scope, callable) tuples.""" + entries = [] + for key, label, method_name in self.resolvers: + method = getattr(self, method_name) + entries.append((key, label, self.group_label, self.scope, method)) + return entries + + +class UserResolverGroup(BaseResolverGroup): + scope = "user" + group_label = "Recipient" + resolvers = [ + ("user.username", "User · Username", "resolve_username"), + ("user.email", "User · Email", "resolve_email"), + ("user.full_name", "User · Full Name", "resolve_full_name"), + ("user.first_name", "User · First Name", "resolve_first_name"), + ("user.last_name", "User · Last Name", "resolve_last_name"), + ] + + def resolve_username(self, context): + return format_value(context.user.username) + + def resolve_email(self, context): + return format_value(context.user.email) + + def resolve_full_name(self, context): + full_name = context.user.get_full_name() + return format_value(full_name or context.user.username) + + def resolve_first_name(self, context): + return format_value(context.user.first_name) + + def resolve_last_name(self, context): + return format_value(context.user.last_name) + + +class ProjectResolverGroup(BaseResolverGroup): + scope = "project" + group_label = "Project" + resolvers = [ + ("project.title", "Project · Title", "resolve_title"), + ("project.status.name", "Project · Status", "resolve_status"), + ("project.field_of_science.description", "Project · Field of Science", "resolve_field_of_science"), + ("project.parent_project.title", "Project · Parent Title", "resolve_parent_title"), + ("project.user_count", "Users · Quantity", "resolve_user_count"), + ("project.usernames", "Users · Usernames", "resolve_usernames"), + ] + + def resolve_title(self, context): + return format_value(context.project.title) + + def resolve_status(self, context): + return format_value(context.project.status.name) + + def resolve_field_of_science(self, context): + project = context.project + if not project.field_of_science_id: + raise MissingValue("project has no field of science") + return format_value(project.field_of_science.description) + + def resolve_parent_title(self, context): + project = context.project + if not project.parent_project_id: + raise MissingValue("project has no parent project") + return format_value(project.parent_project.title) + + def resolve_user_count(self, context): + active_users = context.project.projectuser_set.filter(status__name="Active") + return format_value(active_users.count()) + + def resolve_usernames(self, context): + usernames = list( + context.project.projectuser_set + .filter(status__name="Active") + .order_by("user__username") + .values_list("user__username", flat=True) + ) + return format_value(", ".join(usernames) or None) + + +class PIResolverGroup(BaseResolverGroup): + scope = "project" + group_label = "PI" + resolvers = [ + ("project.pi.full_name", "PI · Full Name", "resolve_pi_full_name"), + ("project.pi.email", "PI · Email", "resolve_pi_email"), + ("project.pi.username", "PI · Username", "resolve_pi_username"), + ] + + def _get_pi(self, context): + project = context.project + if not project.pi_id: + raise MissingValue("project has no PI") + return project.pi + + def resolve_pi_full_name(self, context): + pi = self._get_pi(context) + return format_value(pi.get_full_name() or pi.username) + + def resolve_pi_email(self, context): + return format_value(self._get_pi(context).email) + + def resolve_pi_username(self, context): + return format_value(self._get_pi(context).username) + + +class DepartmentResolverGroup(BaseResolverGroup): + scope = "project" + group_label = "Department" + resolvers = [ + ("project.department.name", "Department · Name", "resolve_department_name"), + ] + + def resolve_department_name(self, context): + department_name = Organization.objects.filter( + projectorganization__project=context.project, + rank="department", + ).values_list("name", flat=True).first() + return format_value(department_name) + + +class RoleResolverGroup(BaseResolverGroup): + scope = "project" + group_label = "Role" + resolvers = [ + ("projectuser.role.name", "Role · Name", "resolve_role_name"), + ] + + def resolve_role_name(self, context): + project_user = ( + ProjectUser.objects + .filter( + user=context.user, + project=context.project, + status__name="Active", + ) + .select_related("role") + .first() + ) + if not project_user or not project_user.role: + raise MissingValue("user has no active role on this project") + return format_value(project_user.role.name) + + +class AllocationResolverGroup(BaseResolverGroup): + scope = "allocation" + group_label = "Allocation" + resolvers = [ + ("allocation.quantity", "Allocation · Quantity", "resolve_quantity"), + ("allocation.start_date", "Allocation · Start Date", "resolve_start_date"), + ("allocation.end_date", "Allocation · End Date", "resolve_end_date"), + ("allocation.status.name", "Allocation · Status", "resolve_status"), + ("allocation.path", "Allocation · Path", "resolve_path"), + ("allocation.quota", "Allocation · Quota (TB)", "resolve_quota"), + ("allocation.usage", "Allocation · Usage", "resolve_usage"), + ] + + def resolve_quantity(self, context): + return format_value(context.allocation.quantity) + + def resolve_start_date(self, context): + return format_value(context.allocation.start_date) + + def resolve_end_date(self, context): + return format_value(context.allocation.end_date) + + def resolve_status(self, context): + return format_value(context.allocation.status.name) + + 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)")) + + def resolve_usage(self, context): + return format_value(context.allocation.usage) + + +class ResourceResolverGroup(BaseResolverGroup): + scope = "allocation" + group_label = "Resource" + resolvers = [ + ("resource.name", "Resource · Name (primary)", "resolve_name"), + ("resource.resource_type.name", "Resource · Type", "resolve_type"), + ("resource.parent_resource.name", "Resource · Parent Name", "resolve_parent_name"), + ("resource.names_joined", "Resource · All Names", "resolve_names_joined"), + ] + + def resolve_name(self, context): + return format_value(context.primary_resource.name) + + def resolve_type(self, context): + resource = context.primary_resource + if not resource.resource_type_id: + raise MissingValue("resource has no type") + return format_value(resource.resource_type.name) + + def resolve_parent_name(self, context): + resource = context.primary_resource + if not resource.parent_resource_id: + raise MissingValue("resource has no parent resource") + return format_value(resource.parent_resource.name) + + def resolve_names_joined(self, context): + resource_names = ", ".join( + resource.name for resource in context.allocation.resources.all() + ) + return format_value(resource_names or None) + + +class ResolverRegistry: + """Collects all resolver groups and provides the public lookup API. + + Builds three structures from the registered groups: + - resolvers: key → callable + - choices: [(key, label, group)] for admin dropdowns + - scopes: key → "user" | "project" | "allocation" + """ + + def __init__(self, groups): + self.resolvers = {} + self.choices = [] + self.scopes = {} + + for group in groups: + for key, label, group_label, scope, resolver_callable in group.get_registry_entries(): + self.resolvers[key] = resolver_callable + self.choices.append((key, label, group_label)) + self.scopes[key] = scope + + def resolve(self, resolver_key: str, context: dict) -> str: + """Dispatch a resolver_key against a context dict. Raises MissingValue on failure.""" + resolver_callable = self.resolvers.get(resolver_key) + if resolver_callable is None: + raise MissingValue(f"unknown resolver key: {resolver_key!r}") + try: + return resolver_callable(ResolverContext(context)) + except MissingValue: + raise + except Exception: + raise MissingValue(f"failed to resolve {resolver_key!r}") + + +registry = ResolverRegistry([ + UserResolverGroup(), + ProjectResolverGroup(), + PIResolverGroup(), + DepartmentResolverGroup(), + RoleResolverGroup(), + AllocationResolverGroup(), + ResourceResolverGroup(), +]) diff --git a/coldfront_notifications/templates/campaign_detail.html b/coldfront_notifications/templates/coldfront_notifications/campaign_detail.html similarity index 95% rename from coldfront_notifications/templates/campaign_detail.html rename to coldfront_notifications/templates/coldfront_notifications/campaign_detail.html index ebafcf1..4fb5252 100644 --- a/coldfront_notifications/templates/campaign_detail.html +++ b/coldfront_notifications/templates/coldfront_notifications/campaign_detail.html @@ -7,6 +7,14 @@

    Notification #{{ campaign.pk }}

    + {% if campaign.status == 'draft' %} + + Edit Draft + + + Delete + + {% else %} Resend @@ -19,6 +27,7 @@

    Notification #{{ campaign.pk }}

    {% endif %} + {% endif %}

    diff --git a/coldfront_notifications/templates/campaign_list.html b/coldfront_notifications/templates/coldfront_notifications/campaign_list.html similarity index 83% rename from coldfront_notifications/templates/campaign_list.html rename to coldfront_notifications/templates/coldfront_notifications/campaign_list.html index f2ec898..7ec4ee4 100644 --- a/coldfront_notifications/templates/campaign_list.html +++ b/coldfront_notifications/templates/coldfront_notifications/campaign_list.html @@ -22,6 +22,7 @@

    All Notifications

    Sent Partial Failed + Draft
    @@ -31,14 +32,14 @@

    All Notifications

    #SubjectTemplate RecipientsDelivered - FailedSent AtStatus + FailedSent AtStatus {% for c in campaigns %} {{ c.pk|stringformat:"03d" }} - {{ c.subject }} + {{ c.display_subject }} {{ c.template.slug|default:"—" }} {{ c.recipient_count }} {{ c.delivered_count }} @@ -52,9 +53,15 @@

    All Notifications

    {% elif c.status == 'draft' %}Draft {% else %}{{ c.status }}{% endif %} + + {% if c.status == 'draft' %} + + + {% endif %} + {% empty %} - No notifications yet. Send one → + No notifications yet. Send one → {% endfor %} diff --git a/coldfront_notifications/templates/compose.html b/coldfront_notifications/templates/coldfront_notifications/compose.html similarity index 77% rename from coldfront_notifications/templates/compose.html rename to coldfront_notifications/templates/coldfront_notifications/compose.html index 2ff81d2..033c091 100644 --- a/coldfront_notifications/templates/compose.html +++ b/coldfront_notifications/templates/coldfront_notifications/compose.html @@ -23,110 +23,57 @@

    Recipients

    - All recipients will be BCC'd
    - +
    - - + +
    - - + +
    - - + +
    - - + +
    - - + +
    - +
    - +
    +
    + + + +
    —
    recipients
    @@ -134,14 +81,13 @@

    — emails

    - -
    + + +
    + + +
    @@ -168,7 +114,7 @@

    {% for t in templates %} -
    {{ t.name }}
    @@ -194,6 +140,7 @@

    {% csrf_token %} + @@ -290,6 +237,7 @@

    + {% endblock %} {% block javascript %} @@ -454,13 +409,20 @@ // Server-rendered data (needs Django context) var TEMPLATES = {{ templates_json|safe }}; var VARIABLES = {{ variables_json|safe }}; -// Django URL endpoints for AJAX calls +var FILTER_DATA = {{ filter_data_json|safe }}; +var DRAFT_DATA = {{ draft_json|safe }}; var URLS = { - previewRender: '{% url "notifications:preview-render" %}', - filterOptions: '{% url "notifications:filter-options" %}', - validate: '{% url "notifications:validate" %}', - recipientCount: '{% url "notifications:recipient-count" %}' + 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" %}' }; - + + + + + + {% endblock %} \ No newline at end of file diff --git a/coldfront_notifications/templates/dashboard.html b/coldfront_notifications/templates/coldfront_notifications/dashboard.html similarity index 99% rename from coldfront_notifications/templates/dashboard.html rename to coldfront_notifications/templates/coldfront_notifications/dashboard.html index f609cd7..13afc83 100644 --- a/coldfront_notifications/templates/dashboard.html +++ b/coldfront_notifications/templates/coldfront_notifications/dashboard.html @@ -80,7 +80,7 @@

    - {{ campaign.subject }} + {{ campaign.display_subject }} diff --git a/coldfront_notifications/templates/coldfront_notifications/draft_delete_confirm.html b/coldfront_notifications/templates/coldfront_notifications/draft_delete_confirm.html new file mode 100644 index 0000000..01aa3da --- /dev/null +++ b/coldfront_notifications/templates/coldfront_notifications/draft_delete_confirm.html @@ -0,0 +1,52 @@ +{% extends "common/base.html" %} +{% block title %}Delete Draft | FAS Research Computing{% endblock %} +{% block content %} +
    +

    Delete Draft

    +
    +
    + +
    +
    +
    +
    +
    Confirm Deletion
    +
    +
    +

    Are you sure you want to delete this draft notification?

    + + + + {% if campaign.template %} + + {% endif %} + + {% if campaign.filter_summary %} + + {% endif %} +
    Subject{{ campaign.subject|default:"(no subject)" }}
    Template{{ campaign.template.slug }}
    Created{{ campaign.created_at|date:"Y-m-d H:i" }}
    Filters + {% for badge in campaign.filter_summary %} + {{ badge }} + {% endfor %} +
    + +

    + This draft has never been sent. Deleting it is permanent and cannot be undone. +

    + + + {% csrf_token %} +
    + + Go Back + + +
    + +
    +
    +
    +
    +{% endblock %} diff --git a/coldfront_notifications/templates/resend_compose.html b/coldfront_notifications/templates/coldfront_notifications/resend_compose.html similarity index 99% rename from coldfront_notifications/templates/resend_compose.html rename to coldfront_notifications/templates/coldfront_notifications/resend_compose.html index 25bbe73..dd21cd2 100644 --- a/coldfront_notifications/templates/resend_compose.html +++ b/coldfront_notifications/templates/coldfront_notifications/resend_compose.html @@ -271,9 +271,9 @@