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).
d7c30a9100bcf6922b36c7f565a4830602eac359e07cec8b7b2bde3f1f472ee0
| --- fossil/admin.py | ||
| +++ fossil/admin.py | ||
| @@ -1,14 +1,16 @@ | ||
| 1 | 1 | from django.contrib import admin |
| 2 | 2 | |
| 3 | 3 | from core.admin import BaseCoreAdmin |
| 4 | 4 | |
| 5 | +from .forum import ForumPost | |
| 5 | 6 | from .models import FossilRepository, FossilSnapshot |
| 6 | 7 | from .notifications import Notification, ProjectWatch |
| 7 | 8 | from .releases import Release, ReleaseAsset |
| 8 | 9 | from .sync_models import GitMirror, SSHKey, SyncLog |
| 9 | 10 | from .user_keys import UserSSHKey |
| 11 | +from .webhooks import Webhook, WebhookDelivery | |
| 10 | 12 | |
| 11 | 13 | |
| 12 | 14 | class FossilSnapshotInline(admin.TabularInline): |
| 13 | 15 | model = FossilSnapshot |
| 14 | 16 | extra = 0 |
| @@ -95,5 +97,34 @@ | ||
| 95 | 97 | class SyncLogAdmin(admin.ModelAdmin): |
| 96 | 98 | list_display = ("mirror", "status", "started_at", "completed_at", "artifacts_synced", "triggered_by") |
| 97 | 99 | list_filter = ("status", "triggered_by") |
| 98 | 100 | search_fields = ("mirror__repository__filename", "message") |
| 99 | 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",) | |
| 100 | 131 | |
| 101 | 132 | ADDED fossil/forum.py |
| 102 | 133 | 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 @@ | ||
| 65 | 65 | def __str__(self): |
| 66 | 66 | return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename |
| 67 | 67 | |
| 68 | 68 | |
| 69 | 69 | # Import related models so they're discoverable by Django |
| 70 | +from fossil.forum import ForumPost # noqa: E402, F401 | |
| 70 | 71 | from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401 |
| 71 | 72 | from fossil.releases import Release, ReleaseAsset # noqa: E402, F401 |
| 72 | 73 | from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401 |
| 73 | 74 | from fossil.user_keys import UserSSHKey # noqa: E402, F401 |
| 75 | +from fossil.webhooks import Webhook, WebhookDelivery # noqa: E402, F401 | |
| 74 | 76 |
| --- 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 @@ | ||
| 189 | 189 | log.status = "failed" |
| 190 | 190 | log.message = "Unexpected error" |
| 191 | 191 | log.completed_at = timezone.now() |
| 192 | 192 | log.save() |
| 193 | 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 | + | |
| 194 | 252 | |
| 195 | 253 | @shared_task(name="fossil.dispatch_notifications") |
| 196 | 254 | def dispatch_notifications(): |
| 197 | 255 | """Check for new Fossil events and send notifications to watchers.""" |
| 198 | 256 | import datetime |
| 199 | 257 |
| --- 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 @@ | ||
| 18 | 18 | path("wiki/create/", views.wiki_create, name="wiki_create"), |
| 19 | 19 | path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"), |
| 20 | 20 | path("wiki/edit/<path:page_name>", views.wiki_edit, name="wiki_edit"), |
| 21 | 21 | path("tickets/create/", views.ticket_create, name="ticket_create"), |
| 22 | 22 | path("forum/", views.forum_list, name="forum"), |
| 23 | + path("forum/create/", views.forum_create, name="forum_create"), | |
| 23 | 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"), | |
| 24 | 32 | path("user/<str:username>/", views.user_activity, name="user_activity"), |
| 25 | 33 | path("branches/", views.branch_list, name="branches"), |
| 26 | 34 | path("tags/", views.tag_list, name="tags"), |
| 27 | 35 | path("technotes/", views.technote_list, name="technotes"), |
| 28 | 36 | path("search/", views.search, name="search"), |
| 29 | 37 |
| --- 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 |
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -442,10 +442,65 @@ | ||
| 442 | 442 | "rendered_html": rendered_html, |
| 443 | 443 | "active_tab": "code", |
| 444 | 444 | }, |
| 445 | 445 | ) |
| 446 | 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 | + | |
| 447 | 502 | |
| 448 | 503 | # --- Checkin Detail --- |
| 449 | 504 | |
| 450 | 505 | |
| 451 | 506 | def checkin_detail(request, slug, checkin_uuid): |
| @@ -519,20 +574,39 @@ | ||
| 519 | 574 | else: |
| 520 | 575 | old_num = old_line |
| 521 | 576 | new_num = new_line |
| 522 | 577 | old_line += 1 |
| 523 | 578 | 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 | + ) | |
| 525 | 596 | |
| 597 | + split_left, split_right = _compute_split_lines(diff_lines) | |
| 526 | 598 | ext = f["name"].rsplit(".", 1)[-1] if "." in f["name"] else "" |
| 527 | 599 | file_diffs.append( |
| 528 | 600 | { |
| 529 | 601 | "name": f["name"], |
| 530 | 602 | "change_type": f["change_type"], |
| 531 | 603 | "uuid": f["uuid"], |
| 532 | 604 | "is_binary": is_binary, |
| 533 | 605 | "diff_lines": diff_lines, |
| 606 | + "split_left": split_left, | |
| 607 | + "split_right": split_right, | |
| 534 | 608 | "additions": additions, |
| 535 | 609 | "deletions": deletions, |
| 536 | 610 | "language": ext, |
| 537 | 611 | } |
| 538 | 612 | ) |
| @@ -726,54 +800,368 @@ | ||
| 726 | 800 | |
| 727 | 801 | # --- Forum --- |
| 728 | 802 | |
| 729 | 803 | |
| 730 | 804 | def forum_list(request, slug): |
| 731 | - project, fossil_repo, reader = _get_repo_and_reader(slug, request) | |
| 805 | + from projects.access import can_write_project | |
| 732 | 806 | |
| 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) | |
| 735 | 843 | |
| 736 | 844 | return render( |
| 737 | 845 | request, |
| 738 | 846 | "fossil/forum_list.html", |
| 739 | 847 | { |
| 740 | 848 | "project": project, |
| 741 | 849 | "fossil_repo": fossil_repo, |
| 742 | - "posts": posts, | |
| 850 | + "posts": merged, | |
| 851 | + "has_write": has_write, | |
| 743 | 852 | "active_tab": "forum", |
| 744 | 853 | }, |
| 745 | 854 | ) |
| 746 | 855 | |
| 747 | 856 | |
| 748 | 857 | 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 | + | |
| 758 | 872 | 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) | |
| 762 | 918 | |
| 763 | 919 | return render( |
| 764 | 920 | request, |
| 765 | 921 | "fossil/forum_thread.html", |
| 766 | 922 | { |
| 767 | 923 | "project": project, |
| 768 | 924 | "fossil_repo": fossil_repo, |
| 769 | 925 | "posts": rendered_posts, |
| 770 | 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}", | |
| 771 | 1007 | "active_tab": "forum", |
| 772 | 1008 | }, |
| 773 | 1009 | ) |
| 774 | 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 | + | |
| 775 | 1163 | |
| 776 | 1164 | # --- Wiki CRUD --- |
| 777 | 1165 | |
| 778 | 1166 | |
| 779 | 1167 | @login_required |
| @@ -1353,24 +1741,71 @@ | ||
| 1353 | 1741 | fromfile=f"a/{fname}", |
| 1354 | 1742 | tofile=f"b/{fname}", |
| 1355 | 1743 | n=3, |
| 1356 | 1744 | ) |
| 1357 | 1745 | diff_lines = [] |
| 1746 | + old_line = 0 | |
| 1747 | + new_line = 0 | |
| 1748 | + additions = 0 | |
| 1749 | + deletions = 0 | |
| 1358 | 1750 | for line in diff: |
| 1359 | 1751 | line_type = "context" |
| 1752 | + old_num = "" | |
| 1753 | + new_num = "" | |
| 1360 | 1754 | if line.startswith("+++") or line.startswith("---"): |
| 1361 | 1755 | line_type = "header" |
| 1362 | 1756 | elif line.startswith("@@"): |
| 1363 | 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)) | |
| 1364 | 1762 | elif line.startswith("+"): |
| 1365 | 1763 | line_type = "add" |
| 1764 | + additions += 1 | |
| 1765 | + new_num = new_line | |
| 1766 | + new_line += 1 | |
| 1366 | 1767 | elif line.startswith("-"): |
| 1367 | 1768 | 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 | + ) | |
| 1369 | 1794 | |
| 1370 | 1795 | 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 | + ) | |
| 1372 | 1807 | |
| 1373 | 1808 | return render( |
| 1374 | 1809 | request, |
| 1375 | 1810 | "fossil/compare.html", |
| 1376 | 1811 | { |
| 1377 | 1812 | |
| 1378 | 1813 | 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 @@ | ||
| 1 | 1 | from django import forms |
| 2 | 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 | |
| 3 | 5 | |
| 4 | 6 | from .models import Organization, Team |
| 5 | 7 | |
| 6 | 8 | tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 7 | 9 | |
| @@ -51,5 +53,100 @@ | ||
| 51 | 53 | def __init__(self, *args, team=None, **kwargs): |
| 52 | 54 | super().__init__(*args, **kwargs) |
| 53 | 55 | if team: |
| 54 | 56 | existing_member_ids = team.members.values_list("id", flat=True) |
| 55 | 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 | |
| 56 | 153 |
| --- 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 @@ | ||
| 9 | 9 | path("", views.org_settings, name="settings"), |
| 10 | 10 | path("edit/", views.org_settings_edit, name="settings_edit"), |
| 11 | 11 | # Members |
| 12 | 12 | path("members/", views.member_list, name="members"), |
| 13 | 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"), | |
| 14 | 18 | path("members/<str:username>/remove/", views.member_remove, name="member_remove"), |
| 15 | 19 | # Teams |
| 16 | 20 | path("teams/", views.team_list, name="team_list"), |
| 17 | 21 | path("teams/create/", views.team_create, name="team_create"), |
| 18 | 22 | path("teams/<slug:slug>/", views.team_detail, name="team_detail"), |
| 19 | 23 |
| --- 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 @@ | ||
| 4 | 4 | from django.http import HttpResponse |
| 5 | 5 | from django.shortcuts import get_object_or_404, redirect, render |
| 6 | 6 | |
| 7 | 7 | from core.permissions import P |
| 8 | 8 | |
| 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 | +) | |
| 10 | 18 | from .models import Organization, OrganizationMember, Team |
| 11 | 19 | |
| 12 | 20 | |
| 13 | 21 | def get_org(): |
| 14 | 22 | return Organization.objects.first() |
| @@ -213,5 +221,105 @@ | ||
| 213 | 221 | return HttpResponse(status=200, headers={"HX-Redirect": f"/settings/teams/{team.slug}/"}) |
| 214 | 222 | |
| 215 | 223 | return redirect("organization:team_detail", slug=team.slug) |
| 216 | 224 | |
| 217 | 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}) | |
| 218 | 326 |
| --- 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 @@ | ||
| 104 | 104 | html:not(.dark) main .hover\:text-brand:hover { color: #8B3138 !important; } |
| 105 | 105 | html:not(.dark) main .hover\:border-brand:hover { border-color: #DC394C !important; } |
| 106 | 106 | /* Selected/hover rows — matches admin --selected-bg */ |
| 107 | 107 | html:not(.dark) main .group:hover { border-color: #DC394C !important; } |
| 108 | 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> | |
| 109 | 111 | <script src="https://unpkg.com/[email protected]"></script> |
| 110 | 112 | <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script> |
| 111 | 113 | <script> |
| 112 | 114 | document.body.addEventListener('htmx:configRequest', function(event) { |
| 113 | 115 | var token = document.querySelector('meta[name="csrf-token"]'); |
| 114 | 116 |
| --- 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 @@ | ||
| 16 | 16 | .diff-gutter:hover { color: #DC394C; } |
| 17 | 17 | .diff-gutter a { color: inherit; text-decoration: none; display: block; } |
| 18 | 18 | .line-row:target { background: rgba(220, 57, 76, 0.15) !important; } |
| 19 | 19 | .line-row:target .diff-gutter { color: #DC394C; font-weight: 600; } |
| 20 | 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; } | |
| 21 | 35 | .line-popover { |
| 22 | 36 | position: absolute; left: 100%; top: 50%; transform: translateY(-50%); |
| 23 | 37 | margin-left: 4px; z-index: 20; white-space: nowrap; |
| 24 | 38 | background: #1f2937; border: 1px solid #374151; border-radius: 6px; |
| 25 | 39 | box-shadow: 0 4px 12px rgba(0,0,0,0.4); padding: 2px; |
| @@ -101,14 +115,21 @@ | ||
| 101 | 115 | {{ fd.name }} |
| 102 | 116 | </a> |
| 103 | 117 | {% endfor %} |
| 104 | 118 | </div> |
| 105 | 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> | |
| 106 | 127 | |
| 107 | 128 | <!-- Diffs --> |
| 108 | 129 | {% 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 }}"> | |
| 110 | 131 | <div class="px-4 py-2.5 border-b border-gray-700 flex items-center justify-between bg-gray-900/50"> |
| 111 | 132 | <div class="flex items-center gap-3"> |
| 112 | 133 | {% if fd.change_type == "added" %} |
| 113 | 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> |
| 114 | 135 | {% elif fd.change_type == "deleted" %} |
| @@ -126,26 +147,102 @@ | ||
| 126 | 147 | <div class="flex items-center gap-2 text-xs"> |
| 127 | 148 | {% if fd.additions %}<span class="text-green-400">+{{ fd.additions }}</span>{% endif %} |
| 128 | 149 | {% if fd.deletions %}<span class="text-red-400">-{{ fd.deletions }}</span>{% endif %} |
| 129 | 150 | </div> |
| 130 | 151 | </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'"> | |
| 135 | 159 | <table class="diff-table"> |
| 136 | 160 | <tbody> |
| 137 | 161 | {% 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 %} | |
| 139 | 163 | {% endfor %} |
| 140 | 164 | </tbody> |
| 141 | 165 | </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' %} {% 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' %} {% 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> | |
| 145 | 194 | </div> |
| 195 | + | |
| 196 | + {% else %} | |
| 197 | + <p class="p-4 text-sm text-gray-500">No diff available</p> | |
| 198 | + {% endif %} | |
| 146 | 199 | </div> |
| 147 | 200 | {% endfor %} |
| 201 | + </div> | |
| 148 | 202 | {% endif %} |
| 149 | 203 | </div> |
| 150 | 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> | |
| 151 | 248 | {% endblock %} |
| 152 | 249 |
| --- 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' %} {% 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' %} {% 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 @@ | ||
| 10 | 10 | .diff-line-del { background: rgba(239, 68, 68, 0.1); } |
| 11 | 11 | .diff-line-del td:last-child { color: #fca5a5; } |
| 12 | 12 | .diff-line-hunk { background: rgba(96, 165, 250, 0.08); } |
| 13 | 13 | .diff-line-hunk td { color: #93c5fd; } |
| 14 | 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; } | |
| 15 | 30 | </style> |
| 16 | 31 | {% endblock %} |
| 17 | 32 | |
| 18 | 33 | {% block content %} |
| 19 | 34 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| @@ -49,29 +64,117 @@ | ||
| 49 | 64 | <div class="mt-1 text-xs text-gray-500">{{ to_detail.user }} · {{ to_detail.timestamp|date:"Y-m-d H:i" }}</div> |
| 50 | 65 | </div> |
| 51 | 66 | </div> |
| 52 | 67 | |
| 53 | 68 | {% 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' %} {% 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' %} {% 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> | |
| 69 | 131 | </div> |
| 70 | 132 | {% else %} |
| 71 | 133 | <p class="text-sm text-gray-500 text-center py-8">No differences found between these checkins.</p> |
| 72 | 134 | {% endif %} |
| 73 | 135 | |
| 74 | 136 | {% elif from_uuid and to_uuid %} |
| 75 | 137 | <p class="text-sm text-gray-500 text-center py-8">One or both checkins not found.</p> |
| 76 | 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> | |
| 77 | 180 | {% endblock %} |
| 78 | 181 | |
| 79 | 182 | 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 }} · {{ 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 }} · {{ 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' %} {% 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' %} {% 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">← 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">← 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 @@ | ||
| 2 | 2 | {% block title %}Forum — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 3 | |
| 4 | 4 | {% block content %} |
| 5 | 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 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> | |
| 7 | 17 | |
| 8 | 18 | <div class="space-y-3"> |
| 9 | 19 | {% for post in posts %} |
| 10 | 20 | <div class="rounded-lg bg-gray-800 border border-gray-700 hover:border-gray-600 transition-colors"> |
| 11 | 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 %} | |
| 12 | 28 | <a href="{% url 'fossil:forum_thread' slug=project.slug thread_uuid=post.uuid %}" |
| 13 | 29 | class="text-base font-medium text-brand-light hover:text-brand"> |
| 14 | 30 | {{ post.title|default:"(untitled)" }} |
| 15 | 31 | </a> |
| 32 | + {% endif %} | |
| 16 | 33 | {% if post.body %} |
| 17 | 34 | <p class="mt-1 text-sm text-gray-400 line-clamp-2">{{ post.body|truncatechars:200 }}</p> |
| 18 | 35 | {% endif %} |
| 19 | 36 | <div class="mt-2 flex items-center gap-3 text-xs text-gray-500"> |
| 37 | + {% if post.source == "fossil" %} | |
| 20 | 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 %} | |
| 21 | 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 %} | |
| 22 | 46 | </div> |
| 23 | 47 | </div> |
| 24 | 48 | </div> |
| 25 | 49 | {% 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> | |
| 27 | 59 | {% endfor %} |
| 28 | 60 | </div> |
| 29 | 61 | {% endblock %} |
| 30 | 62 |
| --- 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 @@ | ||
| 12 | 12 | <div class="space-y-3"> |
| 13 | 13 | {% for item in posts %} |
| 14 | 14 | <div class="rounded-lg bg-gray-800 border border-gray-700 {% if item.post.in_reply_to %}ml-8{% endif %}"> |
| 15 | 15 | <div class="px-5 py-4"> |
| 16 | 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 %} | |
| 17 | 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 %} | |
| 18 | 22 | <span class="text-xs text-gray-500">{{ item.post.timestamp|timesince }} ago</span> |
| 19 | 23 | </div> |
| 20 | 24 | {% if item.post.title and forloop.first %} |
| 21 | 25 | <h2 class="text-lg font-semibold text-gray-100 mb-3">{{ item.post.title }}</h2> |
| 22 | 26 | {% endif %} |
| @@ -31,6 +35,22 @@ | ||
| 31 | 35 | </div> |
| 32 | 36 | {% empty %} |
| 33 | 37 | <p class="text-sm text-gray-500 py-8 text-center">No posts in this thread.</p> |
| 34 | 38 | {% endfor %} |
| 35 | 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 %} | |
| 36 | 56 | {% endblock %} |
| 37 | 57 | |
| 38 | 58 | ADDED templates/fossil/webhook_deliveries.html |
| 39 | 59 | ADDED templates/fossil/webhook_form.html |
| 40 | 60 | 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">← 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">← 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">← 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">← 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 @@ | ||
| 6 | 6 | <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">← Back to Settings</a> |
| 7 | 7 | </div> |
| 8 | 8 | |
| 9 | 9 | <div class="md:flex md:items-center md:justify-between mb-6"> |
| 10 | 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 %} | |
| 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> | |
| 17 | 25 | </div> |
| 18 | 26 | |
| 19 | 27 | <div class="mb-4"> |
| 20 | 28 | <input type="search" |
| 21 | 29 | name="search" |
| 22 | 30 |
| --- 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">← 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">← 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 @@ | ||
| 11 | 11 | </tr> |
| 12 | 12 | </thead> |
| 13 | 13 | <tbody class="divide-y divide-gray-700 bg-gray-800"> |
| 14 | 14 | {% for membership in members %} |
| 15 | 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 }} | |
| 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 | 18 | </td> |
| 19 | 19 | <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ membership.member.email|default:"—" }}</td> |
| 20 | 20 | <td class="px-6 py-4 whitespace-nowrap"> |
| 21 | 21 | {% if membership.is_active %} |
| 22 | 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 | 23 | |
| 24 | 24 | ADDED templates/organization/user_detail.html |
| 25 | 25 | ADDED templates/organization/user_form.html |
| 26 | 26 | ADDED templates/organization/user_password.html |
| 27 | 27 | ADDED tests/test_forum.py |
| 28 | 28 | ADDED tests/test_split_diff.py |
| 29 | 29 | ADDED tests/test_user_management.py |
| 30 | 30 | 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">← 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">← 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">← 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">← 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">← 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">← 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 |