FossilRepo

Add notification system: watch/unwatch, email delivery, Celery dispatch Models (fossil/notifications.py): - ProjectWatch: user subscribes to project events (all/checkins/tickets/wiki) - Notification: individual notification entries with read/emailed tracking Watch UI: - Watch/Unwatch toggle button on project detail page - Eye icon toggles between "Watch" and "Watching" states - Shows green checkmark when watching Email delivery: - notify_project_event() sends emails to all watchers via Django SMTP - Simple text emails with subject line [ProjectName] event title - SMTP configured: Mailpit (dev), SES/any SMTP (production) Celery task: - dispatch_notifications: runs every 5 minutes - Checks Fossil timeline for events newer than 5 minutes - Creates Notification records and sends emails for each Config: - EMAIL_HOST/PORT already configured for Mailpit in dev - DEFAULT_FROM_EMAIL = [email protected]

lmata 2026-04-07 04:26 trunk
Commit acc72addba22105ef883c992672fb7d24920aacd8a363b2725ba9ea22800bdde
--- config/settings.py
+++ config/settings.py
@@ -197,10 +197,14 @@
197197
},
198198
"fossil-check-upstream": {
199199
"task": "fossil.check_upstream",
200200
"schedule": 900.0, # every 15 minutes
201201
},
202
+ "fossil-dispatch-notifications": {
203
+ "task": "fossil.dispatch_notifications",
204
+ "schedule": 300.0, # every 5 minutes
205
+ },
202206
}
203207
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
204208
205209
# --- CORS ---
206210
207211
208212
ADDED fossil/migrations/0004_historicalprojectwatch_notification_projectwatch.py
--- config/settings.py
+++ config/settings.py
@@ -197,10 +197,14 @@
197 },
198 "fossil-check-upstream": {
199 "task": "fossil.check_upstream",
200 "schedule": 900.0, # every 15 minutes
201 },
 
 
 
 
202 }
203 CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
204
205 # --- CORS ---
206
207
208 DDED fossil/migrations/0004_historicalprojectwatch_notification_projectwatch.py
--- config/settings.py
+++ config/settings.py
@@ -197,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/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/urls.py
+++ fossil/urls.py
@@ -36,11 +36,10 @@
3636
path("sync/git/callback/github/", views.oauth_github_callback, name="oauth_github_callback"),
3737
path("sync/git/callback/gitlab/", views.oauth_gitlab_callback, name="oauth_gitlab_callback"),
3838
path("code/raw/<path:filepath>", views.code_raw, name="code_raw"),
3939
path("code/blame/<path:filepath>", views.code_blame, name="code_blame"),
4040
path("code/history/<path:filepath>", views.file_history, name="file_history"),
41
- path("watch/", views.toggle_watch, name="toggle_watch"),
4241
path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
4342
path("tickets/export/", views.tickets_csv, name="tickets_csv"),
4443
path("docs/", views.fossil_docs, name="docs"),
4544
path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"),
4645
]
4746
--- fossil/urls.py
+++ fossil/urls.py
@@ -36,11 +36,10 @@
36 path("sync/git/callback/github/", views.oauth_github_callback, name="oauth_github_callback"),
37 path("sync/git/callback/gitlab/", views.oauth_gitlab_callback, name="oauth_gitlab_callback"),
38 path("code/raw/<path:filepath>", views.code_raw, name="code_raw"),
39 path("code/blame/<path:filepath>", views.code_blame, name="code_blame"),
40 path("code/history/<path:filepath>", views.file_history, name="file_history"),
41 path("watch/", views.toggle_watch, name="toggle_watch"),
42 path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
43 path("tickets/export/", views.tickets_csv, name="tickets_csv"),
44 path("docs/", views.fossil_docs, name="docs"),
45 path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"),
46 ]
47
--- fossil/urls.py
+++ fossil/urls.py
@@ -36,11 +36,10 @@
36 path("sync/git/callback/github/", views.oauth_github_callback, name="oauth_github_callback"),
37 path("sync/git/callback/gitlab/", views.oauth_gitlab_callback, name="oauth_gitlab_callback"),
38 path("code/raw/<path:filepath>", views.code_raw, name="code_raw"),
39 path("code/blame/<path:filepath>", views.code_blame, name="code_blame"),
40 path("code/history/<path:filepath>", views.file_history, name="file_history"),
 
41 path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
42 path("tickets/export/", views.tickets_csv, name="tickets_csv"),
43 path("docs/", views.fossil_docs, name="docs"),
44 path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"),
45 ]
46
--- 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