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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 11 additions & 24 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: .

Expand Down Expand Up @@ -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__}')
"
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ __pycache__/
dist/
build/
*.whl

*bak
# Virtual environments
.venv/
venv/
Expand Down
133 changes: 133 additions & 0 deletions FILTERS.md
Original file line number Diff line number Diff line change
@@ -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": []
}
}
```
Loading
Loading