FossilRepo

Add notification system: watch, email, Celery dispatch

ragelink 2026-04-07 04:26 trunk
Commit 0da83779ea5e316f4cb631418a8b0ba004a7c8e5b21e2120466f183c8e2dc37d
--- config/settings.py
+++ config/settings.py
@@ -140,10 +140,11 @@
140140
141141
if not DEBUG:
142142
SESSION_COOKIE_SECURE = True
143143
CSRF_COOKIE_SECURE = True
144144
SECURE_SSL_REDIRECT = True
145
+ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
145146
146147
# --- i18n ---
147148
148149
LANGUAGE_CODE = "en-us"
149150
TIME_ZONE = "UTC"
@@ -196,10 +197,14 @@
196197
},
197198
"fossil-check-upstream": {
198199
"task": "fossil.check_upstream",
199200
"schedule": 900.0, # every 15 minutes
200201
},
202
+ "fossil-dispatch-notifications": {
203
+ "task": "fossil.dispatch_notifications",
204
+ "schedule": 300.0, # every 5 minutes
205
+ },
201206
}
202207
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
203208
204209
# --- CORS ---
205210
206211
207212
ADDED fossil/migrations/0004_historicalprojectwatch_notification_projectwatch.py
--- config/settings.py
+++ config/settings.py
@@ -140,10 +140,11 @@
140
141 if not DEBUG:
142 SESSION_COOKIE_SECURE = True
143 CSRF_COOKIE_SECURE = True
144 SECURE_SSL_REDIRECT = True
 
