Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
20feabf
feat(notifications): implement transactional email notification system
thejoeejoee Mar 3, 2026
0981c6f
Merge branch 'develop' into feat-notifications
thejoeejoee Mar 3, 2026
82ee3ef
feat(notifications): impl
thejoeejoee Mar 3, 2026
3a2019e
fix: dockerfile
thejoeejoee Mar 6, 2026
1779077
feat(notifications): integrate email notifications into matching and …
thejoeejoee Mar 6, 2026
197103d
fix(notifications): remove dead code, fix P0 bugs, deduplicate INSTAL…
thejoeejoee Mar 6, 2026
e94ae55
chore(notifications): delete orphan Path A template files
thejoeejoee Mar 6, 2026
6379ff8
feat(notifications): add global email opt-out with preferences UI toggle
thejoeejoee Mar 6, 2026
ca0f9e5
feat(notifications): wire recipient_profile to all send_notification_…
thejoeejoee Mar 7, 2026
0b63d5c
fix(CR): user/user_profile and other fixes
thejoeejoee Mar 7, 2026
7c11169
fix(notifications): use CheckEnabledPluginsViewMixin for plugin detec…
thejoeejoee Mar 7, 2026
f0f44ed
chore(docker): drop deprecated compose version key
thejoeejoee Mar 7, 2026
b5be008
fix(sections): fall back to ROOT_DOMAIN when site domain mismatches
thejoeejoee Mar 7, 2026
d70a498
chore(dev): parametrize OrbStack domains with ROOT_DOMAIN
thejoeejoee Mar 7, 2026
3a8c8e8
fix(notifications): use in_space_of_section instead of in_space_of
thejoeejoee Mar 7, 2026
c3d968a
fix(notifications): address code review findings from branch review
thejoeejoee Mar 7, 2026
4b3f13a
fix(notifications): narrow bare except to ObjectDoesNotExist in mailer
thejoeejoee Mar 7, 2026
b1102a3
ci: add test workflow for pull requests
thejoeejoee Mar 7, 2026
b6d3982
fix(ci): add missing RECAPTCHA env vars for test workflow
thejoeejoee Mar 7, 2026
7e78249
fix(ci): add BUILD_DIR, STATIC_ROOT, MEDIA_ROOT env vars for test wor…
thejoeejoee Mar 7, 2026
c217e36
fix(ci): install setuptools for pkg_resources compatibility
thejoeejoee Mar 7, 2026
7e13401
fix(ci): use uv run --with setuptools for pkg_resources
thejoeejoee Mar 7, 2026
dd9e409
fix(ci): use venv python directly to preserve setuptools
thejoeejoee Mar 7, 2026
38e276a
fix(ci): install setuptools directly into venv python
thejoeejoee Mar 7, 2026
a2a201a
fix(ci): use uv pip instead of python -m pip for setuptools
thejoeejoee Mar 7, 2026
77edf6e
fix(ci): target venv explicitly for setuptools install
thejoeejoee Mar 7, 2026
ed3910f
fix(ci): pin setuptools<82 to retain pkg_resources
thejoeejoee Mar 7, 2026
24dd583
fix(ci): make database host configurable via env var for CI
thejoeejoee Mar 7, 2026
bfdba9c
fix(ci): fix SMTP backend, import path, and soft-cancel sent_at rollback
thejoeejoee Mar 7, 2026
f8d4959
fix(notifications): regenerate migration, fix template .user access a…
thejoeejoee Mar 7, 2026
3138c89
fix(tests): fix mock sections, stale factory field, and import path
thejoeejoee Mar 7, 2026
4734755
fix(tests): check global email opt-out in match notifications and fix…
thejoeejoee Mar 7, 2026
9bf7a3f
fix(tests): use empty whatsapp in UserProfileFactory to avoid validat…
thejoeejoee Mar 7, 2026
3fbd6a1
fix(CR): address bot review findings (send return, PII, URLs, templat…
thejoeejoee Mar 7, 2026
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
55 changes: 55 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Tests

on:
pull_request:
branches: [develop, main]

concurrency:
group: tests-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest

services:
postgres:
image: postgres:15
env:
POSTGRES_DB: fiesta
POSTGRES_USER: fiesta
POSTGRES_PASSWORD: fiesta
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U fiesta"
--health-interval=10s
--health-timeout=5s
--health-retries=5

env:
DATABASE_URL: postgres://fiesta:fiesta@localhost:5432/fiesta
DJANGO_SETTINGS_MODULE: fiesta.settings
DJANGO_CONFIGURATION: Development
DJANGO_SECRET_KEY: ci-test-secret-key-not-for-production
DJANGO_RECAPTCHA_SITE_KEY: dummy-recaptcha-site-key
DJANGO_RECAPTCHA_SECRET_KEY: dummy-recaptcha-secret-key
ROOT_DOMAIN: fiesta.test

steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5
with:
enable-cache: true

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

- name: Install dependencies
run: uv sync

- name: Run tests
working-directory: fiesta
run: uv run python manage.py test --verbosity 1
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ ENV UV_PROJECT_ENVIRONMENT=/venv
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync ${UV_SYNC_FLAGS} --no-install-project
# seed setuptools into venv for pkg_resources compat
RUN /venv/bin/pip install setuptools
# seed setuptools into venv for pkg_resources compat (setuptools<72 ships pkg_resources)
RUN uv pip install --python /venv/bin/python "setuptools<72"

# base runtime image
FROM ${PYTHON_IMAGE} as web-base
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ ARG =

MODELS_PNG = models.png
GRAPH_MODELS_CMD = graph_models accounts plugins auth sections events \
universities esncards buddy_system \
universities esncards buddy_system pickup_system notifications \
--verbose-names --disable-sort-fields \
--pydot -X 'ContentType|Base*Model' \
-g -o $(MODELS_PNG)
Expand Down
4 changes: 3 additions & 1 deletion docker-compose.override.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
version: '3.3'
services:
web:
# environment:
Expand Down Expand Up @@ -30,6 +29,9 @@ services:
- webpack_build:/usr/src/build

dockerproxy:
labels:
dev.orbstack.domains: "*.${ROOT_DOMAIN},${ROOT_DOMAIN}"
dev.orbstack.http-port: 80
environment:
- DISABLE_ACCESS_LOGS=1
volumes:
Expand Down
1 change: 0 additions & 1 deletion docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
version: '3.3'
services:
web:
build:
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: '3.3'
name: fiesta-plus
services:
web:
command: python manage.py runserver 0.0.0.0:8000
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.28 on 2026-03-06 23:52

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('accounts', '0027_userprofile_picture_height_userprofile_picture_width_and_more'),
]

