FossilRepo

Add user management, forum write, webhooks, side-by-side diff, syntax highlighting User management: full CRUD in org settings — create/edit/deactivate users, change passwords, user detail with team memberships and SSH keys. 30 tests. Forum write: Django-backed ForumPost model for thread creation and replies, merged with Fossil-native posts in the UI. Markdown body with preview. 25 tests. Webhooks: Webhook + WebhookDelivery models, CRUD config UI (admin-only), Celery task with HMAC-SHA256 signing, exponential backoff retry, delivery log viewer. Encrypted secret storage via EncryptedTextField. 32 tests. Side-by-side diff: Alpine.js toggle (unified/split) with localStorage preference. Split view computed server-side with paired del/add rows. Works on both checkin detail and compare pages. 10 tests. Syntax highlighting: highlight.js CDN added to base.html, auto-detects language from file extension, applies to diff code spans without breaking diff line coloring (add/del backgrounds preserved).

lmata 2026-04-07 07:35 trunk
Commit d7c30a9100bcf6922b36c7f565a4830602eac359e07cec8b7b2bde3f1f472ee0
--- fossil/admin.py
+++ fossil/admin.py
@@ -1,14 +1,16 @@
11
from django.contrib import admin
22
33
from core.admin import BaseCoreAdmin
44
5
+from .forum import ForumPost
56
from .models import FossilRepository, FossilSnapshot
67
from .notifications import Notification, ProjectWatch
78
from .releases import Release, ReleaseAsset
89
from .sync_models import GitMirror, SSHKey, SyncLog
910
from .user_keys import UserSSHKey
11
+from .webhooks import Webhook, WebhookDelivery
1012
1113
1214
class FossilSnapshotInline(admin.TabularInline):
1315
model = FossilSnapshot
1416
extra = 0
@@ -95,5 +97,34 @@
9597
class SyncLogAdmin(admin.ModelAdmin):
9698
list_display = ("mirror", "status", "started_at", "completed_at", "artifacts_synced", "triggered_by")
9799
list_filter = ("status", "triggered_by")
98100
search_fields = ("mirror__repository__filename", "message")
99101
raw_id_fields = ("mirror",)
102
+
103
+
104
+@admin.register(ForumPost)
105
+class ForumPostAdmin(BaseCoreAdmin):
106
+ list_display = ("title", "repository", "parent", "created_by", "created_at")
107
+ search_fields = ("title", "body")
108
+ raw_id_fields = ("repository", "parent", "thread_root")
109
+
110
+
111
+class WebhookDeliveryInline(admin.TabularInline):
112
+ model = WebhookDelivery
113
+ extra = 0
114
+ readonly_fields = ("event_type", "response_status", "success", "delivered_at", "duration_ms", "attempt")
115
+
116
+
117
+@admin.register(Webhook)
118
+class WebhookAdmin(BaseCoreAdmin):
119
+ list_display = ("url", "repository", "events", "is_active", "created_at")
120
+ list_filter = ("is_active", "events")
121
+ search_fields = ("url", "repository__filename")
122
+ raw_id_fields = ("repository",)
123
+ inlines = [WebhookDeliveryInline]
124
+
125
+
126
+@admin.register(WebhookDelivery)
127
+class WebhookDeliveryAdmin(admin.ModelAdmin):
128
+ list_display = ("webhook", "event_type", "response_status", "success", "delivered_at", "duration_ms")
129
+ list_filter = ("success", "event_type")
130
+ raw_id_fields = ("webhook",)
100131
101132
ADDED fossil/forum.py
102133
ADDED fossil/migrations/0007_forumpost_historicalforumpost_historicalwebhook_and_more.py
--- fossil/admin.py
+++ fossil/admin.py
@@ -1,14 +1,16 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
 
5 from .models import FossilRepository, FossilSnapshot
6 from .notifications import Notification, ProjectWatch
7 from .releases import Release, ReleaseAsset
8 from .sync_models import GitMirror, SSHKey, SyncLog
9 from .user_keys import UserSSHKey
 
10
11
12 class FossilSnapshotInline(admin.TabularInline):
13 model = FossilSnapshot
14 extra = 0
@@ -95,5 +97,34 @@
95 class SyncLogAdmin(admin.ModelAdmin):
96 list_display = ("mirror", "status", "started_at", "completed_at", "artifacts_synced", "triggered_by")
97 list_filter = ("status", "triggered_by")
98 search_fields = ("mirror__repository__filename", "message")
99 raw_id_fields = ("mirror",)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
101 DDED fossil/forum.py
102 DDED fossil/migrations/0007_forumpost_historicalforumpost_historicalwebhook_and_more.py
--- fossil/admin.py
+++ fossil/admin.py
@@ -1,14 +1,16 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .forum import ForumPost
6 from .models import FossilRepository, FossilSnapshot
7 from .notifications import Notification, ProjectWatch
8 from .releases import Release, ReleaseAsset
9 from .sync_models import GitMirror, SSHKey, SyncLog
10 from .user_keys import UserSSHKey
11 from .webhooks import Webhook, WebhookDelivery
12
13
14 class FossilSnapshotInline(admin.TabularInline):
15 model = FossilSnapshot
16 extra = 0
@@ -95,5 +97,34 @@
97 class SyncLogAdmin(admin.ModelAdmin):
98 list_display = ("mirror", "status", "started_at", "completed_at", "artifacts_synced", "triggered_by")
99 list_filter = ("status", "triggered_by")
100 search_fields = ("mirror__repository__filename", "message")
101 raw_id_fields = ("mirror",)
102
103
104 @admin.register(ForumPost)
105 class ForumPostAdmin(BaseCoreAdmin):
106 list_display = ("title", "repository", "parent", "created_by", "created_at")
107 search_fields = ("title", "body")
108 raw_id_fields = ("repository", "parent", "thread_root")
109
110
111 class WebhookDeliveryInline(admin.TabularInline):
112 model = WebhookDelivery
113 extra = 0
114 readonly_fields = ("event_type", "response_status", "success", "delivered_at", "duration_ms", "attempt")
115
116
117 @admin.register(Webhook)
118 class WebhookAdmin(BaseCoreAdmin):
119 list_display = ("url", "repository", "events", "is_active", "created_at")
120 list_filter = ("is_active", "events")
121 search_fields = ("url", "repository__filename")
122 raw_id_fields = ("repository",)
123 inlines = [WebhookDeliveryInline]
124
125
126 @admin.register(WebhookDelivery)
127 class WebhookDeliveryAdmin(admin.ModelAdmin):
128 list_display = ("webhook", "event_type", "response_status", "success", "delivered_at", "duration_ms")
129 list_filter = ("success", "event_type")
130 raw_id_fields = ("webhook",)
131
132 DDED fossil/forum.py
133 DDED fossil/migrations/0007_forumpost_historicalforumpost_historicalwebhook_and_more.py
--- a/fossil/forum.py
+++ b/fossil/forum.py
@@ -0,0 +1,34 @@
1
+"""Django-backed forum posts for projects.
2
+
3
+Supplements Fossil's native forum. Fossil's forum is tightly coupled to its
4
+HTTP server and doesn't expose a CLI for creating posts, so we store
5
+Django-side forum posts and display them alongside Fossil-native posts.
6
+"""
7
+
8
+from django.db import models
9
+
10
+from core.models import ActiveManager, Tracking
11
+
12
+
13
+class ForumPost(Tracking):
14
+ """Django-backed forum post for projects. Supplements Fossil's native forum."""
15
+
16
+ repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="forum_posts")
17
+ title = models.CharField(max_length=500, blank=True, default="") # empty for replies
18
+ body = models.TextField()
19
+ parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE, related_name="replies")
20
+ # Thread root -- self-referencing for threading
21
+ thread_root = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE, related_name="thread_posts")
22
+
23
+ objects = ActiveManager()
24
+ all_objects = models.Manager()
25
+
26
+ class Meta:
27
+ ordering = ["created_at"]
28
+
29
+ def __str__(self):
30
+ return self.title or f"Reply by {self.created_by}"
31
+
32
+ @property
33
+ def is_reply(self):
34
+ return self.parent is not None
--- a/fossil/forum.py
+++ b/fossil/forum.py
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/forum.py
+++ b/fossil/forum.py
@@ -0,0 +1,34 @@
1 """Django-backed forum posts for projects.
2
3 Supplements Fossil's native forum. Fossil's forum is tightly coupled to its
4 HTTP server and doesn't expose a CLI for creating posts, so we store
5 Django-side forum posts and display them alongside Fossil-native posts.
6 """
7
8 from django.db import models
9
10 from core.models import ActiveManager, Tracking
11
12
13 class ForumPost(Tracking):
14 """Django-backed forum post for projects. Supplements Fossil's native forum."""
15
16 repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="forum_posts")
17 title = models.CharField(max_length=500, blank=True, default="") # empty for replies
18 body = models.TextField()
19 parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE, related_name="replies")
20 # Thread root -- self-referencing for threading
21 thread_root = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE, related_name="thread_posts")
22
23 objects = ActiveManager()
24 all_objects = models.Manager()
25
26 class Meta:
27 ordering = ["created_at"]
28
29 def __str__(self):
30 return self.title or f"Reply by {self.created_by}"
31
32 @property
33 def is_reply(self):
34 return self.parent is not None
--- a/fossil/migrations/0007_forumpost_historicalforumpost_historicalwebhook_and_more.py
+++ b/fossil/migrations/0007_forumpost_historicalforumpost_historicalwebhook_and_more.py
@@ -0,0 +1,389 @@
1
+# Generated by Django 5.2.12 on 2026-04-07 07:27
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
+import core.fields
9
+
10
+
11
+class Migration(migrations.Migration):
12
+ dependencies = [
13
+ ("fossil", "0006_historicalrelease_release_historicalreleaseasset_and_more"),
14
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name="ForumPost",
20
+ fields=[
21
+ (
22
+ "id",
23
+ models.BigAutoField(
24
+ auto_created=True,
25
+ primary_key=True,
26
+ serialize=False,
27
+ verbose_name="ID",
28
+ ),
29
+ ),
30
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
31
+ ("created_at", models.DateTimeField(auto_now_add=True)),
32
+ ("updated_at", models.DateTimeField(auto_now=True)),
33
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
34
+ ("title", models.CharField(blank=True, default="", max_length=500)),
35
+ ("body", models.TextField()),
36
+ (
37
+ "created_by",
38
+ models.ForeignKey(
39
+ blank=True,
40
+ null=True,
41
+ on_delete=django.db.models.deletion.SET_NULL,
42
+ related_name="+",
43
+ to=settings.AUTH_USER_MODEL,
44
+ ),
45
+ ),
46
+ (
47
+ "deleted_by",
48
+ models.ForeignKey(
49
+ blank=True,
50
+ null=True,
51
+ on_delete=django.db.models.deletion.SET_NULL,
52
+ related_name="+",
53
+ to=settings.AUTH_USER_MODEL,
54
+ ),
55
+ ),
56
+ (
57
+ "parent",
58
+ models.ForeignKey(
59
+ blank=True,
60
+ null=True,
61
+ on_delete=django.db.models.deletion.CASCADE,
62
+ related_name="replies",
63
+ to="fossil.forumpost",
64
+ ),
65
+ ),
66
+ (
67
+ "repository",
68
+ models.ForeignKey(
69
+ on_delete=django.db.models.deletion.CASCADE,
70
+ related_name="forum_posts",
71
+ to="fossil.fossilrepository",
72
+ ),
73
+ ),
74
+ (
75
+ "thread_root",
76
+ models.ForeignKey(
77
+ blank=True,
78
+ null=True,
79
+ on_delete=django.db.models.deletion.CASCADE,
80
+ related_name="thread_posts",
81
+ to="fossil.forumpost",
82
+ ),
83
+ ),
84
+ (
85
+ "updated_by",
86
+ models.ForeignKey(
87
+ blank=True,
88
+ null=True,
89
+ on_delete=django.db.models.deletion.SET_NULL,
90
+ related_name="+",
91
+ to=settings.AUTH_USER_MODEL,
92
+ ),
93
+ ),
94
+ ],
95
+ options={
96
+ "ordering": ["created_at"],
97
+ },
98
+ ),
99
+ migrations.CreateModel(
100
+ name="HistoricalForumPost",
101
+ fields=[
102
+ (
103
+ "id",
104
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
105
+ ),
106
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
107
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
108
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
109
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
110
+ ("title", models.CharField(blank=True, default="", max_length=500)),
111
+ ("body", models.TextField()),
112
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
113
+ ("history_date", models.DateTimeField(db_index=True)),
114
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
115
+ (
116
+ "history_type",
117
+ models.CharField(
118
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
119
+ max_length=1,
120
+ ),
121
+ ),
122
+ (
123
+ "created_by",
124
+ models.ForeignKey(
125
+ blank=True,
126
+ db_constraint=False,
127
+ null=True,
128
+ on_delete=django.db.models.deletion.DO_NOTHING,
129
+ related_name="+",
130
+ to=settings.AUTH_USER_MODEL,
131
+ ),
132
+ ),
133
+ (
134
+ "deleted_by",
135
+ models.ForeignKey(
136
+ blank=True,
137
+ db_constraint=False,
138
+ null=True,
139
+ on_delete=django.db.models.deletion.DO_NOTHING,
140
+ related_name="+",
141
+ to=settings.AUTH_USER_MODEL,
142
+ ),
143
+ ),
144
+ (
145
+ "history_user",
146
+ models.ForeignKey(
147
+ null=True,
148
+ on_delete=django.db.models.deletion.SET_NULL,
149
+ related_name="+",
150
+ to=settings.AUTH_USER_MODEL,
151
+ ),
152
+ ),
153
+ (
154
+ "parent",
155
+ models.ForeignKey(
156
+ blank=True,
157
+ db_constraint=False,
158
+ null=True,
159
+ on_delete=django.db.models.deletion.DO_NOTHING,
160
+ related_name="+",
161
+ to="fossil.forumpost",
162
+ ),
163
+ ),
164
+ (
165
+ "repository",
166
+ models.ForeignKey(
167
+ blank=True,
168
+ db_constraint=False,
169
+ null=True,
170
+ on_delete=django.db.models.deletion.DO_NOTHING,
171
+ related_name="+",
172
+ to="fossil.fossilrepository",
173
+ ),
174
+ ),
175
+ (
176
+ "thread_root",
177
+ models.ForeignKey(
178
+ blank=True,
179
+ db_constraint=False,
180
+ null=True,
181
+ on_delete=django.db.models.deletion.DO_NOTHING,
182
+ related_name="+",
183
+ to="fossil.forumpost",
184
+ ),
185
+ ),
186
+ (
187
+ "updated_by",
188
+ models.ForeignKey(
189
+ blank=True,
190
+ db_constraint=False,
191
+ null=True,
192
+ on_delete=django.db.models.deletion.DO_NOTHING,
193
+ related_name="+",
194
+ to=settings.AUTH_USER_MODEL,
195
+ ),
196
+ ),
197
+ ],
198
+ options={
199
+ "verbose_name": "historical forum post",
200
+ "verbose_name_plural": "historical forum posts",
201
+ "ordering": ("-history_date", "-history_id"),
202
+ "get_latest_by": ("history_date", "history_id"),
203
+ },
204
+ bases=(simple_history.models.HistoricalChanges, models.Model),
205
+ ),
206
+ migrations.CreateModel(
207
+ name="HistoricalWebhook",
208
+ fields=[
209
+ (
210
+ "id",
211
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
212
+ ),
213
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
214
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
215
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
216
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
217
+ ("url", models.URLField(max_length=500)),
218
+ ("secret", core.fields.EncryptedTextField(blank=True, default="")),
219
+ ("events", models.CharField(default="all", max_length=100)),
220
+ ("is_active", models.BooleanField(default=True)),
221
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
222
+ ("history_date", models.DateTimeField(db_index=True)),
223
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
224
+ (
225
+ "history_type",
226
+ models.CharField(
227
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
228
+ max_length=1,
229
+ ),
230
+ ),
231
+ (
232
+ "created_by",
233
+ models.ForeignKey(
234
+ blank=True,
235
+ db_constraint=False,
236
+ null=True,
237
+ on_delete=django.db.models.deletion.DO_NOTHING,
238
+ related_name="+",
239
+ to=settings.AUTH_USER_MODEL,
240
+ ),
241
+ ),
242
+ (
243
+ "deleted_by",
244
+ models.ForeignKey(
245
+ blank=True,
246
+ db_constraint=False,
247
+ null=True,
248
+ on_delete=django.db.models.deletion.DO_NOTHING,
249
+ related_name="+",
250
+ to=settings.AUTH_USER_MODEL,
251
+ ),
252
+ ),
253
+ (
254
+ "history_user",
255
+ models.ForeignKey(
256
+ null=True,
257
+ on_delete=django.db.models.deletion.SET_NULL,
258
+ related_name="+",
259
+ to=settings.AUTH_USER_MODEL,
260
+ ),
261
+ ),
262
+ (
263
+ "repository",
264
+ models.ForeignKey(
265
+ blank=True,
266
+ db_constraint=False,
267
+ null=True,
268
+ on_delete=django.db.models.deletion.DO_NOTHING,
269
+ related_name="+",
270
+ to="fossil.fossilrepository",
271
+ ),
272
+ ),
273
+ (
274
+ "updated_by",
275
+ models.ForeignKey(
276
+ blank=True,
277
+ db_constraint=False,
278
+ null=True,
279
+ on_delete=django.db.models.deletion.DO_NOTHING,
280
+ related_name="+",
281
+ to=settings.AUTH_USER_MODEL,
282
+ ),
283
+ ),
284
+ ],
285
+ options={
286
+ "verbose_name": "historical webhook",
287
+ "verbose_name_plural": "historical webhooks",
288
+ "ordering": ("-history_date", "-history_id"),
289
+ "get_latest_by": ("history_date", "history_id"),
290
+ },
291
+ bases=(simple_history.models.HistoricalChanges, models.Model),
292
+ ),
293
+ migrations.CreateModel(
294
+ name="Webhook",
295
+ fields=[
296
+ (
297
+ "id",
298
+ models.BigAutoField(
299
+ auto_created=True,
300
+ primary_key=True,
301
+ serialize=False,
302
+ verbose_name="ID",
303
+ ),
304
+ ),
305
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
306
+ ("created_at", models.DateTimeField(auto_now_add=True)),
307
+ ("updated_at", models.DateTimeField(auto_now=True)),
308
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
309
+ ("url", models.URLField(max_length=500)),
310
+ ("secret", core.fields.EncryptedTextField(blank=True, default="")),
311
+ ("events", models.CharField(default="all", max_length=100)),
312
+ ("is_active", models.BooleanField(default=True)),
313
+ (
314
+ "created_by",
315
+ models.ForeignKey(
316
+ blank=True,
317
+ null=True,
318
+ on_delete=django.db.models.deletion.SET_NULL,
319
+ related_name="+",
320
+ to=settings.AUTH_USER_MODEL,
321
+ ),
322
+ ),
323
+ (
324
+ "deleted_by",
325
+ models.ForeignKey(
326
+ blank=True,
327
+ null=True,
328
+ on_delete=django.db.models.deletion.SET_NULL,
329
+ related_name="+",
330
+ to=settings.AUTH_USER_MODEL,
331
+ ),
332
+ ),
333
+ (
334
+ "repository",
335
+ models.ForeignKey(
336
+ on_delete=django.db.models.deletion.CASCADE,
337
+ related_name="webhooks",
338
+ to="fossil.fossilrepository",
339
+ ),
340
+ ),
341
+ (
342
+ "updated_by",
343
+ models.ForeignKey(
344
+ blank=True,
345
+ null=True,
346
+ on_delete=django.db.models.deletion.SET_NULL,
347
+ related_name="+",
348
+ to=settings.AUTH_USER_MODEL,
349
+ ),
350
+ ),
351
+ ],
352
+ options={
353
+ "ordering": ["-created_at"],
354
+ },
355
+ ),
356
+ migrations.CreateModel(
357
+ name="WebhookDelivery",
358
+ fields=[
359
+ (
360
+ "id",
361
+ models.BigAutoField(
362
+ auto_created=True,
363
+ primary_key=True,
364
+ serialize=False,
365
+ verbose_name="ID",
366
+ ),
367
+ ),
368
+ ("event_type", models.CharField(max_length=20)),
369
+ ("payload", models.JSONField()),
370
+ ("response_status", models.IntegerField(blank=True, null=True)),
371
+ ("response_body", models.TextField(blank=True, default="")),
372
+ ("success", models.BooleanField(default=False)),
373
+ ("delivered_at", models.DateTimeField(auto_now_add=True)),
374
+ ("duration_ms", models.IntegerField(default=0)),
375
+ ("attempt", models.IntegerField(default=1)),
376
+ (
377
+ "webhook",
378
+ models.ForeignKey(
379
+ on_delete=django.db.models.deletion.CASCADE,
380
+ related_name="deliveries",
381
+ to="fossil.webhook",
382
+ ),
383
+ ),
384
+ ],
385
+ options={
386
+ "ordering": ["-delivered_at"],
387
+ },
388
+ ),
389
+ ]
--- a/fossil/migrations/0007_forumpost_historicalforumpost_historicalwebhook_and_more.py
+++ b/fossil/migrations/0007_forumpost_historicalforumpost_historicalwebhook_and_more.py
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/migrations/0007_forumpost_historicalforumpost_historicalwebhook_and_more.py
+++ b/fossil/migrations/0007_forumpost_historicalforumpost_historicalwebhook_and_more.py
@@ -0,0 +1,389 @@
1 # Generated by Django 5.2.12 on 2026-04-07 07:27
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 import core.fields
9
10
11 class Migration(migrations.Migration):
12 dependencies = [
13 ("fossil", "0006_historicalrelease_release_historicalreleaseasset_and_more"),
14 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15 ]
16
17 operations = [
18 migrations.CreateModel(
19 name="ForumPost",
20 fields=[
21 (
22 "id",
23 models.BigAutoField(
24 auto_created=True,
25 primary_key=True,
26 serialize=False,
27 verbose_name="ID",
28 ),
29 ),
30 ("version", models.PositiveIntegerField(default=1, editable=False)),
31 ("created_at", models.DateTimeField(auto_now_add=True)),
32 ("updated_at", models.DateTimeField(auto_now=True)),
33 ("deleted_at", models.DateTimeField(blank=True, null=True)),
34 ("title", models.CharField(blank=True, default="", max_length=500)),
35 ("body", models.TextField()),
36 (
37 "created_by",
38 models.ForeignKey(
39 blank=True,
40 null=True,
41 on_delete=django.db.models.deletion.SET_NULL,
42 related_name="+",
43 to=settings.AUTH_USER_MODEL,
44 ),
45 ),
46 (
47 "deleted_by",
48 models.ForeignKey(
49 blank=True,
50 null=True,
51 on_delete=django.db.models.deletion.SET_NULL,
52 related_name="+",
53 to=settings.AUTH_USER_MODEL,
54 ),
55 ),
56 (
57 "parent",
58 models.ForeignKey(
59 blank=True,
60 null=True,
61 on_delete=django.db.models.deletion.CASCADE,
62 related_name="replies",
63 to="fossil.forumpost",
64 ),
65 ),
66 (
67 "repository",
68 models.ForeignKey(
69 on_delete=django.db.models.deletion.CASCADE,
70 related_name="forum_posts",
71 to="fossil.fossilrepository",
72 ),
73 ),
74 (
75 "thread_root",
76 models.ForeignKey(
77 blank=True,
78 null=True,
79 on_delete=django.db.models.deletion.CASCADE,
80 related_name="thread_posts",
81 to="fossil.forumpost",
82 ),
83 ),
84 (
85 "updated_by",
86 models.ForeignKey(
87 blank=True,
88 null=True,
89 on_delete=django.db.models.deletion.SET_NULL,
90 related_name="+",
91 to=settings.AUTH_USER_MODEL,
92 ),
93 ),
94 ],
95 options={
96 "ordering": ["created_at"],
97 },
98 ),
99 migrations.CreateModel(
100 name="HistoricalForumPost",
101 fields=[
102 (
103 "id",
104 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
105 ),
106 ("version", models.PositiveIntegerField(default=1, editable=False)),
107 ("created_at", models.DateTimeField(blank=True, editable=False)),
108 ("updated_at", models.DateTimeField(blank=True, editable=False)),
109 ("deleted_at", models.DateTimeField(blank=True, null=True)),
110 ("title", models.CharField(blank=True, default="", max_length=500)),
111 ("body", models.TextField()),
112 ("history_id", models.AutoField(primary_key=True, serialize=False)),
113 ("history_date", models.DateTimeField(db_index=True)),
114 ("history_change_reason", models.CharField(max_length=100, null=True)),
115 (
116 "history_type",
117 models.CharField(
118 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
119 max_length=1,
120 ),
121 ),
122 (
123 "created_by",
124 models.ForeignKey(
125 blank=True,
126 db_constraint=False,
127 null=True,
128 on_delete=django.db.models.deletion.DO_NOTHING,
129 related_name="+",
130 to=settings.AUTH_USER_MODEL,
131 ),
132 ),
133 (
134 "deleted_by",
135 models.ForeignKey(
136 blank=True,
137 db_constraint=False,
138 null=True,
139 on_delete=django.db.models.deletion.DO_NOTHING,
140 related_name="+",
141 to=settings.AUTH_USER_MODEL,
142 ),
143 ),
144 (
145 "history_user",
146 models.ForeignKey(
147 null=True,
148 on_delete=django.db.models.deletion.SET_NULL,
149 related_name="+",
150 to=settings.AUTH_USER_MODEL,
151 ),
152 ),
153 (
154 "parent",
155 models.ForeignKey(
156 blank=True,
157 db_constraint=False,
158 null=True,
159 on_delete=django.db.models.deletion.DO_NOTHING,
160 related_name="+",
161 to="fossil.forumpost",
162 ),
163 ),
164 (
165 "repository",
166 models.ForeignKey(
167 blank=True,
168 db_constraint=False,
169 null=True,
170 on_delete=django.db.models.deletion.DO_NOTHING,
171 related_name="+",
172 to="fossil.fossilrepository",
173 ),
174 ),
175 (
176 "thread_root",
177 models.ForeignKey(
178 blank=True,
179 db_constraint=False,
180 null=True,
181 on_delete=django.db.models.deletion.DO_NOTHING,
182 related_name="+",
183 to="fossil.forumpost",
184 ),
185 ),
186 (
187 "updated_by",
188 models.ForeignKey(
189 blank=True,
190 db_constraint=False,
191 null=True,
192 on_delete=django.db.models.deletion.DO_NOTHING,
193 related_name="+",
194 to=settings.AUTH_USER_MODEL,
195 ),
196 ),
197 ],
198 options={
199 "verbose_name": "historical forum post",
200 "verbose_name_plural": "historical forum posts",
201 "ordering": ("-history_date", "-history_id"),
202 "get_latest_by": ("history_date", "history_id"),
203 },
204 bases=(simple_history.models.HistoricalChanges, models.Model),
205 ),
206 migrations.CreateModel(
207 name="HistoricalWebhook",
208 fields=[
209 (
210 "id",
211 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
212 ),
213 ("version", models.PositiveIntegerField(default=1, editable=False)),
214 ("created_at", models.DateTimeField(blank=True, editable=False)),
215 ("updated_at", models.DateTimeField(blank=True, editable=False)),
216 ("deleted_at", models.DateTimeField(blank=True, null=True)),
217 ("url", models.URLField(max_length=500)),
218 ("secret", core.fields.EncryptedTextField(blank=True, default="")),
219 ("events", models.CharField(default="all", max_length=100)),
220 ("is_active", models.BooleanField(default=True)),
221 ("history_id", models.AutoField(primary_key=True, serialize=False)),
222 ("history_date", models.DateTimeField(db_index=True)),
223 ("history_change_reason", models.CharField(max_length=100, null=True)),
224 (
225 "history_type",
226 models.CharField(
227 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
228 max_length=1,
229 ),
230 ),
231 (
232 "created_by",
233 models.ForeignKey(
234 blank=True,
235 db_constraint=False,
236 null=True,
237 on_delete=django.db.models.deletion.DO_NOTHING,
238 related_name="+",
239 to=settings.AUTH_USER_MODEL,
240 ),
241 ),
242 (
243 "deleted_by",
244 models.ForeignKey(
245 blank=True,
246 db_constraint=False,
247 null=True,
248 on_delete=django.db.models.deletion.DO_NOTHING,
249 related_name="+",
250 to=settings.AUTH_USER_MODEL,
251 ),
252 ),
253 (
254 "history_user",
255 models.ForeignKey(
256 null=True,
257 on_delete=django.db.models.deletion.SET_NULL,
258 related_name="+",
259 to=settings.AUTH_USER_MODEL,
260 ),
261 ),
262 (
263 "repository",
264 models.ForeignKey(
265 blank=True,
266 db_constraint=False,
267 null=True,
268 on_delete=django.db.models.deletion.DO_NOTHING,
269 related_name="+",
270 to="fossil.fossilrepository",
271 ),
272 ),
273 (
274 "updated_by",
275 models.ForeignKey(
276 blank=True,
277 db_constraint=False,
278 null=True,
279 on_delete=django.db.models.deletion.DO_NOTHING,
280 related_name="+",
281 to=settings.AUTH_USER_MODEL,
282 ),
283 ),
284 ],
285 options={
286 "verbose_name": "historical webhook",
287 "verbose_name_plural": "historical webhooks",
288 "ordering": ("-history_date", "-history_id"),
289 "get_latest_by": ("history_date", "history_id"),
290 },
291 bases=(simple_history.models.HistoricalChanges, models.Model),
292 ),
293 migrations.CreateModel(
294 name="Webhook",
295 fields=[
296 (
297 "id",
298 models.BigAutoField(
299 auto_created=True,
300 primary_key=True,
301 serialize=False,
302 verbose_name="ID",
303 ),
304 ),
305 ("version", models.PositiveIntegerField(default=1, editable=False)),
306 ("created_at", models.DateTimeField(auto_now_add=True)),
307 ("updated_at", models.DateTimeField(auto_now=True)),
308 ("deleted_at", models.DateTimeField(blank=True, null=True)),
309 ("url", models.URLField(max_length=500)),
310 ("secret", core.fields.EncryptedTextField(blank=True, default="")),
311 ("events", models.CharField(default="all", max_length=100)),
312 ("is_active", models.BooleanField(default=True)),
313 (
314 "created_by",
315 models.ForeignKey(
316 blank=True,
317 null=True,
318 on_delete=django.db.models.deletion.SET_NULL,
319 related_name="+",
320 to=settings.AUTH_USER_MODEL,
321 ),
322 ),
323 (
324 "deleted_by",
325 models.ForeignKey(
326 blank=True,
327 null=True,
328 on_delete=django.db.models.deletion.SET_NULL,
329 related_name="+",
330 to=settings.AUTH_USER_MODEL,
331 ),
332 ),
333 (
334 "repository",
335 models.ForeignKey(
336 on_delete=django.db.models.deletion.CASCADE,
337 related_name="webhooks",
338 to="fossil.fossilrepository",
339 ),
340 ),
341 (
342 "updated_by",
343 models.ForeignKey(
344 blank=True,
345 null=True,
346 on_delete=django.db.models.deletion.SET_NULL,
347 related_name="+",
348 to=settings.AUTH_USER_MODEL,
349 ),
350 ),
351 ],
352 options={
353 "ordering": ["-created_at"],
354 },
355 ),
356 migrations.CreateModel(
357 name="WebhookDelivery",
358 fields=[
359 (
360 "id",
361 models.BigAutoField(
362 auto_created=True,
363 primary_key=True,
364 serialize=False,
365 verbose_name="ID",
366 ),
367 ),
368 ("event_type", models.CharField(max_length=20)),
369 ("payload", models.JSONField()),
370 ("response_status", models.IntegerField(blank=True, null=True)),
371 ("response_body", models.TextField(blank=True, default="")),
372 ("success", models.BooleanField(default=False)),
373 ("delivered_at", models.DateTimeField(auto_now_add=True)),
374 ("duration_ms", models.IntegerField(default=0)),
375 ("attempt", models.IntegerField(default=1)),
376 (
377 "webhook",
378 models.ForeignKey(
379 on_delete=django.db.models.deletion.CASCADE,
380 related_name="deliveries",
381 to="fossil.webhook",
382 ),
383 ),
384 ],
385 options={
386 "ordering": ["-delivered_at"],
387 },
388 ),
389 ]
--- fossil/models.py
+++ fossil/models.py
@@ -65,9 +65,11 @@
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
6969
# Import related models so they're discoverable by Django
70
+from fossil.forum import ForumPost # noqa: E402, F401
7071
from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401
7172
from fossil.releases import Release, ReleaseAsset # noqa: E402, F401
7273
from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
7374
from fossil.user_keys import UserSSHKey # noqa: E402, F401
75
+from fossil.webhooks import Webhook, WebhookDelivery # noqa: E402, F401
7476
--- fossil/models.py
+++ fossil/models.py
@@ -65,9 +65,11 @@
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.releases import Release, ReleaseAsset # noqa: E402, F401
72 from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
73 from fossil.user_keys import UserSSHKey # noqa: E402, F401
 
