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
112 changes: 112 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: Tests

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
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 report --show-missing --fail-under=80 \
--include="coldfront_notifications/resolvers.py,coldfront_notifications/validators.py,coldfront_notifications/conf.py,coldfront_notifications/__init__.py"
env:
PYTHONPATH: .

- name: Upload coverage
if: matrix.python-version == '3.10'
uses: actions/upload-artifact@v4
with:
name: unit-coverage
path: .coverage

lint:
name: Lint
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.10"

- name: Install dependencies
run: |
pip install --upgrade pip
pip install flake8

- name: Flake8
run: |
flake8 coldfront_notifications/ \
--max-line-length=120 \
--exclude=migrations,__pycache__,tests \
--ignore=E501,W503,E402,E241,E221,E231,W292

build:
name: Build Package
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.10"

- name: Install build tools
run: pip install build twine

- name: Build
run: python -m build

- name: Check package
run: twine check dist/*

- name: Verify wheel contents
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')
"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ htmlcov/

# Logs
*.log
management
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ for url_prefix, module, namespace in getattr(settings, "EXTRA_APPS_URLS", []):
python manage.py migrate coldfront_notifications
```

### Add the navbar dropdown

Add this line to your ColdFront `authorized_navbar.html` (typically in `templates/common/authorized_navbar.html`), inside the `<ul>` that holds the nav items:

```django
{% include "coldfront_notifications/navbar.html" %}
```

The dropdown is only visible to staff/superusers.

### Seed the default Template Variables catalog (optional)

```bash
Expand All @@ -104,7 +114,13 @@ Or create your own variables from the UI: **Notifications → Template Variables

### Configure sender addresses

Go to **Notifications → Settings** and add your sender/reply-to addresses. Flag one as Default From and one as Default Reply-To.
If your ColdFront instance has `EMAIL_SENDER`, `EMAIL_TICKET_SYSTEM_ADDRESS`, or `EMAIL_DIRECTOR_EMAIL_ADDRESS` in settings, seed them in one command:

```bash
python manage.py seed_notification_senders
```

Safe to re-run — skips addresses that already exist. You can also manage addresses manually at **Notifications → Settings**.

---

Expand Down
6 changes: 4 additions & 2 deletions coldfront_notifications/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ class NotificationVariableForm(forms.ModelForm):
"""
resolver_key = forms.ChoiceField(
required=False,
choices=[("", "— choose a query path —")]
+ [(k, f"{group}: {label}") for (k, label, group) in QUERY_CHOICES],
choices=(
[("", "— choose a query path —")]
+ [(k, f"{group}: {label}") for (k, label, group) in QUERY_CHOICES]
),
help_text="Only used when Source = Query.",
)

Expand Down
12 changes: 7 additions & 5 deletions coldfront_notifications/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ class NotificationVariableForm(forms.ModelForm):

resolver_key = forms.ChoiceField(
required=False,
choices=[("", "— choose a query source —")]
+ [(k, f"{group}: {label}") for (k, label, group) in QUERY_CHOICES],
choices=(
[("", "— choose a query source —")]
+ [(k, f"{group}: {label}") for (k, label, group) in QUERY_CHOICES]
),
widget=forms.Select(attrs={"class": "form-control"}),
)

Expand Down Expand Up @@ -102,9 +104,9 @@ class ComposeForm(forms.Form):
)

# recipient filters — all MultipleChoiceField, choices injected in __init__
filter_projects = forms.MultipleChoiceField(required=False)
filter_allocations= forms.MultipleChoiceField(required=False)
filter_departments= forms.MultipleChoiceField(required=False)
filter_projects = forms.MultipleChoiceField(required=False)
filter_allocations = forms.MultipleChoiceField(required=False)
filter_departments = forms.MultipleChoiceField(required=False)
filter_resources = forms.MultipleChoiceField(required=False)
filter_statuses = forms.MultipleChoiceField(required=False)
filter_roles = forms.MultipleChoiceField(required=False)
Expand Down
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""
Seed SenderConfig from ColdFront email settings.

Run once after installation:
python manage.py seed_notification_senders

Safe to re-run — skips addresses that already exist.
"""
from django.conf import settings
from django.core.management.base import BaseCommand

from coldfront_notifications.models import SenderConfig

SETTINGS_MAP = [
("EMAIL_SENDER", "Default Sender", True, False),
("EMAIL_TICKET_SYSTEM_ADDRESS", "Ticket System", False, True),
("EMAIL_DIRECTOR_EMAIL_ADDRESS","Director", False, False),
]


class Command(BaseCommand):
help = "Create SenderConfig entries from EMAIL_SENDER, EMAIL_TICKET_SYSTEM_ADDRESS, EMAIL_DIRECTOR_EMAIL_ADDRESS"

def handle(self, *args, **options):
created = 0
skipped = 0
for setting_name, label, is_default, is_reply_to in SETTINGS_MAP:
email = getattr(settings, setting_name, None)
if not email:
self.stdout.write(f" {setting_name}: not set, skipping")
continue
_, was_created = SenderConfig.objects.get_or_create(
email=email,
defaults={
"label": label,
"is_default": is_default,
"is_reply_to": is_reply_to,
},
)
if was_created:
created += 1
self.stdout.write(self.style.SUCCESS(f" {setting_name}: created {email}"))
else:
skipped += 1
self.stdout.write(f" {setting_name}: {email} already exists, skipped")

self.stdout.write(self.style.SUCCESS(
f"\nDone: {created} created, {skipped} skipped"
))
39 changes: 20 additions & 19 deletions coldfront_notifications/resolvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,30 +64,30 @@ def _project_role(user, project):

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.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)),
"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.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(
"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),
"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"))),
Expand All @@ -96,17 +96,17 @@ def _project_role(user, 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.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),
"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.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
Expand Down Expand Up @@ -173,6 +173,7 @@ def _scope_for(key):
return "project"
return "user"


QUERY_SCOPES = {k: _scope_for(k) for k, _label, _group in QUERY_CHOICES}


Expand Down
Loading
Loading