operations = [
migrations.AddField(
model_name='userprofile',
name='email_notifications_enabled',
field=models.BooleanField(default=True, verbose_name='email notifications'),
),
]
1 change: 1 addition & 0 deletions fiesta/apps/accounts/models/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ class Preferences(enum.Flag):

# TODO: define formfield/widget to handle flagging
preferences = models.PositiveSmallIntegerField(default=0, verbose_name=_("user preferences as flags"))
email_notifications_enabled = models.BooleanField(default=True, verbose_name=_("email notifications"))

State = UserProfileState

Expand Down
17 changes: 17 additions & 0 deletions fiesta/apps/accounts/templates/accounts/user_profile/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,23 @@
Social Accounts
</a>
</li>
{% if request.in_space_of_section %}
<li>
<a href="{% url "notifications:preferences" %}">
<svg class="h-5 w-5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0">
</path>
</svg>
{% trans "Notifications" %}
</a>
</li>
{% endif %}
</ul>
</div>
<div class="w-full md:w-3/4">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 4.2.28 on 2026-03-03 14:17

import datetime
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('buddy_system', '0031_alter_buddysystemconfiguration_rolling_limit_and_more'),
]

operations = [
migrations.AddField(
model_name='buddysystemconfiguration',
name='email_notify_issuer_delay',
field=models.DurationField(default=datetime.timedelta(seconds=3600), help_text='Gives editors time to correct the match before the student is notified.', verbose_name='Delay before notifying the issuer'),
),
migrations.AddField(
model_name='buddysystemconfiguration',
name='email_notify_on_match',
field=models.BooleanField(default=True, verbose_name='Send email notifications on match'),
),
]
10 changes: 10 additions & 0 deletions fiesta/apps/buddy_system/models/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ class BuddySystemConfiguration(BaseRequestSystemConfiguration):
help_text=MatchingPoliciesRegister.DESCRIPTION,
)