74
--- fossil/models.py
+++ fossil/models.py
@@ -65,9 +65,11 @@
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.forum import ForumPost # noqa: E402, F401
71 from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401
72 from fossil.releases import Release, ReleaseAsset # noqa: E402, F401
73 from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
74 from fossil.user_keys import UserSSHKey # noqa: E402, F401
75 from fossil.webhooks import Webhook, WebhookDelivery # noqa: E402, F401
76
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -189,10 +189,68 @@
189189
log.status = "failed"
190190
log.message = "Unexpected error"
191191
log.completed_at = timezone.now()
192192
log.save()
193193
194
+
195
+@shared_task(name="fossil.dispatch_webhook", bind=True, max_retries=3)
196
+def dispatch_webhook(self, webhook_id, event_type, payload):
197
+ """Deliver a webhook with retry and logging."""
198
+ import hashlib
199
+ import hmac
200
+ import json
201
+ import time
202
+
203
+ import requests
204
+
205
+ from fossil.webhooks import Webhook, WebhookDelivery
206
+
207
+ try:
208
+ webhook = Webhook.objects.get(id=webhook_id)
209
+ except Webhook.DoesNotExist:
210
+ logger.warning("Webhook %s not found, skipping delivery", webhook_id)
211
+ return
212
+
213
+ headers = {"Content-Type": "application/json", "X-Fossilrepo-Event": event_type}
214
+ body = json.dumps(payload)
215
+
216
+ if webhook.secret:
217
+ sig = hmac.new(webhook.secret.encode(), body.encode(), hashlib.sha256).hexdigest()
218
+ headers["X-Fossilrepo-Signature"] = f"sha256={sig}"
219
+
220
+ start = time.monotonic()
221
+ try:
222
+ resp = requests.post(webhook.url, data=body, headers=headers, timeout=30)
223
+ duration = int((time.monotonic() - start) * 1000)
224
+
225
+ WebhookDelivery.objects.create(
226
+ webhook=webhook,
227
+ event_type=event_type,
228
+ payload=payload,
229
+ response_status=resp.status_code,
230
+ response_body=resp.text[:5000],
231
+ success=200 <= resp.status_code < 300,
232
+ duration_ms=duration,
233
+ attempt=self.request.retries + 1,
234
+ )
235
+
236
+ if not (200 <= resp.status_code < 300):
237
+ raise self.retry(countdown=60 * (2**self.request.retries))
238
+ except requests.RequestException as exc:
239
+ duration = int((time.monotonic() - start) * 1000)
240
+ WebhookDelivery.objects.create(
241
+ webhook=webhook,
242
+ event_type=event_type,
243
+ payload=payload,
244
+ response_status=0,
245
+ response_body=str(exc)[:5000],
246
+ success=False,
247
+ duration_ms=duration,
248
+ attempt=self.request.retries + 1,
249
+ )
250
+ raise self.retry(exc=exc, countdown=60 * (2**self.request.retries)) from exc
251
+
194252
195253
@shared_task(name="fossil.dispatch_notifications")
196254
def dispatch_notifications():
197255
"""Check for new Fossil events and send notifications to watchers."""
198256
import datetime
199257
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -189,10 +189,68 @@
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
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -189,10 +189,68 @@
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_webhook", bind=True, max_retries=3)
196 def dispatch_webhook(self, webhook_id, event_type, payload):
197 """Deliver a webhook with retry and logging."""
198 import hashlib
199 import hmac
200 import json
201 import time
202
203 import requests
204
205 from fossil.webhooks import Webhook, WebhookDelivery
206
207 try:
208 webhook = Webhook.objects.get(id=webhook_id)
209 except Webhook.DoesNotExist:
210 logger.warning("Webhook %s not found, skipping delivery", webhook_id)
211 return
212
213 headers = {"Content-Type": "application/json", "X-Fossilrepo-Event": event_type}
214 body = json.dumps(payload)
215
216 if webhook.secret:
217 sig = hmac.new(webhook.secret.encode(), body.encode(), hashlib.sha256).hexdigest()
218 headers["X-Fossilrepo-Signature"] = f"sha256={sig}"
219
220 start = time.monotonic()
221 try:
222 resp = requests.post(webhook.url, data=body, headers=headers, timeout=30)
223 duration = int((time.monotonic() - start) * 1000)
224
225 WebhookDelivery.objects.create(
226 webhook=webhook,
227 event_type=event_type,
228 payload=payload,
229 response_status=resp.status_code,
230 response_body=resp.text[:5000],
231 success=200 <= resp.status_code < 300,
232 duration_ms=duration,
233 attempt=self.request.retries + 1,
234 )
235
236 if not (200 <= resp.status_code < 300):
237 raise self.retry(countdown=60 * (2**self.request.retries))
238 except requests.RequestException as exc:
239 duration = int((time.monotonic() - start) * 1000)
240 WebhookDelivery.objects.create(
241 webhook=webhook,
242 event_type=event_type,
243 payload=payload,
244 response_status=0,
245 response_body=str(exc)[:5000],
246 success=False,
247 duration_ms=duration,
248 attempt=self.request.retries + 1,
249 )
250 raise self.retry(exc=exc, countdown=60 * (2**self.request.retries)) from exc
251
252
253 @shared_task(name="fossil.dispatch_notifications")
254 def dispatch_notifications():
255 """Check for new Fossil events and send notifications to watchers."""
256 import datetime
257
--- fossil/urls.py
+++ fossil/urls.py
@@ -18,11 +18,19 @@
1818
path("wiki/create/", views.wiki_create, name="wiki_create"),
1919
path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"),
2020
path("wiki/edit/<path:page_name>", views.wiki_edit, name="wiki_edit"),
2121
path("tickets/create/", views.ticket_create, name="ticket_create"),
2222
path("forum/", views.forum_list, name="forum"),
23
+ path("forum/create/", views.forum_create, name="forum_create"),
2324
path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"),
25
+ path("forum/<int:post_id>/reply/", views.forum_reply, name="forum_reply"),
26
+ # Webhooks
27
+ path("webhooks/", views.webhook_list, name="webhooks"),
28
+ path("webhooks/create/", views.webhook_create, name="webhook_create"),
29
+ path("webhooks/<int:webhook_id>/edit/", views.webhook_edit, name="webhook_edit"),
30
+ path("webhooks/<int:webhook_id>/delete/", views.webhook_delete, name="webhook_delete"),
31
+ path("webhooks/<int:webhook_id>/deliveries/", views.webhook_deliveries, name="webhook_deliveries"),
2432
path("user/<str:username>/", views.user_activity, name="user_activity"),
2533
path("branches/", views.branch_list, name="branches"),
2634
path("tags/", views.tag_list, name="tags"),
2735
path("technotes/", views.technote_list, name="technotes"),
2836
path("search/", views.search, name="search"),
2937
--- fossil/urls.py
+++ fossil/urls.py
@@ -18,11 +18,19 @@
18 path("wiki/create/", views.wiki_create, name="wiki_create"),
19 path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"),
20 path("wiki/edit/<path:page_name>", views.wiki_edit, name="wiki_edit"),
21 path("tickets/create/", views.ticket_create, name="ticket_create"),
22 path("forum/", views.forum_list, name="forum"),
 
23 path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"),
 
 
 
 
 
 
 
24 path("user/<str:username>/", views.user_activity, name="user_activity"),
25 path("branches/", views.branch_list, name="branches"),
26 path("tags/", views.tag_list, name="tags"),
27 path("technotes/", views.technote_list, name="technotes"),
28 path("search/", views.search, name="search"),
29
--- fossil/urls.py
+++ fossil/urls.py
@@ -18,11 +18,19 @@
18 path("wiki/create/", views.wiki_create, name="wiki_create"),
19 path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"),
20 path("wiki/edit/<path:page_name>", views.wiki_edit, name="wiki_edit"),
21 path("tickets/create/", views.ticket_create, name="ticket_create"),
22 path("forum/", views.forum_list, name="forum"),
23 path("forum/create/", views.forum_create, name="forum_create"),
24 path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"),
25 path("forum/<int:post_id>/reply/", views.forum_reply, name="forum_reply"),
26 # Webhooks
27 path("webhooks/", views.webhook_list, name="webhooks"),
28 path("webhooks/create/", views.webhook_create, name="webhook_create"),
29 path("webhooks/<int:webhook_id>/edit/", views.webhook_edit, name="webhook_edit"),
30 path("webhooks/<int:webhook_id>/delete/", views.webhook_delete, name="webhook_delete"),
31 path("webhooks/<int:webhook_id>/deliveries/", views.webhook_deliveries, name="webhook_deliveries"),
32 path("user/<str:username>/", views.user_activity, name="user_activity"),
33 path("branches/", views.branch_list, name="branches"),
34 path("tags/", views.tag_list, name="tags"),
35 path("technotes/", views.technote_list, name="technotes"),
36 path("search/", views.search, name="search"),
37
+454 -19
--- fossil/views.py
+++ fossil/views.py
@@ -442,10 +442,65 @@
442442
"rendered_html": rendered_html,
443443
"active_tab": "code",
444444
},
445445
)
446446
447
+
448
+# --- Split-diff helper ---
449
+
450
+
451
+def _compute_split_lines(diff_lines):
452
+ """Convert unified diff lines into parallel left/right arrays for split view.
453
+
454
+ Context lines appear on both sides. Deletions appear only on the left with
455
+ an empty placeholder on the right. Additions appear only on the right with
456
+ an empty placeholder on the left. Adjacent del+add runs are paired row-by-row
457
+ so moves read naturally.
458
+ """
459
+ left = []
460
+ right = []
461
+
462
+ # Collect runs of consecutive del/add lines so we can pair them
463
+ i = 0
464
+ while i < len(diff_lines):
465
+ dl = diff_lines[i]
466
+ if dl["type"] in ("header", "hunk"):
467
+ left.append(dl)
468
+ right.append(dl)
469
+ i += 1
470
+ continue
471
+
472
+ if dl["type"] == "del":
473
+ # Gather contiguous del block, then contiguous add block
474
+ dels = []
475
+ while i < len(diff_lines) and diff_lines[i]["type"] == "del":
476
+ dels.append(diff_lines[i])
477
+ i += 1
478
+ adds = []
479
+ while i < len(diff_lines) and diff_lines[i]["type"] == "add":
480
+ adds.append(diff_lines[i])
481
+ i += 1
482
+ max_len = max(len(dels), len(adds))
483
+ for j in range(max_len):
484
+ left.append(dels[j] if j < len(dels) else {"text": "", "type": "empty", "old_num": "", "new_num": ""})
485
+ right.append(adds[j] if j < len(adds) else {"text": "", "type": "empty", "old_num": "", "new_num": ""})
486
+ continue
487
+
488
+ if dl["type"] == "add":
489
+ # Orphan add with no preceding del
490
+ left.append({"text": "", "type": "empty", "old_num": "", "new_num": ""})
491
+ right.append(dl)
492
+ i += 1
493
+ continue
494
+
495
+ # Context line
496
+ left.append(dl)
497
+ right.append(dl)
498
+ i += 1
499
+
500
+ return left, right
501
+
447502
448503
# --- Checkin Detail ---
449504
450505
451506
def checkin_detail(request, slug, checkin_uuid):
@@ -519,20 +574,39 @@
519574
else:
520575
old_num = old_line
521576
new_num = new_line
522577
old_line += 1
523578
new_line += 1
524
- diff_lines.append({"text": line, "type": line_type, "old_num": old_num, "new_num": new_num})
579
+ # Separate prefix character from code text for syntax highlighting
580
+ if line_type in ("add", "del", "context") and len(line) > 0:
581
+ prefix = line[0]
582
+ code = line[1:]
583
+ else:
584
+ prefix = ""
585
+ code = line
586
+ diff_lines.append(
587
+ {
588
+ "text": line,
589
+ "type": line_type,
590
+ "old_num": old_num,
591
+ "new_num": new_num,
592
+ "prefix": prefix,
593
+ "code": code,
594
+ }
595
+ )
525596
597
+ split_left, split_right = _compute_split_lines(diff_lines)
526598
ext = f["name"].rsplit(".", 1)[-1] if "." in f["name"] else ""
527599
file_diffs.append(
528600
{
529601
"name": f["name"],
530602
"change_type": f["change_type"],
531603
"uuid": f["uuid"],
532604
"is_binary": is_binary,
533605
"diff_lines": diff_lines,
606
+ "split_left": split_left,
607
+ "split_right": split_right,
534608
"additions": additions,
535609
"deletions": deletions,
536610
"language": ext,
537611
}
538612
)
@@ -726,54 +800,368 @@
726800
727801
# --- Forum ---
728802
729803
730804
def forum_list(request, slug):
731
- project, fossil_repo, reader = _get_repo_and_reader(slug, request)
805
+ from projects.access import can_write_project
732806
733
- with reader:
734
- posts = reader.get_forum_posts()
807
+ project, fossil_repo = _get_project_and_repo(slug, request, "read")
808
+
809
+ # Read Fossil-native forum posts if the .fossil file exists
810
+ fossil_posts = []
811
+ if fossil_repo.exists_on_disk:
812
+ with FossilReader(fossil_repo.full_path) as reader:
813
+ fossil_posts = reader.get_forum_posts()
814
+
815
+ # Merge Django-backed forum posts alongside Fossil native posts
816
+ from fossil.forum import ForumPost as DjangoForumPost
817
+
818
+ django_threads = DjangoForumPost.objects.filter(
819
+ repository=fossil_repo,
820
+ parent__isnull=True,
821
+ ).select_related("created_by")
822
+
823
+ # Build unified post list with a common interface
824
+ merged = []
825
+ for p in fossil_posts:
826
+ merged.append({"uuid": p.uuid, "title": p.title, "body": p.body, "user": p.user, "timestamp": p.timestamp, "source": "fossil"})
827
+ for p in django_threads:
828
+ merged.append(
829
+ {
830
+ "uuid": str(p.pk),
831
+ "title": p.title,
832
+ "body": p.body,
833
+ "user": p.created_by.username if p.created_by else "",
834
+ "timestamp": p.created_at,
835
+ "source": "django",
836
+ }
837
+ )
838
+
839
+ # Sort merged list by timestamp descending
840
+ merged.sort(key=lambda x: x["timestamp"], reverse=True)
841
+
842
+ has_write = can_write_project(request.user, project)
735843
736844
return render(
737845
request,
738846
"fossil/forum_list.html",
739847
{
740848
"project": project,
741849
"fossil_repo": fossil_repo,
742
- "posts": posts,
850
+ "posts": merged,
851
+ "has_write": has_write,
743852
"active_tab": "forum",
744853
},
745854
)
746855
747856
748857
def forum_thread(request, slug, thread_uuid):
749
- project, fossil_repo, reader = _get_repo_and_reader(slug, request)
750
-
751
- with reader:
752
- posts = reader.get_forum_thread(thread_uuid)
753
-
754
- if not posts:
755
- raise Http404("Forum thread not found")
756
-
757
- # Render each post's body through the content renderer
858
+ from projects.access import can_write_project
859
+
860
+ project, fossil_repo = _get_project_and_repo(slug, request, "read")
861
+
862
+ # Check if this is a Fossil-native thread or a Django-backed thread
863
+ is_django_thread = False
864
+ from fossil.forum import ForumPost as DjangoForumPost
865
+
866
+ try:
867
+ django_root = DjangoForumPost.objects.get(pk=int(thread_uuid))
868
+ is_django_thread = True
869
+ except (ValueError, DjangoForumPost.DoesNotExist):
870
+ django_root = None
871
+
758872
rendered_posts = []
759
- for post in posts:
760
- body_html = mark_safe(_render_fossil_content(post.body, project_slug=slug)) if post.body else ""
761
- rendered_posts.append({"post": post, "body_html": body_html})
873
+
874
+ if is_django_thread:
875
+ # Django-backed thread: root + replies
876
+ root = django_root
877
+ body_html = mark_safe(md.markdown(root.body, extensions=["fenced_code", "tables"])) if root.body else ""
878
+ rendered_posts.append(
879
+ {
880
+ "post": {
881
+ "user": root.created_by.username if root.created_by else "",
882
+ "title": root.title,
883
+ "timestamp": root.created_at,
884
+ "in_reply_to": "",
885
+ },
886
+ "body_html": body_html,
887
+ }
888
+ )
889
+ for reply in DjangoForumPost.objects.filter(thread_root=root).exclude(pk=root.pk).select_related("created_by"):
890
+ reply_html = mark_safe(md.markdown(reply.body, extensions=["fenced_code", "tables"])) if reply.body else ""
891
+ rendered_posts.append(
892
+ {
893
+ "post": {
894
+ "user": reply.created_by.username if reply.created_by else "",
895
+ "title": "",
896
+ "timestamp": reply.created_at,
897
+ "in_reply_to": str(root.pk),
898
+ },
899
+ "body_html": reply_html,
900
+ }
901
+ )
902
+ else:
903
+ # Fossil-native thread -- requires .fossil file on disk
904
+ if not fossil_repo.exists_on_disk:
905
+ raise Http404("Forum thread not found")
906
+
907
+ with FossilReader(fossil_repo.full_path) as reader:
908
+ posts = reader.get_forum_thread(thread_uuid)
909
+
910
+ if not posts:
911
+ raise Http404("Forum thread not found")
912
+
913
+ for post in posts:
914
+ body_html = mark_safe(_render_fossil_content(post.body, project_slug=slug)) if post.body else ""
915
+ rendered_posts.append({"post": post, "body_html": body_html})
916
+
917
+ has_write = can_write_project(request.user, project)
762918
763919
return render(
764920
request,
765921
"fossil/forum_thread.html",
766922
{
767923
"project": project,
768924
"fossil_repo": fossil_repo,
769925
"posts": rendered_posts,
770926
"thread_uuid": thread_uuid,
927
+ "is_django_thread": is_django_thread,
928
+ "has_write": has_write,
929
+ "active_tab": "forum",
930
+ },
931
+ )
932
+
933
+
934
+@login_required
935
+def forum_create(request, slug):
936
+ """Create a new Django-backed forum thread."""
937
+ from django.contrib import messages
938
+
939
+ project, fossil_repo = _get_project_and_repo(slug, request, "write")
940
+
941
+ if request.method == "POST":
942
+ title = request.POST.get("title", "").strip()
943
+ body = request.POST.get("body", "")
944
+ if title and body:
945
+ from fossil.forum import ForumPost as DjangoForumPost
946
+
947
+ post = DjangoForumPost.objects.create(
948
+ repository=fossil_repo,
949
+ title=title,
950
+ body=body,
951
+ created_by=request.user,
952
+ )
953
+ # Thread root is self for root posts
954
+ post.thread_root = post
955
+ post.save(update_fields=["thread_root", "updated_at", "version"])
956
+ messages.success(request, f'Thread "{title}" created.')
957
+ return redirect("fossil:forum_thread", slug=slug, thread_uuid=str(post.pk))
958
+
959
+ return render(
960
+ request,
961
+ "fossil/forum_form.html",
962
+ {
963
+ "project": project,
964
+ "fossil_repo": fossil_repo,
965
+ "form_title": "New Thread",
966
+ "active_tab": "forum",
967
+ },
968
+ )
969
+
970
+
971
+@login_required
972
+def forum_reply(request, slug, post_id):
973
+ """Reply to a Django-backed forum thread."""
974
+ from django.contrib import messages
975
+
976
+ project, fossil_repo = _get_project_and_repo(slug, request, "write")
977
+
978
+ from fossil.forum import ForumPost as DjangoForumPost
979
+
980
+ parent = get_object_or_404(DjangoForumPost, pk=post_id, deleted_at__isnull=True)
981
+
982
+ # Determine the thread root
983
+ thread_root = parent.thread_root if parent.thread_root else parent
984
+
985
+ if request.method == "POST":
986
+ body = request.POST.get("body", "")
987
+ if body:
988
+ DjangoForumPost.objects.create(
989
+ repository=fossil_repo,
990
+ title="",
991
+ body=body,
992
+ parent=parent,
993
+ thread_root=thread_root,
994
+ created_by=request.user,
995
+ )
996
+ messages.success(request, "Reply posted.")
997
+ return redirect("fossil:forum_thread", slug=slug, thread_uuid=str(thread_root.pk))
998
+
999
+ return render(
1000
+ request,
1001
+ "fossil/forum_form.html",
1002
+ {
1003
+ "project": project,
1004
+ "fossil_repo": fossil_repo,
1005
+ "parent": parent,
1006
+ "form_title": f"Reply to: {thread_root.title}",
7711007
"active_tab": "forum",
7721008
},
7731009
)
7741010
1011
+
1012
+# --- Webhook Management ---
1013
+
1014
+
1015
+@login_required
1016
+def webhook_list(request, slug):
1017
+ """List webhooks for a project."""
1018
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1019
+
1020
+ from fossil.webhooks import Webhook
1021
+
1022
+ webhooks = Webhook.objects.filter(repository=fossil_repo)
1023
+
1024
+ return render(
1025
+ request,
1026
+ "fossil/webhook_list.html",
1027
+ {
1028
+ "project": project,
1029
+ "fossil_repo": fossil_repo,
1030
+ "webhooks": webhooks,
1031
+ "active_tab": "settings",
1032
+ },
1033
+ )
1034
+
1035
+
1036
+@login_required
1037
+def webhook_create(request, slug):
1038
+ """Create a new webhook."""
1039
+ from django.contrib import messages
1040
+
1041
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1042
+
1043
+ from fossil.webhooks import Webhook
1044
+
1045
+ if request.method == "POST":
1046
+ url = request.POST.get("url", "").strip()
1047
+ secret = request.POST.get("secret", "").strip()
1048
+ events = request.POST.getlist("events")
1049
+ is_active = request.POST.get("is_active") == "on"
1050
+
1051
+ if url:
1052
+ events_str = ",".join(events) if events else "all"
1053
+ Webhook.objects.create(
1054
+ repository=fossil_repo,
1055
+ url=url,
1056
+ secret=secret,
1057
+ events=events_str,
1058
+ is_active=is_active,
1059
+ created_by=request.user,
1060
+ )
1061
+ messages.success(request, f"Webhook for {url} created.")
1062
+ return redirect("fossil:webhooks", slug=slug)
1063
+
1064
+ return render(
1065
+ request,
1066
+ "fossil/webhook_form.html",
1067
+ {
1068
+ "project": project,
1069
+ "fossil_repo": fossil_repo,
1070
+ "form_title": "Create Webhook",
1071
+ "submit_label": "Create Webhook",
1072
+ "event_choices": Webhook.EventType.choices,
1073
+ "active_tab": "settings",
1074
+ },
1075
+ )
1076
+
1077
+
1078
+@login_required
1079
+def webhook_edit(request, slug, webhook_id):
1080
+ """Edit an existing webhook."""
1081
+ from django.contrib import messages
1082
+
1083
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1084
+
1085
+ from fossil.webhooks import Webhook
1086
+
1087
+ webhook = get_object_or_404(Webhook, pk=webhook_id, repository=fossil_repo, deleted_at__isnull=True)
1088
+
1089
+ if request.method == "POST":
1090
+ url = request.POST.get("url", "").strip()
1091
+ secret = request.POST.get("secret", "").strip()
1092
+ events = request.POST.getlist("events")
1093
+ is_active = request.POST.get("is_active") == "on"
1094
+
1095
+ if url:
1096
+ webhook.url = url
1097
+ # Only update secret if a new one was provided (don't blank it on edit)
1098
+ if secret:
1099
+ webhook.secret = secret
1100
+ webhook.events = ",".join(events) if events else "all"
1101
+ webhook.is_active = is_active
1102
+ webhook.updated_by = request.user
1103
+ webhook.save()
1104
+ messages.success(request, f"Webhook for {webhook.url} updated.")
1105
+ return redirect("fossil:webhooks", slug=slug)
1106
+
1107
+ return render(
1108
+ request,
1109
+ "fossil/webhook_form.html",
1110
+ {
1111
+ "project": project,
1112
+ "fossil_repo": fossil_repo,
1113
+ "webhook": webhook,
1114
+ "form_title": f"Edit Webhook: {webhook.url}",
1115
+ "submit_label": "Update Webhook",
1116
+ "event_choices": Webhook.EventType.choices,
1117
+ "active_tab": "settings",
1118
+ },
1119
+ )
1120
+
1121
+
1122
+@login_required
1123
+def webhook_delete(request, slug, webhook_id):
1124
+ """Soft-delete a webhook."""
1125
+ from django.contrib import messages
1126
+
1127
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1128
+
1129
+ from fossil.webhooks import Webhook
1130
+
1131
+ webhook = get_object_or_404(Webhook, pk=webhook_id, repository=fossil_repo, deleted_at__isnull=True)
1132
+
1133
+ if request.method == "POST":
1134
+ webhook.soft_delete(user=request.user)
1135
+ messages.success(request, f"Webhook for {webhook.url} deleted.")
1136
+ return redirect("fossil:webhooks", slug=slug)
1137
+
1138
+ return redirect("fossil:webhooks", slug=slug)
1139
+
1140
+
1141
+@login_required
1142
+def webhook_deliveries(request, slug, webhook_id):
1143
+ """View delivery log for a webhook."""
1144
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1145
+
1146
+ from fossil.webhooks import Webhook, WebhookDelivery
1147
+
1148
+ webhook = get_object_or_404(Webhook, pk=webhook_id, repository=fossil_repo, deleted_at__isnull=True)
1149
+ deliveries = WebhookDelivery.objects.filter(webhook=webhook)[:100]
1150
+
1151
+ return render(
1152
+ request,
1153
+ "fossil/webhook_deliveries.html",
1154
+ {
1155
+ "project": project,
1156
+ "fossil_repo": fossil_repo,
1157
+ "webhook": webhook,
1158
+ "deliveries": deliveries,
1159
+ "active_tab": "settings",
1160
+ },
1161
+ )
1162
+
7751163
7761164
# --- Wiki CRUD ---
7771165
7781166
7791167
@login_required
@@ -1353,24 +1741,71 @@
13531741
fromfile=f"a/{fname}",
13541742
tofile=f"b/{fname}",
13551743
n=3,
13561744
)
13571745
diff_lines = []
1746
+ old_line = 0
1747
+ new_line = 0
1748
+ additions = 0
1749
+ deletions = 0
13581750
for line in diff:
13591751
line_type = "context"
1752
+ old_num = ""
1753
+ new_num = ""
13601754
if line.startswith("+++") or line.startswith("---"):
13611755
line_type = "header"
13621756
elif line.startswith("@@"):
13631757
line_type = "hunk"
1758
+ hunk_match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
1759
+ if hunk_match:
1760
+ old_line = int(hunk_match.group(1))
1761
+ new_line = int(hunk_match.group(2))
13641762
elif line.startswith("+"):
13651763
line_type = "add"
1764
+ additions += 1
1765
+ new_num = new_line
1766
+ new_line += 1
13661767
elif line.startswith("-"):
13671768
line_type = "del"
1368
- diff_lines.append({"text": line, "type": line_type})
1769
+ deletions += 1
1770
+ old_num = old_line
1771
+ old_line += 1
1772
+ else:
1773
+ old_num = old_line
1774
+ new_num = new_line
1775
+ old_line += 1
1776
+ new_line += 1
1777
+ # Separate prefix from code text for syntax highlighting
1778
+ if line_type in ("add", "del", "context") and len(line) > 0:
1779
+ prefix = line[0]
1780
+ code = line[1:]
1781
+ else:
1782
+ prefix = ""
1783
+ code = line
1784
+ diff_lines.append(
1785
+ {
1786
+ "text": line,
1787
+ "type": line_type,
1788
+ "old_num": old_num,
1789
+ "new_num": new_num,
1790
+ "prefix": prefix,
1791
+ "code": code,
1792
+ }
1793
+ )
13691794
13701795
if diff_lines:
1371
- file_diffs.append({"name": fname, "diff_lines": diff_lines})
1796
+ split_left, split_right = _compute_split_lines(diff_lines)
1797
+ file_diffs.append(
1798
+ {
1799
+ "name": fname,
1800
+ "diff_lines": diff_lines,
1801
+ "split_left": split_left,
1802
+ "split_right": split_right,
1803
+ "additions": additions,
1804
+ "deletions": deletions,
1805
+ }
1806
+ )
13721807
13731808
return render(
13741809
request,
13751810
"fossil/compare.html",
13761811
{
13771812
13781813
ADDED fossil/webhooks.py
--- fossil/views.py
+++ fossil/views.py
@@ -442,10 +442,65 @@
442 "rendered_html": rendered_html,
443 "active_tab": "code",
444 },
445 )
446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
448 # --- Checkin Detail ---
449
450
451 def checkin_detail(request, slug, checkin_uuid):
@@ -519,20 +574,39 @@
519 else:
520 old_num = old_line
521 new_num = new_line
522 old_line += 1
523 new_line += 1
524 diff_lines.append({"text": line, "type": line_type, "old_num": old_num, "new_num": new_num})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
 
526 ext = f["name"].rsplit(".", 1)[-1] if "." in f["name"] else ""
527 file_diffs.append(
528 {
529 "name": f["name"],
530 "change_type": f["change_type"],
531 "uuid": f["uuid"],
532 "is_binary": is_binary,
533 "diff_lines": diff_lines,
 
 
534 "additions": additions,
535 "deletions": deletions,
536 "language": ext,
537 }
538 )
@@ -726,54 +800,368 @@
726
727 # --- Forum ---
728
729
730 def forum_list(request, slug):
731 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
732
733 with reader:
734 posts = reader.get_forum_posts()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
736 return render(
737 request,
738 "fossil/forum_list.html",
739 {
740 "project": project,
741 "fossil_repo": fossil_repo,
742 "posts": posts,
 
743 "active_tab": "forum",
744 },
745 )
746
747
748 def forum_thread(request, slug, thread_uuid):
749 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
750
751 with reader:
752 posts = reader.get_forum_thread(thread_uuid)
753
754 if not posts:
755 raise Http404("Forum thread not found")
756
757 # Render each post's body through the content renderer
 
 
 
 
 
758 rendered_posts = []
759 for post in posts:
760 body_html = mark_safe(_render_fossil_content(post.body, project_slug=slug)) if post.body else ""
761 rendered_posts.append({"post": post, "body_html": body_html})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
762
763 return render(
764 request,
765 "fossil/forum_thread.html",
766 {
767 "project": project,
768 "fossil_repo": fossil_repo,
769 "posts": rendered_posts,
770 "thread_uuid": thread_uuid,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
771 "active_tab": "forum",
772 },
773 )
774
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
775
776 # --- Wiki CRUD ---
777
778
779 @login_required
@@ -1353,24 +1741,71 @@
1353 fromfile=f"a/{fname}",
1354 tofile=f"b/{fname}",
1355 n=3,
1356 )
1357 diff_lines = []
 
 
 
 
1358 for line in diff:
1359 line_type = "context"
 
 
1360 if line.startswith("+++") or line.startswith("---"):
1361 line_type = "header"
1362 elif line.startswith("@@"):
1363 line_type = "hunk"
 
 
 
 
1364 elif line.startswith("+"):
1365 line_type = "add"
 
 
 
