FossilRepo

fossilrepo / fossil / notifications.py
Blame History Raw 168 lines
1
"""Notification system for Fossilrepo.
2
3
Simple SMTP-based notifications for self-hosted deployments.
4
Users watch projects and get emails on checkins, tickets, wiki, forum changes.
5
"""
6
7
import logging
8
9
from django.conf import settings
10
from django.contrib.auth.models import User
11
from django.core.mail import send_mail
12
from django.db import models
13
14
from core.models import ActiveManager, Tracking
15
16
logger = logging.getLogger(__name__)
17
18
19
class ProjectWatch(Tracking):
20
"""User's subscription to project notifications."""
21
22
class EventType(models.TextChoices):
23
ALL = "all", "All Events"
24
CHECKINS = "checkins", "Checkins Only"
25
TICKETS = "tickets", "Tickets Only"
26
WIKI = "wiki", "Wiki Only"
27
28
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="project_watches")
29
project = models.ForeignKey("projects.Project", on_delete=models.CASCADE, related_name="watchers")
30
event_filter = models.CharField(max_length=20, choices=EventType.choices, default=EventType.ALL)
31
email_enabled = models.BooleanField(default=True)
32
33
objects = ActiveManager()
34
all_objects = models.Manager()
35
36
class Meta:
37
unique_together = ("user", "project")
38
39
def __str__(self):
40
return f"{self.user.username} watching {self.project.name}"
41
42
43
class Notification(models.Model):
44
"""Individual notification entry."""
45
46
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="notifications")
47
project = models.ForeignKey("projects.Project", on_delete=models.CASCADE, related_name="notifications")
48
event_type = models.CharField(max_length=20) # checkin, ticket, wiki, forum
49
title = models.CharField(max_length=300)
50
body = models.TextField(blank=True, default="")
51
url = models.CharField(max_length=500, blank=True, default="")
52
read = models.BooleanField(default=False)
53
emailed = models.BooleanField(default=False)
54
created_at = models.DateTimeField(auto_now_add=True)
55
56
class Meta:
57
ordering = ["-created_at"]
58
59
def __str__(self):
60
return f"{self.title} → {self.user.username}"
61
62
63
class NotificationPreference(models.Model):
64
"""Per-user notification delivery preferences."""
65
66
class DeliveryMode(models.TextChoices):
67
IMMEDIATE = "immediate", "Immediate (per event)"
68
DAILY = "daily", "Daily Digest"
69
WEEKLY = "weekly", "Weekly Digest"
70
OFF = "off", "Off"
71
72
user = models.OneToOneField("auth.User", on_delete=models.CASCADE, related_name="notification_prefs")
73
delivery_mode = models.CharField(max_length=20, choices=DeliveryMode.choices, default=DeliveryMode.IMMEDIATE)
74
75
# Event type toggles
76
notify_checkins = models.BooleanField(default=True)
77
notify_tickets = models.BooleanField(default=True)
78
notify_wiki = models.BooleanField(default=True)
79
notify_releases = models.BooleanField(default=True)
80
notify_forum = models.BooleanField(default=False)
81
82
class Meta:
83
verbose_name = "Notification Preference"
84
85
def __str__(self):
86
return f"{self.user.username}: {self.delivery_mode}"
87
88
89
def notify_project_event(project, event_type: str, title: str, body: str = "", url: str = "", exclude_user=None):
90
"""Create notifications for all watchers of a project.
91
92
Args:
93
project: Project instance
94
event_type: "checkin", "ticket", "wiki", "forum"
95
title: Short description
96
body: Detail text
97
url: Relative URL to the event
98
exclude_user: Don't notify this user (typically the actor)
99
"""
100
from django.template.loader import render_to_string
101
102
watches = ProjectWatch.objects.filter(
103
project=project,
104
deleted_at__isnull=True,
105
email_enabled=True,
106
)
107
108
for watch in watches:
109
if exclude_user and watch.user == exclude_user:
110
continue
111
112
# Check event filter
113
if watch.event_filter != "all" and watch.event_filter != event_type + "s":
114
continue
115
116
# Skip non-immediate users -- they get digests instead
117
prefs = NotificationPreference.objects.filter(user=watch.user).first()
118
if prefs and prefs.delivery_mode != "immediate":
119
# Still create the notification record for the digest
120
Notification.objects.create(
121
user=watch.user,
122
project=project,
123
event_type=event_type,
124
title=title,
125
body=body,
126
url=url,
127
)
128
continue
129
130
notification = Notification.objects.create(
131
user=watch.user,
132
project=project,
133
event_type=event_type,
134
title=title,
135
body=body,
136
url=url,
137
)
138
139
# Send email with HTML template
140
if watch.email_enabled and watch.user.email:
141
try:
142
subject = f"[{project.name}] {event_type}: {title[:80]}"
143
text_body = f"{title}\n\n{body}\n\nView: {url}" if url else f"{title}\n\n{body}"
144
html_body = render_to_string(
145
"email/notification.html",
146
{
147
"event_type": event_type,
148
"project_name": project.name,
149
"message": body or title,
150
"action_url": url,
151
"project_url": f"/projects/{project.slug}/",
152
"unsubscribe_url": f"/projects/{project.slug}/fossil/watch/",
153
"preferences_url": "/auth/notifications/",
154
},
155
)
156
send_mail(
157
subject=subject,
158
message=text_body,
159
from_email=settings.DEFAULT_FROM_EMAIL,
160
recipient_list=[watch.user.email],
161
html_message=html_body,
162
fail_silently=True,
163
)
164
notification.emailed = True
165
notification.save(update_fields=["emailed"])
166
except Exception:
167
logger.exception("Failed to send notification email to %s", watch.user.email)
168

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button