email_notify_on_match = models.BooleanField(
default=True,
verbose_name=_("Send email notifications on match"),
)
email_notify_issuer_delay = models.DurationField(
default=datetime.timedelta(hours=1),
verbose_name=_("Delay before notifying the issuer"),
help_text=_("Gives editors time to correct the match before the student is notified."),
)

@property
def matching_policy_instance(self) -> BaseMatchingPolicy:
return MatchingPoliciesRegister.get_policy(self)
Expand Down
16 changes: 16 additions & 0 deletions fiesta/apps/buddy_system/views/editor.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import logging

from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
Expand All @@ -19,6 +21,8 @@
from apps.utils.breadcrumbs import with_breadcrumb, with_object_breadcrumb, with_plugin_home_breadcrumb
from apps.utils.views import AjaxViewMixin

logger = logging.getLogger(__name__)


class BuddyRequestsTable(BaseRequestsTable):
match_request = TemplateColumn(
Expand Down Expand Up @@ -91,6 +95,18 @@ class QuickBuddyMatchView(BaseQuickRequestMatchView):
form_url = "buddy_system:quick-match"
match_model = BuddyRequestMatch

def after_match_created(self, match, fiesta_request) -> None:
from apps.notifications.services.match import notify_buddy_match

try:
notify_buddy_match(
match=match,
request=fiesta_request,
section=self.request.in_space_of_section,
)
except Exception:
logger.exception("Failed to send buddy match notification for match pk=%s", match.pk)


class UpdateBuddyRequestStateView(BaseUpdateRequestStateView):
model = BuddyRequest
Expand Down
16 changes: 16 additions & 0 deletions fiesta/apps/buddy_system/views/matching.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import logging

from django.contrib.auth.mixins import PermissionRequiredMixin
from django.template.loader import render_to_string
from django.urls import reverse_lazy
Expand All @@ -18,6 +20,8 @@
from apps.sections.views.mixins.section_space import EnsureInSectionSpaceViewMixin
from apps.utils.breadcrumbs import with_breadcrumb, with_plugin_home_breadcrumb

logger = logging.getLogger(__name__)


@with_plugin_home_breadcrumb
@with_breadcrumb(_("Waiting Requests"))
Expand Down Expand Up @@ -94,6 +98,18 @@ def get_form(self, form_class=None):
)
return form

def after_match_created(self, match, fiesta_request) -> None:
from apps.notifications.services.match import notify_buddy_match

try:
notify_buddy_match(
match=match,
request=fiesta_request,
section=self.request.in_space_of_section,
)
except Exception:
logger.exception("Failed to send buddy match notification for match pk=%s", match.pk)


class ServeFilesFromBuddiesMixin:
@classmethod
Expand Down
1 change: 0 additions & 1 deletion fiesta/apps/dashboard/models/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@


class DashboardConfiguration(BasePluginConfiguration):

class Meta:
verbose_name = _("dashboard configuration")
verbose_name_plural = _("dashboard configurations")
Expand Down
12 changes: 10 additions & 2 deletions fiesta/apps/fiestarequests/views/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ def get_initial(self):
except ObjectDoesNotExist:
return {}

def after_match_created(self, match, fiesta_request) -> None:
"""Hook for subclasses to trigger notifications after a match is created."""

@transaction.atomic
def form_valid(self, form):
br: BaseRequestProtocol | models.Model = form.instance
Expand All @@ -64,17 +67,19 @@ def form_valid(self, form):

matcher: User = form.cleaned_data.get("matcher")