145
146 # --- i18n ---
147
148 LANGUAGE_CODE = "en-us"
149 TIME_ZONE = "UTC"
@@ -196,10 +197,14 @@
196 },
197 "fossil-check-upstream": {
198 "task": "fossil.check_upstream",
199 "schedule": 900.0, # every 15 minutes
200 },
 
 
 
 
201 }
202 CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
203
204 # --- CORS ---
205
206
207 DDED fossil/migrations/0004_historicalprojectwatch_notification_projectwatch.py
--- config/settings.py
+++ config/settings.py
@@ -140,10 +140,11 @@
140
141 if not DEBUG:
142 SESSION_COOKIE_SECURE = True
143 CSRF_COOKIE_SECURE = True
144 SECURE_SSL_REDIRECT = True
145 SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
146
147 # --- i18n ---
148
149 LANGUAGE_CODE = "en-us"
150 TIME_ZONE = "UTC"
@@ -196,10 +197,14 @@
197 },
198 "fossil-check-upstream": {
199 "task": "fossil.check_upstream",
200 "schedule": 900.0, # every 15 minutes
201 },
202 "fossil-dispatch-notifications": {
203 "task": "fossil.dispatch_notifications",
204 "schedule": 300.0, # every 5 minutes
205 },
206 }
207 CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
208
209 # --- CORS ---
210
211
212 DDED fossil/migrations/0004_historicalprojectwatch_notification_projectwatch.py
--- a/fossil/migrations/0004_historicalprojectwatch_notification_projectwatch.py
+++ b/fossil/migrations/0004_historicalprojectwatch_notification_projectwatch.py
@@ -0,0 +1,232 @@
1
+# Generated by Django 5.2.12 on 2026-04-07 04:25
2
+
3
+import django.db.models.deletion
4
+import simple_history.models
5
+from django.conf import settings
6
+from django.db import migrations, models
7
+
8
+
9
+class Migration(migrations.Migration):
10
+00)),
11
+ ("r "update),
12
+ ),
13
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
14
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
15
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
16
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
17
+ (
18
+ "event_filter",
19
+ models.CharField(
20
+ choices=[
21
+ ("all", "All Events"),
22
+ ("checkins", "Checkins Only"),
23
+ ("tickets", "Tickets Only"),
24
+ ("wiki", "Wiki Only"),
25
+ ],
26
+ default="all",
27
+ max_length=20,
28
+ ),
29
+ ),
30
+ ("email_enabled", models.BooleanField(default=True)),
31
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
32
+ ("history_date", models.DateTimeField(db_index=True)),
33
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
34
+ (
35
+ "history_type",
36
+ models.CharField(
37
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
38
+ max_length=1,
39
+ ),
40
+ ),
41
+ (
42
+ "created_by",
43
+ models.ForeignKey(
44
+ blank=True,
45
+ db_constraint=False,
46
+ null=True,
47
+ on_delete=django.db.models.deletion.DO_NOTHING,
48
+ related_name="+",
49
+ to=settings.AUTH_USER_MODEL,
50
+ ),
51
+ ),
52
+ (
53
+ "deleted_by",
54
+ models.ForeignKey(
55
+ blank=True,
56
+ db_constraint=False,
57
+ null=True,
58
+ on_delete=django.db.models.deletion.DO_NOTHING,
59
+ related_name="+",
60
+ to=settings.AUTH_USER_MODEL,
61
+ ),
62
+ ),
63
+ (
64
+ "history_user",
65
+ models.ForeignKey(
66
+ null=True,
67
+ on_delete=django.db.models.deletion.SET_NULL,
68
+ related_name="+",
69
+ to=settings.AUTH_USER_MODEL,
70
+ ),
71
+ ),
72
+ (
73
+ "project",
74
+ models.ForeignKey(
75
+ blank=True,
76
+ db_constraint=False,
77
+ null=True,
78
+ on_delete=django.db.models.deletion.DO_NOTHING,
79
+ related_name="+",
80
+ to="projects.project",
81
+ ),
82
+ ),
83
+ (
84
+ "updated_by",
85
+ models.ForeignKey(
86
+ blank=True,
87
+ db_constraint=False,
88
+ null=True,
89
+ on_delete=django.db.models.deletion.DO_NOTHING,
90
+ related_name="+",
91
+ to=settings.AUTH_USER_MODEL,
92
+ ),
93
+ ),
94
+ (
95
+ "user",
96
+ models.ForeignKey(
97
+ blank=True,
98
+ db_constraint=False,
99
+ null=True,
100
+ on_delete=django.db.models.deletion.DO_NOTHING,
101
+ related_name="+",
102
+ to=settings.AUTH_USER_MODEL,
103
+ ),
104
+ ),
105
+ ],
106
+ options={
107
+ "verbose_name": "historical project watch",
108
+ "verbose_name_plural": "historical project watchs",
109
+ "ordering": ("-history_date", "-history_id"),
110
+ "get_latest_by": ("history_date", "history_id"),
111
+ },
112
+ bases=(simple_history.models.HistoricalChanges, models.Model),
113
+ ),
114
+ migrations.CreateModel(
115
+ name="Notification",
116
+ fields=[
117
+ (
118
+ "id",
119
+ models.BigAutoField(
120
+ auto_created=True,
121
+ primary_key=True,
122
+ serialize=False,
123
+ verbose_name="ID",
124
+ ),
125
+ ),
126
+ ("event_type", models.CharField(max_length=20)),
127
+ ("title", models.CharField(max_length=300)),
128
+ ("body", models.TextField(blank=True, default="")),
129
+ ("url", models.CharField(blank=True, default="", max_length=500)),
130
+ ("read", models.BooleanField(default=False)),
131
+ ("emailed", models.BooleanField(default=False)),
132
+ ("created_at", models.DateTimeField(auto_now_add=True)),
133
+ (
134
+ "project",
135
+ models.ForeignKey(
136
+ on_delete=django.db.models.deletion.CASCADE,
137
+ related_name="notifications",
138
+ to="projects.project",
139
+ ),
140
+ ),
141
+ (
142
+ "user",
143
+ models.ForeignKey(
144
+ on_delete=django.db.models.deletion.CASCADE,
145
+ related_name="notifications",
146
+ to=settings.AUTH_USER_MODEL,
147
+ ),
148
+ ),
149
+ ],
150
+ options={
151
+ "ordering": ["-created_at"],
152
+ },
153
+ ),
154
+ migrations.CreateModel(
155
+ name="ProjectWatch",
156
+ fields=[
157
+ (
158
+ "id",
159
+ models.BigAutoField(
160
+ auto_created=True,
161
+ primary_key=True,
162
+ serialize=False,
163
+ verbose_name="ID",
164
+ ),
165
+ ),
166
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
167
+ ("created_at", models.DateTimeField(auto_now_add=True)),
168
+ ("updated_at", models.DateTimeField(auto_now=True)),
169
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
170
+ (
171
+ "event_filter",
172
+ models.CharField(
173
+ choices=[
174
+ ("all", "All Events"),
175
+ ("checkins", "Checkins Only"),
176
+ ("tickets", "Tickets Only"),
177
+ ("wiki", "Wiki Only"),
178
+ ],
179
+ default="all",
180
+ max_length=20,
181
+ ),
182
+ ),
183
+ ("email_enabled", models.BooleanField(default=True)),
184
+ (
185
+ "created_by",
186
+ models.ForeignKey(
187
+ blank=True,
188
+ null=True,
189
+ on_delete=django.db.models.deletion.SET_NULL,
190
+ related_name="+",
191
+ to=settings.AUTH_USER_MODEL,
192
+ ),
193
+ ),
194
+ (
195
+ "deleted_by",
196
+ models.ForeignKey(
197
+ blank=True,
198
+ null=True,
199
+ on_delete=django.db.models.deletion.SET_NULL,
200
+ related_name="+",
201
+ to=settings.AUTH_USER_MODEL,
202
+ ),
203
+ ),
204
+ (
205
+ "project",
206
+ models.ForeignKey(
207
+ on_delete=django.db.models.deletion.CASCADE,
208
+ related_name="watchers",
209
+ to="projects.project",
210
+ ),
211
+ ),
212
+ (
213
+ "updated_by",
214
+ models.ForeignKey(
215
+ blank=True,
216
+ null=True,
217
+ on_delete=django.db.models.deletion.SET_NULL,
218
+ related_name="+",
219
+ to=settings.AUTH_USER_MODEL,
220
+ ),
221
+ ),
222
+ (
223
+ "user",
224
+ models.ForeignKey(
225
+ on_delete=django.db.models.deletion.CASCADE,
226
+ related_name="project_watches",
227
+ to=settings.AUTH_USER_MODEL,
228
+ ),
229
+ ),
230
+ ],
231
+ options={
232
+ "unique_together": {
--- a/fossil/migrations/0004_historicalprojectwatch_notification_projectwatch.py
+++ b/fossil/migrations/0004_historicalprojectwatch_notification_projectwatch.py
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/migrations/0004_historicalprojectwatch_notification_projectwatch.py
+++ b/fossil/migrations/0004_historicalprojectwatch_notification_projectwatch.py
@@ -0,0 +1,232 @@
1 # Generated by Django 5.2.12 on 2026-04-07 04:25
2
3 import django.db.models.deletion
4 import simple_history.models
5 from django.conf import settings
6 from django.db import migrations, models
7
8
9 class Migration(migrations.Migration):
10 00)),
11 ("r "update),
12 ),
13 ("version", models.PositiveIntegerField(default=1, editable=False)),
14 ("created_at", models.DateTimeField(blank=True, editable=False)),
15 ("updated_at", models.DateTimeField(blank=True, editable=False)),
16 ("deleted_at", models.DateTimeField(blank=True, null=True)),
17 (
18 "event_filter",
19 models.CharField(
20 choices=[
21 ("all", "All Events"),
22 ("checkins", "Checkins Only"),
23 ("tickets", "Tickets Only"),
24 ("wiki", "Wiki Only"),
25 ],
26 default="all",
27 max_length=20,
28 ),
29 ),
30 ("email_enabled", models.BooleanField(default=True)),
31 ("history_id", models.AutoField(primary_key=True, serialize=False)),
32 ("history_date", models.DateTimeField(db_index=True)),
33 ("history_change_reason", models.CharField(max_length=100, null=True)),
34 (
35 "history_type",
36 models.CharField(
37 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
38 max_length=1,
39 ),
40 ),
41 (
42 "created_by",
43 models.ForeignKey(
44 blank=True,
45 db_constraint=False,
46 null=True,
47 on_delete=django.db.models.deletion.DO_NOTHING,
48 related_name="+",
49 to=settings.AUTH_USER_MODEL,
50 ),
51 ),
52 (
53 "deleted_by",
54 models.ForeignKey(
55 blank=True,
56 db_constraint=False,
57 null=True,
58 on_delete=django.db.models.deletion.DO_NOTHING,
59 related_name="+",
60 to=settings.AUTH_USER_MODEL,
61 ),
62 ),
63 (
64 "history_user",
65 models.ForeignKey(
66 null=True,
67 on_delete=django.db.models.deletion.SET_NULL,
68 related_name="+",
69 to=settings.AUTH_USER_MODEL,
70 ),
71 ),
72 (
73 "project",
74 models.ForeignKey(
75 blank=True,
76 db_constraint=False,
77 null=True,
78 on_delete=django.db.models.deletion.DO_NOTHING,
79 related_name="+",
80 to="projects.project",
81 ),
82 ),
83 (
84 "updated_by",
85 models.ForeignKey(
86 blank=True,
87 db_constraint=False,
88 null=True,
89 on_delete=django.db.models.deletion.DO_NOTHING,
90 related_name="+",
91 to=settings.AUTH_USER_MODEL,
92 ),
93 ),
94 (
95 "user",
96 models.ForeignKey(
97 blank=True,
98 db_constraint=False,
99 null=True,
100 on_delete=django.db.models.deletion.DO_NOTHING,
101 related_name="+",
102 to=settings.AUTH_USER_MODEL,
103 ),
104 ),
105 ],
106 options={
107 "verbose_name": "historical project watch",
108 "verbose_name_plural": "historical project watchs",
109 "ordering": ("-history_date", "-history_id"),
110 "get_latest_by": ("history_date", "history_id"),
111 },
112 bases=(simple_history.models.HistoricalChanges, models.Model),
113 ),
114 migrations.CreateModel(
115 name="Notification",
116 fields=[
117 (
118 "id",
119 models.BigAutoField(
120 auto_created=True,
121 primary_key=True,
122 serialize=False,
123 verbose_name="ID",
124 ),
125 ),
126 ("event_type", models.CharField(max_length=20)),
127 ("title", models.CharField(max_length=300)),
128 ("body", models.TextField(blank=True, default="")),
129 ("url", models.CharField(blank=True, default="", max_length=500)),
130 ("read", models.BooleanField(default=False)),
131 ("emailed", models.BooleanField(default=False)),
132 ("created_at", models.DateTimeField(auto_now_add=True)),
133 (
134 "project",
135 models.ForeignKey(
136 on_delete=django.db.models.deletion.CASCADE,
137 related_name="notifications",
138 to="projects.project",
139 ),
140 ),
141 (
142 "user",
143 models.ForeignKey(
144 on_delete=django.db.models.deletion.CASCADE,
145 related_name="notifications",
146 to=settings.AUTH_USER_MODEL,
147 ),
148 ),
149 ],
150 options={
151 "ordering": ["-created_at"],
152 },
153 ),
154 migrations.CreateModel(
155 name="ProjectWatch",
156 fields=[
157 (
158 "id",
159 models.BigAutoField(
160 auto_created=True,
161 primary_key=True,
162 serialize=False,
163 verbose_name="ID",
164 ),
165 ),
166 ("version", models.PositiveIntegerField(default=1, editable=False)),
167 ("created_at", models.DateTimeField(auto_now_add=True)),
168 ("updated_at", models.DateTimeField(auto_now=True)),
169 ("deleted_at", models.DateTimeField(blank=True, null=True)),
170 (
171 "event_filter",
172 models.CharField(
173 choices=[
174 ("all", "All Events"),
175 ("checkins", "Checkins Only"),
176 ("tickets", "Tickets Only"),
177 ("wiki", "Wiki Only"),
178 ],
179 default="all",
180 max_length=20,
181 ),
182 ),
183 ("email_enabled", models.BooleanField(default=True)),
184 (
185 "created_by",
186 models.ForeignKey(
187 blank=True,
188 null=True,
189 on_delete=django.db.models.deletion.SET_NULL,
190 related_name="+",
191 to=settings.AUTH_USER_MODEL,
192 ),
193 ),
194 (
195 "deleted_by",
196 models.ForeignKey(
197 blank=True,
198 null=True,
199 on_delete=django.db.models.deletion.SET_NULL,
200 related_name="+",
201 to=settings.AUTH_USER_MODEL,
202 ),
203 ),
204 (
205 "project",
206 models.ForeignKey(
207 on_delete=django.db.models.deletion.CASCADE,
208 related_name="watchers",
209 to="projects.project",
210 ),
211 ),
212 (
213 "updated_by",
214 models.ForeignKey(
215 blank=True,
216 null=True,
217 on_delete=django.db.models.deletion.SET_NULL,
218 related_name="+",
219 to=settings.AUTH_USER_MODEL,
220 ),
221 ),
222 (
223 "user",
224 models.ForeignKey(
225 on_delete=django.db.models.deletion.CASCADE,
226 related_name="project_watches",
227 to=settings.AUTH_USER_MODEL,
228 ),
229 ),
230 ],
231 options={
232 "unique_together": {
--- fossil/models.py
+++ fossil/models.py
@@ -64,7 +64,8 @@
6464
6565
def __str__(self):
6666
return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename
6767
6868
69
-# Import sync models so they're discoverable by Django
69
+# Import related models so they're discoverable by Django
70
+from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401
7071
from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
7172
7273
ADDED fossil/notifications.py
--- fossil/models.py
+++ fossil/models.py
@@ -64,7 +64,8 @@
64
65 def __str__(self):
66 return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename
67
68
69 # Import sync models so they're discoverable by Django
 
70 from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
71
72 DDED fossil/notifications.py
--- fossil/models.py
+++ fossil/models.py
@@ -64,7 +64,8 @@
64
65 def __str__(self):
66 return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename
67
68
69 # Import related models so they're discoverable by Django
70 from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401
71 from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
72
73 DDED fossil/notifications.py
--- a/fossil/notifications.py
+++ b/fossil/notifications.py
@@ -0,0 +1,62 @@
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} �ion entry."""
61
+
62
+ user = models.ForeignKey(User, on_delete=models
--- a/fossil/notifications.py
+++ b/fossil/notifications.py
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/notifications.py
+++ b/fossil/notifications.py
@@ -0,0 +1,62 @@
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} �ion entry."""
61
62 user = models.ForeignKey(User, on_delete=models
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -188,5 +188,46 @@
188188
logger.exception("Git sync error for %s", repo.filename)
189189
log.status = "failed"
190190
log.message = "Unexpected error"
191191
log.completed_at = timezone.now()
192192
log.save()
193
+
194
+
195
+@shared_task(name="fossil.dispatch_notifications")
196
+def dispatch_notifications():
197
+ """Check for new Fossil events and send notifications to watchers."""
198
+ import datetime
199
+
200
+ from django.utils import timezone
201
+
202
+ from fossil.models import FossilRepository
203
+ from fossil.notifications import ProjectWatch, notify_project_event
204
+ from fossil.reader import FossilReader
205
+
206
+ watched_project_ids = set(ProjectWatch.objects.filter(deleted_at__isnull=True).values_list("project_id", flat=True))
207
+ if not watched_project_ids:
208
+ return
209
+
210
+ cutoff = timezone.now() - datetime.timedelta(minutes=5)
211
+
212
+ for repo in FossilRepository.objects.filter(project_id__in=watched_project_ids, deleted_at__isnull=True):
213
+ if not repo.exists_on_disk:
214
+ continue
215
+ try:
216
+ with FossilReader(repo.full_path) as reader:
217
+ entries = reader.get_timeline(limit=10)
218
+ for entry in entries:
219
+ if entry.timestamp < cutoff:
220
+ break
221
+ event_type = {"ci": "checkin", "w": "wiki", "t": "ticket", "f": "forum"}.get(entry.event_type, "other")
222
+ url = f"/projects/{repo.project.slug}/fossil/"
223
+ if entry.event_type == "ci":
224
+ url += f"checkin/{entry.uuid}/"
225
+ notify_project_event(
226
+ project=repo.project,
227
+ event_type=event_type,
228
+ title=f"{entry.user}: {entry.comment[:100]}" if entry.comment else f"New {event_type}",
229
+ body=entry.comment or "",
230
+ url=url,
231
+ )
232
+ except Exception:
233
+ logger.exception("Notification dispatch error for %s", repo.filename)
193234
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -188,5 +188,46 @@
188 logger.exception("Git sync error for %s", repo.filename)
189 log.status = "failed"
190 log.message = "Unexpected error"
191 log.completed_at = timezone.now()
192 log.save()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -188,5 +188,46 @@
188 logger.exception("Git sync error for %s", repo.filename)
189 log.status = "failed"
190 log.message = "Unexpected error"
191 log.completed_at = timezone.now()
192 log.save()
193
194
195 @shared_task(name="fossil.dispatch_notifications")
196 def dispatch_notifications():
197 """Check for new Fossil events and send notifications to watchers."""
198 import datetime
199
200 from django.utils import timezone
201
202 from fossil.models import FossilRepository
203 from fossil.notifications import ProjectWatch, notify_project_event
204 from fossil.reader import FossilReader
205
206 watched_project_ids = set(ProjectWatch.objects.filter(deleted_at__isnull=True).values_list("project_id", flat=True))
207 if not watched_project_ids:
208 return
209
210 cutoff = timezone.now() - datetime.timedelta(minutes=5)
211
212 for repo in FossilRepository.objects.filter(project_id__in=watched_project_ids, deleted_at__isnull=True):
213 if not repo.exists_on_disk:
214 continue
215 try:
216 with FossilReader(repo.full_path) as reader:
217 entries = reader.get_timeline(limit=10)
218 for entry in entries:
219 if entry.timestamp < cutoff:
220 break
221 event_type = {"ci": "checkin", "w": "wiki", "t": "ticket", "f": "forum"}.get(entry.event_type, "other")
222 url = f"/projects/{repo.project.slug}/fossil/"
223 if entry.event_type == "ci":
224 url += f"checkin/{entry.uuid}/"
225 notify_project_event(
226 project=repo.project,
227 event_type=event_type,
228 title=f"{entry.user}: {entry.comment[:100]}" if entry.comment else f"New {event_type}",
229 body=entry.comment or "",
230 url=url,
231 )
232 except Exception:
233 logger.exception("Notification dispatch error for %s", repo.filename)
234
--- fossil/views.py
+++ fossil/views.py
@@ -1122,10 +1122,37 @@
11221122
11231123
from django.shortcuts import redirect
11241124
11251125
return redirect("fossil:git_mirror", slug=slug)
11261126
1127
+
1128
+# --- Watch / Notifications ---
1129
+
1130
+
1131
+@login_required
1132
+def toggle_watch(request, slug):
1133
+ """Toggle project watch on/off."""
1134
+ from fossil.notifications import ProjectWatch
1135
+
1136
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1137
+
1138
+ if request.method == "POST":
1139
+ watch = ProjectWatch.objects.filter(user=request.user, project=project, deleted_at__isnull=True).first()
1140
+ if watch:
1141
+ watch.soft_delete(user=request.user)
1142
+ from django.contrib import messages
1143
+
1144
+ messages.info(request, f"Unwatched {project.name}.")
1145
+ else:
1146
+ event_filter = request.POST.get("event_filter", "all")
1147
+ ProjectWatch.objects.create(user=request.user, project=project, event_filter=event_filter, created_by=request.user)
1148
+ from django.contrib import messages
1149
+
1150
+ messages.success(request, f"Watching {project.name}. You'll get email notifications.")
1151
+
1152
+ return redirect("projects:detail", slug=slug)
1153
+
11271154
11281155
# --- OAuth ---
11291156
11301157
11311158
@login_required
11321159
--- fossil/views.py
+++ fossil/views.py
@@ -1122,10 +1122,37 @@
1122
1123 from django.shortcuts import redirect
1124
1125 return redirect("fossil:git_mirror", slug=slug)
1126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1127
1128 # --- OAuth ---
1129
1130
1131 @login_required
1132
--- fossil/views.py
+++ fossil/views.py
@@ -1122,10 +1122,37 @@
1122
1123 from django.shortcuts import redirect
1124
1125 return redirect("fossil:git_mirror", slug=slug)
1126
1127
1128 # --- Watch / Notifications ---
1129
1130
1131 @login_required
1132 def toggle_watch(request, slug):
1133 """Toggle project watch on/off."""
1134 from fossil.notifications import ProjectWatch
1135
1136 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1137
1138 if request.method == "POST":
1139 watch = ProjectWatch.objects.filter(user=request.user, project=project, deleted_at__isnull=True).first()
1140 if watch:
1141 watch.soft_delete(user=request.user)
1142 from django.contrib import messages
1143
1144 messages.info(request, f"Unwatched {project.name}.")
1145 else:
1146 event_filter = request.POST.get("event_filter", "all")
1147 ProjectWatch.objects.create(user=request.user, project=project, event_filter=event_filter, created_by=request.user)
1148 from django.contrib import messages
1149
1150 messages.success(request, f"Watching {project.name}. You'll get email notifications.")
1151
1152 return redirect("projects:detail", slug=slug)
1153
1154
1155 # --- OAuth ---
1156
1157
1158 @login_required
1159
--- projects/views.py
+++ projects/views.py
@@ -71,10 +71,17 @@
7171
except Exception:
7272
pass
7373
7474
import json
7575
76
+ # Check if user is watching this project
77
+ is_watching = False
78
+ if request.user.is_authenticated:
79
+ from fossil.notifications import ProjectWatch
80
+
81
+ is_watching = ProjectWatch.objects.filter(user=request.user, project=project, deleted_at__isnull=True).exists()
82
+
7683
return render(
7784
request,
7885
"projects/project_detail.html",
7986
{
8087
"project": project,
@@ -81,10 +88,11 @@
8188
"project_teams": project_teams,
8289
"repo_stats": repo_stats,
8390
"recent_commits": recent_commits,
8491
"commit_activity_json": json.dumps([c["count"] for c in commit_activity]),
8592
"top_contributors": top_contributors,
93
+ "is_watching": is_watching,
8694
},
8795
)
8896
8997
9098
@login_required
9199
--- projects/views.py
+++ projects/views.py
@@ -71,10 +71,17 @@
71 except Exception:
72 pass
73
74 import json
75
 
 
 
 
 
 
 
76 return render(
77 request,
78 "projects/project_detail.html",
79 {
80 "project": project,
@@ -81,10 +88,11 @@
81 "project_teams": project_teams,
82 "repo_stats": repo_stats,
83 "recent_commits": recent_commits,
84 "commit_activity_json": json.dumps([c["count"] for c in commit_activity]),
85 "top_contributors": top_contributors,
 
86 },
87 )
88
89
90 @login_required
91
--- projects/views.py
+++ projects/views.py
@@ -71,10 +71,17 @@
71 except Exception:
72 pass
73
74 import json
75
76 # Check if user is watching this project
77 is_watching = False
78 if request.user.is_authenticated:
79 from fossil.notifications import ProjectWatch
80
81 is_watching = ProjectWatch.objects.filter(user=request.user, project=project, deleted_at__isnull=True).exists()
82
83 return render(
84 request,
85 "projects/project_detail.html",
86 {
87 "project": project,
@@ -81,10 +88,11 @@
88 "project_teams": project_teams,
89 "repo_stats": repo_stats,
90 "recent_commits": recent_commits,
91 "commit_activity_json": json.dumps([c["count"] for c in commit_activity]),
92 "top_contributors": top_contributors,
93 "is_watching": is_watching,
94 },
95 )
96
97
98 @login_required
99
--- templates/projects/project_detail.html
+++ templates/projects/project_detail.html
@@ -10,10 +10,26 @@
1010
<div>
1111
<h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1>
1212
<p class="mt-1 text-sm text-gray-400">{{ project.description|default:"No description." }}</p>
1313
</div>
1414
<div class="flex gap-3">
15
+ {% if user.is_authenticated %}
16
+ <form method="post" action="{% url 'fossil:toggle_watch' slug=project.slug %}">
17
+ {% csrf_token %}
18
+ {% if is_watching %}
19
+ <button type="submit" class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
20
+ <svg class="h-4 w-4 text-brand" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
21
+ Watching
22
+ </button>
23
+ {% else %}
24
+ <button type="submit" class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
25
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
26
+ Watch
27
+ </button>
28
+ {% endif %}
29
+ </form>
30
+ {% endif %}
1531
{% if perms.projects.change_project %}
1632
<a href="{% url 'projects:update' slug=project.slug %}"
1733
class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
1834
Edit
1935
</a>
2036
--- templates/projects/project_detail.html
+++ templates/projects/project_detail.html
@@ -10,10 +10,26 @@
10 <div>
11 <h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1>
12 <p class="mt-1 text-sm text-gray-400">{{ project.description|default:"No description." }}</p>
13 </div>
14 <div class="flex gap-3">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15 {% if perms.projects.change_project %}
16 <a href="{% url 'projects:update' slug=project.slug %}"
17 class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
18 Edit
19 </a>
20
--- templates/projects/project_detail.html
+++ templates/projects/project_detail.html
@@ -10,10 +10,26 @@
10 <div>
11 <h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1>
12 <p class="mt-1 text-sm text-gray-400">{{ project.description|default:"No description." }}</p>
13 </div>
14 <div class="flex gap-3">
15 {% if user.is_authenticated %}
16 <form method="post" action="{% url 'fossil:toggle_watch' slug=project.slug %}">
17 {% csrf_token %}
18 {% if is_watching %}
19 <button type="submit" class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
20 <svg class="h-4 w-4 text-brand" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
21 Watching
22 </button>
23 {% else %}
24 <button type="submit" class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
25 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
26 Watch
27 </button>
28 {% endif %}
29 </form>
30 {% endif %}
31 {% if perms.projects.change_project %}
32 <a href="{% url 'projects:update' slug=project.slug %}"
33 class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
34 Edit
35 </a>
36

Keyboard Shortcuts

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