Files
RosterChief/notifications/models.py
Bernard Siebens 5f7ca98eae Add a reusable notification system; wire it into news publishing
New `notifications` app: Notification (club-scoped, keyed to the
member it's about, generic `source` via a ContentType/object_id pair
so future activities can reuse this without a new model each time)
plus notify_members(), which resolves each member's own email (if
they hold a login) and every parent/guardian's, always -- a child
with their own account doesn't opt their parents out -- and emails
the club-branded template to whichever addresses that resolves to.
Delivery is email-only for now (no in-app feed exists yet); the row
is created either way, ready for one later.

news.tasks.notify_news_published resolves the audience (a team-scoped
item's current rosters, or every active member if it's club-wide) and
calls notify_members with the item's title/plain-text body.
NewsPublishForm gained a "Notify linked members" checkbox (opt-in,
default off); when checked, NewsPublishView schedules the task with
Celery's `eta` set to the item's own published_at -- a scheduled
item's notification arrives when it actually goes live, and an
immediate publish (eta in the past) just runs right away, no separate
branch needed.

Registered the new notification email on the Club identity page's
Email tab alongside the others.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 09:27:55 +02:00

50 lines
2.4 KiB
Python

from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils.translation import gettext_lazy as _
from members.models import Member
from rosterchief.base import ClubScopedModel, validate_club_scope
class Notification(ClubScopedModel):
"""One member's copy of a notification -- "this member should know about
X" -- built for news publishing first (see news.tasks.notify_news_published),
but `source` is generic on purpose: the whole point is reusing this for
other kinds of activity later without a new model each time.
Always keyed to the Member it's *about*, even though actual delivery may
go to a parent/guardian's email instead of (or as well as) the member's
own -- see notifications.services.notify_members. That's what a future
in-app feed for this member would list, regardless of who was actually
emailed; `sent_to_emails` is the audit trail of who that was.
"""
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="notifications", verbose_name=_("member"))
title = models.CharField(_("title"), max_length=255)
body = models.TextField(_("body"))
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True)
object_id = models.CharField(max_length=255, null=True, blank=True) # noqa: DJ001 -- GenericForeignKey needs a real NULL, not "", for "no source"
source = GenericForeignKey("content_type", "object_id")
sent_at = models.DateTimeField(_("sent at"), null=True, blank=True)
sent_to_emails = models.JSONField(_("sent to"), default=list, blank=True, help_text=_("Every email address this actually went to -- the member's own, and/or a parent/guardian's. Empty means nobody reachable was found."))
read_at = models.DateTimeField(_("read at"), null=True, blank=True, help_text=_("Set once there's somewhere for a member to read this -- not used yet."))
class Meta:
verbose_name = _("notification")
verbose_name_plural = _("notifications")
ordering = ["-created"]
indexes = [models.Index(fields=["content_type", "object_id"])]
def __str__(self):
return f"{self.member}{self.title}"
def clean(self):
validate_club_scope(self, self.club_id, member_fields=("member",))
@property
def is_sent(self) -> bool:
return self.sent_at is not None