match = self.match_model(
new_match = self.match_model(
request=br,
matcher=matcher,
matcher_faculty=matcher.profile_or_none.faculty,
)

match.save()
new_match.save()

br.state = BaseRequestProtocol.State.MATCHED
br.save(update_fields=["state"])

transaction.on_commit(lambda: self.after_match_created(new_match, br))

return super().form_valid(form)


Expand Down Expand Up @@ -108,6 +113,9 @@ def form_valid(self, form: ModelForm):

# TODO: django.lifecycle would be probably better
if before == BaseRequestProtocol.State.MATCHED and after == BaseRequestProtocol.State.CREATED:
from apps.notifications.services.scheduler import cancel_scheduled_notifications_for

cancel_scheduled_notifications_for(self.object.match)
self.object.match.delete()

return resp
8 changes: 7 additions & 1 deletion fiesta/apps/fiestarequests/views/matching.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from django.contrib.messages.views import SuccessMessageMixin
from django.db import models
from django.db import models, transaction
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext as _
Expand Down Expand Up @@ -56,6 +56,10 @@ def get_context_data(self, **kwargs):
data["form_url"] = reverse(self.form_url, kwargs={"pk": self.fiesta_request.pk})
return data

def after_match_created(self, match, fiesta_request) -> None:
"""Hook for subclasses to trigger notifications after a match is created."""

@transaction.atomic
def form_valid(self, form):
match: BaseRequestMatchProtocol = form.instance
match.request = self.fiesta_request
Expand All @@ -68,4 +72,6 @@ def form_valid(self, form):
self.fiesta_request.state = BaseRequestProtocol.State.MATCHED
self.fiesta_request.save(update_fields=["state"])

transaction.on_commit(lambda: self.after_match_created(match, self.fiesta_request))

return response
Empty file.
50 changes: 50 additions & 0 deletions fiesta/apps/notifications/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from django.contrib import admin
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

from apps.notifications.models import ScheduledNotification, SectionNotificationPreferences

if TYPE_CHECKING:
from django.db.models import QuerySet
from django.http import HttpRequest


@admin.register(SectionNotificationPreferences)
class SectionNotificationPreferencesAdmin(admin.ModelAdmin):
list_display = ["user", "section", "notify_on_match", "notify_on_new_member_waiting", "created"]
list_filter = ["section", "notify_on_match", "notify_on_new_member_waiting"]
# user is FK → accounts.User
search_fields = ["user__email", "user__first_name", "user__last_name", "section__name"]
readonly_fields = ["created", "modified"]


@admin.register(ScheduledNotification)
class ScheduledNotificationAdmin(admin.ModelAdmin):
list_display = ["kind", "recipient", "section", "send_after", "sent_at", "cancelled_at", "created"]
list_filter = ["kind", "section"]
# recipient is FK to accounts.User
search_fields = ["recipient__email", "recipient__first_name", "recipient__last_name"]
readonly_fields = ["created", "modified", "content_type", "object_id", "content_object"]
date_hierarchy = "send_after"
actions = ["mark_as_sent", "cancel_notifications"]

def get_queryset(self, request: HttpRequest) -> QuerySet[ScheduledNotification]:
return super().get_queryset(request).select_related("recipient", "section", "content_type")

@admin.action(description=_("Send now (bypass send_after)"))
def mark_as_sent(self, request: HttpRequest, queryset: QuerySet[ScheduledNotification]) -> None:
updated = queryset.filter(sent_at__isnull=True, cancelled_at__isnull=True).update(send_after=timezone.now())
self.message_user(request, _("Scheduled %s notification(s) to send immediately.") % updated)

@admin.action(description=_("Cancel selected notifications"))
def cancel_notifications(self, request: HttpRequest, queryset: QuerySet[ScheduledNotification]) -> None:
count = 0
for notification in queryset.filter(sent_at__isnull=True, cancelled_at__isnull=True):
notification.cancel()
notification.save(update_fields=["cancelled_at", "modified"])
count += 1
self.message_user(request, _("Cancelled %s notification(s).") % count)
Loading
Loading