1366 elif line.startswith("-"):
1367 line_type = "del"
1368 diff_lines.append({"text": line, "type": line_type})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1369
1370 if diff_lines:
1371 file_diffs.append({"name": fname, "diff_lines": diff_lines})
 
 
 
 
 
 
 
 
 
 
1372
1373 return render(
1374 request,
1375 "fossil/compare.html",
1376 {
1377
1378 DDED fossil/webhooks.py
--- fossil/views.py
+++ fossil/views.py
@@ -442,10 +442,65 @@
442 "rendered_html": rendered_html,
443 "active_tab": "code",
444 },
445 )
446
447
448 # --- Split-diff helper ---
449
450
451 def _compute_split_lines(diff_lines):
452 """Convert unified diff lines into parallel left/right arrays for split view.
453
454 Context lines appear on both sides. Deletions appear only on the left with
455 an empty placeholder on the right. Additions appear only on the right with
456 an empty placeholder on the left. Adjacent del+add runs are paired row-by-row
457 so moves read naturally.
458 """
459 left = []
460 right = []
461
462 # Collect runs of consecutive del/add lines so we can pair them
463 i = 0
464 while i < len(diff_lines):
465 dl = diff_lines[i]
466 if dl["type"] in ("header", "hunk"):
467 left.append(dl)
468 right.append(dl)
469 i += 1
470 continue
471
472 if dl["type"] == "del":
473 # Gather contiguous del block, then contiguous add block
474 dels = []
475 while i < len(diff_lines) and diff_lines[i]["type"] == "del":
476 dels.append(diff_lines[i])
477 i += 1
478 adds = []
479 while i < len(diff_lines) and diff_lines[i]["type"] == "add":
480 adds.append(diff_lines[i])
481 i += 1
482 max_len = max(len(dels), len(adds))
483 for j in range(max_len):
484 left.append(dels[j] if j < len(dels) else {"text": "", "type": "empty", "old_num": "", "new_num": ""})
485 right.append(adds[j] if j < len(adds) else {"text": "", "type": "empty", "old_num": "", "new_num": ""})
486 continue
487
488 if dl["type"] == "add":
489 # Orphan add with no preceding del
490 left.append({"text": "", "type": "empty", "old_num": "", "new_num": ""})
491 right.append(dl)
492 i += 1
493 continue
494
495 # Context line
496 left.append(dl)
497 right.append(dl)
498 i += 1
499
500 return left, right
501
502
503 # --- Checkin Detail ---
504
505
506 def checkin_detail(request, slug, checkin_uuid):
@@ -519,20 +574,39 @@
574 else:
575 old_num = old_line
576 new_num = new_line
577 old_line += 1
578 new_line += 1
579 # Separate prefix character from code text for syntax highlighting
580 if line_type in ("add", "del", "context") and len(line) > 0:
581 prefix = line[0]
582 code = line[1:]
583 else:
584 prefix = ""
585 code = line
586 diff_lines.append(
587 {
588 "text": line,
589 "type": line_type,
590 "old_num": old_num,
591 "new_num": new_num,
592 "prefix": prefix,
593 "code": code,
594 }
595 )
596
597 split_left, split_right = _compute_split_lines(diff_lines)
598 ext = f["name"].rsplit(".", 1)[-1] if "." in f["name"] else ""
599 file_diffs.append(
600 {
601 "name": f["name"],
602 "change_type": f["change_type"],
603 "uuid": f["uuid"],
604 "is_binary": is_binary,
605 "diff_lines": diff_lines,
606 "split_left": split_left,
607 "split_right": split_right,
608 "additions": additions,
609 "deletions": deletions,
610 "language": ext,
611 }
612 )
@@ -726,54 +800,368 @@
800
801 # --- Forum ---
802
803
804 def forum_list(request, slug):
805 from projects.access import can_write_project
806
807 project, fossil_repo = _get_project_and_repo(slug, request, "read")
808
809 # Read Fossil-native forum posts if the .fossil file exists
810 fossil_posts = []
811 if fossil_repo.exists_on_disk:
812 with FossilReader(fossil_repo.full_path) as reader:
813 fossil_posts = reader.get_forum_posts()
814
815 # Merge Django-backed forum posts alongside Fossil native posts
816 from fossil.forum import ForumPost as DjangoForumPost
817
818 django_threads = DjangoForumPost.objects.filter(
819 repository=fossil_repo,
820 parent__isnull=True,
821 ).select_related("created_by")
822
823 # Build unified post list with a common interface
824 merged = []
825 for p in fossil_posts:
826 merged.append({"uuid": p.uuid, "title": p.title, "body": p.body, "user": p.user, "timestamp": p.timestamp, "source": "fossil"})
827 for p in django_threads:
828 merged.append(
829 {
830 "uuid": str(p.pk),
831 "title": p.title,
832 "body": p.body,
833 "user": p.created_by.username if p.created_by else "",
834 "timestamp": p.created_at,
835 "source": "django",
836 }
837 )
838
839 # Sort merged list by timestamp descending
840 merged.sort(key=lambda x: x["timestamp"], reverse=True)
841
842 has_write = can_write_project(request.user, project)
843
844 return render(
845 request,
846 "fossil/forum_list.html",
847 {
848 "project": project,
849 "fossil_repo": fossil_repo,
850 "posts": merged,
851 "has_write": has_write,
852 "active_tab": "forum",
853 },
854 )
855
856
857 def forum_thread(request, slug, thread_uuid):
858 from projects.access import can_write_project
859
860 project, fossil_repo = _get_project_and_repo(slug, request, "read")
861
862 # Check if this is a Fossil-native thread or a Django-backed thread
863 is_django_thread = False
864 from fossil.forum import ForumPost as DjangoForumPost
865
866 try:
867 django_root = DjangoForumPost.objects.get(pk=int(thread_uuid))
868 is_django_thread = True
869 except (ValueError, DjangoForumPost.DoesNotExist):
870 django_root = None
871
872 rendered_posts = []
873
874 if is_django_thread:
875 # Django-backed thread: root + replies
876 root = django_root
877 body_html = mark_safe(md.markdown(root.body, extensions=["fenced_code", "tables"])) if root.body else ""
878 rendered_posts.append(
879 {
880 "post": {
881 "user": root.created_by.username if root.created_by else "",
882 "title": root.title,
883 "timestamp": root.created_at,
884 "in_reply_to": "",
885 },
886 "body_html": body_html,
887 }
888 )
889 for reply in DjangoForumPost.objects.filter(thread_root=root).exclude(pk=root.pk).select_related("created_by"):
890 reply_html = mark_safe(md.markdown(reply.body, extensions=["fenced_code", "tables"])) if reply.body else ""
891 rendered_posts.append(
892 {
893 "post": {
894 "user": reply.created_by.username if reply.created_by else "",
895 "title": "",
896 "timestamp": reply.created_at,
897 "in_reply_to": str(root.pk),
898 },
899 "body_html": reply_html,
900 }
901 )
902 else:
903 # Fossil-native thread -- requires .fossil file on disk
904 if not fossil_repo.exists_on_disk:
905 raise Http404("Forum thread not found")
906
907 with FossilReader(fossil_repo.full_path) as reader:
908 posts = reader.get_forum_thread(thread_uuid)
909
910 if not posts:
911 raise Http404("Forum thread not found")
912
913 for post in posts:
914 body_html = mark_safe(_render_fossil_content(post.body, project_slug=slug)) if post.body else ""
915 rendered_posts.append({"post": post, "body_html": body_html})
916
917 has_write = can_write_project(request.user, project)
918
919 return render(
920 request,
921 "fossil/forum_thread.html",
922 {
923 "project": project,
924 "fossil_repo": fossil_repo,
925 "posts": rendered_posts,
926 "thread_uuid": thread_uuid,
927 "is_django_thread": is_django_thread,
928 "has_write": has_write,
929 "active_tab": "forum",
930 },
931 )
932
933
934 @login_required
935 def forum_create(request, slug):
936 """Create a new Django-backed forum thread."""
937 from django.contrib import messages
938
939 project, fossil_repo = _get_project_and_repo(slug, request, "write")
940
941 if request.method == "POST":
942 title = request.POST.get("title", "").strip()
943 body = request.POST.get("body", "")
944 if title and body:
945 from fossil.forum import ForumPost as DjangoForumPost
946
947 post = DjangoForumPost.objects.create(
948 repository=fossil_repo,
949 title=title,
950 body=body,
951 created_by=request.user,
952 )
953 # Thread root is self for root posts
954 post.thread_root = post
955 post.save(update_fields=["thread_root", "updated_at", "version"])
956 messages.success(request, f'Thread "{title}" created.')
957 return redirect("fossil:forum_thread", slug=slug, thread_uuid=str(post.pk))
958
959 return render(
960 request,
961 "fossil/forum_form.html",
962 {
963 "project": project,
964 "fossil_repo": fossil_repo,
965 "form_title": "New Thread",
966 "active_tab": "forum",
967 },
968 )
969
970
971 @login_required
972 def forum_reply(request, slug, post_id):
973 """Reply to a Django-backed forum thread."""
974 from django.contrib import messages
975
976 project, fossil_repo = _get_project_and_repo(slug, request, "write")
977
978 from fossil.forum import ForumPost as DjangoForumPost
979
980 parent = get_object_or_404(DjangoForumPost, pk=post_id, deleted_at__isnull=True)
981
982 # Determine the thread root
983 thread_root = parent.thread_root if parent.thread_root else parent
984
985 if request.method == "POST":
986 body = request.POST.get("body", "")
987 if body:
988 DjangoForumPost.objects.create(
989 repository=fossil_repo,
990 title="",
991 body=body,
992 parent=parent,
993 thread_root=thread_root,
994 created_by=request.user,
995 )
996 messages.success(request, "Reply posted.")
997 return redirect("fossil:forum_thread", slug=slug, thread_uuid=str(thread_root.pk))
998
999 return render(
1000 request,
1001 "fossil/forum_form.html",
1002 {
1003 "project": project,
1004 "fossil_repo": fossil_repo,
1005 "parent": parent,
1006 "form_title": f"Reply to: {thread_root.title}",
1007 "active_tab": "forum",
1008 },
1009 )
1010
1011
1012 # --- Webhook Management ---
1013
1014
1015 @login_required
1016 def webhook_list(request, slug):
1017 """List webhooks for a project."""
1018 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1019
1020 from fossil.webhooks import Webhook
1021
1022 webhooks = Webhook.objects.filter(repository=fossil_repo)
1023
1024 return render(
1025 request,
1026 "fossil/webhook_list.html",
1027 {
1028 "project": project,
1029 "fossil_repo": fossil_repo,
1030 "webhooks": webhooks,
1031 "active_tab": "settings",
1032 },
1033 )
1034
1035
1036 @login_required
1037 def webhook_create(request, slug):
1038 """Create a new webhook."""
1039 from django.contrib import messages
1040
1041 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1042
1043 from fossil.webhooks import Webhook
1044
1045 if request.method == "POST":
1046 url = request.POST.get("url", "").strip()
1047 secret = request.POST.get("secret", "").strip()
1048 events = request.POST.getlist("events")
1049 is_active = request.POST.get("is_active") == "on"
1050
1051 if url:
1052 events_str = ",".join(events) if events else "all"
1053 Webhook.objects.create(
1054 repository=fossil_repo,
1055 url=url,
1056 secret=secret,
1057 events=events_str,
1058 is_active=is_active,
1059 created_by=request.user,
1060 )
1061 messages.success(request, f"Webhook for {url} created.")
1062 return redirect("fossil:webhooks", slug=slug)
1063
1064 return render(
1065 request,
1066 "fossil/webhook_form.html",
1067 {
1068 "project": project,
1069 "fossil_repo": fossil_repo,
1070 "form_title": "Create Webhook",
1071 "submit_label": "Create Webhook",
1072 "event_choices": Webhook.EventType.choices,
1073 "active_tab": "settings",
1074 },
1075 )
1076
1077
1078 @login_required
1079 def webhook_edit(request, slug, webhook_id):
1080 """Edit an existing webhook."""
1081 from django.contrib import messages
1082
1083 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1084
1085 from fossil.webhooks import Webhook
1086
1087 webhook = get_object_or_404(Webhook, pk=webhook_id, repository=fossil_repo, deleted_at__isnull=True)
1088
1089 if request.method == "POST":
1090 url = request.POST.get("url", "").strip()
1091 secret = request.POST.get("secret", "").strip()
1092 events = request.POST.getlist("events")
1093 is_active = request.POST.get("is_active") == "on"
1094
1095 if url:
1096 webhook.url = url
1097 # Only update secret if a new one was provided (don't blank it on edit)
1098 if secret:
1099 webhook.secret = secret
1100 webhook.events = ",".join(events) if events else "all"
1101 webhook.is_active = is_active
1102 webhook.updated_by = request.user
1103 webhook.save()
1104 messages.success(request, f"Webhook for {webhook.url} updated.")
1105 return redirect("fossil:webhooks", slug=slug)
1106
1107 return render(
1108 request,
1109 "fossil/webhook_form.html",
1110 {
1111 "project": project,
1112 "fossil_repo": fossil_repo,
1113 "webhook": webhook,
1114 "form_title": f"Edit Webhook: {webhook.url}",
1115 "submit_label": "Update Webhook",
1116 "event_choices": Webhook.EventType.choices,
1117 "active_tab": "settings",
1118 },
1119 )
1120
1121
1122 @login_required
1123 def webhook_delete(request, slug, webhook_id):
1124 """Soft-delete a webhook."""
1125 from django.contrib import messages
1126
1127 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1128
1129 from fossil.webhooks import Webhook
1130
1131 webhook = get_object_or_404(Webhook, pk=webhook_id, repository=fossil_repo, deleted_at__isnull=True)
1132
1133 if request.method == "POST":
1134 webhook.soft_delete(user=request.user)
1135 messages.success(request, f"Webhook for {webhook.url} deleted.")
1136 return redirect("fossil:webhooks", slug=slug)
1137
1138 return redirect("fossil:webhooks", slug=slug)
1139
1140
1141 @login_required
1142 def webhook_deliveries(request, slug, webhook_id):
1143 """View delivery log for a webhook."""
1144 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1145
1146 from fossil.webhooks import Webhook, WebhookDelivery
1147
1148 webhook = get_object_or_404(Webhook, pk=webhook_id, repository=fossil_repo, deleted_at__isnull=True)
1149 deliveries = WebhookDelivery.objects.filter(webhook=webhook)[:100]
1150
1151 return render(
1152 request,
1153 "fossil/webhook_deliveries.html",
1154 {
1155 "project": project,
1156 "fossil_repo": fossil_repo,
1157 "webhook": webhook,
1158 "deliveries": deliveries,
1159 "active_tab": "settings",
1160 },
1161 )
1162
1163
1164 # --- Wiki CRUD ---
1165
1166
1167 @login_required
@@ -1353,24 +1741,71 @@
1741 fromfile=f"a/{fname}",
1742 tofile=f"b/{fname}",
1743 n=3,
1744 )
1745 diff_lines = []
1746 old_line = 0
1747 new_line = 0
1748 additions = 0
1749 deletions = 0
1750 for line in diff:
1751 line_type = "context"
1752 old_num = ""
1753 new_num = ""
1754 if line.startswith("+++") or line.startswith("---"):
1755 line_type = "header"
1756 elif line.startswith("@@"):
1757 line_type = "hunk"
1758 hunk_match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
1759 if hunk_match:
1760 old_line = int(hunk_match.group(1))
1761 new_line = int(hunk_match.group(2))
1762 elif line.startswith("+"):
1763 line_type = "add"
1764 additions += 1
1765 new_num = new_line
1766 new_line += 1
1767 elif line.startswith("-"):
1768 line_type = "del"
1769 deletions += 1
1770 old_num = old_line
1771 old_line += 1
1772 else:
1773 old_num = old_line
1774 new_num = new_line
1775 old_line += 1
1776 new_line += 1
1777 # Separate prefix from code text for syntax highlighting
1778 if line_type in ("add", "del", "context") and len(line) > 0:
1779 prefix = line[0]
1780 code = line[1:]
1781 else:
1782 prefix = ""
1783 code = line
1784 diff_lines.append(
1785 {
1786 "text": line,
1787 "type": line_type,
1788 "old_num": old_num,
1789 "new_num": new_num,
1790 "prefix": prefix,
1791 "code": code,
1792 }
1793 )
1794
1795 if diff_lines:
1796 split_left, split_right = _compute_split_lines(diff_lines)
1797 file_diffs.append(
1798 {
1799 "name": fname,
1800 "diff_lines": diff_lines,
1801 "split_left": split_left,
1802 "split_right": split_right,
1803 "additions": additions,
1804 "deletions": deletions,
1805 }
1806 )
1807
1808 return render(
1809 request,
1810 "fossil/compare.html",
1811 {
1812
1813 DDED fossil/webhooks.py
--- a/fossil/webhooks.py
+++ b/fossil/webhooks.py
@@ -0,0 +1,56 @@
1
+"""Outbound webhooks for project events.
2
+
3
+Webhooks fire on Fossil events (checkin, ticket, wiki, release) and deliver
4
+JSON payloads to configured URLs with HMAC signature verification.
5
+"""
6
+
7
+from django.db import models
8
+
9
+from core.fields import EncryptedTextField
10
+from core.models import ActiveManager, Tracking
11
+
12
+
13
+class Webhook(Tracking):
14
+ """Outbound webhook for project events."""
15
+
16
+ class EventType(models.TextChoices):
17
+ CHECKIN = "checkin", "New Checkin"
18
+ TICKET = "ticket", "Ticket Change"
19
+ WIKI = "wiki", "Wiki Edit"
20
+ RELEASE = "release", "New Release"
21
+ ALL = "all", "All Events"
22
+
23
+ repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="webhooks")
24
+ url = models.URLField(max_length=500)
25
+ secret = EncryptedTextField(blank=True, default="")
26
+ events = models.CharField(max_length=100, default="all") # comma-separated event types
27
+ is_active = models.BooleanField(default=True)
28
+
29
+ objects = ActiveManager()
30
+ all_objects = models.Manager()
31
+
32
+ class Meta:
33
+ ordering = ["-created_at"]
34
+
35
+ def __str__(self):
36
+ return f"{self.url} ({self.events})"
37
+
38
+
39
+class WebhookDelivery(models.Model):
40
+ """Log of webhook delivery attempts."""
41
+
42
+ webhook = models.ForeignKey(Webhook, on_delete=models.CASCADE, related_name="deliveries")
43
+ event_type = models.CharField(max_length=20)
44
+ payload = models.JSONField()
45
+ response_status = models.IntegerField(null=True, blank=True)
46
+ response_body = models.TextField(blank=True, default="")
47
+ success = models.BooleanField(default=False)
48
+ delivered_at = models.DateTimeField(auto_now_add=True)
49
+ duration_ms = models.IntegerField(default=0)
50
+ attempt = models.IntegerField(default=1)
51
+
52
+ class Meta:
53
+ ordering = ["-delivered_at"]
54
+
55
+ def __str__(self):
56
+ return f"{self.webhook.url} @ {self.delivered_at}"
--- a/fossil/webhooks.py
+++ b/fossil/webhooks.py
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/webhooks.py
+++ b/fossil/webhooks.py
@@ -0,0 +1,56 @@
1 """Outbound webhooks for project events.
2
3 Webhooks fire on Fossil events (checkin, ticket, wiki, release) and deliver
4 JSON payloads to configured URLs with HMAC signature verification.
5 """
6
7 from django.db import models
8
9 from core.fields import EncryptedTextField
10 from core.models import ActiveManager, Tracking
11
12
13 class Webhook(Tracking):
14 """Outbound webhook for project events."""
15
16 class EventType(models.TextChoices):
17 CHECKIN = "checkin", "New Checkin"
18 TICKET = "ticket", "Ticket Change"
19 WIKI = "wiki", "Wiki Edit"
20 RELEASE = "release", "New Release"
21 ALL = "all", "All Events"
22
23 repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="webhooks")
24 url = models.URLField(max_length=500)
25 secret = EncryptedTextField(blank=True, default="")
26 events = models.CharField(max_length=100, default="all") # comma-separated event types
27 is_active = models.BooleanField(default=True)
28
29 objects = ActiveManager()
30 all_objects = models.Manager()
31
32 class Meta:
33 ordering = ["-created_at"]
34
35 def __str__(self):
36 return f"{self.url} ({self.events})"
37
38
39 class WebhookDelivery(models.Model):
40 """Log of webhook delivery attempts."""
41
42 webhook = models.ForeignKey(Webhook, on_delete=models.CASCADE, related_name="deliveries")
43 event_type = models.CharField(max_length=20)
44 payload = models.JSONField()
45 response_status = models.IntegerField(null=True, blank=True)
46 response_body = models.TextField(blank=True, default="")
47 success = models.BooleanField(default=False)
48 delivered_at = models.DateTimeField(auto_now_add=True)
49 duration_ms = models.IntegerField(default=0)
50 attempt = models.IntegerField(default=1)
51
52 class Meta:
53 ordering = ["-delivered_at"]
54
55 def __str__(self):
56 return f"{self.webhook.url} @ {self.delivered_at}"
--- organization/forms.py
+++ organization/forms.py
@@ -1,7 +1,9 @@
11
from django import forms
22
from django.contrib.auth.models import User
3
+from django.contrib.auth.password_validation import validate_password
4
+from django.core.exceptions import ValidationError
35
46
from .models import Organization, Team
57
68
tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
79
@@ -51,5 +53,100 @@
5153
def __init__(self, *args, team=None, **kwargs):
5254
super().__init__(*args, **kwargs)
5355
if team:
5456
existing_member_ids = team.members.values_list("id", flat=True)
5557
self.fields["user"].queryset = User.objects.filter(is_active=True).exclude(id__in=existing_member_ids)
58
+
59
+
60
+class UserCreateForm(forms.ModelForm):
61
+ password1 = forms.CharField(
62
+ label="Password",
63
+ widget=forms.PasswordInput(attrs={"class": tw, "placeholder": "Password"}),
64
+ strip=False,
65
+ )
66
+ password2 = forms.CharField(
67
+ label="Confirm Password",
68
+ widget=forms.PasswordInput(attrs={"class": tw, "placeholder": "Confirm password"}),
69
+ strip=False,
70
+ )
71
+
72
+ class Meta:
73
+ model = User
74
+ fields = ["username", "email", "first_name", "last_name"]
75
+ widgets = {
76
+ "username": forms.TextInput(attrs={"class": tw, "placeholder": "Username"}),
77
+ "email": forms.EmailInput(attrs={"class": tw, "placeholder": "[email protected]"}),
78
+ "first_name": forms.TextInput(attrs={"class": tw, "placeholder": "First name"}),
79
+ "last_name": forms.TextInput(attrs={"class": tw, "placeholder": "Last name"}),
80
+ }
81
+
82
+ def clean_password1(self):
83
+ password = self.cleaned_data.get("password1")
84
+ try:
85
+ validate_password(password)
86
+ except ValidationError as e:
87
+ raise ValidationError(e.messages) from None
88
+ return password
89
+
90
+ def clean(self):
91
+ cleaned_data = super().clean()
92
+ p1 = cleaned_data.get("password1")
93
+ p2 = cleaned_data.get("password2")
94
+ if p1 and p2 and p1 != p2:
95
+ self.add_error("password2", "Passwords do not match.")
96
+ return cleaned_data
97
+
98
+ def save(self, commit=True):
99
+ user = super().save(commit=False)
100
+ user.set_password(self.cleaned_data["password1"])
101
+ if commit:
102
+ user.save()
103
+ return user
104
+
105
+
106
+class UserEditForm(forms.ModelForm):
107
+ class Meta:
108
+ model = User
109
+ fields = ["email", "first_name", "last_name", "is_active", "is_staff"]
110
+ widgets = {
111
+ "email": forms.EmailInput(attrs={"class": tw, "placeholder": "[email protected]"}),
112
+ "first_name": forms.TextInput(attrs={"class": tw, "placeholder": "First name"}),
113
+ "last_name": forms.TextInput(attrs={"class": tw, "placeholder": "Last name"}),
114
+ "is_active": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand focus:ring-brand"}),
115
+ "is_staff": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand focus:ring-brand"}),
116
+ }
117
+
118
+ def __init__(self, *args, editing_self=False, **kwargs):
119
+ super().__init__(*args, **kwargs)
120
+ if editing_self:
121
+ # Prevent self-lockout: cannot toggle own is_active
122
+ self.fields["is_active"].disabled = True
123
+ self.fields["is_active"].help_text = "You cannot deactivate your own account."
124
+
125
+
126
+class UserPasswordForm(forms.Form):
127
+ new_password1 = forms.CharField(
128
+ label="New Password",
129
+ widget=forms.PasswordInput(attrs={"class": tw, "placeholder": "New password"}),
130
+ strip=False,
131
+ )
132
+ new_password2 = forms.CharField(
133
+ label="Confirm New Password",
134
+ widget=forms.PasswordInput(attrs={"class": tw, "placeholder": "Confirm new password"}),
135
+ strip=False,
136
+ )
137
+
138
+ def clean_new_password1(self):
139
+ password = self.cleaned_data.get("new_password1")
140
+ try:
141
+ validate_password(password)
142
+ except ValidationError as e:
143
+ raise ValidationError(e.messages) from None
144
+ return password
145
+
146
+ def clean(self):
147
+ cleaned_data = super().clean()
148
+ p1 = cleaned_data.get("new_password1")
149
+ p2 = cleaned_data.get("new_password2")
150
+ if p1 and p2 and p1 != p2:
151
+ self.add_error("new_password2", "Passwords do not match.")
152
+ return cleaned_data
56153
--- organization/forms.py
+++ organization/forms.py
@@ -1,7 +1,9 @@
1 from django import forms
2 from django.contrib.auth.models import User
 
 
3
4 from .models import Organization, Team
5
6 tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
7
@@ -51,5 +53,100 @@
51 def __init__(self, *args, team=None, **kwargs):
52 super().__init__(*args, **kwargs)
53 if team:
54 existing_member_ids = team.members.values_list("id", flat=True)
55 self.fields["user"].queryset = User.objects.filter(is_active=True).exclude(id__in=existing_member_ids)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
--- organization/forms.py
+++ organization/forms.py
@@ -1,7 +1,9 @@
1 from django import forms
2 from django.contrib.auth.models import User
3 from django.contrib.auth.password_validation import validate_password
4 from django.core.exceptions import ValidationError
5
6 from .models import Organization, Team
7
8 tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
9
@@ -51,5 +53,100 @@
53 def __init__(self, *args, team=None, **kwargs):
54 super().__init__(*args, **kwargs)
55 if team:
56 existing_member_ids = team.members.values_list("id", flat=True)
57 self.fields["user"].queryset = User.objects.filter(is_active=True).exclude(id__in=existing_member_ids)
58
59
60 class UserCreateForm(forms.ModelForm):
61 password1 = forms.CharField(
62 label="Password",
63 widget=forms.PasswordInput(attrs={"class": tw, "placeholder": "Password"}),
64 strip=False,
65 )
66 password2 = forms.CharField(
67 label="Confirm Password",
68 widget=forms.PasswordInput(attrs={"class": tw, "placeholder": "Confirm password"}),
69 strip=False,
70 )
71
72 class Meta:
73 model = User
74 fields = ["username", "email", "first_name", "last_name"]
75 widgets = {
76 "username": forms.TextInput(attrs={"class": tw, "placeholder": "Username"}),
77 "email": forms.EmailInput(attrs={"class": tw, "placeholder": "[email protected]"}),
78 "first_name": forms.TextInput(attrs={"class": tw, "placeholder": "First name"}),
79 "last_name": forms.TextInput(attrs={"class": tw, "placeholder": "Last name"}),
80 }
81
82 def clean_password1(self):
83 password = self.cleaned_data.get("password1")
84 try:
85 validate_password(password)
86 except ValidationError as e:
87 raise ValidationError(e.messages) from None
88 return password
89
90 def clean(self):
91 cleaned_data = super().clean()
92 p1 = cleaned_data.get("password1")
93 p2 = cleaned_data.get("password2")
94 if p1 and p2 and p1 != p2:
95 self.add_error("password2", "Passwords do not match.")
96 return cleaned_data
97
98 def save(self, commit=True):
99 user = super().save(commit=False)
100 user.set_password(self.cleaned_data["password1"])
101 if commit:
102 user.save()
103 return user
104
105
106 class UserEditForm(forms.ModelForm):
107 class Meta:
108 model = User
109 fields = ["email", "first_name", "last_name", "is_active", "is_staff"]
110 widgets = {
111 "email": forms.EmailInput(attrs={"class": tw, "placeholder": "[email protected]"}),
112 "first_name": forms.TextInput(attrs={"class": tw, "placeholder": "First name"}),
113 "last_name": forms.TextInput(attrs={"class": tw, "placeholder": "Last name"}),
114 "is_active": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand focus:ring-brand"}),
115 "is_staff": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand focus:ring-brand"}),
116 }
117
118 def __init__(self, *args, editing_self=False, **kwargs):
119 super().__init__(*args, **kwargs)
120 if editing_self:
121 # Prevent self-lockout: cannot toggle own is_active
122 self.fields["is_active"].disabled = True
123 self.fields["is_active"].help_text = "You cannot deactivate your own account."
124
125
126 class UserPasswordForm(forms.Form):
127 new_password1 = forms.CharField(
128 label="New Password",
129 widget=forms.PasswordInput(attrs={"class": tw, "placeholder": "New password"}),
130 strip=False,
131 )
132 new_password2 = forms.CharField(
133 label="Confirm New Password",
134 widget=forms.PasswordInput(attrs={"class": tw, "placeholder": "Confirm new password"}),
135 strip=False,
136 )
137
138 def clean_new_password1(self):
139 password = self.cleaned_data.get("new_password1")
140 try:
141 validate_password(password)
142 except ValidationError as e:
143 raise ValidationError(e.messages) from None
144 return password
145
146 def clean(self):
147 cleaned_data = super().clean()
148 p1 = cleaned_data.get("new_password1")
149 p2 = cleaned_data.get("new_password2")
150 if p1 and p2 and p1 != p2:
151 self.add_error("new_password2", "Passwords do not match.")
152 return cleaned_data
153
--- organization/urls.py
+++ organization/urls.py
@@ -9,10 +9,14 @@
99
path("", views.org_settings, name="settings"),
1010
path("edit/", views.org_settings_edit, name="settings_edit"),
1111
# Members
1212
path("members/", views.member_list, name="members"),
1313
path("members/add/", views.member_add, name="member_add"),
14
+ path("members/create/", views.user_create, name="user_create"),
15
+ path("members/<str:username>/", views.user_detail, name="user_detail"),
16
+ path("members/<str:username>/edit/", views.user_edit, name="user_edit"),
17
+ path("members/<str:username>/password/", views.user_password, name="user_password"),
1418
path("members/<str:username>/remove/", views.member_remove, name="member_remove"),
1519
# Teams
1620
path("teams/", views.team_list, name="team_list"),
1721
path("teams/create/", views.team_create, name="team_create"),
1822
path("teams/<slug:slug>/", views.team_detail, name="team_detail"),
1923
--- organization/urls.py
+++ organization/urls.py
@@ -9,10 +9,14 @@
9 path("", views.org_settings, name="settings"),
10 path("edit/", views.org_settings_edit, name="settings_edit"),
11 # Members
12 path("members/", views.member_list, name="members"),
13 path("members/add/", views.member_add, name="member_add"),
 
 
 
 
14 path("members/<str:username>/remove/", views.member_remove, name="member_remove"),
15 # Teams
16 path("teams/", views.team_list, name="team_list"),
17 path("teams/create/", views.team_create, name="team_create"),
18 path("teams/<slug:slug>/", views.team_detail, name="team_detail"),
19
--- organization/urls.py
+++ organization/urls.py
@@ -9,10 +9,14 @@
9 path("", views.org_settings, name="settings"),
10 path("edit/", views.org_settings_edit, name="settings_edit"),
11 # Members
12 path("members/", views.member_list, name="members"),
13 path("members/add/", views.member_add, name="member_add"),
14 path("members/create/", views.user_create, name="user_create"),
15 path("members/<str:username>/", views.user_detail, name="user_detail"),
16 path("members/<str:username>/edit/", views.user_edit, name="user_edit"),
17 path("members/<str:username>/password/", views.user_password, name="user_password"),
18 path("members/<str:username>/remove/", views.member_remove, name="member_remove"),
19 # Teams
20 path("teams/", views.team_list, name="team_list"),
21 path("teams/create/", views.team_create, name="team_create"),
22 path("teams/<slug:slug>/", views.team_detail, name="team_detail"),
23
--- organization/views.py
+++ organization/views.py
@@ -4,11 +4,19 @@
44
from django.http import HttpResponse
55
from django.shortcuts import get_object_or_404, redirect, render
66
77
from core.permissions import P
88
9
-from .forms import MemberAddForm, OrganizationSettingsForm, TeamForm, TeamMemberAddForm
9
+from .forms import (
10
+ MemberAddForm,
11
+ OrganizationSettingsForm,
12
+ TeamForm,
13
+ TeamMemberAddForm,
14
+ UserCreateForm,
15
+ UserEditForm,
16
+ UserPasswordForm,
17
+)
1018
from .models import Organization, OrganizationMember, Team
1119
1220
1321
def get_org():
1422
return Organization.objects.first()
@@ -213,5 +221,105 @@
213221
return HttpResponse(status=200, headers={"HX-Redirect": f"/settings/teams/{team.slug}/"})
214222
215223
return redirect("organization:team_detail", slug=team.slug)
216224
217225
return render(request, "organization/team_member_confirm_remove.html", {"team": team, "member_user": user})
226
+
227
+
228
+# --- User Management ---
229
+
230
+
231
+def _check_user_management_permission(request):
232
+ """User management requires superuser or ORGANIZATION_CHANGE permission."""
233
+ if request.user.is_superuser:
234
+ return True
235
+ return P.ORGANIZATION_CHANGE.check(request.user)
236
+
237
+
238
+@login_required
239
+def user_create(request):
240
+ _check_user_management_permission(request)
241
+ org = get_org()
242
+
243
+ if request.method == "POST":
244
+ form = UserCreateForm(request.POST)
245
+ if form.is_valid():
246
+ user = form.save()
247
+ OrganizationMember.objects.create(member=user, organization=org, created_by=request.user)
248
+ messages.success(request, f'User "{user.username}" created and added as member.')
249
+ return redirect("organization:members")
250
+ else:
251
+ form = UserCreateForm()
252
+
253
+ return render(request, "organization/user_form.html", {"form": form, "title": "New User"})
254
+
255
+
256
+@login_required
257
+def user_detail(request, username):
258
+ P.ORGANIZATION_MEMBER_VIEW.check(request.user)
259
+ org = get_org()
260
+ target_user = get_object_or_404(User, username=username)
261
+ membership = OrganizationMember.objects.filter(member=target_user, organization=org, deleted_at__isnull=True).first()
262
+ user_teams = Team.objects.filter(members=target_user, organization=org, deleted_at__isnull=True)
263
+
264
+ from fossil.user_keys import UserSSHKey
265
+
266
+ ssh_keys = UserSSHKey.objects.filter(user=target_user)
267
+
268
+ can_manage = request.user.is_superuser or P.ORGANIZATION_CHANGE.check(request.user, raise_error=False)
269
+
270
+ return render(
271
+ request,
272
+ "organization/user_detail.html",
273
+ {
274
+ "target_user": target_user,
275
+ "membership": membership,
276
+ "user_teams": user_teams,
277
+ "ssh_keys": ssh_keys,
278
+ "can_manage": can_manage,
279
+ "org": org,
280
+ },
281
+ )
282
+
283
+
284
+@login_required
285
+def user_edit(request, username):
286
+ _check_user_management_permission(request)
287
+ target_user = get_object_or_404(User, username=username)
288
+ editing_self = request.user.pk == target_user.pk
289
+
290
+ if request.method == "POST":
291
+ form = UserEditForm(request.POST, instance=target_user, editing_self=editing_self)
292
+ if form.is_valid():
293
+ form.save()
294
+ messages.success(request, f'User "{target_user.username}" updated.')
295
+ return redirect("organization:members")
296
+ else:
297
+ form = UserEditForm(instance=target_user, editing_self=editing_self)
298
+
299
+ return render(
300
+ request,
301
+ "organization/user_form.html",
302
+ {"form": form, "title": f"Edit {target_user.username}", "edit_user": target_user},
303
+ )
304
+
305
+
306
+@login_required
307
+def user_password(request, username):
308
+ target_user = get_object_or_404(User, username=username)
309
+ editing_own = request.user.pk == target_user.pk
310
+
311
+ # Allow changing own password, or require admin/org-change for others
312
+ if not editing_own:
313
+ _check_user_management_permission(request)
314
+
315
+ if request.method == "POST":
316
+ form = UserPasswordForm(request.POST)
317
+ if form.is_valid():
318
+ target_user.set_password(form.cleaned_data["new_password1"])
319
+ target_user.save()
320
+ messages.success(request, f'Password changed for "{target_user.username}".')
321
+ return redirect("organization:user_detail", username=target_user.username)
322
+ else:
323
+ form = UserPasswordForm()
324
+
325
+ return render(request, "organization/user_password.html", {"form": form, "target_user": target_user})
218326
--- organization/views.py
+++ organization/views.py
@@ -4,11 +4,19 @@
4 from django.http import HttpResponse
5 from django.shortcuts import get_object_or_404, redirect, render
6
7 from core.permissions import P
8
9 from .forms import MemberAddForm, OrganizationSettingsForm, TeamForm, TeamMemberAddForm
 
 
 
 
 
 
 
 
10 from .models import Organization, OrganizationMember, Team
11
12
13 def get_org():
14 return Organization.objects.first()
@@ -213,5 +221,105 @@
213 return HttpResponse(status=200, headers={"HX-Redirect": f"/settings/teams/{team.slug}/"})
214
215 return redirect("organization:team_detail", slug=team.slug)
216
217 return render(request, "organization/team_member_confirm_remove.html", {"team": team, "member_user": user})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
--- organization/views.py
+++ organization/views.py
@@ -4,11 +4,19 @@
4 from django.http import HttpResponse
5 from django.shortcuts import get_object_or_404, redirect, render
6
7 from core.permissions import P
8
9 from .forms import (
10 MemberAddForm,
11 OrganizationSettingsForm,
12 TeamForm,
13 TeamMemberAddForm,
14 UserCreateForm,
15 UserEditForm,
16 UserPasswordForm,
17 )
18 from .models import Organization, OrganizationMember, Team
19
20
21 def get_org():
22 return Organization.objects.first()
@@ -213,5 +221,105 @@
221 return HttpResponse(status=200, headers={"HX-Redirect": f"/settings/teams/{team.slug}/"})
222
223 return redirect("organization:team_detail", slug=team.slug)
224
225 return render(request, "organization/team_member_confirm_remove.html", {"team": team, "member_user": user})
226
227
228 # --- User Management ---
229
230
231 def _check_user_management_permission(request):
232 """User management requires superuser or ORGANIZATION_CHANGE permission."""
233 if request.user.is_superuser:
234 return True
235 return P.ORGANIZATION_CHANGE.check(request.user)
236
237
238 @login_required
239 def user_create(request):
240 _check_user_management_permission(request)
241 org = get_org()
242
243 if request.method == "POST":
244 form = UserCreateForm(request.POST)
245 if form.is_valid():
246 user = form.save()
247 OrganizationMember.objects.create(member=user, organization=org, created_by=request.user)
248 messages.success(request, f'User "{user.username}" created and added as member.')
249 return redirect("organization:members")
250 else:
251 form = UserCreateForm()
252
253 return render(request, "organization/user_form.html", {"form": form, "title": "New User"})
254
255
256 @login_required
257 def user_detail(request, username):
258 P.ORGANIZATION_MEMBER_VIEW.check(request.user)
259 org = get_org()
260 target_user = get_object_or_404(User, username=username)
261 membership = OrganizationMember.objects.filter(member=target_user, organization=org, deleted_at__isnull=True).first()
262 user_teams = Team.objects.filter(members=target_user, organization=org, deleted_at__isnull=True)
263
264 from fossil.user_keys import UserSSHKey
265
266 ssh_keys = UserSSHKey.objects.filter(user=target_user)
267
268 can_manage = request.user.is_superuser or P.ORGANIZATION_CHANGE.check(request.user, raise_error=False)
269
270 return render(
271 request,
272 "organization/user_detail.html",
273 {
274 "target_user": target_user,
275 "membership": membership,
276 "user_teams": user_teams,
277 "ssh_keys": ssh_keys,
278 "can_manage": can_manage,
279 "org": org,
280 },
281 )
282
283
284 @login_required
285 def user_edit(request, username):
286 _check_user_management_permission(request)
287 target_user = get_object_or_404(User, username=username)
288 editing_self = request.user.pk == target_user.pk
289
290 if request.method == "POST":
291 form = UserEditForm(request.POST, instance=target_user, editing_self=editing_self)
292 if form.is_valid():
293 form.save()
294 messages.success(request, f'User "{target_user.username}" updated.')
295 return redirect("organization:members")
296 else:
297 form = UserEditForm(instance=target_user, editing_self=editing_self)
298
299 return render(
300 request,
301 "organization/user_form.html",
302 {"form": form, "title": f"Edit {target_user.username}", "edit_user": target_user},
303 )
304
305
306 @login_required
307 def user_password(request, username):
308 target_user = get_object_or_404(User, username=username)
309 editing_own = request.user.pk == target_user.pk
310
311 # Allow changing own password, or require admin/org-change for others
312 if not editing_own:
313 _check_user_management_permission(request)
314
315 if request.method == "POST":
316 form = UserPasswordForm(request.POST)
317 if form.is_valid():
318 target_user.set_password(form.cleaned_data["new_password1"])
319 target_user.save()
320 messages.success(request, f'Password changed for "{target_user.username}".')
321 return redirect("organization:user_detail", username=target_user.username)
322 else:
323 form = UserPasswordForm()
324
325 return render(request, "organization/user_password.html", {"form": form, "target_user": target_user})
326
--- templates/base.html
+++ templates/base.html
@@ -104,10 +104,12 @@
104104
html:not(.dark) main .hover\:text-brand:hover { color: #8B3138 !important; }
105105
html:not(.dark) main .hover\:border-brand:hover { border-color: #DC394C !important; }
106106
/* Selected/hover rows — matches admin --selected-bg */
107107
html:not(.dark) main .group:hover { border-color: #DC394C !important; }
108108
</style>
109
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
110
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
109111
<script src="https://unpkg.com/[email protected]"></script>
110112
<script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script>
111113
<script>
112114
document.body.addEventListener('htmx:configRequest', function(event) {
113115
var token = document.querySelector('meta[name="csrf-token"]');
114116
--- templates/base.html
+++ templates/base.html
@@ -104,10 +104,12 @@
104 html:not(.dark) main .hover\:text-brand:hover { color: #8B3138 !important; }
105 html:not(.dark) main .hover\:border-brand:hover { border-color: #DC394C !important; }
106 /* Selected/hover rows — matches admin --selected-bg */
107 html:not(.dark) main .group:hover { border-color: #DC394C !important; }
108 </style>
 
 
109 <script src="https://unpkg.com/[email protected]"></script>
110 <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script>
111 <script>
112 document.body.addEventListener('htmx:configRequest', function(event) {
113 var token = document.querySelector('meta[name="csrf-token"]');
114
--- templates/base.html
+++ templates/base.html
@@ -104,10 +104,12 @@
104 html:not(.dark) main .hover\:text-brand:hover { color: #8B3138 !important; }
105 html:not(.dark) main .hover\:border-brand:hover { border-color: #DC394C !important; }
106 /* Selected/hover rows — matches admin --selected-bg */
107 html:not(.dark) main .group:hover { border-color: #DC394C !important; }
108 </style>
109 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
110 <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
111 <script src="https://unpkg.com/[email protected]"></script>
112 <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script>
113 <script>
114 document.body.addEventListener('htmx:configRequest', function(event) {
115 var token = document.querySelector('meta[name="csrf-token"]');
116
--- templates/fossil/checkin_detail.html
+++ templates/fossil/checkin_detail.html
@@ -16,10 +16,24 @@
1616
.diff-gutter:hover { color: #DC394C; }
1717
.diff-gutter a { color: inherit; text-decoration: none; display: block; }
1818
.line-row:target { background: rgba(220, 57, 76, 0.15) !important; }
1919
.line-row:target .diff-gutter { color: #DC394C; font-weight: 600; }
2020
.line-row { scroll-margin-top: 40vh; }
21
+ /* Split diff view */
22
+ .split-diff { display: grid; grid-template-columns: 1fr 1fr; }
23
+ .split-diff-side { overflow-x: auto; }
24
+ .split-diff-side:first-child { border-right: 1px solid #374151; }
25
+ .split-diff-side .diff-table td:last-child { width: 100%; }
26
+ .split-line-add { background: rgba(34, 197, 94, 0.1); }
27
+ .split-line-add td:last-child { color: #86efac; }
28
+ .split-line-del { background: rgba(239, 68, 68, 0.1); }
29
+ .split-line-del td:last-child { color: #fca5a5; }
30
+ .split-line-empty { background: rgba(107, 114, 128, 0.05); }
31
+ .split-line-empty td:last-child { color: transparent; }
32
+ /* Syntax highlighting: preserve diff bg colors over hljs */
33
+ .diff-code .hljs { background: transparent !important; padding: 0 !important; }
34
+ .diff-code { display: inline; }
2135
.line-popover {
2236
position: absolute; left: 100%; top: 50%; transform: translateY(-50%);
2337
margin-left: 4px; z-index: 20; white-space: nowrap;
2438
background: #1f2937; border: 1px solid #374151; border-radius: 6px;
2539
box-shadow: 0 4px 12px rgba(0,0,0,0.4); padding: 2px;
@@ -101,14 +115,21 @@
101115
{{ fd.name }}
102116
</a>
103117
{% endfor %}
104118
</div>
105119
</div>
120
+
121
+ <!-- Diff mode toggle -->
122
+ <div x-data="{ mode: localStorage.getItem('diff-mode') || 'unified' }" x-init="$watch('mode', val => localStorage.setItem('diff-mode', val))" x-ref="diffToggle">
123
+ <div class="flex items-center gap-2 mb-3">
124
+ <button @click="mode = 'unified'" :class="mode === 'unified' ? 'bg-gray-700 text-gray-100' : 'text-gray-400 hover:text-gray-200'" class="px-3 py-1 text-xs rounded-md">Unified</button>
125
+ <button @click="mode = 'split'" :class="mode === 'split' ? 'bg-gray-700 text-gray-100' : 'text-gray-400 hover:text-gray-200'" class="px-3 py-1 text-xs rounded-md">Split</button>
126
+ </div>
106127
107128
<!-- Diffs -->
108129
{% for fd in file_diffs %}
109
- <div class="rounded-lg bg-gray-800 border border-gray-700 overflow-hidden" id="diff-{{ forloop.counter }}">
130
+ <div class="rounded-lg bg-gray-800 border border-gray-700 overflow-hidden mb-4" id="diff-{{ forloop.counter }}" data-filename="{{ fd.name }}">
110131
<div class="px-4 py-2.5 border-b border-gray-700 flex items-center justify-between bg-gray-900/50">
111132
<div class="flex items-center gap-3">
112133
{% if fd.change_type == "added" %}
113134
<span class="inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold bg-green-900/50 text-green-300">A</span>
114135
{% elif fd.change_type == "deleted" %}
@@ -126,26 +147,102 @@
126147
<div class="flex items-center gap-2 text-xs">
127148
{% if fd.additions %}<span class="text-green-400">+{{ fd.additions }}</span>{% endif %}
128149
{% if fd.deletions %}<span class="text-red-400">-{{ fd.deletions }}</span>{% endif %}
129150
</div>
130151
</div>
131
- <div class="overflow-x-auto">
132
- {% if fd.is_binary %}
133
- <p class="p-4 text-sm text-gray-500">Binary file</p>
134
- {% elif fd.diff_lines %}
152
+
153
+ {% if fd.is_binary %}
154
+ <p class="p-4 text-sm text-gray-500">Binary file</p>
155
+ {% elif fd.diff_lines %}
156
+
157
+ <!-- Unified view -->
158
+ <div class="overflow-x-auto" x-show="mode === 'unified'">
135159
<table class="diff-table">
136160
<tbody>
137161
{% for dl in fd.diff_lines %}
138
-{% if dl.new_num %}<tr class="diff-line-{{ dl.type }} line-row" id="diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}"><td class="diff-gutter">{{ dl.old_num }}</td><td class="diff-gutter" style="position:relative" x-data="{ pop: false, copied: false }" @click.stop="pop = !pop; window.location.hash = 'diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}'" @click.outside="pop = false">{{ dl.new_num }}<div class="line-popover" x-show="pop" x-transition @click.stop><button @click="navigator.clipboard.writeText(window.location.origin + window.location.pathname + '#diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}'); copied = true; setTimeout(() => { copied = false; pop = false }, 1000)" :class="copied && 'copied'"><span x-show="!copied">Copy link</span><span x-show="copied">Copied!</span></button></div></td><td>{{ dl.text }}</td></tr>{% elif dl.old_num %}<tr class="diff-line-{{ dl.type }} line-row" id="diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}"><td class="diff-gutter" style="position:relative" x-data="{ pop: false, copied: false }" @click.stop="pop = !pop; window.location.hash = 'diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}'" @click.outside="pop = false">{{ dl.old_num }}<div class="line-popover" x-show="pop" x-transition @click.stop><button @click="navigator.clipboard.writeText(window.location.origin + window.location.pathname + '#diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}'); copied = true; setTimeout(() => { copied = false; pop = false }, 1000)" :class="copied && 'copied'"><span x-show="!copied">Copy link</span><span x-show="copied">Copied!</span></button></div></td><td class="diff-gutter"></td><td>{{ dl.text }}</td></tr>{% else %}<tr class="diff-line-{{ dl.type }}"><td class="diff-gutter"></td><td class="diff-gutter"></td><td>{{ dl.text }}</td></tr>{% endif %}
162
+{% if dl.new_num %}<tr class="diff-line-{{ dl.type }} line-row" id="diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}"><td class="diff-gutter">{{ dl.old_num }}</td><td class="diff-gutter" style="position:relative" x-data="{ pop: false, copied: false }" @click.stop="pop = !pop; window.location.hash = 'diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}'" @click.outside="pop = false">{{ dl.new_num }}<div class="line-popover" x-show="pop" x-transition @click.stop><button @click="navigator.clipboard.writeText(window.location.origin + window.location.pathname + '#diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}'); copied = true; setTimeout(() => { copied = false; pop = false }, 1000)" :class="copied && 'copied'"><span x-show="!copied">Copy link</span><span x-show="copied">Copied!</span></button></div></td><td><span class="diff-prefix">{{ dl.prefix }}</span><span class="diff-code">{{ dl.code }}</span></td></tr>{% elif dl.old_num %}<tr class="diff-line-{{ dl.type }} line-row" id="diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}"><td class="diff-gutter" style="position:relative" x-data="{ pop: false, copied: false }" @click.stop="pop = !pop; window.location.hash = 'diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}'" @click.outside="pop = false">{{ dl.old_num }}<div class="line-popover" x-show="pop" x-transition @click.stop><button @click="navigator.clipboard.writeText(window.location.origin + window.location.pathname + '#diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}'); copied = true; setTimeout(() => { copied = false; pop = false }, 1000)" :class="copied && 'copied'"><span x-show="!copied">Copy link</span><span x-show="copied">Copied!</span></button></div></td><td class="diff-gutter"></td><td><span class="diff-prefix">{{ dl.prefix }}</span><span class="diff-code">{{ dl.code }}</span></td></tr>{% else %}<tr class="diff-line-{{ dl.type }}"><td class="diff-gutter"></td><td class="diff-gutter"></td><td><span class="diff-prefix">{{ dl.prefix }}</span><span class="diff-code">{{ dl.code }}</span></td></tr>{% endif %}
139163
{% endfor %}
140164
</tbody>
141165
</table>
142
- {% else %}
143
- <p class="p-4 text-sm text-gray-500">No diff available</p>
144
- {% endif %}
166
+ </div>
167
+
168
+ <!-- Split view -->
169
+ <div class="split-diff" x-show="mode === 'split'" x-cloak>
170
+ <div class="split-diff-side">
171
+ <table class="diff-table">
172
+ <tbody>
173
+ {% for dl in fd.split_left %}
174
+ <tr class="{% if dl.type == 'del' %}split-line-del{% elif dl.type == 'hunk' %}diff-line-hunk{% elif dl.type == 'header' %}diff-line-header{% elif dl.type == 'empty' %}split-line-empty{% endif %}">
175
+ <td class="diff-gutter">{{ dl.old_num }}</td>
176
+ <td>{% if dl.type == 'empty' %}&nbsp;{% elif dl.type == 'hunk' or dl.type == 'header' %}{{ dl.text }}{% else %}<span class="diff-code">{{ dl.code }}</span>{% endif %}</td>
177
+ </tr>
178
+ {% endfor %}
179
+ </tbody>
180
+ </table>
181
+ </div>
182
+ <div class="split-diff-side">
183
+ <table class="diff-table">
184
+ <tbody>
185
+ {% for dl in fd.split_right %}
186
+ <tr class="{% if dl.type == 'add' %}split-line-add{% elif dl.type == 'hunk' %}diff-line-hunk{% elif dl.type == 'header' %}diff-line-header{% elif dl.type == 'empty' %}split-line-empty{% endif %}">
187
+ <td class="diff-gutter">{{ dl.new_num }}</td>
188
+ <td>{% if dl.type == 'empty' %}&nbsp;{% elif dl.type == 'hunk' or dl.type == 'header' %}{{ dl.text }}{% else %}<span class="diff-code">{{ dl.code }}</span>{% endif %}</td>
189
+ </tr>
190
+ {% endfor %}
191
+ </tbody>
192
+ </table>
193
+ </div>
145194
</div>
195
+
196
+ {% else %}
197
+ <p class="p-4 text-sm text-gray-500">No diff available</p>
198
+ {% endif %}
146199
</div>
147200
{% endfor %}
201
+ </div>
148202
{% endif %}
149203
</div>
150204
<!-- scroll-margin-top on .line-row handles anchor positioning via CSS -->
205
+<script>
206
+(function() {
207
+ var langMap = {
208
+ 'py': 'python', 'js': 'javascript', 'ts': 'typescript',
209
+ 'html': 'html', 'css': 'css', 'json': 'json', 'yaml': 'yaml',
210
+ 'yml': 'yaml', 'md': 'markdown', 'sh': 'bash', 'sql': 'sql',
211
+ 'rs': 'rust', 'go': 'go', 'rb': 'ruby', 'java': 'java',
212
+ 'toml': 'toml', 'xml': 'xml', 'jsx': 'javascript', 'tsx': 'typescript',
213
+ 'c': 'c', 'h': 'c', 'cpp': 'cpp', 'hpp': 'cpp',
214
+ };
215
+ function highlightDiffCode() {
216
+ document.querySelectorAll('[data-filename]').forEach(function(container) {
217
+ var filename = container.dataset.filename || '';
218
+ var ext = filename.split('.').pop();
219
+ var lang = langMap[ext] || '';
220
+ if (lang && window.hljs) {
221
+ container.querySelectorAll('.diff-code').forEach(function(el) {
222
+ if (el.dataset.highlighted) return;
223
+ var text = el.textContent;
224
+ if (!text.trim()) return;
225
+ try {
226
+ var result = hljs.highlight(text, { language: lang, ignoreIllegals: true });
227
+ el.innerHTML = result.value;
228
+ el.dataset.highlighted = '1';
229
+ } catch(e) {}
230
+ });
231
+ }
232
+ });
233
+ }
234
+ // Run after DOM ready and after Alpine toggles
235
+ if (document.readyState === 'loading') {
236
+ document.addEventListener('DOMContentLoaded', highlightDiffCode);
237
+ } else {
238
+ highlightDiffCode();
239
+ }
240
+ // Re-run when split view is toggled (elements become visible)
241
+ document.addEventListener('click', function(e) {
242
+ if (e.target.closest('[x-ref="diffToggle"]')) {
243
+ setTimeout(highlightDiffCode, 50);
244
+ }
245
+ });
246
+})();
247
+</script>
151248
{% endblock %}
152249
--- templates/fossil/checkin_detail.html
+++ templates/fossil/checkin_detail.html
@@ -16,10 +16,24 @@
16 .diff-gutter:hover { color: #DC394C; }
17 .diff-gutter a { color: inherit; text-decoration: none; display: block; }
18 .line-row:target { background: rgba(220, 57, 76, 0.15) !important; }
19 .line-row:target .diff-gutter { color: #DC394C; font-weight: 600; }
20 .line-row { scroll-margin-top: 40vh; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21 .line-popover {
22 position: absolute; left: 100%; top: 50%; transform: translateY(-50%);
23 margin-left: 4px; z-index: 20; white-space: nowrap;
24 background: #1f2937; border: 1px solid #374151; border-radius: 6px;
25 box-shadow: 0 4px 12px rgba(0,0,0,0.4); padding: 2px;
@@ -101,14 +115,21 @@
101 {{ fd.name }}
102 </a>
103 {% endfor %}
104 </div>
105 </div>
 
 
 
 
 
 
 
106
107 <!-- Diffs -->
108 {% for fd in file_diffs %}
109 <div class="rounded-lg bg-gray-800 border border-gray-700 overflow-hidden" id="diff-{{ forloop.counter }}">
110 <div class="px-4 py-2.5 border-b border-gray-700 flex items-center justify-between bg-gray-900/50">
111 <div class="flex items-center gap-3">
112 {% if fd.change_type == "added" %}
113 <span class="inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold bg-green-900/50 text-green-300">A</span>
114 {% elif fd.change_type == "deleted" %}
@@ -126,26 +147,102 @@
126 <div class="flex items-center gap-2 text-xs">
127 {% if fd.additions %}<span class="text-green-400">+{{ fd.additions }}</span>{% endif %}
128 {% if fd.deletions %}<span class="text-red-400">-{{ fd.deletions }}</span>{% endif %}
129 </div>
130 </div>
131 <div class="overflow-x-auto">
132 {% if fd.is_binary %}
133 <p class="p-4 text-sm text-gray-500">Binary file</p>
134 {% elif fd.diff_lines %}
 
 
 
135 <table class="diff-table">
136 <tbody>
137 {% for dl in fd.diff_lines %}
138 {% if dl.new_num %}<tr class="diff-line-{{ dl.type }} line-row" id="diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}"><td class="diff-gutter">{{ dl.old_num }}</td><td class="diff-gutter" style="position:relative" x-data="{ pop: false, copied: false }" @click.stop="pop = !pop; window.location.hash = 'diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}'" @click.outside="pop = false">{{ dl.new_num }}<div class="line-popover" x-show="pop" x-transition @click.stop><button @click="navigator.clipboard.writeText(window.location.origin + window.location.pathname + '#diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}'); copied = true; setTimeout(() => { copied = false; pop = false }, 1000)" :class="copied && 'copied'"><span x-show="!copied">Copy link</span><span x-show="copied">Copied!</span></button></div></td><td>{{ dl.text }}</td></tr>{% elif dl.old_num %}<tr class="diff-line-{{ dl.type }} line-row" id="diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}"><td class="diff-gutter" style="position:relative" x-data="{ pop: false, copied: false }" @click.stop="pop = !pop; window.location.hash = 'diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}'" @click.outside="pop = false">{{ dl.old_num }}<div class="line-popover" x-show="pop" x-transition @click.stop><button @click="navigator.clipboard.writeText(window.location.origin + window.location.pathname + '#diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}'); copied = true; setTimeout(() => { copied = false; pop = false }, 1000)" :class="copied && 'copied'"><span x-show="!copied">Copy link</span><span x-show="copied">Copied!</span></button></div></td><td class="diff-gutter"></td><td>{{ dl.text }}</td></tr>{% else %}<tr class="diff-line-{{ dl.type }}"><td class="diff-gutter"></td><td class="diff-gutter"></td><td>{{ dl.text }}</td></tr>{% endif %}
139 {% endfor %}
140 </tbody>
141 </table>
142 {% else %}
143 <p class="p-4 text-sm text-gray-500">No diff available</p>
144 {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145 </div>
 
 
 
 
146 </div>
147 {% endfor %}
 
148 {% endif %}
149 </div>
150 <!-- scroll-margin-top on .line-row handles anchor positioning via CSS -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151 {% endblock %}
152
--- templates/fossil/checkin_detail.html
+++ templates/fossil/checkin_detail.html
@@ -16,10 +16,24 @@
16 .diff-gutter:hover { color: #DC394C; }
17 .diff-gutter a { color: inherit; text-decoration: none; display: block; }
18 .line-row:target { background: rgba(220, 57, 76, 0.15) !important; }
19 .line-row:target .diff-gutter { color: #DC394C; font-weight: 600; }
20 .line-row { scroll-margin-top: 40vh; }
21 /* Split diff view */
22 .split-diff { display: grid; grid-template-columns: 1fr 1fr; }
23 .split-diff-side { overflow-x: auto; }
24 .split-diff-side:first-child { border-right: 1px solid #374151; }
25 .split-diff-side .diff-table td:last-child { width: 100%; }
26 .split-line-add { background: rgba(34, 197, 94, 0.1); }
27 .split-line-add td:last-child { color: #86efac; }
28 .split-line-del { background: rgba(239, 68, 68, 0.1); }
29 .split-line-del td:last-child { color: #fca5a5; }
30 .split-line-empty { background: rgba(107, 114, 128, 0.05); }
31 .split-line-empty td:last-child { color: transparent; }
32 /* Syntax highlighting: preserve diff bg colors over hljs */
33 .diff-code .hljs { background: transparent !important; padding: 0 !important; }
34 .diff-code { display: inline; }
35 .line-popover {
36 position: absolute; left: 100%; top: 50%; transform: translateY(-50%);
37 margin-left: 4px; z-index: 20; white-space: nowrap;
38 background: #1f2937; border: 1px solid #374151; border-radius: 6px;
39 box-shadow: 0 4px 12px rgba(0,0,0,0.4); padding: 2px;
@@ -101,14 +115,21 @@
115 {{ fd.name }}
116 </a>
117 {% endfor %}
118 </div>
119 </div>
120
121 <!-- Diff mode toggle -->
122 <div x-data="{ mode: localStorage.getItem('diff-mode') || 'unified' }" x-init="$watch('mode', val => localStorage.setItem('diff-mode', val))" x-ref="diffToggle">
123 <div class="flex items-center gap-2 mb-3">
124 <button @click="mode = 'unified'" :class="mode === 'unified' ? 'bg-gray-700 text-gray-100' : 'text-gray-400 hover:text-gray-200'" class="px-3 py-1 text-xs rounded-md">Unified</button>
125 <button @click="mode = 'split'" :class="mode === 'split' ? 'bg-gray-700 text-gray-100' : 'text-gray-400 hover:text-gray-200'" class="px-3 py-1 text-xs rounded-md">Split</button>
126 </div>
127
128 <!-- Diffs -->
129 {% for fd in file_diffs %}
130 <div class="rounded-lg bg-gray-800 border border-gray-700 overflow-hidden mb-4" id="diff-{{ forloop.counter }}" data-filename="{{ fd.name }}">
131 <div class="px-4 py-2.5 border-b border-gray-700 flex items-center justify-between bg-gray-900/50">
132 <div class="flex items-center gap-3">
133 {% if fd.change_type == "added" %}
134 <span class="inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold bg-green-900/50 text-green-300">A</span>
135 {% elif fd.change_type == "deleted" %}
@@ -126,26 +147,102 @@
147 <div class="flex items-center gap-2 text-xs">
148 {% if fd.additions %}<span class="text-green-400">+{{ fd.additions }}</span>{% endif %}
149 {% if fd.deletions %}<span class="text-red-400">-{{ fd.deletions }}</span>{% endif %}
150 </div>
151 </div>
152
153 {% if fd.is_binary %}
154 <p class="p-4 text-sm text-gray-500">Binary file</p>
155 {% elif fd.diff_lines %}
156
157 <!-- Unified view -->
158 <div class="overflow-x-auto" x-show="mode === 'unified'">
159 <table class="diff-table">
160 <tbody>
161 {% for dl in fd.diff_lines %}
162 {% if dl.new_num %}<tr class="diff-line-{{ dl.type }} line-row" id="diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}"><td class="diff-gutter">{{ dl.old_num }}</td><td class="diff-gutter" style="position:relative" x-data="{ pop: false, copied: false }" @click.stop="pop = !pop; window.location.hash = 'diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}'" @click.outside="pop = false">{{ dl.new_num }}<div class="line-popover" x-show="pop" x-transition @click.stop><button @click="navigator.clipboard.writeText(window.location.origin + window.location.pathname + '#diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}'); copied = true; setTimeout(() => { copied = false; pop = false }, 1000)" :class="copied && 'copied'"><span x-show="!copied">Copy link</span><span x-show="copied">Copied!</span></button></div></td><td><span class="diff-prefix">{{ dl.prefix }}</span><span class="diff-code">{{ dl.code }}</span></td></tr>{% elif dl.old_num %}<tr class="diff-line-{{ dl.type }} line-row" id="diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}"><td class="diff-gutter" style="position:relative" x-data="{ pop: false, copied: false }" @click.stop="pop = !pop; window.location.hash = 'diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}'" @click.outside="pop = false">{{ dl.old_num }}<div class="line-popover" x-show="pop" x-transition @click.stop><button @click="navigator.clipboard.writeText(window.location.origin + window.location.pathname + '#diff-{{ forloop.parentloop.counter }}-L{{ dl.old_num }}'); copied = true; setTimeout(() => { copied = false; pop = false }, 1000)" :class="copied && 'copied'"><span x-show="!copied">Copy link</span><span x-show="copied">Copied!</span></button></div></td><td class="diff-gutter"></td><td><span class="diff-prefix">{{ dl.prefix }}</span><span class="diff-code">{{ dl.code }}</span></td></tr>{% else %}<tr class="diff-line-{{ dl.type }}"><td class="diff-gutter"></td><td class="diff-gutter"></td><td><span class="diff-prefix">{{ dl.prefix }}</span><span class="diff-code">{{ dl.code }}</span></td></tr>{% endif %}
163 {% endfor %}
164 </tbody>
165 </table>
166 </div>
167
168 <!-- Split view -->
169 <div class="split-diff" x-show="mode === 'split'" x-cloak>
170 <div class="split-diff-side">
171 <table class="diff-table">
172 <tbody>
173 {% for dl in fd.split_left %}
174 <tr class="{% if dl.type == 'del' %}split-line-del{% elif dl.type == 'hunk' %}diff-line-hunk{% elif dl.type == 'header' %}diff-line-header{% elif dl.type == 'empty' %}split-line-empty{% endif %}">
175 <td class="diff-gutter">{{ dl.old_num }}</td>
176 <td>{% if dl.type == 'empty' %}&nbsp;{% elif dl.type == 'hunk' or dl.type == 'header' %}{{ dl.text }}{% else %}<span class="diff-code">{{ dl.code }}</span>{% endif %}</td>
177 </tr>
178 {% endfor %}
179 </tbody>
180 </table>
181 </div>
182 <div class="split-diff-side">
183 <table class="diff-table">
184 <tbody>
185 {% for dl in fd.split_right %}
186 <tr class="{% if dl.type == 'add' %}split-line-add{% elif dl.type == 'hunk' %}diff-line-hunk{% elif dl.type == 'header' %}diff-line-header{% elif dl.type == 'empty' %}split-line-empty{% endif %}">
187 <td class="diff-gutter">{{ dl.new_num }}</td>
188 <td>{% if dl.type == 'empty' %}&nbsp;{% elif dl.type == 'hunk' or dl.type == 'header' %}{{ dl.text }}{% else %}<span class="diff-code">{{ dl.code }}</span>{% endif %}</td>
189 </tr>
190 {% endfor %}
191 </tbody>
192 </table>
193 </div>
194 </div>
195
196 {% else %}
197 <p class="p-4 text-sm text-gray-500">No diff available</p>
198 {% endif %}
199 </div>
200 {% endfor %}
201 </div>
202 {% endif %}
203 </div>
204 <!-- scroll-margin-top on .line-row handles anchor positioning via CSS -->
205 <script>
206 (function() {
207 var langMap = {
208 'py': 'python', 'js': 'javascript', 'ts': 'typescript',
209 'html': 'html', 'css': 'css', 'json': 'json', 'yaml': 'yaml',
210 'yml': 'yaml', 'md': 'markdown', 'sh': 'bash', 'sql': 'sql',
211 'rs': 'rust', 'go': 'go', 'rb': 'ruby', 'java': 'java',
212 'toml': 'toml', 'xml': 'xml', 'jsx': 'javascript', 'tsx': 'typescript',
213 'c': 'c', 'h': 'c', 'cpp': 'cpp', 'hpp': 'cpp',
214 };
215 function highlightDiffCode() {
216 document.querySelectorAll('[data-filename]').forEach(function(container) {
217 var filename = container.dataset.filename || '';
218 var ext = filename.split('.').pop();
219 var lang = langMap[ext] || '';
220 if (lang && window.hljs) {
221 container.querySelectorAll('.diff-code').forEach(function(el) {
222 if (el.dataset.highlighted) return;
223 var text = el.textContent;
224 if (!text.trim()) return;
225 try {
226 var result = hljs.highlight(text, { language: lang, ignoreIllegals: true });
227 el.innerHTML = result.value;
228 el.dataset.highlighted = '1';
229 } catch(e) {}
230 });
231 }
232 });
233 }
234 // Run after DOM ready and after Alpine toggles
235 if (document.readyState === 'loading') {
236 document.addEventListener('DOMContentLoaded', highlightDiffCode);
237 } else {
238 highlightDiffCode();
239 }
240 // Re-run when split view is toggled (elements become visible)
241 document.addEventListener('click', function(e) {
242 if (e.target.closest('[x-ref="diffToggle"]')) {
243 setTimeout(highlightDiffCode, 50);
244 }
245 });
246 })();
247 </script>
248 {% endblock %}
249
--- templates/fossil/compare.html
+++ templates/fossil/compare.html
@@ -10,10 +10,25 @@
1010
.diff-line-del { background: rgba(239, 68, 68, 0.1); }
1111
.diff-line-del td:last-child { color: #fca5a5; }
1212
.diff-line-hunk { background: rgba(96, 165, 250, 0.08); }
1313
.diff-line-hunk td { color: #93c5fd; }
1414
.diff-line-header td { color: #6b7280; }
15
+ .diff-gutter { width: 1%; user-select: none; color: #4b5563; text-align: right; padding: 0 6px; border-right: 1px solid #374151; white-space: nowrap; }
16
+ /* Split diff view */
17
+ .split-diff { display: grid; grid-template-columns: 1fr 1fr; }
18
+ .split-diff-side { overflow-x: auto; }
19
+ .split-diff-side:first-child { border-right: 1px solid #374151; }
20
+ .split-diff-side .diff-table td:last-child { width: 100%; }
21
+ .split-line-add { background: rgba(34, 197, 94, 0.1); }
22
+ .split-line-add td:last-child { color: #86efac; }
23
+ .split-line-del { background: rgba(239, 68, 68, 0.1); }
24
+ .split-line-del td:last-child { color: #fca5a5; }
25
+ .split-line-empty { background: rgba(107, 114, 128, 0.05); }
26
+ .split-line-empty td:last-child { color: transparent; }
27
+ /* Syntax highlighting: preserve diff bg colors over hljs */
28
+ .diff-code .hljs { background: transparent !important; padding: 0 !important; }
29
+ .diff-code { display: inline; }
1530
</style>
1631
{% endblock %}
1732
1833
{% block content %}
1934
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
@@ -49,29 +64,117 @@
4964
<div class="mt-1 text-xs text-gray-500">{{ to_detail.user }} &middot; {{ to_detail.timestamp|date:"Y-m-d H:i" }}</div>
5065
</div>
5166
</div>
5267
5368
{% if file_diffs %}
54
-<div class="space-y-4">
55
- {% for fd in file_diffs %}
56
- <div class="rounded-lg bg-gray-800 border border-gray-700 overflow-hidden">
57
- <div class="px-4 py-2.5 border-b border-gray-700 bg-gray-900/50">
58
- <span class="text-sm font-mono text-gray-300">{{ fd.name }}</span>
59
- </div>
60
- <div class="overflow-x-auto">
61
- <table class="diff-table">
62
- <tbody>
63
- {% for dl in fd.diff_lines %}<tr class="diff-line-{{ dl.type }}"><td>{{ dl.text }}</td></tr>{% endfor %}
64
- </tbody>
65
- </table>
66
- </div>
67
- </div>
68
- {% endfor %}
69
+<div x-data="{ mode: localStorage.getItem('diff-mode') || 'unified' }" x-init="$watch('mode', val => localStorage.setItem('diff-mode', val))" x-ref="diffToggle">
70
+ <div class="flex items-center gap-2 mb-3">
71
+ <button @click="mode = 'unified'" :class="mode === 'unified' ? 'bg-gray-700 text-gray-100' : 'text-gray-400 hover:text-gray-200'" class="px-3 py-1 text-xs rounded-md">Unified</button>
72
+ <button @click="mode = 'split'" :class="mode === 'split' ? 'bg-gray-700 text-gray-100' : 'text-gray-400 hover:text-gray-200'" class="px-3 py-1 text-xs rounded-md">Split</button>
73
+ </div>
74
+
75
+ <div class="space-y-4">
76
+ {% for fd in file_diffs %}
77
+ <div class="rounded-lg bg-gray-800 border border-gray-700 overflow-hidden" data-filename="{{ fd.name }}">
78
+ <div class="px-4 py-2.5 border-b border-gray-700 bg-gray-900/50 flex items-center justify-between">
79
+ <span class="text-sm font-mono text-gray-300">{{ fd.name }}</span>
80
+ <div class="flex items-center gap-2 text-xs">
81
+ {% if fd.additions %}<span class="text-green-400">+{{ fd.additions }}</span>{% endif %}
82
+ {% if fd.deletions %}<span class="text-red-400">-{{ fd.deletions }}</span>{% endif %}
83
+ </div>
84
+ </div>
85
+
86
+ <!-- Unified view -->
87
+ <div class="overflow-x-auto" x-show="mode === 'unified'">
88
+ <table class="diff-table">
89
+ <tbody>
90
+ {% for dl in fd.diff_lines %}
91
+ <tr class="diff-line-{{ dl.type }}">
92
+ <td class="diff-gutter">{{ dl.old_num }}</td>
93
+ <td class="diff-gutter">{{ dl.new_num }}</td>
94
+ <td><span class="diff-prefix">{{ dl.prefix }}</span><span class="diff-code">{{ dl.code }}</span></td>
95
+ </tr>
96
+ {% endfor %}
97
+ </tbody>
98
+ </table>
99
+ </div>
100
+
101
+ <!-- Split view -->
102
+ <div class="split-diff" x-show="mode === 'split'" x-cloak>
103
+ <div class="split-diff-side">
104
+ <table class="diff-table">
105
+ <tbody>
106
+ {% for dl in fd.split_left %}
107
+ <tr class="{% if dl.type == 'del' %}split-line-del{% elif dl.type == 'hunk' %}diff-line-hunk{% elif dl.type == 'header' %}diff-line-header{% elif dl.type == 'empty' %}split-line-empty{% endif %}">
108
+ <td class="diff-gutter">{{ dl.old_num }}</td>
109
+ <td>{% if dl.type == 'empty' %}&nbsp;{% elif dl.type == 'hunk' or dl.type == 'header' %}{{ dl.text }}{% else %}<span class="diff-code">{{ dl.code }}</span>{% endif %}</td>
110
+ </tr>
111
+ {% endfor %}
112
+ </tbody>
113
+ </table>
114
+ </div>
115
+ <div class="split-diff-side">
116
+ <table class="diff-table">
117
+ <tbody>
118
+ {% for dl in fd.split_right %}
119
+ <tr class="{% if dl.type == 'add' %}split-line-add{% elif dl.type == 'hunk' %}diff-line-hunk{% elif dl.type == 'header' %}diff-line-header{% elif dl.type == 'empty' %}split-line-empty{% endif %}">
120
+ <td class="diff-gutter">{{ dl.new_num }}</td>
121
+ <td>{% if dl.type == 'empty' %}&nbsp;{% elif dl.type == 'hunk' or dl.type == 'header' %}{{ dl.text }}{% else %}<span class="diff-code">{{ dl.code }}</span>{% endif %}</td>
122
+ </tr>
123
+ {% endfor %}
124
+ </tbody>
125
+ </table>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ {% endfor %}
130
+ </div>
69131
</div>
70132
{% else %}
71133
<p class="text-sm text-gray-500 text-center py-8">No differences found between these checkins.</p>
72134
{% endif %}
73135
74136
{% elif from_uuid and to_uuid %}
75137
<p class="text-sm text-gray-500 text-center py-8">One or both checkins not found.</p>
76138
{% endif %}
139
+<script>
140
+(function() {
141
+ var langMap = {
142
+ 'py': 'python', 'js': 'javascript', 'ts': 'typescript',
143
+ 'html': 'html', 'css': 'css', 'json': 'json', 'yaml': 'yaml',
144
+ 'yml': 'yaml', 'md': 'markdown', 'sh': 'bash', 'sql': 'sql',
145
+ 'rs': 'rust', 'go': 'go', 'rb': 'ruby', 'java': 'java',
146
+ 'toml': 'toml', 'xml': 'xml', 'jsx': 'javascript', 'tsx': 'typescript',
147
+ 'c': 'c', 'h': 'c', 'cpp': 'cpp', 'hpp': 'cpp',
148
+ };
149
+ function highlightDiffCode() {
150
+ document.querySelectorAll('[data-filename]').forEach(function(container) {
151
+ var filename = container.dataset.filename || '';
152
+ var ext = filename.split('.').pop();
153
+ var lang = langMap[ext] || '';
154
+ if (lang && window.hljs) {
155
+ container.querySelectorAll('.diff-code').forEach(function(el) {
156
+ if (el.dataset.highlighted) return;
157
+ var text = el.textContent;
158
+ if (!text.trim()) return;
159
+ try {
160
+ var result = hljs.highlight(text, { language: lang, ignoreIllegals: true });
161
+ el.innerHTML = result.value;
162
+ el.dataset.highlighted = '1';
163
+ } catch(e) {}
164
+ });
165
+ }
166
+ });
167
+ }
168
+ if (document.readyState === 'loading') {
169
+ document.addEventListener('DOMContentLoaded', highlightDiffCode);
170
+ } else {
171
+ highlightDiffCode();
172
+ }
173
+ document.addEventListener('click', function(e) {
174
+ if (e.target.closest('[x-ref="diffToggle"]')) {
175
+ setTimeout(highlightDiffCode, 50);
176
+ }
177
+ });
178
+})();
179
+</script>
77180
{% endblock %}
78181
79182
ADDED templates/fossil/forum_form.html
--- templates/fossil/compare.html
+++ templates/fossil/compare.html
@@ -10,10 +10,25 @@
10 .diff-line-del { background: rgba(239, 68, 68, 0.1); }
11 .diff-line-del td:last-child { color: #fca5a5; }
12 .diff-line-hunk { background: rgba(96, 165, 250, 0.08); }
13 .diff-line-hunk td { color: #93c5fd; }
14 .diff-line-header td { color: #6b7280; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15 </style>
16 {% endblock %}
17
18 {% block content %}
19 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
@@ -49,29 +64,117 @@
49 <div class="mt-1 text-xs text-gray-500">{{ to_detail.user }} &middot; {{ to_detail.timestamp|date:"Y-m-d H:i" }}</div>
50 </div>
51 </div>
52
53 {% if file_diffs %}
54 <div class="space-y-4">
55 {% for fd in file_diffs %}
56 <div class="rounded-lg bg-gray-800 border border-gray-700 overflow-hidden">
57 <div class="px-4 py-2.5 border-b border-gray-700 bg-gray-900/50">
58 <span class="text-sm font-mono text-gray-300">{{ fd.name }}</span>
59 </div>
60 <div class="overflow-x-auto">
61 <table class="diff-table">
62 <tbody>
63 {% for dl in fd.diff_lines %}<tr class="diff-line-{{ dl.type }}"><td>{{ dl.text }}</td></tr>{% endfor %}
64 </tbody>
65 </table>
66 </div>
67 </div>
68 {% endfor %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69 </div>
70 {% else %}
71 <p class="text-sm text-gray-500 text-center py-8">No differences found between these checkins.</p>
72 {% endif %}
73
74 {% elif from_uuid and to_uuid %}
75 <p class="text-sm text-gray-500 text-center py-8">One or both checkins not found.</p>
76 {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77 {% endblock %}
78
79 DDED templates/fossil/forum_form.html
--- templates/fossil/compare.html
+++ templates/fossil/compare.html
@@ -10,10 +10,25 @@
10 .diff-line-del { background: rgba(239, 68, 68, 0.1); }
11 .diff-line-del td:last-child { color: #fca5a5; }
12 .diff-line-hunk { background: rgba(96, 165, 250, 0.08); }
13 .diff-line-hunk td { color: #93c5fd; }
14 .diff-line-header td { color: #6b7280; }
15 .diff-gutter { width: 1%; user-select: none; color: #4b5563; text-align: right; padding: 0 6px; border-right: 1px solid #374151; white-space: nowrap; }
16 /* Split diff view */
17 .split-diff { display: grid; grid-template-columns: 1fr 1fr; }
18 .split-diff-side { overflow-x: auto; }
19 .split-diff-side:first-child { border-right: 1px solid #374151; }
20 .split-diff-side .diff-table td:last-child { width: 100%; }
21 .split-line-add { background: rgba(34, 197, 94, 0.1); }
22 .split-line-add td:last-child { color: #86efac; }
23 .split-line-del { background: rgba(239, 68, 68, 0.1); }
24 .split-line-del td:last-child { color: #fca5a5; }
25 .split-line-empty { background: rgba(107, 114, 128, 0.05); }
26 .split-line-empty td:last-child { color: transparent; }
27 /* Syntax highlighting: preserve diff bg colors over hljs */
28 .diff-code .hljs { background: transparent !important; padding: 0 !important; }
29 .diff-code { display: inline; }
30 </style>
31 {% endblock %}
32
33 {% block content %}
34 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
@@ -49,29 +64,117 @@
64 <div class="mt-1 text-xs text-gray-500">{{ to_detail.user }} &middot; {{ to_detail.timestamp|date:"Y-m-d H:i" }}</div>
65 </div>
66 </div>
67
68 {% if file_diffs %}
69 <div x-data="{ mode: localStorage.getItem('diff-mode') || 'unified' }" x-init="$watch('mode', val => localStorage.setItem('diff-mode', val))" x-ref="diffToggle">
70 <div class="flex items-center gap-2 mb-3">
71 <button @click="mode = 'unified'" :class="mode === 'unified' ? 'bg-gray-700 text-gray-100' : 'text-gray-400 hover:text-gray-200'" class="px-3 py-1 text-xs rounded-md">Unified</button>
72 <button @click="mode = 'split'" :class="mode === 'split' ? 'bg-gray-700 text-gray-100' : 'text-gray-400 hover:text-gray-200'" class="px-3 py-1 text-xs rounded-md">Split</button>
73 </div>
74
75 <div class="space-y-4">
76 {% for fd in file_diffs %}
77 <div class="rounded-lg bg-gray-800 border border-gray-700 overflow-hidden" data-filename="{{ fd.name }}">
78 <div class="px-4 py-2.5 border-b border-gray-700 bg-gray-900/50 flex items-center justify-between">
79 <span class="text-sm font-mono text-gray-300">{{ fd.name }}</span>
80 <div class="flex items-center gap-2 text-xs">
81 {% if fd.additions %}<span class="text-green-400">+{{ fd.additions }}</span>{% endif %}
82 {% if fd.deletions %}<span class="text-red-400">-{{ fd.deletions }}</span>{% endif %}
83 </div>
84 </div>
85
86 <!-- Unified view -->
87 <div class="overflow-x-auto" x-show="mode === 'unified'">
88 <table class="diff-table">
89 <tbody>
90 {% for dl in fd.diff_lines %}
91 <tr class="diff-line-{{ dl.type }}">
92 <td class="diff-gutter">{{ dl.old_num }}</td>
93 <td class="diff-gutter">{{ dl.new_num }}</td>
94 <td><span class="diff-prefix">{{ dl.prefix }}</span><span class="diff-code">{{ dl.code }}</span></td>
95 </tr>
96 {% endfor %}
97 </tbody>
98 </table>
99 </div>
100
101 <!-- Split view -->
102 <div class="split-diff" x-show="mode === 'split'" x-cloak>
103 <div class="split-diff-side">
104 <table class="diff-table">
105 <tbody>
106 {% for dl in fd.split_left %}
107 <tr class="{% if dl.type == 'del' %}split-line-del{% elif dl.type == 'hunk' %}diff-line-hunk{% elif dl.type == 'header' %}diff-line-header{% elif dl.type == 'empty' %}split-line-empty{% endif %}">
108 <td class="diff-gutter">{{ dl.old_num }}</td>
109 <td>{% if dl.type == 'empty' %}&nbsp;{% elif dl.type == 'hunk' or dl.type == 'header' %}{{ dl.text }}{% else %}<span class="diff-code">{{ dl.code }}</span>{% endif %}</td>
110 </tr>
111 {% endfor %}
112 </tbody>
113 </table>
114 </div>
115 <div class="split-diff-side">
116 <table class="diff-table">
117 <tbody>
118 {% for dl in fd.split_right %}
119 <tr class="{% if dl.type == 'add' %}split-line-add{% elif dl.type == 'hunk' %}diff-line-hunk{% elif dl.type == 'header' %}diff-line-header{% elif dl.type == 'empty' %}split-line-empty{% endif %}">
120 <td class="diff-gutter">{{ dl.new_num }}</td>
121 <td>{% if dl.type == 'empty' %}&nbsp;{% elif dl.type == 'hunk' or dl.type == 'header' %}{{ dl.text }}{% else %}<span class="diff-code">{{ dl.code }}</span>{% endif %}</td>
122 </tr>
123 {% endfor %}
124 </tbody>
125 </table>
126 </div>
127 </div>
128 </div>
129 {% endfor %}
130 </div>
131 </div>
132 {% else %}
133 <p class="text-sm text-gray-500 text-center py-8">No differences found between these checkins.</p>
134 {% endif %}
135
136 {% elif from_uuid and to_uuid %}
137 <p class="text-sm text-gray-500 text-center py-8">One or both checkins not found.</p>
138 {% endif %}
139 <script>
140 (function() {
141 var langMap = {
142 'py': 'python', 'js': 'javascript', 'ts': 'typescript',
143 'html': 'html', 'css': 'css', 'json': 'json', 'yaml': 'yaml',
144 'yml': 'yaml', 'md': 'markdown', 'sh': 'bash', 'sql': 'sql',
145 'rs': 'rust', 'go': 'go', 'rb': 'ruby', 'java': 'java',
146 'toml': 'toml', 'xml': 'xml', 'jsx': 'javascript', 'tsx': 'typescript',
147 'c': 'c', 'h': 'c', 'cpp': 'cpp', 'hpp': 'cpp',
148 };
149 function highlightDiffCode() {
150 document.querySelectorAll('[data-filename]').forEach(function(container) {
151 var filename = container.dataset.filename || '';
152 var ext = filename.split('.').pop();
153 var lang = langMap[ext] || '';
154 if (lang && window.hljs) {
155 container.querySelectorAll('.diff-code').forEach(function(el) {
156 if (el.dataset.highlighted) return;
157 var text = el.textContent;
158 if (!text.trim()) return;
159 try {
160 var result = hljs.highlight(text, { language: lang, ignoreIllegals: true });
161 el.innerHTML = result.value;
162 el.dataset.highlighted = '1';
163 } catch(e) {}
164 });
165 }
166 });
167 }
168 if (document.readyState === 'loading') {
169 document.addEventListener('DOMContentLoaded', highlightDiffCode);
170 } else {
171 highlightDiffCode();
172 }
173 document.addEventListener('click', function(e) {
174 if (e.target.closest('[x-ref="diffToggle"]')) {
175 setTimeout(highlightDiffCode, 50);
176 }
177 });
178 })();
179 </script>
180 {% endblock %}
181
182 DDED templates/fossil/forum_form.html
--- a/templates/fossil/forum_form.html
+++ b/templates/fossil/forum_form.html
@@ -0,0 +1,25 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ form_title }} — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block extra_head %}
5
+<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
6
+{% endblock %}
7
+
8
+{% block content %}
9
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
10
+{% include "fossil/_project_nav.html" %}
11
+
12
+<div class="mb-4">
13
+ <a href="{% url 'fossil:forum' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Forum</a>
14
+</div>
15
+
16
+<div class="mx-auto max-w-3xl" x-data="{ tab: 'write' }">
17
+ <div class="flex items-center justify-between mb-4">
18
+ <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2>
19
+ <div class="flex items-center gap-1 text-xs">
20
+ <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button>
21
+ <button @click="tab = 'preview'; document.getElementById('marked.parse(document.getElemetById('body-input').value))" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button>
22
+ </div>
23
+ </div>
24
+
25
+ <form method="post" class="space-y-4 rounded-lg bg-gray-80
--- a/templates/fossil/forum_form.html
+++ b/templates/fossil/forum_form.html
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/forum_form.html
+++ b/templates/fossil/forum_form.html
@@ -0,0 +1,25 @@
1 {% extends "base.html" %}
2 {% block title %}{{ form_title }} — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block extra_head %}
5 <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
6 {% endblock %}
7
8 {% block content %}
9 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
10 {% include "fossil/_project_nav.html" %}
11
12 <div class="mb-4">
13 <a href="{% url 'fossil:forum' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Forum</a>
14 </div>
15
16 <div class="mx-auto max-w-3xl" x-data="{ tab: 'write' }">
17 <div class="flex items-center justify-between mb-4">
18 <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2>
19 <div class="flex items-center gap-1 text-xs">
20 <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button>
21 <button @click="tab = 'preview'; document.getElementById('marked.parse(document.getElemetById('body-input').value))" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button>
22 </div>
23 </div>
24
25 <form method="post" class="space-y-4 rounded-lg bg-gray-80
--- templates/fossil/forum_list.html
+++ templates/fossil/forum_list.html
@@ -2,28 +2,60 @@
22
{% block title %}Forum — {{ project.name }} — Fossilrepo{% endblock %}
33
44
{% block content %}
55
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
66
{% include "fossil/_project_nav.html" %}
7
+
8
+<div class="flex items-center justify-between mb-6">
9
+ <h2 class="text-lg font-semibold text-gray-200">Forum</h2>
10
+ {% if has_write %}
11
+ <a href="{% url 'fossil:forum_create' slug=project.slug %}"
12
+ class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
13
+ New Thread
14
+ </a>
15
+ {% endif %}
16
+</div>
717
818
<div class="space-y-3">
919
{% for post in posts %}
1020
<div class="rounded-lg bg-gray-800 border border-gray-700 hover:border-gray-600 transition-colors">
1121
<div class="px-5 py-4">
22
+ {% if post.source == "django" %}
23
+ <a href="{% url 'fossil:forum_thread' slug=project.slug thread_uuid=post.uuid %}"
24
+ class="text-base font-medium text-brand-light hover:text-brand">
25
+ {{ post.title|default:"(untitled)" }}
26
+ </a>
27
+ {% else %}
1228
<a href="{% url 'fossil:forum_thread' slug=project.slug thread_uuid=post.uuid %}"
1329
class="text-base font-medium text-brand-light hover:text-brand">
1430
{{ post.title|default:"(untitled)" }}
1531
</a>
32
+ {% endif %}
1633
{% if post.body %}
1734
<p class="mt-1 text-sm text-gray-400 line-clamp-2">{{ post.body|truncatechars:200 }}</p>
1835
{% endif %}
1936
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
37
+ {% if post.source == "fossil" %}
2038
<a href="{% url 'fossil:user_activity' slug=project.slug username=post.user %}" class="font-medium text-gray-400 hover:text-brand-light">{{ post.user }}</a>
39
+ {% else %}
40
+ <span class="font-medium text-gray-400">{{ post.user }}</span>
41
+ {% endif %}
2142
<span>{{ post.timestamp|timesince }} ago</span>
43
+ {% if post.source == "django" %}
44
+ <span class="inline-flex rounded-full bg-brand/20 px-2 py-0.5 text-xs text-brand-light">local</span>
45
+ {% endif %}
2246
</div>
2347
</div>
2448
</div>
2549
{% empty %}
26
- <p class="text-sm text-gray-500 py-8 text-center">No forum posts.</p>
50
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center">
51
+ <p class="text-sm text-gray-500">No forum posts.</p>
52
+ {% if has_write %}
53
+ <a href="{% url 'fossil:forum_create' slug=project.slug %}"
54
+ class="mt-3 inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
55
+ Start the first thread
56
+ </a>
57
+ {% endif %}
58
+ </div>
2759
{% endfor %}
2860
</div>
2961
{% endblock %}
3062
--- templates/fossil/forum_list.html
+++ templates/fossil/forum_list.html
@@ -2,28 +2,60 @@
2 {% block title %}Forum — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
 
 
 
 
 
 
 
 
 
 
7
8 <div class="space-y-3">
9 {% for post in posts %}
10 <div class="rounded-lg bg-gray-800 border border-gray-700 hover:border-gray-600 transition-colors">
11 <div class="px-5 py-4">
 
 
 
 
 
 
12 <a href="{% url 'fossil:forum_thread' slug=project.slug thread_uuid=post.uuid %}"
13 class="text-base font-medium text-brand-light hover:text-brand">
14 {{ post.title|default:"(untitled)" }}
15 </a>
 
16 {% if post.body %}
17 <p class="mt-1 text-sm text-gray-400 line-clamp-2">{{ post.body|truncatechars:200 }}</p>
18 {% endif %}
19 <div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
 
20 <a href="{% url 'fossil:user_activity' slug=project.slug username=post.user %}" class="font-medium text-gray-400 hover:text-brand-light">{{ post.user }}</a>
 
 
 
21 <span>{{ post.timestamp|timesince }} ago</span>
 
 
 
22 </div>
23 </div>
24 </div>
25 {% empty %}
26 <p class="text-sm text-gray-500 py-8 text-center">No forum posts.</p>
 
 
 
 
 
 
 
 
27 {% endfor %}
28 </div>
29 {% endblock %}
30
--- templates/fossil/forum_list.html
+++ templates/fossil/forum_list.html
@@ -2,28 +2,60 @@
2 {% block title %}Forum — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="flex items-center justify-between mb-6">
9 <h2 class="text-lg font-semibold text-gray-200">Forum</h2>
10 {% if has_write %}
11 <a href="{% url 'fossil:forum_create' slug=project.slug %}"
12 class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
13 New Thread
14 </a>
15 {% endif %}
16 </div>
17
18 <div class="space-y-3">
19 {% for post in posts %}
20 <div class="rounded-lg bg-gray-800 border border-gray-700 hover:border-gray-600 transition-colors">
21 <div class="px-5 py-4">
22 {% if post.source == "django" %}
23 <a href="{% url 'fossil:forum_thread' slug=project.slug thread_uuid=post.uuid %}"
24 class="text-base font-medium text-brand-light hover:text-brand">
25 {{ post.title|default:"(untitled)" }}
26 </a>
27 {% else %}
28 <a href="{% url 'fossil:forum_thread' slug=project.slug thread_uuid=post.uuid %}"
29 class="text-base font-medium text-brand-light hover:text-brand">
30 {{ post.title|default:"(untitled)" }}
31 </a>
32 {% endif %}
33 {% if post.body %}
34 <p class="mt-1 text-sm text-gray-400 line-clamp-2">{{ post.body|truncatechars:200 }}</p>
35 {% endif %}
36 <div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
37 {% if post.source == "fossil" %}
38 <a href="{% url 'fossil:user_activity' slug=project.slug username=post.user %}" class="font-medium text-gray-400 hover:text-brand-light">{{ post.user }}</a>
39 {% else %}
40 <span class="font-medium text-gray-400">{{ post.user }}</span>
41 {% endif %}
42 <span>{{ post.timestamp|timesince }} ago</span>
43 {% if post.source == "django" %}
44 <span class="inline-flex rounded-full bg-brand/20 px-2 py-0.5 text-xs text-brand-light">local</span>
45 {% endif %}
46 </div>
47 </div>
48 </div>
49 {% empty %}
50 <div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center">
51 <p class="text-sm text-gray-500">No forum posts.</p>
52 {% if has_write %}
53 <a href="{% url 'fossil:forum_create' slug=project.slug %}"
54 class="mt-3 inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
55 Start the first thread
56 </a>
57 {% endif %}
58 </div>
59 {% endfor %}
60 </div>
61 {% endblock %}
62
--- templates/fossil/forum_thread.html
+++ templates/fossil/forum_thread.html
@@ -12,11 +12,15 @@
1212
<div class="space-y-3">
1313
{% for item in posts %}
1414
<div class="rounded-lg bg-gray-800 border border-gray-700 {% if item.post.in_reply_to %}ml-8{% endif %}">
1515
<div class="px-5 py-4">
1616
<div class="flex items-center justify-between mb-2">
17
+ {% if is_django_thread %}
18
+ <span class="text-sm font-medium text-gray-200">{{ item.post.user }}</span>
19
+ {% else %}
1720
<a href="{% url 'fossil:user_activity' slug=project.slug username=item.post.user %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ item.post.user }}</a>
21
+ {% endif %}
1822
<span class="text-xs text-gray-500">{{ item.post.timestamp|timesince }} ago</span>
1923
</div>
2024
{% if item.post.title and forloop.first %}
2125
<h2 class="text-lg font-semibold text-gray-100 mb-3">{{ item.post.title }}</h2>
2226
{% endif %}
@@ -31,6 +35,22 @@
3135
</div>
3236
{% empty %}
3337
<p class="text-sm text-gray-500 py-8 text-center">No posts in this thread.</p>
3438
{% endfor %}
3539
</div>
40
+
41
+{% if has_write and is_django_thread %}
42
+<div class="mt-6">
43
+ <h3 class="text-sm font-semibold text-gray-300 mb-3">Reply</h3>
44
+ <form method="post" action="{% url 'fossil:forum_reply' slug=project.slug post_id=thread_uuid %}" class="space-y-3 rounded-lg bg-gray-800 p-5 border border-gray-700">
45
+ {% csrf_token %}
46
+ <textarea name="body" rows="6" required placeholder="Write your reply in Markdown..."
47
+ class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm font-mono"></textarea>
48
+ <div class="flex justify-end">
49
+ <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
50
+ Post Reply
51
+ </button>
52
+ </div>
53
+ </form>
54
+</div>
55
+{% endif %}
3656
{% endblock %}
3757
3858
ADDED templates/fossil/webhook_deliveries.html
3959
ADDED templates/fossil/webhook_form.html
4060
ADDED templates/fossil/webhook_list.html
--- templates/fossil/forum_thread.html
+++ templates/fossil/forum_thread.html
@@ -12,11 +12,15 @@
12 <div class="space-y-3">
13 {% for item in posts %}
14 <div class="rounded-lg bg-gray-800 border border-gray-700 {% if item.post.in_reply_to %}ml-8{% endif %}">
15 <div class="px-5 py-4">
16 <div class="flex items-center justify-between mb-2">
 
 
 
17 <a href="{% url 'fossil:user_activity' slug=project.slug username=item.post.user %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ item.post.user }}</a>
 
18 <span class="text-xs text-gray-500">{{ item.post.timestamp|timesince }} ago</span>
19 </div>
20 {% if item.post.title and forloop.first %}
21 <h2 class="text-lg font-semibold text-gray-100 mb-3">{{ item.post.title }}</h2>
22 {% endif %}
@@ -31,6 +35,22 @@
31 </div>
32 {% empty %}
33 <p class="text-sm text-gray-500 py-8 text-center">No posts in this thread.</p>
34 {% endfor %}
35 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36 {% endblock %}
37
38 DDED templates/fossil/webhook_deliveries.html
39 DDED templates/fossil/webhook_form.html
40 DDED templates/fossil/webhook_list.html
--- templates/fossil/forum_thread.html
+++ templates/fossil/forum_thread.html
@@ -12,11 +12,15 @@
12 <div class="space-y-3">
13 {% for item in posts %}
14 <div class="rounded-lg bg-gray-800 border border-gray-700 {% if item.post.in_reply_to %}ml-8{% endif %}">
15 <div class="px-5 py-4">
16 <div class="flex items-center justify-between mb-2">
17 {% if is_django_thread %}
18 <span class="text-sm font-medium text-gray-200">{{ item.post.user }}</span>
19 {% else %}
20 <a href="{% url 'fossil:user_activity' slug=project.slug username=item.post.user %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ item.post.user }}</a>
21 {% endif %}
22 <span class="text-xs text-gray-500">{{ item.post.timestamp|timesince }} ago</span>
23 </div>
24 {% if item.post.title and forloop.first %}
25 <h2 class="text-lg font-semibold text-gray-100 mb-3">{{ item.post.title }}</h2>
26 {% endif %}
@@ -31,6 +35,22 @@
35 </div>
36 {% empty %}
37 <p class="text-sm text-gray-500 py-8 text-center">No posts in this thread.</p>
38 {% endfor %}
39 </div>
40
41 {% if has_write and is_django_thread %}
42 <div class="mt-6">
43 <h3 class="text-sm font-semibold text-gray-300 mb-3">Reply</h3>
44 <form method="post" action="{% url 'fossil:forum_reply' slug=project.slug post_id=thread_uuid %}" class="space-y-3 rounded-lg bg-gray-800 p-5 border border-gray-700">
45 {% csrf_token %}
46 <textarea name="body" rows="6" required placeholder="Write your reply in Markdown..."
47 class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm font-mono"></textarea>
48 <div class="flex justify-end">
49 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
50 Post Reply
51 </button>
52 </div>
53 </form>
54 </div>
55 {% endif %}
56 {% endblock %}
57
58 DDED templates/fossil/webhook_deliveries.html
59 DDED templates/fossil/webhook_form.html
60 DDED templates/fossil/webhook_list.html
--- a/templates/fossil/webhook_deliveries.html
+++ b/templates/fossil/webhook_deliveries.html
@@ -0,0 +1,57 @@
1
+{% extends "base.html" %}
2
+{% block title %}Deliveries — {{ webhook.url }} — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6
+{% include "fossil/_project_nav.html" %}
7
+
8
+<div class="mb-4">
9
+ <a href="{% url 'fossil:webhooks' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Webhooks</a>
10
+</div>
11
+
12
+<div class="flex items-center justify-between mb-6">
13
+ <div>
14
+ <h2 class="text-lg font-semibold text-gray-200">Delivery Log</h2>
15
+ <p class="text-sm text-gray-400 mt-1 truncate max-w-xl">{{ webhook.url }}</p>
16
+ </div>
17
+</div>
18
+
19
+{% if deliveries %}
20
+<div class="overflow-x-auto rounded-lg border border-gray-700">
21
+ <table class="min-w-full divide-y divide-gray-700">
22
+ <thead class="bg-gray-800">
23
+ <tr>
24
+ <th class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Status</th>
25
+ <th class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Event</th>
26
+ <th class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Response</th>
27
+ <th class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Duration</th>
28
+ <th class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Attempt</th>
29
+ <th class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Delivered</th>
30
+ </tr>
31
+ </thead>
32
+ <tbody class="divide-y divide-gray-800 bg-gray-900">
33
+ {% for d in deliveries %}
34
+ <tr class="hover:bg-gray-800/50">
35
+ <td class="px-4 py-3 whitespace-nowrap">
36
+ {% if d.success %}
37
+ <span class="inline-flex rounded-full bg-green-900/50 px-2 py-0.5 text-xs font-semibold text-green-300">OK</span>
38
+ {% else %}
39
+ <span class="inline-flex rounded-full bg-red-900/50 px-2 py-0.5 text-xs font-semibold text-red-300">Failed</span>
40
+ {% endif %}
41
+ </td>
42
+ <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-300">{{ d.event_type }}</td>
43
+ <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-300">{{ d.response_status|default:"--" }}</td>
44
+ <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-400">{{ d.duration_ms }}ms</td>
45
+ <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-400">#{{ d.attempt }}</td>
46
+ <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">{{ d.delivered_at|timesince }} ago</td>
47
+ </tr>
48
+ {% endfor %}
49
+ </tbody>
50
+ </table>
51
+</div>
52
+{% else %}
53
+<div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center">
54
+ <p class="text-sm text-gray-500">No deliveries yet.</p>
55
+</div>
56
+{% endif %}
57
+{% endblock %}
--- a/templates/fossil/webhook_deliveries.html
+++ b/templates/fossil/webhook_deliveries.html
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/webhook_deliveries.html
+++ b/templates/fossil/webhook_deliveries.html
@@ -0,0 +1,57 @@
1 {% extends "base.html" %}
2 {% block title %}Deliveries — {{ webhook.url }} — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="mb-4">
9 <a href="{% url 'fossil:webhooks' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Webhooks</a>
10 </div>
11
12 <div class="flex items-center justify-between mb-6">
13 <div>
14 <h2 class="text-lg font-semibold text-gray-200">Delivery Log</h2>
15 <p class="text-sm text-gray-400 mt-1 truncate max-w-xl">{{ webhook.url }}</p>
16 </div>
17 </div>
18
19 {% if deliveries %}
20 <div class="overflow-x-auto rounded-lg border border-gray-700">
21 <table class="min-w-full divide-y divide-gray-700">
22 <thead class="bg-gray-800">
23 <tr>
24 <th class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Status</th>
25 <th class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Event</th>
26 <th class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Response</th>
27 <th class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Duration</th>
28 <th class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Attempt</th>
29 <th class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Delivered</th>
30 </tr>
31 </thead>
32 <tbody class="divide-y divide-gray-800 bg-gray-900">
33 {% for d in deliveries %}
34 <tr class="hover:bg-gray-800/50">
35 <td class="px-4 py-3 whitespace-nowrap">
36 {% if d.success %}
37 <span class="inline-flex rounded-full bg-green-900/50 px-2 py-0.5 text-xs font-semibold text-green-300">OK</span>
38 {% else %}
39 <span class="inline-flex rounded-full bg-red-900/50 px-2 py-0.5 text-xs font-semibold text-red-300">Failed</span>
40 {% endif %}
41 </td>
42 <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-300">{{ d.event_type }}</td>
43 <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-300">{{ d.response_status|default:"--" }}</td>
44 <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-400">{{ d.duration_ms }}ms</td>
45 <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-400">#{{ d.attempt }}</td>
46 <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">{{ d.delivered_at|timesince }} ago</td>
47 </tr>
48 {% endfor %}
49 </tbody>
50 </table>
51 </div>
52 {% else %}
53 <div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center">
54 <p class="text-sm text-gray-500">No deliveries yet.</p>
55 </div>
56 {% endif %}
57 {% endblock %}
--- a/templates/fossil/webhook_form.html
+++ b/templates/fossil/webhook_form.html
@@ -0,0 +1,64 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ form_title }} — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6
+{% include "fossil/_project_nav.html" %}
7
+
8
+<div class="mb-4">
9
+ <a href="{% url 'fossil:webhooks' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Webhooks</a>
10
+</div>
11
+
12
+<div class="mx-auto max-w-3xl">
13
+ <h2 class="text-xl font-bold text-gray-100 mb-4">{{ form_title }}</h2>
14
+
15
+ <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
16
+ {% csrf_token %}
17
+
18
+ <div>
19
+ <label class="block text-sm font-medium text-gray-300 mb-1">Payload URL <span class="text-red-400">*</span></label>
20
+ <input type="url" name="url" required placeholder="https://example.com/webhook"
21
+ value="{% if webhook %}{{ webhook.url }}{% endif %}"
22
+ class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm font-mono">
23
+ </div>
24
+
25
+ <div>
26
+ <label class="block text-sm font-medium text-gray-300 mb-1">Secret</label>
27
+ <input type="password" name="secret" placeholder="{% if webhook and webhook.secret %}(unchanged){% else %}Optional HMAC secret{% endif %}"
28
+ class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm font-mono">
29
+ <p class="mt-1 text-xs text-gray-500">Used to sign payloads with HMAC-SHA256. Leave blank for no signature.</p>
30
+ </div>
31
+
32
+ <div>
33
+ <label class="block text-sm font-medium text-gray-300 mb-2">Events</label>
34
+ <div class="space-y-2">
35
+ {% for value, label in event_choices %}
36
+ <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
37
+ <input type="checkbox" name="events" value="{{ value }}"
38
+ {% if webhook %}{% if value in webhook.events %}checked{% endif %}{% elif value == "all" %}checked{% endif %}
39
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
40
+ {{ label }}
41
+ </label>
42
+ {% endfor %}
43
+ </div>
44
+ </div>
45
+
46
+ <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
47
+ <input type="checkbox" name="is_active"
48
+ {% if webhook %}{% if webhook.is_active %}checked{% endif %}{% else %}checked{% endif %}
49
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
50
+ Active
51
+ </label>
52
+
53
+ <div class="flex justify-end gap-3 pt-2">
54
+ <a href="{% url 'fossil:webhooks' slug=project.slug %}"
55
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
56
+ Cancel
57
+ </a>
58
+ <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
59
+ {{ submit_label }}
60
+ </button>
61
+ </div>
62
+ </form>
63
+</div>
64
+{% endblock %}
--- a/templates/fossil/webhook_form.html
+++ b/templates/fossil/webhook_form.html
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/webhook_form.html
+++ b/templates/fossil/webhook_form.html
@@ -0,0 +1,64 @@
1 {% extends "base.html" %}
2 {% block title %}{{ form_title }} — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="mb-4">
9 <a href="{% url 'fossil:webhooks' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Webhooks</a>
10 </div>
11
12 <div class="mx-auto max-w-3xl">
13 <h2 class="text-xl font-bold text-gray-100 mb-4">{{ form_title }}</h2>
14
15 <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
16 {% csrf_token %}
17
18 <div>
19 <label class="block text-sm font-medium text-gray-300 mb-1">Payload URL <span class="text-red-400">*</span></label>
20 <input type="url" name="url" required placeholder="https://example.com/webhook"
21 value="{% if webhook %}{{ webhook.url }}{% endif %}"
22 class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm font-mono">
23 </div>
24
25 <div>
26 <label class="block text-sm font-medium text-gray-300 mb-1">Secret</label>
27 <input type="password" name="secret" placeholder="{% if webhook and webhook.secret %}(unchanged){% else %}Optional HMAC secret{% endif %}"
28 class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm font-mono">
29 <p class="mt-1 text-xs text-gray-500">Used to sign payloads with HMAC-SHA256. Leave blank for no signature.</p>
30 </div>
31
32 <div>
33 <label class="block text-sm font-medium text-gray-300 mb-2">Events</label>
34 <div class="space-y-2">
35 {% for value, label in event_choices %}
36 <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
37 <input type="checkbox" name="events" value="{{ value }}"
38 {% if webhook %}{% if value in webhook.events %}checked{% endif %}{% elif value == "all" %}checked{% endif %}
39 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
40 {{ label }}
41 </label>
42 {% endfor %}
43 </div>
44 </div>
45
46 <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
47 <input type="checkbox" name="is_active"
48 {% if webhook %}{% if webhook.is_active %}checked{% endif %}{% else %}checked{% endif %}
49 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
50 Active
51 </label>
52
53 <div class="flex justify-end gap-3 pt-2">
54 <a href="{% url 'fossil:webhooks' slug=project.slug %}"
55 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
56 Cancel
57 </a>
58 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
59 {{ submit_label }}
60 </button>
61 </div>
62 </form>
63 </div>
64 {% endblock %}
--- a/templates/fossil/webhook_list.html
+++ b/templates/fossil/webhook_list.html
@@ -0,0 +1,34 @@
1
+{% extends "base.html" %}
2
+{% block title %}Webhooks — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6
+{% include "fossil/_project_nav.html" %}
7
+
8
+<div class="flex items-center justify-between mb-6">
9
+ <h2 class="text-lg font-semibold text-ase.html" %}
10
+{% block title %}Webhooks — {{ project.name }} — {% extends "base.html" %}
11
+{% block title %}Webhooks — {{ project.name }} — Fossilrepo{% endblock %}
12
+
13
+{% block content %}
14
+<h1 classAdd Webhook
15
+ </a>
16
+</div>
17
+
18
+{% if webhooks %}
19
+<div class="space-y-3">
20
+ {% for webhook in webhooks %}
21
+ <div class="rounded-lg bg-gray-800 border border-gray-700">
22
+ <div class="px-5 py-4">
23
+ <div class="flex items-start justify-between gap-4">
24
+ <div class="flex-1 min-w-0">
25
+ <div class="flex items-center gap-3 mb-1">
26
+ <span class="text-sm font-medium text-gray-100 truncate">{{ webhook.url }}</span>
27
+ {% if webhook.is_active %}
28
+ <span class="inline-flex rounded-full bg-green-900/50 px-2 py-0.5 text-xs font-semibold text-green-300">Active</span>
29
+ {% else %}
30
+ <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5 text-xs font-semibold text-gray-400">Inactive</span>
31
+ {% endif %}
32
+ </div>
33
+ <div class="flex items-center gap-3 text-xs text-gray-500 mt-1">
34
+ <span>
--- a/templates/fossil/webhook_list.html
+++ b/templates/fossil/webhook_list.html
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/webhook_list.html
+++ b/templates/fossil/webhook_list.html
@@ -0,0 +1,34 @@
1 {% extends "base.html" %}
2 {% block title %}Webhooks — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="flex items-center justify-between mb-6">
9 <h2 class="text-lg font-semibold text-ase.html" %}
10 {% block title %}Webhooks — {{ project.name }} — {% extends "base.html" %}
11 {% block title %}Webhooks — {{ project.name }} — Fossilrepo{% endblock %}
12
13 {% block content %}
14 <h1 classAdd Webhook
15 </a>
16 </div>
17
18 {% if webhooks %}
19 <div class="space-y-3">
20 {% for webhook in webhooks %}
21 <div class="rounded-lg bg-gray-800 border border-gray-700">
22 <div class="px-5 py-4">
23 <div class="flex items-start justify-between gap-4">
24 <div class="flex-1 min-w-0">
25 <div class="flex items-center gap-3 mb-1">
26 <span class="text-sm font-medium text-gray-100 truncate">{{ webhook.url }}</span>
27 {% if webhook.is_active %}
28 <span class="inline-flex rounded-full bg-green-900/50 px-2 py-0.5 text-xs font-semibold text-green-300">Active</span>
29 {% else %}
30 <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5 text-xs font-semibold text-gray-400">Inactive</span>
31 {% endif %}
32 </div>
33 <div class="flex items-center gap-3 text-xs text-gray-500 mt-1">
34 <span>
--- templates/organization/member_list.html
+++ templates/organization/member_list.html
@@ -6,16 +6,24 @@
66
<a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
77
</div>
88
99
<div class="md:flex md:items-center md:justify-between mb-6">
1010
<h1 class="text-2xl font-bold text-gray-100">Members</h1>
11
- {% if perms.organization.add_organizationmember %}
12
- <a href="{% url 'organization:member_add' %}"
13
- class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
14
- Add Member
15
- </a>
16
- {% endif %}
11
+ <div class="mt-4 md:mt-0 flex gap-3">
12
+ {% if perms.organization.change_organization or user.is_superuser %}
13
+ <a href="{% url 'organization:user_create' %}"
14
+ class="inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
15
+ Create User
16
+ </a>
17
+ {% endif %}
18
+ {% if perms.organization.add_organizationmember %}
19
+ <a href="{% url 'organization:member_add' %}"
20
+ class="inline-flex items-center rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
21
+ Add Existing Member
22
+ </a>
23
+ {% endif %}
24
+ </div>
1725
</div>
1826
1927
<div class="mb-4">
2028
<input type="search"
2129
name="search"
2230
--- templates/organization/member_list.html
+++ templates/organization/member_list.html
@@ -6,16 +6,24 @@
6 <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
7 </div>
8
9 <div class="md:flex md:items-center md:justify-between mb-6">
10 <h1 class="text-2xl font-bold text-gray-100">Members</h1>
11 {% if perms.organization.add_organizationmember %}
12 <a href="{% url 'organization:member_add' %}"
13 class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
14 Add Member
15 </a>
16 {% endif %}
 
 
 
 
 
 
 
 
17 </div>
18
19 <div class="mb-4">
20 <input type="search"
21 name="search"
22
--- templates/organization/member_list.html
+++ templates/organization/member_list.html
@@ -6,16 +6,24 @@
6 <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
7 </div>
8
9 <div class="md:flex md:items-center md:justify-between mb-6">
10 <h1 class="text-2xl font-bold text-gray-100">Members</h1>
11 <div class="mt-4 md:mt-0 flex gap-3">
12 {% if perms.organization.change_organization or user.is_superuser %}
13 <a href="{% url 'organization:user_create' %}"
14 class="inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
15 Create User
16 </a>
17 {% endif %}
18 {% if perms.organization.add_organizationmember %}
19 <a href="{% url 'organization:member_add' %}"
20 class="inline-flex items-center rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
21 Add Existing Member
22 </a>
23 {% endif %}
24 </div>
25 </div>
26
27 <div class="mb-4">
28 <input type="search"
29 name="search"
30
--- templates/organization/partials/member_table.html
+++ templates/organization/partials/member_table.html
@@ -11,12 +11,12 @@
1111
</tr>
1212
</thead>
1313
<tbody class="divide-y divide-gray-700 bg-gray-800">
1414
{% for membership in members %}
1515
<tr class="hover:bg-gray-700/50">
16
- <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-100">
17
- {{ membership.member.username }}
16
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
17
+ <a href="{% url 'organization:user_detail' username=membership.member.username %}" class="text-brand-light hover:text-brand">{{ membership.member.username }}</a>
1818
</td>
1919
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ membership.member.email|default:"—" }}</td>
2020
<td class="px-6 py-4 whitespace-nowrap">
2121
{% if membership.is_active %}
2222
<span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span>
2323
2424
ADDED templates/organization/user_detail.html
2525
ADDED templates/organization/user_form.html
2626
ADDED templates/organization/user_password.html
2727
ADDED tests/test_forum.py
2828
ADDED tests/test_split_diff.py
2929
ADDED tests/test_user_management.py
3030
ADDED tests/test_webhooks.py
--- templates/organization/partials/member_table.html
+++ templates/organization/partials/member_table.html
@@ -11,12 +11,12 @@
11 </tr>
12 </thead>
13 <tbody class="divide-y divide-gray-700 bg-gray-800">
14 {% for membership in members %}
15 <tr class="hover:bg-gray-700/50">
16 <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-100">
17 {{ membership.member.username }}
18 </td>
19 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ membership.member.email|default:"—" }}</td>
20 <td class="px-6 py-4 whitespace-nowrap">
21 {% if membership.is_active %}
22 <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span>
23
24 DDED templates/organization/user_detail.html
25 DDED templates/organization/user_form.html
26 DDED templates/organization/user_password.html
27 DDED tests/test_forum.py
28 DDED tests/test_split_diff.py
29 DDED tests/test_user_management.py
30 DDED tests/test_webhooks.py
--- templates/organization/partials/member_table.html
+++ templates/organization/partials/member_table.html
@@ -11,12 +11,12 @@
11 </tr>
12 </thead>
13 <tbody class="divide-y divide-gray-700 bg-gray-800">
14 {% for membership in members %}
15 <tr class="hover:bg-gray-700/50">
16 <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
17 <a href="{% url 'organization:user_detail' username=membership.member.username %}" class="text-brand-light hover:text-brand">{{ membership.member.username }}</a>
18 </td>
19 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ membership.member.email|default:"—" }}</td>
20 <td class="px-6 py-4 whitespace-nowrap">
21 {% if membership.is_active %}
22 <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span>
23
24 DDED templates/organization/user_detail.html
25 DDED templates/organization/user_form.html
26 DDED templates/organization/user_password.html
27 DDED tests/test_forum.py
28 DDED tests/test_split_diff.py
29 DDED tests/test_user_management.py
30 DDED tests/test_webhooks.py
--- a/templates/organization/user_detail.html
+++ b/templates/organization/user_detail.html
@@ -0,0 +1,61 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ target_user.username }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Members</a>
7
+</div>
8
+
9
+<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
10
+ <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between">
11
+ <div>
12
+ <h1 class="text-2xl font-bold text-gray-100">{{ target_user.username }}</h1>
13
+ <p class="mt-1 text-sm text-gray-400">{{ target_user.get_full_name|default:"No name set" }}</p>
14
+ </div>
15
+ <div class="mt-4 flex gap-3 sm:mt-0">
16
+ {% if can_manage %}
17
+ <a href="{% url 'organization:user_edit' username=target_user.username %}"
18
+ 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">
19
+ Edit
20
+ </a>
21
+ <a href="{% url 'organization:user_password' username=target_user.username %}"
22
+ 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">
23
+ Change Password
24
+ </a>
25
+ {% elif request.user.pk == target_user.pk %}
26
+ <a href="{% url 'organization:user_password' username=target_user.username %}"
27
+ 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">
28
+ Change Password
29
+ </a>
30
+ {% endif %}
31
+ </div>
32
+ </div>
33
+
34
+ <div class="border-t border-gray-700 px-6 py-5">
35
+ <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
36
+ <div>
37
+ <dt class="text-sm font-medium text-gray-400">Email</dt>
38
+ <dd class="mt-1 text-sm text-gray-100">{{ target_{% endif %}
39
+ </dl>
40
+ </div>
41
+</div>
42
+
43
+<div class="mt-8">
44
+ <h2 class="text-lg font-semibold text-gray-100 mb-4">Team Memberships</hidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
45
+ <table class="min-w-full divide-y divide-gray-700">
46
+ <thead class="bg-gray-900">
47
+ <tr>
48
+ <th class="px-6 py-3 te uppercase text-gray-400">Team</th>
49
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Description</th>
50
+ </tr>
51
+ </thead>
52
+ <tbody class="divide-y divide-gray-700 bg-gray-800">
53
+ {% for team in user_teams %}
54
+ <tr class="hover:bg-gray-700/50">
55
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
56
+ <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-brand-light hover:text-brand">{{ team.name }}</a>
57
+ </td>
58
+ <td class="px-6 py-4 text-sm text-gray-400">{{ team.description|default:"--"|truncatewords:15 }}</td>
59
+ </tr>
60
+ {% empty %}
61
+ <tr>
--- a/templates/organization/user_detail.html
+++ b/templates/organization/user_detail.html
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/user_detail.html
+++ b/templates/organization/user_detail.html
@@ -0,0 +1,61 @@
1 {% extends "base.html" %}
2 {% block title %}{{ target_user.username }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Members</a>
7 </div>
8
9 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
10 <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between">
11 <div>
12 <h1 class="text-2xl font-bold text-gray-100">{{ target_user.username }}</h1>
13 <p class="mt-1 text-sm text-gray-400">{{ target_user.get_full_name|default:"No name set" }}</p>
14 </div>
15 <div class="mt-4 flex gap-3 sm:mt-0">
16 {% if can_manage %}
17 <a href="{% url 'organization:user_edit' username=target_user.username %}"
18 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">
19 Edit
20 </a>
21 <a href="{% url 'organization:user_password' username=target_user.username %}"
22 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">
23 Change Password
24 </a>
25 {% elif request.user.pk == target_user.pk %}
26 <a href="{% url 'organization:user_password' username=target_user.username %}"
27 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">
28 Change Password
29 </a>
30 {% endif %}
31 </div>
32 </div>
33
34 <div class="border-t border-gray-700 px-6 py-5">
35 <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
36 <div>
37 <dt class="text-sm font-medium text-gray-400">Email</dt>
38 <dd class="mt-1 text-sm text-gray-100">{{ target_{% endif %}
39 </dl>
40 </div>
41 </div>
42
43 <div class="mt-8">
44 <h2 class="text-lg font-semibold text-gray-100 mb-4">Team Memberships</hidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
45 <table class="min-w-full divide-y divide-gray-700">
46 <thead class="bg-gray-900">
47 <tr>
48 <th class="px-6 py-3 te uppercase text-gray-400">Team</th>
49 <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Description</th>
50 </tr>
51 </thead>
52 <tbody class="divide-y divide-gray-700 bg-gray-800">
53 {% for team in user_teams %}
54 <tr class="hover:bg-gray-700/50">
55 <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
56 <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-brand-light hover:text-brand">{{ team.name }}</a>
57 </td>
58 <td class="px-6 py-4 text-sm text-gray-400">{{ team.description|default:"--"|truncatewords:15 }}</td>
59 </tr>
60 {% empty %}
61 <tr>
--- a/templates/organization/user_form.html
+++ b/templates/organization/user_form.html
@@ -0,0 +1,57 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ title }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Members</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
+
12
+ {% if edit_user %}
13
+ <div class="mb-6 rounded-lg bg-gray-800 p-4 shadow border border-gray-700">
14
+ <p class="text-sm text-gray-400">Username: <span class="font-medium text-gray-100">{{ edit_user.username }}</span></p>
15
+ </div>
16
+ {% endif %}
17
+
18
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
19
+ {% csrf_token %}
20
+
21
+ {% for field in form %}
22
+ <div>
23
+ {% if field.field.widget.input_type == "checkbox" %}
24
+ <div class="flex items-center gap-3">
25
+ {{ field }}
26
+ <label for="{{ field.id_for_label }}" class="text-sm font-medium text-gray-300">
27
+ {{ field.label }}
28
+ </label>
29
+ </div>
30
+ {% if field.help_text %}
31
+ <p class="mt-1 text-sm text-gray-500">{{ field.help_text }}</p>
32
+ {% endif %}
33
+ {% else %}
34
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
35
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
36
+ </label>
37
+ <div class="mt-1">{{ field }}</div>
38
+ {% endif %}
39
+ {% if field.errors %}
40
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
41
+ {% endif %}
42
+ </div>
43
+ {% endfor %}
44
+
45
+ <div class="flex justify-end gap-3 pt-4">
46
+ <a href="{% url 'organization:members' %}"
47
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
48
+ Cancel
49
+ </a>
50
+ <button type="submit"
51
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
52
+ {% if edit_user %}Update{% else %}Create User{% endif %}
53
+ </button>
54
+ </div>
55
+ </form>
56
+</div>
57
+{% endblock %}
--- a/templates/organization/user_form.html
+++ b/templates/organization/user_form.html
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/user_form.html
+++ b/templates/organization/user_form.html
@@ -0,0 +1,57 @@
1 {% extends "base.html" %}
2 {% block title %}{{ title }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Members</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
12 {% if edit_user %}
13 <div class="mb-6 rounded-lg bg-gray-800 p-4 shadow border border-gray-700">
14 <p class="text-sm text-gray-400">Username: <span class="font-medium text-gray-100">{{ edit_user.username }}</span></p>
15 </div>
16 {% endif %}
17
18 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
19 {% csrf_token %}
20
21 {% for field in form %}
22 <div>
23 {% if field.field.widget.input_type == "checkbox" %}
24 <div class="flex items-center gap-3">
25 {{ field }}
26 <label for="{{ field.id_for_label }}" class="text-sm font-medium text-gray-300">
27 {{ field.label }}
28 </label>
29 </div>
30 {% if field.help_text %}
31 <p class="mt-1 text-sm text-gray-500">{{ field.help_text }}</p>
32 {% endif %}
33 {% else %}
34 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
35 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
36 </label>
37 <div class="mt-1">{{ field }}</div>
38 {% endif %}
39 {% if field.errors %}
40 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
41 {% endif %}
42 </div>
43 {% endfor %}
44
45 <div class="flex justify-end gap-3 pt-4">
46 <a href="{% url 'organization:members' %}"
47 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
48 Cancel
49 </a>
50 <button type="submit"
51 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
52 {% if edit_user %}Update{% else %}Create User{% endif %}
53 </button>
54 </div>
55 </form>
56 </div>
57 {% endblock %}
--- a/templates/organization/user_password.html
+++ b/templates/organization/user_password.html
@@ -0,0 +1,41 @@
1
+{% extends "base.html" %}
2
+{% block title %}Change Password — {{ target_user.username }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:user_detail' username=target_user.username %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ target_user.username }}</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">Change Password for {{ target_user.username }}</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ </div>
25
+ {% endfor %}
26
+
27
+ <p class="text-sm text-gray-500">Password must be at least 8 characters and not entirely numeric or too common.</p>
28
+
29
+ <div class="flex justify-end gap-3 pt-4">
30
+ <a href="{% url 'organization:user_detail' username=target_user.username %}"
31
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
32
+ Cancel
33
+ </a>
34
+ <button type="submit"
35
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
36
+ Change Password
37
+ </button>
38
+ </div>
39
+ </form>
40
+</div>
41
+{% endblock %}
--- a/templates/organization/user_password.html
+++ b/templates/organization/user_password.html
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/user_password.html
+++ b/templates/organization/user_password.html
@@ -0,0 +1,41 @@
1 {% extends "base.html" %}
2 {% block title %}Change Password — {{ target_user.username }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:user_detail' username=target_user.username %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ target_user.username }}</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">Change Password for {{ target_user.username }}</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 </div>
25 {% endfor %}
26
27 <p class="text-sm text-gray-500">Password must be at least 8 characters and not entirely numeric or too common.</p>
28
29 <div class="flex justify-end gap-3 pt-4">
30 <a href="{% url 'organization:user_detail' username=target_user.username %}"
31 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
32 Cancel
33 </a>
34 <button type="submit"
35 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
36 Change Password
37 </button>
38 </div>
39 </form>
40 </div>
41 {% endblock %}
--- a/tests/test_forum.py
+++ b/tests/test_forum.py
@@ -0,0 +1,247 @@
1
+import pytest
2
+from django.contrib.auth.models import User
3
+from django.test import Client
4
+
5
+from fossil.forum import ForumPost
6
+from fossil.models import FossilRepository
7
+from organization.models import Team
8
+from projects.models import ProjectTeam
9
+
10
+
11
+@pytest.fixture
12
+def fossil_repo_obj(sample_project):
13
+ """Return the auto-created FossilRepository for sample_project."""
14
+ return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
15
+
16
+
17
+@pytest.fixture
18
+def forum_thread(fossil_repo_obj, admin_user):
19
+ """Create a root forum post (thread starter)."""
20
+ post = ForumPost.objects.create(
21
+ repository=fossil_repo_obj,
22
+ title="Test Thread",
23
+ body="This is a test thread body.",
24
+ created_by=admin_user,
25
+ )
26
+ post.thread_root = post
27
+ post.save(update_fields=["thread_root", "updated_at", "version"])
28
+ return post
29
+
30
+
31
+@pytest.fixture
32
+def forum_reply_post(forum_thread, admin_user):
33
+ """Create a reply to the thread."""
34
+ return ForumPost.objects.create(
35
+ repository=forum_thread.repository,
36
+ title="",
37
+ body="This is a reply.",
38
+ parent=forum_thread,
39
+ thread_root=forum_thread,
40
+ created_by=admin_user,
41
+ )
42
+
43
+
44
+@pytest.fixture
45
+def writer_user(db, admin_user, sample_project):
46
+ """User with write access but not admin."""
47
+ writer = User.objects.create_user(username="writer", password="testpass123")
48
+ team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user)
49
+ team.members.add(writer)
50
+ ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
51
+ return writer
52
+
53
+
54
+@pytest.fixture
55
+def writer_client(writer_user):
56
+ client = Client()
57
+ client.login(username="writer", password="testpass123")
58
+ return client
59
+
60
+
61
+# --- ForumPost Model Tests ---
62
+
63
+
64
+@pytest.mark.django_db
65
+class TestForumPostModel:
66
+ def test_create_thread(self, forum_thread):
67
+ assert forum_thread.pk is not None
68
+ assert str(forum_thread) == "Test Thread"
69
+ assert forum_thread.is_reply is False
70
+ assert forum_thread.thread_root == forum_thread
71
+
72
+ def test_create_reply(self, forum_reply_post, forum_thread):
73
+ assert forum_reply_post.pk is not None
74
+ assert forum_reply_post.is_reply is True
75
+ assert forum_reply_post.parent == forum_thread
76
+ assert forum_reply_post.thread_root == forum_thread
77
+
78
+ def test_soft_delete(self, forum_thread, admin_user):
79
+ forum_thread.soft_delete(user=admin_user)
80
+ assert forum_thread.is_deleted
81
+ assert ForumPost.objects.filter(pk=forum_thread.pk).count() == 0
82
+ assert ForumPost.all_objects.filter(pk=forum_thread.pk).count() == 1
83
+
84
+ def test_ordering(self, fossil_repo_obj, admin_user):
85
+ """Posts are ordered by created_at ascending."""
86
+ p1 = ForumPost.objects.create(repository=fossil_repo_obj, title="First", body="body", created_by=admin_user)
87
+ p2 = ForumPost.objects.create(repository=fossil_repo_obj, title="Second", body="body", created_by=admin_user)
88
+ posts = list(ForumPost.objects.filter(repository=fossil_repo_obj))
89
+ assert posts[0] == p1
90
+ assert posts[1] == p2
91
+
92
+ def test_reply_str_fallback(self, forum_reply_post):
93
+ """Replies with no title use created_by in __str__."""
94
+ result = str(forum_reply_post)
95
+ assert "admin" in result
96
+
97
+
98
+# --- Forum List View Tests ---
99
+
100
+
101
+@pytest.mark.django_db
102
+class TestForumListView:
103
+ def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
104
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/forum/")
105
+ assert response.status_code == 200
106
+ assert "No forum posts" in response.content.decode()
107
+
108
+ def test_list_with_django_thread(self, admin_client, sample_project, forum_thread):
109
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/forum/")
110
+ assert response.status_code == 200
111
+ content = response.content.decode()
112
+ assert "Test Thread" in content
113
+ assert "local" in content # Django posts show "local" badge
114
+
115
+ def test_new_thread_button_visible_for_writers(self, writer_client, sample_project, fossil_repo_obj):
116
+ response = writer_client.get(f"/projects/{sample_project.slug}/fossil/forum/")
117
+ assert response.status_code == 200
118
+ assert "New Thread" in response.content.decode()
119
+
120
+ def test_new_thread_button_hidden_for_no_perm(self, no_perm_client, sample_project, fossil_repo_obj):
121
+ # Make project public so no_perm can read it
122
+ sample_project.visibility = "public"
123
+ sample_project.save()
124
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/forum/")
125
+ assert response.status_code == 200
126
+ assert "New Thread" not in response.content.decode()
127
+
128
+ def test_list_denied_for_private_project_no_perm(self, no_perm_client, sample_project):
129
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/forum/")
130
+ assert response.status_code == 403
131
+
132
+
133
+# --- Forum Create View Tests ---
134
+
135
+
136
+@pytest.mark.django_db
137
+class TestForumCreateView:
138
+ def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
139
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/forum/create/")
140
+ assert response.status_code == 200
141
+ assert "New Thread" in response.content.decode()
142
+
143
+ def test_create_thread(self, admin_client, sample_project, fossil_repo_obj):
144
+ response = admin_client.post(
145
+ f"/projects/{sample_project.slug}/fossil/forum/create/",
146
+ {"title": "My New Thread", "body": "Thread body content"},
147
+ )
148
+ assert response.status_code == 302
149
+ post = ForumPost.objects.get(title="My New Thread")
150
+ assert post.body == "Thread body content"
151
+ assert post.thread_root == post
152
+ assert post.parent is None
153
+ assert post.created_by.username == "admin"
154
+
155
+ def test_create_denied_for_no_perm(self, no_perm_client, sample_project):
156
+ response = no_perm_client.post(
157
+ f"/projects/{sample_project.slug}/fossil/forum/create/",
158
+ {"title": "Nope", "body": "Should fail"},
159
+ )
160
+ assert response.status_code == 403
161
+
162
+ def test_create_denied_for_anon(self, client, sample_project):
163
+ response = client.get(f"/projects/{sample_project.slug}/fossil/forum/create/")
164
+ assert response.status_code == 302 # redirect to login
165
+
166
+ def test_create_empty_fields_stays_on_form(self, admin_client, sample_project, fossil_repo_obj):
167
+ response = admin_client.post(
168
+ f"/projects/{sample_project.slug}/fossil/forum/create/",
169
+ {"title": "", "body": ""},
170
+ )
171
+ assert response.status_code == 200 # stays on form, no redirect
172
+
173
+
174
+# --- Forum Reply View Tests ---
175
+
176
+
177
+@pytest.mark.django_db
178
+class TestForumReplyView:
179
+ def test_reply_form(self, admin_client, sample_project, forum_thread):
180
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/reply/")
181
+ assert response.status_code == 200
182
+ assert "Reply to:" in response.content.decode()
183
+
184
+ def test_post_reply(self, admin_client, sample_project, forum_thread):
185
+ response = admin_client.post(
186
+ f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/reply/",
187
+ {"body": "This is my reply"},
188
+ )
189
+ assert response.status_code == 302
190
+ reply = ForumPost.objects.filter(parent=forum_thread).first()
191
+ assert reply is not None
192
+ assert reply.body == "This is my reply"
193
+ assert reply.thread_root == forum_thread
194
+ assert reply.is_reply is True
195
+
196
+ def test_reply_denied_for_no_perm(self, no_perm_client, sample_project, forum_thread):
197
+ response = no_perm_client.post(
198
+ f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/reply/",
199
+ {"body": "Should fail"},
200
+ )
201
+ assert response.status_code == 403
202
+
203
+ def test_reply_denied_for_anon(self, client, sample_project, forum_thread):
204
+ response = client.post(
205
+ f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/reply/",
206
+ {"body": "Should redirect"},
207
+ )
208
+ assert response.status_code == 302 # redirect to login
209
+
210
+ def test_reply_to_nonexistent_post(self, admin_client, sample_project, fossil_repo_obj):
211
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/forum/99999/reply/")
212
+ assert response.status_code == 404
213
+
214
+
215
+# --- Forum Thread View Tests ---
216
+
217
+
218
+@pytest.mark.django_db
219
+class TestForumThreadView:
220
+ def test_django_thread_detail(self, admin_client, sample_project, forum_thread):
221
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/")
222
+ assert response.status_code == 200
223
+ content = response.content.decode()
224
+ assert "Test Thread" in content
225
+ assert "test thread body" in content.lower()
226
+
227
+ def test_django_thread_with_replies(self, admin_client, sample_project, forum_thread, forum_reply_post):
228
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/")
229
+ assert response.status_code == 200
230
+ content = response.content.decode()
231
+ assert "This is a reply" in content
232
+
233
+ def test_thread_shows_reply_form_for_writers(self, writer_client, sample_project, forum_thread):
234
+ response = writer_client.get(f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/")
235
+ assert response.status_code == 200
236
+ assert "Post Reply" in response.content.decode()
237
+
238
+ def test_thread_hides_reply_form_for_no_perm(self, no_perm_client, sample_project, forum_thread):
239
+ sample_project.visibility = "public"
240
+ sample_project.save()
241
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/")
242
+ assert response.status_code == 200
243
+ assert "Post Reply" not in response.content.decode()
244
+
245
+ def test_thread_denied_for_private_no_perm(self, no_perm_client, sample_project, forum_thread):
246
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/")
247
+ assert response.status_code == 403
--- a/tests/test_forum.py
+++ b/tests/test_forum.py
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_forum.py
+++ b/tests/test_forum.py
@@ -0,0 +1,247 @@
1 import pytest
2 from django.contrib.auth.models import User
3 from django.test import Client
4
5 from fossil.forum import ForumPost
6 from fossil.models import FossilRepository
7 from organization.models import Team
8 from projects.models import ProjectTeam
9
10
11 @pytest.fixture
12 def fossil_repo_obj(sample_project):
13 """Return the auto-created FossilRepository for sample_project."""
14 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
15
16
17 @pytest.fixture
18 def forum_thread(fossil_repo_obj, admin_user):
19 """Create a root forum post (thread starter)."""
20 post = ForumPost.objects.create(
21 repository=fossil_repo_obj,
22 title="Test Thread",
23 body="This is a test thread body.",
24 created_by=admin_user,
25 )
26 post.thread_root = post
27 post.save(update_fields=["thread_root", "updated_at", "version"])
28 return post
29
30
31 @pytest.fixture
32 def forum_reply_post(forum_thread, admin_user):
33 """Create a reply to the thread."""
34 return ForumPost.objects.create(
35 repository=forum_thread.repository,
36 title="",
37 body="This is a reply.",
38 parent=forum_thread,
39 thread_root=forum_thread,
40 created_by=admin_user,
41 )
42
43
44 @pytest.fixture
45 def writer_user(db, admin_user, sample_project):
46 """User with write access but not admin."""
47 writer = User.objects.create_user(username="writer", password="testpass123")
48 team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user)
49 team.members.add(writer)
50 ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
51 return writer
52
53
54 @pytest.fixture
55 def writer_client(writer_user):
56 client = Client()
57 client.login(username="writer", password="testpass123")
58 return client
59
60
61 # --- ForumPost Model Tests ---
62
63
64 @pytest.mark.django_db
65 class TestForumPostModel:
66 def test_create_thread(self, forum_thread):
67 assert forum_thread.pk is not None
68 assert str(forum_thread) == "Test Thread"
69 assert forum_thread.is_reply is False
70 assert forum_thread.thread_root == forum_thread
71
72 def test_create_reply(self, forum_reply_post, forum_thread):
73 assert forum_reply_post.pk is not None
74 assert forum_reply_post.is_reply is True
75 assert forum_reply_post.parent == forum_thread
76 assert forum_reply_post.thread_root == forum_thread
77
78 def test_soft_delete(self, forum_thread, admin_user):
79 forum_thread.soft_delete(user=admin_user)
80 assert forum_thread.is_deleted
81 assert ForumPost.objects.filter(pk=forum_thread.pk).count() == 0
82 assert ForumPost.all_objects.filter(pk=forum_thread.pk).count() == 1
83
84 def test_ordering(self, fossil_repo_obj, admin_user):
85 """Posts are ordered by created_at ascending."""
86 p1 = ForumPost.objects.create(repository=fossil_repo_obj, title="First", body="body", created_by=admin_user)
87 p2 = ForumPost.objects.create(repository=fossil_repo_obj, title="Second", body="body", created_by=admin_user)
88 posts = list(ForumPost.objects.filter(repository=fossil_repo_obj))
89 assert posts[0] == p1
90 assert posts[1] == p2
91
92 def test_reply_str_fallback(self, forum_reply_post):
93 """Replies with no title use created_by in __str__."""
94 result = str(forum_reply_post)
95 assert "admin" in result
96
97
98 # --- Forum List View Tests ---
99
100
101 @pytest.mark.django_db
102 class TestForumListView:
103 def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
104 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/forum/")
105 assert response.status_code == 200
106 assert "No forum posts" in response.content.decode()
107
108 def test_list_with_django_thread(self, admin_client, sample_project, forum_thread):
109 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/forum/")
110 assert response.status_code == 200
111 content = response.content.decode()
112 assert "Test Thread" in content
113 assert "local" in content # Django posts show "local" badge
114
115 def test_new_thread_button_visible_for_writers(self, writer_client, sample_project, fossil_repo_obj):
116 response = writer_client.get(f"/projects/{sample_project.slug}/fossil/forum/")
117 assert response.status_code == 200
118 assert "New Thread" in response.content.decode()
119
120 def test_new_thread_button_hidden_for_no_perm(self, no_perm_client, sample_project, fossil_repo_obj):
121 # Make project public so no_perm can read it
122 sample_project.visibility = "public"
123 sample_project.save()
124 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/forum/")
125 assert response.status_code == 200
126 assert "New Thread" not in response.content.decode()
127
128 def test_list_denied_for_private_project_no_perm(self, no_perm_client, sample_project):
129 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/forum/")
130 assert response.status_code == 403
131
132
133 # --- Forum Create View Tests ---
134
135
136 @pytest.mark.django_db
137 class TestForumCreateView:
138 def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
139 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/forum/create/")
140 assert response.status_code == 200
141 assert "New Thread" in response.content.decode()
142
143 def test_create_thread(self, admin_client, sample_project, fossil_repo_obj):
144 response = admin_client.post(
145 f"/projects/{sample_project.slug}/fossil/forum/create/",
146 {"title": "My New Thread", "body": "Thread body content"},
147 )
148 assert response.status_code == 302
149 post = ForumPost.objects.get(title="My New Thread")
150 assert post.body == "Thread body content"
151 assert post.thread_root == post
152 assert post.parent is None
153 assert post.created_by.username == "admin"
154
155 def test_create_denied_for_no_perm(self, no_perm_client, sample_project):
156 response = no_perm_client.post(
157 f"/projects/{sample_project.slug}/fossil/forum/create/",
158 {"title": "Nope", "body": "Should fail"},
159 )
160 assert response.status_code == 403
161
162 def test_create_denied_for_anon(self, client, sample_project):
163 response = client.get(f"/projects/{sample_project.slug}/fossil/forum/create/")
164 assert response.status_code == 302 # redirect to login
165
166 def test_create_empty_fields_stays_on_form(self, admin_client, sample_project, fossil_repo_obj):
167 response = admin_client.post(
168 f"/projects/{sample_project.slug}/fossil/forum/create/",
169 {"title": "", "body": ""},
170 )
171 assert response.status_code == 200 # stays on form, no redirect
172
173
174 # --- Forum Reply View Tests ---
175
176
177 @pytest.mark.django_db
178 class TestForumReplyView:
179 def test_reply_form(self, admin_client, sample_project, forum_thread):
180 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/reply/")
181 assert response.status_code == 200
182 assert "Reply to:" in response.content.decode()
183
184 def test_post_reply(self, admin_client, sample_project, forum_thread):
185 response = admin_client.post(
186 f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/reply/",
187 {"body": "This is my reply"},
188 )
189 assert response.status_code == 302
190 reply = ForumPost.objects.filter(parent=forum_thread).first()
191 assert reply is not None
192 assert reply.body == "This is my reply"
193 assert reply.thread_root == forum_thread
194 assert reply.is_reply is True
195
196 def test_reply_denied_for_no_perm(self, no_perm_client, sample_project, forum_thread):
197 response = no_perm_client.post(
198 f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/reply/",
199 {"body": "Should fail"},
200 )
201 assert response.status_code == 403
202
203 def test_reply_denied_for_anon(self, client, sample_project, forum_thread):
204 response = client.post(
205 f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/reply/",
206 {"body": "Should redirect"},
207 )
208 assert response.status_code == 302 # redirect to login
209
210 def test_reply_to_nonexistent_post(self, admin_client, sample_project, fossil_repo_obj):
211 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/forum/99999/reply/")
212 assert response.status_code == 404
213
214
215 # --- Forum Thread View Tests ---
216
217
218 @pytest.mark.django_db
219 class TestForumThreadView:
220 def test_django_thread_detail(self, admin_client, sample_project, forum_thread):
221 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/")
222 assert response.status_code == 200
223 content = response.content.decode()
224 assert "Test Thread" in content
225 assert "test thread body" in content.lower()
226
227 def test_django_thread_with_replies(self, admin_client, sample_project, forum_thread, forum_reply_post):
228 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/")
229 assert response.status_code == 200
230 content = response.content.decode()
231 assert "This is a reply" in content
232
233 def test_thread_shows_reply_form_for_writers(self, writer_client, sample_project, forum_thread):
234 response = writer_client.get(f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/")
235 assert response.status_code == 200
236 assert "Post Reply" in response.content.decode()
237
238 def test_thread_hides_reply_form_for_no_perm(self, no_perm_client, sample_project, forum_thread):
239 sample_project.visibility = "public"
240 sample_project.save()
241 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/")
242 assert response.status_code == 200
243 assert "Post Reply" not in response.content.decode()
244
245 def test_thread_denied_for_private_no_perm(self, no_perm_client, sample_project, forum_thread):
246 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/forum/{forum_thread.pk}/")
247 assert response.status_code == 403
--- a/tests/test_split_diff.py
+++ b/tests/test_split_diff.py
@@ -0,0 +1,164 @@
1
+"""Tests for the split-diff view helper (_compute_split_lines)."""
2
+
3
+from fossil.views import _compute_split_lines
4
+
5
+
6
+def _make_line(text, line_type, old_num="", new_num=""):
7
+ """Build a diff-line dict matching the shape produced by checkin_detail."""
8
+ if line_type in ("add", "del", "context") and text:
9
+ prefix = text[0]
10
+ code = text[1:]
11
+ else:
12
+ prefix = ""
13
+ code = text
14
+ return {
15
+ "text": text,
16
+ "type": line_type,
17
+ "old_num": old_num,
18
+ "new_num": new_num,
19
+ "prefix": prefix,
20
+ "code": code,
21
+ }
22
+
23
+
24
+class TestComputeSplitLines:
25
+ """Unit tests for _compute_split_lines."""
26
+
27
+ def test_context_lines_appear_on_both_sides(self):
28
+ lines = [
29
+ _make_line(" hello", "context", old_num=1, new_num=1),
30
+ _make_line(" world", "context", old_num=2, new_num=2),
31
+ ]
32
+ left, right = _compute_split_lines(lines)
33
+ assert len(left) == 2
34
+ assert len(right) == 2
35
+ assert left[0]["text"] == " hello"
36
+ assert right[0]["text"] == " hello"
37
+ assert left[1]["text"] == " world"
38
+ assert right[1]["text"] == " world"
39
+
40
+ def test_deletion_only_on_left(self):
41
+ lines = [
42
+ _make_line("-removed", "del", old_num=5),
43
+ ]
44
+ left, right = _compute_split_lines(lines)
45
+ assert len(left) == 1
46
+ assert len(right) == 1
47
+ assert left[0]["type"] == "del"
48
+ assert left[0]["text"] == "-removed"
49
+ assert right[0]["type"] == "empty"
50
+ assert right[0]["text"] == ""
51
+
52
+ def test_addition_only_on_right(self):
53
+ lines = [
54
+ _make_line("+added", "add", new_num=10),
55
+ ]
56
+ left, right = _compute_split_lines(lines)
57
+ assert len(left) == 1
58
+ assert len(right) == 1
59
+ assert left[0]["type"] == "empty"
60
+ assert right[0]["type"] == "add"
61
+ assert right[0]["text"] == "+added"
62
+
63
+ def test_paired_del_add_block(self):
64
+ """Adjacent del+add lines should be paired row-by-row."""
65
+ lines = [
66
+ _make_line("-old_a", "del", old_num=1),
67
+ _make_line("-old_b", "del", old_num=2),
68
+ _make_line("+new_a", "add", new_num=1),
69
+ _make_line("+new_b", "add", new_num=2),
70
+ ]
71
+ left, right = _compute_split_lines(lines)
72
+ assert len(left) == 2
73
+ assert len(right) == 2
74
+ assert left[0]["type"] == "del"
75
+ assert right[0]["type"] == "add"
76
+ assert left[1]["type"] == "del"
77
+ assert right[1]["type"] == "add"
78
+
79
+ def test_unequal_del_add_pads_with_empty(self):
80
+ """When there are more dels than adds, right side gets empty placeholders."""
81
+ lines = [
82
+ _make_line("-old_a", "del", old_num=1),
83
+ _make_line("-old_b", "del", old_num=2),
84
+ _make_line("-old_c", "del", old_num=3),
85
+ _make_line("+new_a", "add", new_num=1),
86
+ ]
87
+ left, right = _compute_split_lines(lines)
88
+ assert len(left) == 3
89
+ assert len(right) == 3
90
+ assert left[0]["type"] == "del"
91
+ assert right[0]["type"] == "add"
92
+ assert left[1]["type"] == "del"
93
+ assert right[1]["type"] == "empty"
94
+ assert left[2]["type"] == "del"
95
+ assert right[2]["type"] == "empty"
96
+
97
+ def test_more_adds_than_dels_pads_left(self):
98
+ """When there are more adds than dels, left side gets empty placeholders."""
99
+ lines = [
100
+ _make_line("-old", "del", old_num=1),
101
+ _make_line("+new_a", "add", new_num=1),
102
+ _make_line("+new_b", "add", new_num=2),
103
+ ]
104
+ left, right = _compute_split_lines(lines)
105
+ assert len(left) == 2
106
+ assert len(right) == 2
107
+ assert left[0]["type"] == "del"
108
+ assert right[0]["type"] == "add"
109
+ assert left[1]["type"] == "empty"
110
+ assert right[1]["type"] == "add"
111
+
112
+ def test_hunk_and_header_lines_on_both_sides(self):
113
+ lines = [
114
+ _make_line("--- a/file.py", "header"),
115
+ _make_line("+++ b/file.py", "header"),
116
+ _make_line("@@ -1,3 +1,3 @@", "hunk"),
117
+ _make_line(" ctx", "context", old_num=1, new_num=1),
118
+ ]
119
+ left, right = _compute_split_lines(lines)
120
+ assert len(left) == 4
121
+ assert len(right) == 4
122
+ assert left[0]["type"] == "header"
123
+ assert right[0]["type"] == "header"
124
+ assert left[2]["type"] == "hunk"
125
+ assert right[2]["type"] == "hunk"
126
+ assert left[3]["type"] == "context"
127
+ assert right[3]["type"] == "context"
128
+
129
+ def test_mixed_sequence(self):
130
+ """Full realistic sequence: header, hunk, context, del, add, context."""
131
+ lines = [
132
+ _make_line("--- a/f.py", "header"),
133
+ _make_line("+++ b/f.py", "header"),
134
+ _make_line("@@ -1,4 +1,4 @@", "hunk"),
135
+ _make_line(" line1", "context", old_num=1, new_num=1),
136
+ _make_line("-old2", "del", old_num=2),
137
+ _make_line("+new2", "add", new_num=2),
138
+ _make_line(" line3", "context", old_num=3, new_num=3),
139
+ ]
140
+ left, right = _compute_split_lines(lines)
141
+ # header(2) + hunk(1) + context(1) + paired del/add(1) + context(1) = 6
142
+ assert len(left) == 6
143
+ assert len(right) == 6
144
+ # Check paired del/add at index 4
145
+ assert left[4]["type"] == "del"
146
+ assert right[4]["type"] == "add"
147
+
148
+ def test_empty_input(self):
149
+ left, right = _compute_split_lines([])
150
+ assert left == []
151
+ assert right == []
152
+
153
+ def test_orphan_add_without_preceding_del(self):
154
+ """An add line not preceded by a del should still work."""
155
+ lines = [
156
+ _make_line(" ctx", "context", old_num=1, new_num=1),
157
+ _make_line("+new", "add", new_num=2),
158
+ _make_line(" ctx2", "context", old_num=2, new_num=3),
159
+ ]
160
+ left, right = _compute_split_lines(lines)
161
+ assert len(left) == 3
162
+ assert len(right) == 3
163
+ assert left[1]["type"] == "empty"
164
+ assert right[1]["type"] == "add"
--- a/tests/test_split_diff.py
+++ b/tests/test_split_diff.py
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_split_diff.py
+++ b/tests/test_split_diff.py
@@ -0,0 +1,164 @@
1 """Tests for the split-diff view helper (_compute_split_lines)."""
2
3 from fossil.views import _compute_split_lines
4
5
6 def _make_line(text, line_type, old_num="", new_num=""):
7 """Build a diff-line dict matching the shape produced by checkin_detail."""
8 if line_type in ("add", "del", "context") and text:
9 prefix = text[0]
10 code = text[1:]
11 else:
12 prefix = ""
13 code = text
14 return {
15 "text": text,
16 "type": line_type,
17 "old_num": old_num,
18 "new_num": new_num,
19 "prefix": prefix,
20 "code": code,
21 }
22
23
24 class TestComputeSplitLines:
25 """Unit tests for _compute_split_lines."""
26
27 def test_context_lines_appear_on_both_sides(self):
28 lines = [
29 _make_line(" hello", "context", old_num=1, new_num=1),
30 _make_line(" world", "context", old_num=2, new_num=2),
31 ]
32 left, right = _compute_split_lines(lines)
33 assert len(left) == 2
34 assert len(right) == 2
35 assert left[0]["text"] == " hello"
36 assert right[0]["text"] == " hello"
37 assert left[1]["text"] == " world"
38 assert right[1]["text"] == " world"
39
40 def test_deletion_only_on_left(self):
41 lines = [
42 _make_line("-removed", "del", old_num=5),
43 ]
44 left, right = _compute_split_lines(lines)
45 assert len(left) == 1
46 assert len(right) == 1
47 assert left[0]["type"] == "del"
48 assert left[0]["text"] == "-removed"
49 assert right[0]["type"] == "empty"
50 assert right[0]["text"] == ""
51
52 def test_addition_only_on_right(self):
53 lines = [
54 _make_line("+added", "add", new_num=10),
55 ]
56 left, right = _compute_split_lines(lines)
57 assert len(left) == 1
58 assert len(right) == 1
59 assert left[0]["type"] == "empty"
60 assert right[0]["type"] == "add"
61 assert right[0]["text"] == "+added"
62
63 def test_paired_del_add_block(self):
64 """Adjacent del+add lines should be paired row-by-row."""
65 lines = [
66 _make_line("-old_a", "del", old_num=1),
67 _make_line("-old_b", "del", old_num=2),
68 _make_line("+new_a", "add", new_num=1),
69 _make_line("+new_b", "add", new_num=2),
70 ]
71 left, right = _compute_split_lines(lines)
72 assert len(left) == 2
73 assert len(right) == 2
74 assert left[0]["type"] == "del"
75 assert right[0]["type"] == "add"
76 assert left[1]["type"] == "del"
77 assert right[1]["type"] == "add"
78
79 def test_unequal_del_add_pads_with_empty(self):
80 """When there are more dels than adds, right side gets empty placeholders."""
81 lines = [
82 _make_line("-old_a", "del", old_num=1),
83 _make_line("-old_b", "del", old_num=2),
84 _make_line("-old_c", "del", old_num=3),
85 _make_line("+new_a", "add", new_num=1),
86 ]
87 left, right = _compute_split_lines(lines)
88 assert len(left) == 3
89 assert len(right) == 3
90 assert left[0]["type"] == "del"
91 assert right[0]["type"] == "add"
92 assert left[1]["type"] == "del"
93 assert right[1]["type"] == "empty"
94 assert left[2]["type"] == "del"
95 assert right[2]["type"] == "empty"
96
97 def test_more_adds_than_dels_pads_left(self):
98 """When there are more adds than dels, left side gets empty placeholders."""
99 lines = [
100 _make_line("-old", "del", old_num=1),
101 _make_line("+new_a", "add", new_num=1),
102 _make_line("+new_b", "add", new_num=2),
103 ]
104 left, right = _compute_split_lines(lines)
105 assert len(left) == 2
106 assert len(right) == 2
107 assert left[0]["type"] == "del"
108 assert right[0]["type"] == "add"
109 assert left[1]["type"] == "empty"
110 assert right[1]["type"] == "add"
111
112 def test_hunk_and_header_lines_on_both_sides(self):
113 lines = [
114 _make_line("--- a/file.py", "header"),
115 _make_line("+++ b/file.py", "header"),
116 _make_line("@@ -1,3 +1,3 @@", "hunk"),
117 _make_line(" ctx", "context", old_num=1, new_num=1),
118 ]
119 left, right = _compute_split_lines(lines)
120 assert len(left) == 4
121 assert len(right) == 4
122 assert left[0]["type"] == "header"
123 assert right[0]["type"] == "header"
124 assert left[2]["type"] == "hunk"
125 assert right[2]["type"] == "hunk"
126 assert left[3]["type"] == "context"
127 assert right[3]["type"] == "context"
128
129 def test_mixed_sequence(self):
130 """Full realistic sequence: header, hunk, context, del, add, context."""
131 lines = [
132 _make_line("--- a/f.py", "header"),
133 _make_line("+++ b/f.py", "header"),
134 _make_line("@@ -1,4 +1,4 @@", "hunk"),
135 _make_line(" line1", "context", old_num=1, new_num=1),
136 _make_line("-old2", "del", old_num=2),
137 _make_line("+new2", "add", new_num=2),
138 _make_line(" line3", "context", old_num=3, new_num=3),
139 ]
140 left, right = _compute_split_lines(lines)
141 # header(2) + hunk(1) + context(1) + paired del/add(1) + context(1) = 6
142 assert len(left) == 6
143 assert len(right) == 6
144 # Check paired del/add at index 4
145 assert left[4]["type"] == "del"
146 assert right[4]["type"] == "add"
147
148 def test_empty_input(self):
149 left, right = _compute_split_lines([])
150 assert left == []
151 assert right == []
152
153 def test_orphan_add_without_preceding_del(self):
154 """An add line not preceded by a del should still work."""
155 lines = [
156 _make_line(" ctx", "context", old_num=1, new_num=1),
157 _make_line("+new", "add", new_num=2),
158 _make_line(" ctx2", "context", old_num=2, new_num=3),
159 ]
160 left, right = _compute_split_lines(lines)
161 assert len(left) == 3
162 assert len(right) == 3
163 assert left[1]["type"] == "empty"
164 assert right[1]["type"] == "add"
--- a/tests/test_user_management.py
+++ b/tests/test_user_management.py
@@ -0,0 +1,339 @@
1
+import pytest
2
+from django.contrib.auth.models import User
3
+from django.test import Client
4
+from django.urls import reverse
5
+
6
+from organization.models import OrganizationMember
7
+
8
+
9
+@pytest.fixture
10
+def org_admin_user(db, org):
11
+ """A non-superuser who has ORGANIZATION_CHANGE permission via group."""
12
+ from django.contrib.auth.models import Group, Permission
13
+
14
+ user = User.objects.create_user(username="orgadmin", email="[email protected]", password="testpass123")
15
+ group, _ = Group.objects.get_or_create(name="OrgAdmins")
16
+ change_perm = Permission.objects.get(content_type__app_label="organization", codename="change_organization")
17
+ view_perm = Permission.objects.get(content_type__app_label="organization", codename="view_organizationmember")
18
+ group.permissions.add(change_perm, view_perm)
19
+ user.groups.add(group)
20
+ OrganizationMember.objects.create(member=user, organization=org)
21
+ return user
22
+
23
+
24
+@pytest.fixture
25
+def org_admin_client(org_admin_user):
26
+ c = Client()
27
+ c.login(username="orgadmin", password="testpass123")
28
+ return c
29
+
30
+
31
+@pytest.fixture
32
+def target_user(db, org, admin_user):
33
+ """A regular user who is an org member, to be the target of management actions."""
34
+ user = User.objects.create_user(
35
+ username="targetuser", email="[email protected]", password="testpass123", first_name="Target", last_name="User"
36
+ )
37
+ OrganizationMember.objects.create(member=user, organization=org, created_by=admin_user)
38
+ return user
39
+
40
+
41
+# --- user_create ---
42
+
43
+
44
+@pytest.mark.django_db
45
+class TestUserCreate:
46
+ def test_get_form(self, admin_client):
47
+ response = admin_client.get(reverse("organization:user_create"))
48
+ assert response.status_code == 200
49
+ assert "New User" in response.content.decode()
50
+
51
+ def test_create_user(self, admin_client, org):
52
+ response = admin_client.post(
53
+ reverse("organization:user_create"),
54
+ {
55
+ "username": "newuser",
56
+ "email": "[email protected]",
57
+ "first_name": "New",
58
+ "last_name": "User",
59
+ "password1": "Str0ng!Pass99",
60
+ "password2": "Str0ng!Pass99",
61
+ },
62
+ )
63
+ assert response.status_code == 302
64
+ user = User.objects.get(username="newuser")
65
+ assert user.email == "[email protected]"
66
+ assert user.first_name == "New"
67
+ assert user.check_password("Str0ng!Pass99")
68
+ # Verify auto-added as org member
69
+ assert OrganizationMember.objects.filter(member=user, organization=org, deleted_at__isnull=True).exists()
70
+
71
+ def test_create_password_mismatch(self, admin_client):
72
+ response = admin_client.post(
73
+ reverse("organization:user_create"),
74
+ {
75
+ "username": "baduser",
76
+ "email": "[email protected]",
77
+ "password1": "Str0ng!Pass99",
78
+ "password2": "differentpass",
79
+ },
80
+ )
81
+ assert response.status_code == 200
82
+ assert not User.objects.filter(username="baduser").exists()
83
+
84
+ def test_create_duplicate_username(self, admin_client, target_user):
85
+ response = admin_client.post(
86
+ reverse("organization:user_create"),
87
+ {
88
+ "username": "targetuser",
89
+ "email": "[email protected]",
90
+ "password1": "Str0ng!Pass99",
91
+ "password2": "Str0ng!Pass99",
92
+ },
93
+ )
94
+ assert response.status_code == 200 # Form re-rendered with errors
95
+
96
+ def test_create_denied_for_viewer(self, viewer_client):
97
+ response = viewer_client.get(reverse("organization:user_create"))
98
+ assert response.status_code == 403
99
+
100
+ def test_create_denied_for_anon(self, client):
101
+ response = client.get(reverse("organization:user_create"))
102
+ assert response.status_code == 302 # Redirect to login
103
+
104
+ def test_create_allowed_for_org_admin(self, org_admin_client, org):
105
+ response = org_admin_client.post(
106
+ reverse("organization:user_create"),
107
+ {
108
+ "username": "orgcreated",
109
+ "email": "[email protected]",
110
+ "password1": "Str0ng!Pass99",
111
+ "password2": "Str0ng!Pass99",
112
+ },
113
+ )
114
+ assert response.status_code == 302
115
+ assert User.objects.filter(username="orgcreated").exists()
116
+
117
+
118
+# --- user_detail ---
119
+
120
+
121
+@pytest.mark.django_db
122
+class TestUserDetail:
123
+ def test_view_user(self, admin_client, target_user):
124
+ response = admin_client.get(reverse("organization:user_detail", kwargs={"username": "targetuser"}))
125
+ assert response.status_code == 200
126
+ content = response.content.decode()
127
+ assert "targetuser" in content
128
+ assert "[email protected]" in content
129
+ assert "Target User" in content
130
+
131
+ def test_view_shows_teams(self, admin_client, target_user, sample_team):
132
+ sample_team.members.add(target_user)
133
+ response = admin_client.get(reverse("organization:user_detail", kwargs={"username": "targetuser"}))
134
+ assert response.status_code == 200
135
+ assert "Core Devs" in response.content.decode()
136
+
137
+ def test_view_denied_for_no_perm(self, no_perm_client, target_user):
138
+ response = no_perm_client.get(reverse("organization:user_detail", kwargs={"username": "targetuser"}))
139
+ assert response.status_code == 403
140
+
141
+ def test_view_allowed_for_viewer(self, viewer_client, target_user):
142
+ response = viewer_client.get(reverse("organization:user_detail", kwargs={"username": "targetuser"}))
143
+ assert response.status_code == 200
144
+
145
+ def test_view_404_for_missing_user(self, admin_client):
146
+ response = admin_client.get(reverse("organization:user_detail", kwargs={"username": "nonexistent"}))
147
+ assert response.status_code == 404
148
+
149
+
150
+# --- user_edit ---
151
+
152
+
153
+@pytest.mark.django_db
154
+class TestUserEdit:
155
+ def test_get_edit_form(self, admin_client, target_user):
156
+ response = admin_client.get(reverse("organization:user_edit", kwargs={"username": "targetuser"}))
157
+ assert response.status_code == 200
158
+ content = response.content.decode()
159
+ assert "Edit targetuser" in content
160
+
161
+ def test_edit_user(self, admin_client, target_user):
162
+ response = admin_client.post(
163
+ reverse("organization:user_edit", kwargs={"username": "targetuser"}),
164
+ {
165
+ "email": "[email protected]",
166
+ "first_name": "Updated",
167
+ "last_name": "Name",
168
+ "is_active": "on",
169
+ },
170
+ )
171
+ assert response.status_code == 302
172
+ target_user.refresh_from_db()
173
+ assert target_user.email == "[email protected]"
174
+ assert target_user.first_name == "Updated"
175
+
176
+ def test_edit_deactivate_user(self, admin_client, target_user):
177
+ response = admin_client.post(
178
+ reverse("organization:user_edit", kwargs={"username": "targetuser"}),
179
+ {
180
+ "email": "[email protected]",
181
+ "first_name": "Target",
182
+ "last_name": "User",
183
+ # is_active omitted = False for checkbox
184
+ },
185
+ )
186
+ assert response.status_code == 302
187
+ target_user.refresh_from_db()
188
+ assert target_user.is_active is False
189
+
190
+ def test_edit_self_cannot_deactivate(self, admin_client, admin_user):
191
+ """Superuser editing themselves should not be able to toggle is_active."""
192
+ response = admin_client.post(
193
+ reverse("organization:user_edit", kwargs={"username": "admin"}),
194
+ {
195
+ "email": "[email protected]",
196
+ "first_name": "Admin",
197
+ "last_name": "",
198
+ # is_active omitted -- but field is disabled so value comes from instance
199
+ },
200
+ )
201
+ assert response.status_code == 302
202
+ admin_user.refresh_from_db()
203
+ # Should still be active because the field was disabled
204
+ assert admin_user.is_active is True
205
+
206
+ def test_edit_denied_for_viewer(self, viewer_client, target_user):
207
+ response = viewer_client.get(reverse("organization:user_edit", kwargs={"username": "targetuser"}))
208
+ assert response.status_code == 403
209
+
210
+ def test_edit_denied_for_no_perm(self, no_perm_client, target_user):
211
+ response = no_perm_client.get(reverse("organization:user_edit", kwargs={"username": "targetuser"}))
212
+ assert response.status_code == 403
213
+
214
+ def test_edit_allowed_for_org_admin(self, org_admin_client, target_user):
215
+ response = org_admin_client.post(
216
+ reverse("organization:user_edit", kwargs={"username": "targetuser"}),
217
+ {
218
+ "email": "[email protected]",
219
+ "first_name": "Org",
220
+ "last_name": "Edited",
221
+ "is_active": "on",
222
+ },
223
+ )
224
+ assert response.status_code == 302
225
+ target_user.refresh_from_db()
226
+ assert target_user.email == "[email protected]"
227
+
228
+
229
+# --- user_password ---
230
+
231
+
232
+@pytest.mark.django_db
233
+class TestUserPassword:
234
+ def test_get_password_form(self, admin_client, target_user):
235
+ response = admin_client.get(reverse("organization:user_password", kwargs={"username": "targetuser"}))
236
+ assert response.status_code == 200
237
+ assert "Change Password" in response.content.decode()
238
+
239
+ def test_change_password(self, admin_client, target_user):
240
+ response = admin_client.post(
241
+ reverse("organization:user_password", kwargs={"username": "targetuser"}),
242
+ {
243
+ "new_password1": "NewStr0ng!Pass99",
244
+ "new_password2": "NewStr0ng!Pass99",
245
+ },
246
+ )
247
+ assert response.status_code == 302
248
+ target_user.refresh_from_db()
249
+ assert target_user.check_password("NewStr0ng!Pass99")
250
+
251
+ def test_change_password_mismatch(self, admin_client, target_user):
252
+ response = admin_client.post(
253
+ reverse("organization:user_password", kwargs={"username": "targetuser"}),
254
+ {
255
+ "new_password1": "NewStr0ng!Pass99",
256
+ "new_password2": "different",
257
+ },
258
+ )
259
+ assert response.status_code == 200 # Form re-rendered
260
+ target_user.refresh_from_db()
261
+ assert target_user.check_password("testpass123") # Unchanged
262
+
263
+ def test_change_own_password(self, target_user):
264
+ """A regular user (no special perms) can change their own password."""
265
+ c = Client()
266
+ c.login(username="targetuser", password="testpass123")
267
+ response = c.post(
268
+ reverse("organization:user_password", kwargs={"username": "targetuser"}),
269
+ {
270
+ "new_password1": "MyNewStr0ng!Pass99",
271
+ "new_password2": "MyNewStr0ng!Pass99",
272
+ },
273
+ )
274
+ assert response.status_code == 302
275
+ target_user.refresh_from_db()
276
+ assert target_user.check_password("MyNewStr0ng!Pass99")
277
+
278
+ def test_change_other_password_denied_for_no_perm(self, no_perm_client, target_user):
279
+ response = no_perm_client.post(
280
+ reverse("organization:user_password", kwargs={"username": "targetuser"}),
281
+ {
282
+ "new_password1": "HackedStr0ng!Pass99",
283
+ "new_password2": "HackedStr0ng!Pass99",
284
+ },
285
+ )
286
+ assert response.status_code == 403
287
+ target_user.refresh_from_db()
288
+ assert target_user.check_password("testpass123") # Unchanged
289
+
290
+ def test_change_other_password_denied_for_viewer(self, viewer_client, target_user):
291
+ response = viewer_client.post(
292
+ reverse("organization:user_password", kwargs={"username": "targetuser"}),
293
+ {
294
+ "new_password1": "HackedStr0ng!Pass99",
295
+ "new_password2": "HackedStr0ng!Pass99",
296
+ },
297
+ )
298
+ assert response.status_code == 403
299
+
300
+ def test_change_password_denied_for_anon(self, client, target_user):
301
+ response = client.get(reverse("organization:user_password", kwargs={"username": "targetuser"}))
302
+ assert response.status_code == 302 # Redirect to login
303
+
304
+ def test_change_other_password_allowed_for_org_admin(self, org_admin_client, target_user):
305
+ response = org_admin_client.post(
306
+ reverse("organization:user_password", kwargs={"username": "targetuser"}),
307
+ {
308
+ "new_password1": "OrgAdminSet!Pass99",
309
+ "new_password2": "OrgAdminSet!Pass99",
310
+ },
311
+ )
312
+ assert response.status_code == 302
313
+ target_user.refresh_from_db()
314
+ assert target_user.check_password("OrgAdminSet!Pass99")
315
+
316
+
317
+# --- member_list updates ---
318
+
319
+
320
+@pytest.mark.django_db
321
+class TestMemberListUpdates:
322
+ def test_usernames_are_clickable_links(self, admin_client, org, admin_user):
323
+ response = admin_client.get(reverse("organization:members"))
324
+ assert response.status_code == 200
325
+ content = response.content.decode()
326
+ expected_url = reverse("organization:user_detail", kwargs={"username": admin_user.username})
327
+ assert expected_url in content
328
+
329
+ def test_create_user_button_visible_for_superuser(self, admin_client, org):
330
+ response = admin_client.get(reverse("organization:members"))
331
+ assert response.status_code == 200
332
+ content = response.content.decode()
333
+ assert "Create User" in content
334
+
335
+ def test_create_user_button_hidden_for_viewer(self, viewer_client, org):
336
+ response = viewer_client.get(reverse("organization:members"))
337
+ assert response.status_code == 200
338
+ content = response.content.decode()
339
+ assert "Create User" not in content
--- a/tests/test_user_management.py
+++ b/tests/test_user_management.py
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_user_management.py
+++ b/tests/test_user_management.py
@@ -0,0 +1,339 @@
1 import pytest
2 from django.contrib.auth.models import User
3 from django.test import Client
4 from django.urls import reverse
5
6 from organization.models import OrganizationMember
7
8
9 @pytest.fixture
10 def org_admin_user(db, org):
11 """A non-superuser who has ORGANIZATION_CHANGE permission via group."""
12 from django.contrib.auth.models import Group, Permission
13
14 user = User.objects.create_user(username="orgadmin", email="[email protected]", password="testpass123")
15 group, _ = Group.objects.get_or_create(name="OrgAdmins")
16 change_perm = Permission.objects.get(content_type__app_label="organization", codename="change_organization")
17 view_perm = Permission.objects.get(content_type__app_label="organization", codename="view_organizationmember")
18 group.permissions.add(change_perm, view_perm)
19 user.groups.add(group)
20 OrganizationMember.objects.create(member=user, organization=org)
21 return user
22
23
24 @pytest.fixture
25 def org_admin_client(org_admin_user):
26 c = Client()
27 c.login(username="orgadmin", password="testpass123")
28 return c
29
30
31 @pytest.fixture
32 def target_user(db, org, admin_user):
33 """A regular user who is an org member, to be the target of management actions."""
34 user = User.objects.create_user(
35 username="targetuser", email="[email protected]", password="testpass123", first_name="Target", last_name="User"
36 )
37 OrganizationMember.objects.create(member=user, organization=org, created_by=admin_user)
38 return user
39
40
41 # --- user_create ---
42
43
44 @pytest.mark.django_db
45 class TestUserCreate:
46 def test_get_form(self, admin_client):
47 response = admin_client.get(reverse("organization:user_create"))
48 assert response.status_code == 200
49 assert "New User" in response.content.decode()
50
51 def test_create_user(self, admin_client, org):
52 response = admin_client.post(
53 reverse("organization:user_create"),
54 {
55 "username": "newuser",
56 "email": "[email protected]",
57 "first_name": "New",
58 "last_name": "User",
59 "password1": "Str0ng!Pass99",
60 "password2": "Str0ng!Pass99",
61 },
62 )
63 assert response.status_code == 302
64 user = User.objects.get(username="newuser")
65 assert user.email == "[email protected]"
66 assert user.first_name == "New"
67 assert user.check_password("Str0ng!Pass99")
68 # Verify auto-added as org member
69 assert OrganizationMember.objects.filter(member=user, organization=org, deleted_at__isnull=True).exists()
70
71 def test_create_password_mismatch(self, admin_client):
72 response = admin_client.post(
73 reverse("organization:user_create"),
74 {
75 "username": "baduser",
76 "email": "[email protected]",
77 "password1": "Str0ng!Pass99",
78 "password2": "differentpass",
79 },
80 )
81 assert response.status_code == 200
82 assert not User.objects.filter(username="baduser").exists()
83
84 def test_create_duplicate_username(self, admin_client, target_user):
85 response = admin_client.post(
86 reverse("organization:user_create"),
87 {
88 "username": "targetuser",
89 "email": "[email protected]",
90 "password1": "Str0ng!Pass99",
91 "password2": "Str0ng!Pass99",
92 },
93 )
94 assert response.status_code == 200 # Form re-rendered with errors
95
96 def test_create_denied_for_viewer(self, viewer_client):
97 response = viewer_client.get(reverse("organization:user_create"))
98 assert response.status_code == 403
99
100 def test_create_denied_for_anon(self, client):
101 response = client.get(reverse("organization:user_create"))
102 assert response.status_code == 302 # Redirect to login
103
104 def test_create_allowed_for_org_admin(self, org_admin_client, org):
105 response = org_admin_client.post(
106 reverse("organization:user_create"),
107 {
108 "username": "orgcreated",
109 "email": "[email protected]",
110 "password1": "Str0ng!Pass99",
111 "password2": "Str0ng!Pass99",
112 },
113 )
114 assert response.status_code == 302
115 assert User.objects.filter(username="orgcreated").exists()
116
117
118 # --- user_detail ---
119
120
121 @pytest.mark.django_db
122 class TestUserDetail:
123 def test_view_user(self, admin_client, target_user):
124 response = admin_client.get(reverse("organization:user_detail", kwargs={"username": "targetuser"}))
125 assert response.status_code == 200
126 content = response.content.decode()
127 assert "targetuser" in content
128 assert "[email protected]" in content
129 assert "Target User" in content
130
131 def test_view_shows_teams(self, admin_client, target_user, sample_team):
132 sample_team.members.add(target_user)
133 response = admin_client.get(reverse("organization:user_detail", kwargs={"username": "targetuser"}))
134 assert response.status_code == 200
135 assert "Core Devs" in response.content.decode()
136
137 def test_view_denied_for_no_perm(self, no_perm_client, target_user):
138 response = no_perm_client.get(reverse("organization:user_detail", kwargs={"username": "targetuser"}))
139 assert response.status_code == 403
140
141 def test_view_allowed_for_viewer(self, viewer_client, target_user):
142 response = viewer_client.get(reverse("organization:user_detail", kwargs={"username": "targetuser"}))
143 assert response.status_code == 200
144
145 def test_view_404_for_missing_user(self, admin_client):
146 response = admin_client.get(reverse("organization:user_detail", kwargs={"username": "nonexistent"}))
147 assert response.status_code == 404
148
149
150 # --- user_edit ---
151
152
153 @pytest.mark.django_db
154 class TestUserEdit:
155 def test_get_edit_form(self, admin_client, target_user):
156 response = admin_client.get(reverse("organization:user_edit", kwargs={"username": "targetuser"}))
157 assert response.status_code == 200
158 content = response.content.decode()
159 assert "Edit targetuser" in content
160
161 def test_edit_user(self, admin_client, target_user):
162 response = admin_client.post(
163 reverse("organization:user_edit", kwargs={"username": "targetuser"}),
164 {
165 "email": "[email protected]",
166 "first_name": "Updated",
167 "last_name": "Name",
168 "is_active": "on",
169 },
170 )
171 assert response.status_code == 302
172 target_user.refresh_from_db()
173 assert target_user.email == "[email protected]"
174 assert target_user.first_name == "Updated"
175
176 def test_edit_deactivate_user(self, admin_client, target_user):
177 response = admin_client.post(
178 reverse("organization:user_edit", kwargs={"username": "targetuser"}),
179 {
180 "email": "[email protected]",
181 "first_name": "Target",
182 "last_name": "User",
183 # is_active omitted = False for checkbox
184 },
185 )
186 assert response.status_code == 302
187 target_user.refresh_from_db()
188 assert target_user.is_active is False
189
190 def test_edit_self_cannot_deactivate(self, admin_client, admin_user):
191 """Superuser editing themselves should not be able to toggle is_active."""
192 response = admin_client.post(
193 reverse("organization:user_edit", kwargs={"username": "admin"}),
194 {
195 "email": "[email protected]",
196 "first_name": "Admin",
197 "last_name": "",
198 # is_active omitted -- but field is disabled so value comes from instance
199 },
200 )
201 assert response.status_code == 302
202 admin_user.refresh_from_db()
203 # Should still be active because the field was disabled
204 assert admin_user.is_active is True
205
206 def test_edit_denied_for_viewer(self, viewer_client, target_user):
207 response = viewer_client.get(reverse("organization:user_edit", kwargs={"username": "targetuser"}))
208 assert response.status_code == 403
209
210 def test_edit_denied_for_no_perm(self, no_perm_client, target_user):
211 response = no_perm_client.get(reverse("organization:user_edit", kwargs={"username": "targetuser"}))
212 assert response.status_code == 403
213
214 def test_edit_allowed_for_org_admin(self, org_admin_client, target_user):
215 response = org_admin_client.post(
216 reverse("organization:user_edit", kwargs={"username": "targetuser"}),
217 {
218 "email": "[email protected]",
219 "first_name": "Org",
220 "last_name": "Edited",
221 "is_active": "on",
222 },
223 )
224 assert response.status_code == 302
225 target_user.refresh_from_db()
226 assert target_user.email == "[email protected]"
227
228
229 # --- user_password ---
230
231
232 @pytest.mark.django_db
233 class TestUserPassword:
234 def test_get_password_form(self, admin_client, target_user):
235 response = admin_client.get(reverse("organization:user_password", kwargs={"username": "targetuser"}))
236 assert response.status_code == 200
237 assert "Change Password" in response.content.decode()
238
239 def test_change_password(self, admin_client, target_user):
240 response = admin_client.post(
241 reverse("organization:user_password", kwargs={"username": "targetuser"}),
242 {
243 "new_password1": "NewStr0ng!Pass99",
244 "new_password2": "NewStr0ng!Pass99",
245 },
246 )
247 assert response.status_code == 302
248 target_user.refresh_from_db()
249 assert target_user.check_password("NewStr0ng!Pass99")
250
251 def test_change_password_mismatch(self, admin_client, target_user):
252 response = admin_client.post(
253 reverse("organization:user_password", kwargs={"username": "targetuser"}),
254 {
255 "new_password1": "NewStr0ng!Pass99",
256 "new_password2": "different",
257 },
258 )
259 assert response.status_code == 200 # Form re-rendered
260 target_user.refresh_from_db()
261 assert target_user.check_password("testpass123") # Unchanged
262
263 def test_change_own_password(self, target_user):
264 """A regular user (no special perms) can change their own password."""
265 c = Client()
266 c.login(username="targetuser", password="testpass123")
267 response = c.post(
268 reverse("organization:user_password", kwargs={"username": "targetuser"}),
269 {
270 "new_password1": "MyNewStr0ng!Pass99",
271 "new_password2": "MyNewStr0ng!Pass99",
272 },
273 )
274 assert response.status_code == 302
275 target_user.refresh_from_db()
276 assert target_user.check_password("MyNewStr0ng!Pass99")
277
278 def test_change_other_password_denied_for_no_perm(self, no_perm_client, target_user):
279 response = no_perm_client.post(
280 reverse("organization:user_password", kwargs={"username": "targetuser"}),
281 {
282 "new_password1": "HackedStr0ng!Pass99",
283 "new_password2": "HackedStr0ng!Pass99",
284 },
285 )
286 assert response.status_code == 403
287 target_user.refresh_from_db()
288 assert target_user.check_password("testpass123") # Unchanged
289
290 def test_change_other_password_denied_for_viewer(self, viewer_client, target_user):
291 response = viewer_client.post(
292 reverse("organization:user_password", kwargs={"username": "targetuser"}),
293 {
294 "new_password1": "HackedStr0ng!Pass99",
295 "new_password2": "HackedStr0ng!Pass99",
296 },
297 )
298 assert response.status_code == 403
299
300 def test_change_password_denied_for_anon(self, client, target_user):
301 response = client.get(reverse("organization:user_password", kwargs={"username": "targetuser"}))
302 assert response.status_code == 302 # Redirect to login
303
304 def test_change_other_password_allowed_for_org_admin(self, org_admin_client, target_user):
305 response = org_admin_client.post(
306 reverse("organization:user_password", kwargs={"username": "targetuser"}),
307 {
308 "new_password1": "OrgAdminSet!Pass99",
309 "new_password2": "OrgAdminSet!Pass99",
310 },
311 )
312 assert response.status_code == 302
313 target_user.refresh_from_db()
314 assert target_user.check_password("OrgAdminSet!Pass99")
315
316
317 # --- member_list updates ---
318
319
320 @pytest.mark.django_db
321 class TestMemberListUpdates:
322 def test_usernames_are_clickable_links(self, admin_client, org, admin_user):
323 response = admin_client.get(reverse("organization:members"))
324 assert response.status_code == 200
325 content = response.content.decode()
326 expected_url = reverse("organization:user_detail", kwargs={"username": admin_user.username})
327 assert expected_url in content
328
329 def test_create_user_button_visible_for_superuser(self, admin_client, org):
330 response = admin_client.get(reverse("organization:members"))
331 assert response.status_code == 200
332 content = response.content.decode()
333 assert "Create User" in content
334
335 def test_create_user_button_hidden_for_viewer(self, viewer_client, org):
336 response = viewer_client.get(reverse("organization:members"))
337 assert response.status_code == 200
338 content = response.content.decode()
339 assert "Create User" not in content
--- a/tests/test_webhooks.py
+++ b/tests/test_webhooks.py
@@ -0,0 +1,326 @@
1
+import json
2
+from unittest.mock import patch
3
+
4
+import pytest
5
+from django.contrib.auth.models import User
6
+from django.test import Client
7
+
8
+from fossil.models import FossilRepository
9
+from fossil.webhooks import Webhook, WebhookDelivery
10
+from organization.models import Team
11
+from projects.models import ProjectTeam
12
+
13
+
14
+@pytest.fixture
15
+def fossil_repo_obj(sample_project):
16
+ """Return the auto-created FossilRepository for sample_project."""
17
+ return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
18
+
19
+
20
+@pytest.fixture
21
+def webhook(fossil_repo_obj, admin_user):
22
+ return Webhook.objects.create(
23
+ repository=fossil_repo_obj,
24
+ url="https://example.com/webhook",
25
+ secret="test-secret",
26
+ events="all",
27
+ is_active=True,
28
+ created_by=admin_user,
29
+ )
30
+
31
+
32
+@pytest.fixture
33
+def inactive_webhook(fossil_repo_obj, admin_user):
34
+ return Webhook.objects.create(
35
+ repository=fossil_repo_obj,
36
+ url="https://example.com/inactive",
37
+ secret="",
38
+ events="checkin",
39
+ is_active=False,
40
+ created_by=admin_user,
41
+ )
42
+
43
+
44
+@pytest.fixture
45
+def delivery(webhook):
46
+ return WebhookDelivery.objects.create(
47
+ webhook=webhook,
48
+ event_type="checkin",
49
+ payload={"hash": "abc123", "user": "dev"},
50
+ response_status=200,
51
+ response_body="OK",
52
+ success=True,
53
+ duration_ms=150,
54
+ attempt=1,
55
+ )
56
+
57
+
58
+@pytest.fixture
59
+def writer_user(db, admin_user, sample_project):
60
+ """User with write access but not admin."""
61
+ writer = User.objects.create_user(username="writer", password="testpass123")
62
+ team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user)
63
+ team.members.add(writer)
64
+ ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
65
+ return writer
66
+
67
+
68
+@pytest.fixture
69
+def writer_client(writer_user):
70
+ client = Client()
71
+ client.login(username="writer", password="testpass123")
72
+ return client
73
+
74
+
75
+# --- Webhook Model Tests ---
76
+
77
+
78
+@pytest.mark.django_db
79
+class TestWebhookModel:
80
+ def test_create_webhook(self, webhook):
81
+ assert webhook.pk is not None
82
+ assert str(webhook) == "https://example.com/webhook (all)"
83
+
84
+ def test_soft_delete(self, webhook, admin_user):
85
+ webhook.soft_delete(user=admin_user)
86
+ assert webhook.is_deleted
87
+ assert Webhook.objects.filter(pk=webhook.pk).count() == 0
88
+ assert Webhook.all_objects.filter(pk=webhook.pk).count() == 1
89
+
90
+ def test_secret_encrypted_at_rest(self, webhook):
91
+ """EncryptedTextField encrypts the value in the DB."""
92
+ # Read raw value from DB bypassing the field's from_db_value
93
+ from django.db import connection
94
+
95
+ with connection.cursor() as cursor:
96
+ cursor.execute("SELECT secret FROM fossil_webhook WHERE id = %s", [webhook.pk])
97
+ raw = cursor.fetchone()[0]
98
+ # Raw DB value should NOT be the plaintext
99
+ assert raw != "test-secret"
100
+ # But accessing via the model decrypts it
101
+ webhook.refresh_from_db()
102
+ assert webhook.secret == "test-secret"
103
+
104
+ def test_ordering(self, fossil_repo_obj, admin_user):
105
+ w1 = Webhook.objects.create(repository=fossil_repo_obj, url="https://a.com/hook", events="all", created_by=admin_user)
106
+ w2 = Webhook.objects.create(repository=fossil_repo_obj, url="https://b.com/hook", events="all", created_by=admin_user)
107
+ hooks = list(Webhook.objects.filter(repository=fossil_repo_obj))
108
+ # Ordered by -created_at, so newest first
109
+ assert hooks[0] == w2
110
+ assert hooks[1] == w1
111
+
112
+
113
+@pytest.mark.django_db
114
+class TestWebhookDeliveryModel:
115
+ def test_create_delivery(self, delivery):
116
+ assert delivery.pk is not None
117
+ assert delivery.success is True
118
+ assert delivery.response_status == 200
119
+ assert "abc123" in json.dumps(delivery.payload)
120
+
121
+ def test_delivery_str(self, delivery):
122
+ assert "example.com/webhook" in str(delivery)
123
+
124
+ def test_ordering(self, webhook):
125
+ d1 = WebhookDelivery.objects.create(webhook=webhook, event_type="checkin", payload={}, success=True)
126
+ d2 = WebhookDelivery.objects.create(webhook=webhook, event_type="ticket", payload={}, success=False)
127
+ deliveries = list(WebhookDelivery.objects.filter(webhook=webhook))
128
+ # Ordered by -delivered_at, so newest first
129
+ assert deliveries[0] == d2
130
+ assert deliveries[1] == d1
131
+
132
+
133
+# --- Webhook List View Tests ---
134
+
135
+
136
+@pytest.mark.django_db
137
+class TestWebhookListView:
138
+ def test_list_webhooks(self, admin_client, sample_project, webhook):
139
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/")
140
+ assert response.status_code == 200
141
+ content = response.content.decode()
142
+ assert "example.com/webhook" in content
143
+ assert "Active" in content
144
+
145
+ def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
146
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/")
147
+ assert response.status_code == 200
148
+ assert "No webhooks configured" in response.content.decode()
149
+
150
+ def test_list_denied_for_writer(self, writer_client, sample_project, webhook):
151
+ """Webhook management requires admin, not just write."""
152
+ response = writer_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/")
153
+ assert response.status_code == 403
154
+
155
+ def test_list_denied_for_no_perm(self, no_perm_client, sample_project):
156
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/")
157
+ assert response.status_code == 403
158
+
159
+ def test_list_denied_for_anon(self, client, sample_project):
160
+ response = client.get(f"/projects/{sample_project.slug}/fossil/webhooks/")
161
+ assert response.status_code == 302 # redirect to login
162
+
163
+
164
+# --- Webhook Create View Tests ---
165
+
166
+
167
+@pytest.mark.django_db
168
+class TestWebhookCreateView:
169
+ def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
170
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/create/")
171
+ assert response.status_code == 200
172
+ assert "Create Webhook" in respbhook management requirnt
173
+
174
+ def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
175
+ response.status_code == 302 # redirect to login
176
+
177
+
178
+# --- Webhook Create View Te assert "example.com/webhook" in content
179
+ assert "Active" in content
180
+
181
+ def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
182
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/")
183
+ assert response.status_code == 200
184
+ assert "No webhooks configured" in response.contentbhook managemeadmin_client, sample_project, fossil_repo_obj):
185
+ response = admin_client. response.status_code == 302 # redirect to login
186
+
187
+
188
+# --- Webhook Create View Te assert "exall"ontent
189
+
190
+ def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
191
+ response = admin_client.get(f"/projects/{sampleall")
192
+ assert on(self, client, sample_project):
193
+ response = client.get(f"/projects/{sample_project.slug}/fossil/webhooks/")
194
+ assert response.status_code == 302 # redirect to login
195
+
196
+
197
+# --- Webhook Create View Tests ---
198
+
199
+
200
+@pytest.mark.django_db
201
+class TestWebhookCreateView:
202
+ def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
203
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/create/")
204
+ assert response.status_code == 200
205
+ assert "Create Webhook" in response.content.decode()
206
+
207
+ @patch("core.url_validation.is_safe_outbound_url", return_value=(True, ""))
208
+ def test_create_webhook(self, mock_url_check, admin_client, sample_project, fossil_repo_obj):
209
+ def test_edit_webhook(self, adm """Editing without providossil/webhooks/")
210
+ assert response.status_code == 403
211
+
212
+ def test_list_dject.slug}/fossil/webhort webhook.url == "https:, "events": ["wiki"], "is_active": "on"},
213
+ )
214
+ assert response.status_code == 302
215
+ webhook.refresh_from_db()
216
+ assert webhook.url == "https://new-url.example.com/hook"
217
+ assert webhook.events == "wiki"
218
+
219
+ def test_edit_preserves_secret_when_blank(self, admin_client, sample_project, webhook):
220
+ """Editing without providing a new secret should keep the old one."""
221
+ old_secret = webhook.secret
222
+ response = admin_client.post(
223
+ f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/edit/",
224
+ {"url": "https://example.com/webhook", "secret": "", "events": ["all"], "is_active": "on"},
225
+ )
226
+ assert response.status_code == 302
227
+ webhook.refresh_from_db()
228
+ assert webhook.secret == old_secret
229
+
230
+ def test_edit_denied_for_writer(self, writer_client, sample_project, webhook):
231
+ response = writer_client.post(
232
+ f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/edit/",
233
+ {"url": "https://evil.com/hook"},
234
+ )
235
+ assert response.status_code == 403
236
+
237
+ def test_edit_nonexistent_webhook(self, admin_client, sample_project, fossil_repo_obj):
238
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/99999/edit/")
239
+ assert response.status_code == 404
240
+
241
+
242
+# --- Webhook Delete View Tests ---
243
+
244
+
245
+@pytest.mark.django_db
246
+class TestWebhookDeleteView:
247
+ def test_delete_webhook(self, admin_client, sample_project, webhook):
248
+ response = admin_client.post(f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/delete/")
249
+ assert response.status_code == 302
250
+ webhook.refresh_from_db()
251
+ assert webhook.is_deleted
252
+
253
+ def test_delete_get_redirects(self, admin_client, sample_project, webhook):
254
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/delete/")
255
+ assert response.status_code == 302 # GET redirects to list
256
+
257
+ def test_delete_denied_for_writer(self, writer_client, sample_project, webhook):
258
+ response = writer_client.post(f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/delete/")
259
+ assert response.status_code == 403
260
+
261
+
262
+# --- Webhook Deliveries View Tests ---
263
+
264
+
265
+@pytest.mark.django_db
266
+class TestWebhookDeliveriesView:
267
+ def test_view_deliveries(self, admin_client, sample_project, webhook, delivery):
268
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/deliveries/")
269
+ assert response.status_code == 200
270
+ content = response.content.decode()
271
+ assert "checkin" in content
272
+ assert "200" in content
273
+ assert "150ms" in content
274
+
275
+ def test_view_empty_deliveries(self, admin_client, sample_project, webhook):
276
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/deliveries/")
277
+ assert response.status_code == 200
278
+ assert "No deliveries yet" in response.content.decode()
279
+
280
+ def test_deliveries_denied_for_writer(self, writer_client, sample_project, webhook):
281
+ response = writer_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/deliveries/")
282
+ assert response.status_code == 403
283
+
284
+
285
+# --- Webhook Dispatch Task Tests ---
286
+
287
+
288
+@pytest.mark.django_db
289
+class TestDispatchWebhookTask:
290
+ def test_successful_delivery(self, webhook):
291
+ """Test dispatch_webhook task with a successful response."""
292
+ from fossil.tasks import dispatch_webhook
293
+
294
+ mock_response = type("Response", (), {"status_code": 200, "text": "OK"})()
295
+
296
+ with patch("requests.post", return_value=mock_response):
297
+ dispatch_webhook.apply(args=[webhook.pk, "checkin", {"hash": "abc"}])
298
+
299
+ delivery = WebhookDelivery.objects.get(webhook=webhook)
300
+ assert delivery.success is True
301
+ assert delivery.response_status == 200
302
+ assert delivery.event_type == "checkin"
303
+
304
+ def test_failed_delivery_logs(self, webhook):
305
+ """Test that failed HTTP responses are logged and delivery is recorded."""
306
+ from fossil.tasks import dispatch_webhook
307
+
308
+ mock_response = type("Response", (), {"status_code": 500, "text": "Internal Server Error"})()
309
+
310
+ with patch("requests.post", return_value=mock_response):
311
+ dispatch_webhook.apply(args=[webhook.pk, "checkin", {"hash": "abc"}])
312
+
313
+ # The task retries on non-2xx, so apply() catches the Retry internally
314
+ delivery = WebhookDelivery.objects.filter(webhook=webhook).first()
315
+ assert delivery is not None
316
+ assert delivery.success is False
317
+ assert delivery.response_status == 500
318
+
319
+ def test_nonexistent_webhook_no_crash(self):
320
+ """Task should handle missing webhook gracefully."""
321
+ from fossil.tasks import dispatch_webhook
322
+
323
+ # Should return without error (logs warning)
324
+ dispatch_webhook.apply(args=[99999, "checkin", {"hash": "abc"}])
325
+ # No deliveries created
326
+ a
--- a/tests/test_webhooks.py
+++ b/tests/test_webhooks.py
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_webhooks.py
+++ b/tests/test_webhooks.py
@@ -0,0 +1,326 @@
1 import json
2 from unittest.mock import patch
3
4 import pytest
5 from django.contrib.auth.models import User
6 from django.test import Client
7
8 from fossil.models import FossilRepository
9 from fossil.webhooks import Webhook, WebhookDelivery
10 from organization.models import Team
11 from projects.models import ProjectTeam
12
13
14 @pytest.fixture
15 def fossil_repo_obj(sample_project):
16 """Return the auto-created FossilRepository for sample_project."""
17 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
18
19
20 @pytest.fixture
21 def webhook(fossil_repo_obj, admin_user):
22 return Webhook.objects.create(
23 repository=fossil_repo_obj,
24 url="https://example.com/webhook",
25 secret="test-secret",
26 events="all",
27 is_active=True,
28 created_by=admin_user,
29 )
30
31
32 @pytest.fixture
33 def inactive_webhook(fossil_repo_obj, admin_user):
34 return Webhook.objects.create(
35 repository=fossil_repo_obj,
36 url="https://example.com/inactive",
37 secret="",
38 events="checkin",
39 is_active=False,
40 created_by=admin_user,
41 )
42
43
44 @pytest.fixture
45 def delivery(webhook):
46 return WebhookDelivery.objects.create(
47 webhook=webhook,
48 event_type="checkin",
49 payload={"hash": "abc123", "user": "dev"},
50 response_status=200,
51 response_body="OK",
52 success=True,
53 duration_ms=150,
54 attempt=1,
55 )
56
57
58 @pytest.fixture
59 def writer_user(db, admin_user, sample_project):
60 """User with write access but not admin."""
61 writer = User.objects.create_user(username="writer", password="testpass123")
62 team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user)
63 team.members.add(writer)
64 ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
65 return writer
66
67
68 @pytest.fixture
69 def writer_client(writer_user):
70 client = Client()
71 client.login(username="writer", password="testpass123")
72 return client
73
74
75 # --- Webhook Model Tests ---
76
77
78 @pytest.mark.django_db
79 class TestWebhookModel:
80 def test_create_webhook(self, webhook):
81 assert webhook.pk is not None
82 assert str(webhook) == "https://example.com/webhook (all)"
83
84 def test_soft_delete(self, webhook, admin_user):
85 webhook.soft_delete(user=admin_user)
86 assert webhook.is_deleted
87 assert Webhook.objects.filter(pk=webhook.pk).count() == 0
88 assert Webhook.all_objects.filter(pk=webhook.pk).count() == 1
89
90 def test_secret_encrypted_at_rest(self, webhook):
91 """EncryptedTextField encrypts the value in the DB."""
92 # Read raw value from DB bypassing the field's from_db_value
93 from django.db import connection
94
95 with connection.cursor() as cursor:
96 cursor.execute("SELECT secret FROM fossil_webhook WHERE id = %s", [webhook.pk])
97 raw = cursor.fetchone()[0]
98 # Raw DB value should NOT be the plaintext
99 assert raw != "test-secret"
100 # But accessing via the model decrypts it
101 webhook.refresh_from_db()
102 assert webhook.secret == "test-secret"
103
104 def test_ordering(self, fossil_repo_obj, admin_user):
105 w1 = Webhook.objects.create(repository=fossil_repo_obj, url="https://a.com/hook", events="all", created_by=admin_user)
106 w2 = Webhook.objects.create(repository=fossil_repo_obj, url="https://b.com/hook", events="all", created_by=admin_user)
107 hooks = list(Webhook.objects.filter(repository=fossil_repo_obj))
108 # Ordered by -created_at, so newest first
109 assert hooks[0] == w2
110 assert hooks[1] == w1
111
112
113 @pytest.mark.django_db
114 class TestWebhookDeliveryModel:
115 def test_create_delivery(self, delivery):
116 assert delivery.pk is not None
117 assert delivery.success is True
118 assert delivery.response_status == 200
119 assert "abc123" in json.dumps(delivery.payload)
120
121 def test_delivery_str(self, delivery):
122 assert "example.com/webhook" in str(delivery)
123
124 def test_ordering(self, webhook):
125 d1 = WebhookDelivery.objects.create(webhook=webhook, event_type="checkin", payload={}, success=True)
126 d2 = WebhookDelivery.objects.create(webhook=webhook, event_type="ticket", payload={}, success=False)
127 deliveries = list(WebhookDelivery.objects.filter(webhook=webhook))
128 # Ordered by -delivered_at, so newest first
129 assert deliveries[0] == d2
130 assert deliveries[1] == d1
131
132
133 # --- Webhook List View Tests ---
134
135
136 @pytest.mark.django_db
137 class TestWebhookListView:
138 def test_list_webhooks(self, admin_client, sample_project, webhook):
139 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/")
140 assert response.status_code == 200
141 content = response.content.decode()
142 assert "example.com/webhook" in content
143 assert "Active" in content
144
145 def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
146 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/")
147 assert response.status_code == 200
148 assert "No webhooks configured" in response.content.decode()
149
150 def test_list_denied_for_writer(self, writer_client, sample_project, webhook):
151 """Webhook management requires admin, not just write."""
152 response = writer_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/")
153 assert response.status_code == 403
154
155 def test_list_denied_for_no_perm(self, no_perm_client, sample_project):
156 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/")
157 assert response.status_code == 403
158
159 def test_list_denied_for_anon(self, client, sample_project):
160 response = client.get(f"/projects/{sample_project.slug}/fossil/webhooks/")
161 assert response.status_code == 302 # redirect to login
162
163
164 # --- Webhook Create View Tests ---
165
166
167 @pytest.mark.django_db
168 class TestWebhookCreateView:
169 def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
170 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/create/")
171 assert response.status_code == 200
172 assert "Create Webhook" in respbhook management requirnt
173
174 def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
175 response.status_code == 302 # redirect to login
176
177
178 # --- Webhook Create View Te assert "example.com/webhook" in content
179 assert "Active" in content
180
181 def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
182 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/")
183 assert response.status_code == 200
184 assert "No webhooks configured" in response.contentbhook managemeadmin_client, sample_project, fossil_repo_obj):
185 response = admin_client. response.status_code == 302 # redirect to login
186
187
188 # --- Webhook Create View Te assert "exall"ontent
189
190 def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
191 response = admin_client.get(f"/projects/{sampleall")
192 assert on(self, client, sample_project):
193 response = client.get(f"/projects/{sample_project.slug}/fossil/webhooks/")
194 assert response.status_code == 302 # redirect to login
195
196
197 # --- Webhook Create View Tests ---
198
199
200 @pytest.mark.django_db
201 class TestWebhookCreateView:
202 def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
203 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/create/")
204 assert response.status_code == 200
205 assert "Create Webhook" in response.content.decode()
206
207 @patch("core.url_validation.is_safe_outbound_url", return_value=(True, ""))
208 def test_create_webhook(self, mock_url_check, admin_client, sample_project, fossil_repo_obj):
209 def test_edit_webhook(self, adm """Editing without providossil/webhooks/")
210 assert response.status_code == 403
211
212 def test_list_dject.slug}/fossil/webhort webhook.url == "https:, "events": ["wiki"], "is_active": "on"},
213 )
214 assert response.status_code == 302
215 webhook.refresh_from_db()
216 assert webhook.url == "https://new-url.example.com/hook"
217 assert webhook.events == "wiki"
218
219 def test_edit_preserves_secret_when_blank(self, admin_client, sample_project, webhook):
220 """Editing without providing a new secret should keep the old one."""
221 old_secret = webhook.secret
222 response = admin_client.post(
223 f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/edit/",
224 {"url": "https://example.com/webhook", "secret": "", "events": ["all"], "is_active": "on"},
225 )
226 assert response.status_code == 302
227 webhook.refresh_from_db()
228 assert webhook.secret == old_secret
229
230 def test_edit_denied_for_writer(self, writer_client, sample_project, webhook):
231 response = writer_client.post(
232 f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/edit/",
233 {"url": "https://evil.com/hook"},
234 )
235 assert response.status_code == 403
236
237 def test_edit_nonexistent_webhook(self, admin_client, sample_project, fossil_repo_obj):
238 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/99999/edit/")
239 assert response.status_code == 404
240
241
242 # --- Webhook Delete View Tests ---
243
244
245 @pytest.mark.django_db
246 class TestWebhookDeleteView:
247 def test_delete_webhook(self, admin_client, sample_project, webhook):
248 response = admin_client.post(f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/delete/")
249 assert response.status_code == 302
250 webhook.refresh_from_db()
251 assert webhook.is_deleted
252
253 def test_delete_get_redirects(self, admin_client, sample_project, webhook):
254 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/delete/")
255 assert response.status_code == 302 # GET redirects to list
256
257 def test_delete_denied_for_writer(self, writer_client, sample_project, webhook):
258 response = writer_client.post(f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/delete/")
259 assert response.status_code == 403
260
261
262 # --- Webhook Deliveries View Tests ---
263
264
265 @pytest.mark.django_db
266 class TestWebhookDeliveriesView:
267 def test_view_deliveries(self, admin_client, sample_project, webhook, delivery):
268 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/deliveries/")
269 assert response.status_code == 200
270 content = response.content.decode()
271 assert "checkin" in content
272 assert "200" in content
273 assert "150ms" in content
274
275 def test_view_empty_deliveries(self, admin_client, sample_project, webhook):
276 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/deliveries/")
277 assert response.status_code == 200
278 assert "No deliveries yet" in response.content.decode()
279
280 def test_deliveries_denied_for_writer(self, writer_client, sample_project, webhook):
281 response = writer_client.get(f"/projects/{sample_project.slug}/fossil/webhooks/{webhook.pk}/deliveries/")
282 assert response.status_code == 403
283
284
285 # --- Webhook Dispatch Task Tests ---
286
287
288 @pytest.mark.django_db
289 class TestDispatchWebhookTask:
290 def test_successful_delivery(self, webhook):
291 """Test dispatch_webhook task with a successful response."""
292 from fossil.tasks import dispatch_webhook
293
294 mock_response = type("Response", (), {"status_code": 200, "text": "OK"})()
295
296 with patch("requests.post", return_value=mock_response):
297 dispatch_webhook.apply(args=[webhook.pk, "checkin", {"hash": "abc"}])
298
299 delivery = WebhookDelivery.objects.get(webhook=webhook)
300 assert delivery.success is True
301 assert delivery.response_status == 200
302 assert delivery.event_type == "checkin"
303
304 def test_failed_delivery_logs(self, webhook):
305 """Test that failed HTTP responses are logged and delivery is recorded."""
306 from fossil.tasks import dispatch_webhook
307
308 mock_response = type("Response", (), {"status_code": 500, "text": "Internal Server Error"})()
309
310 with patch("requests.post", return_value=mock_response):
311 dispatch_webhook.apply(args=[webhook.pk, "checkin", {"hash": "abc"}])
312
313 # The task retries on non-2xx, so apply() catches the Retry internally
314 delivery = WebhookDelivery.objects.filter(webhook=webhook).first()
315 assert delivery is not None
316 assert delivery.success is False
317 assert delivery.response_status == 500
318
319 def test_nonexistent_webhook_no_crash(self):
320 """Task should handle missing webhook gracefully."""
321 from fossil.tasks import dispatch_webhook
322
323 # Should return without error (logs warning)
324 dispatch_webhook.apply(args=[99999, "checkin", {"hash": "abc"}])
325 # No deliveries created
326 a

Keyboard Shortcuts

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