FossilRepo

Add custom ticket fields, ticket reports, bundle CLI, artifact shunning, email digest, notification preferences Custom ticket fields: admin-defined extra fields per project with dynamic form rendering. 6 field types, sort ordering, uniqueness validation. 22 tests. Ticket reports: custom SQL queries against Fossil ticket tables. Read-only execution with SQL injection prevention (keyword blocklist + SELECT-only). Public/private report visibility. 37 tests. Bundle CLI: fossilrepo-ctl bundle export/import wrapping fossil bundle commands for offline transfer. 12 tests. Artifact shunning: admin UI with type-to-confirm safety (must type first 8 chars of UUID). CLI wrapper for fossil shun. Irreversible warning. 18 tests. Email digest: daily/weekly batch delivery mode via Celery Beat. Collects unread notifications, sends summary email, marks as read. 16 tests. Notification preferences: per-user delivery mode (immediate/daily/weekly/off) and event type toggles at /auth/notifications/. Auto-created on first visit.

lmata 2026-04-07 15:33 trunk
Commit 8032e48b1ba8263d723a48f8f77891098b41412694f79deab9cf4ee2d35d5c17
--- accounts/urls.py
+++ accounts/urls.py
@@ -7,6 +7,7 @@
77
urlpatterns = [
88
path("login/", views.login_view, name="login"),
99
path("logout/", views.logout_view, name="logout"),
1010
path("ssh-keys/", views.ssh_keys, name="ssh_keys"),
1111
path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"),
12
+ path("notifications/", views.notification_preferences, name="notification_prefs"),
1213
]
1314
--- accounts/urls.py
+++ accounts/urls.py
@@ -7,6 +7,7 @@
7 urlpatterns = [
8 path("login/", views.login_view, name="login"),
9 path("logout/", views.logout_view, name="logout"),
10 path("ssh-keys/", views.ssh_keys, name="ssh_keys"),
11 path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"),
 
12 ]
13
--- accounts/urls.py
+++ accounts/urls.py
@@ -7,6 +7,7 @@
7 urlpatterns = [
8 path("login/", views.login_view, name="login"),
9 path("logout/", views.logout_view, name="logout"),
10 path("ssh-keys/", views.ssh_keys, name="ssh_keys"),
11 path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"),
12 path("notifications/", views.notification_preferences, name="notification_prefs"),
13 ]
14
--- accounts/views.py
+++ accounts/views.py
@@ -193,5 +193,31 @@
193193
194194
if request.headers.get("HX-Request"):
195195
return HttpResponse(status=200, headers={"HX-Redirect": "/auth/ssh-keys/"})
196196
197197
return redirect("accounts:ssh_keys")
198
+
199
+
200
+@login_required
201
+def notification_preferences(request):
202
+ """User notification preferences page."""
203
+ from fossil.notifications import NotificationPreference
204
+
205
+ prefs, _ = NotificationPreference.objects.get_or_create(user=request.user)
206
+
207
+ if request.method == "POST":
208
+ prefs.delivery_mode = request.POST.get("delivery_mode", "immediate")
209
+ prefs.notify_checkins = "notify_checkins" in request.POST
210
+ prefs.notify_tickets = "notify_tickets" in request.POST
211
+ prefs.notify_wiki = "notify_wiki" in request.POST
212
+ prefs.notify_releases = "notify_releases" in request.POST
213
+ prefs.notify_forum = "notify_forum" in request.POST
214
+ prefs.save()
215
+
216
+ messages.success(request, "Notification preferences updated.")
217
+
218
+ if request.headers.get("HX-Request"):
219
+ return HttpResponse(status=200, headers={"HX-Redirect": "/auth/notifications/"})
220
+
221
+ return redirect("accounts:notification_prefs")
222
+
223
+ return render(request, "accounts/notification_prefs.html", {"prefs": prefs})
198224
--- accounts/views.py
+++ accounts/views.py
@@ -193,5 +193,31 @@
193
194 if request.headers.get("HX-Request"):
195 return HttpResponse(status=200, headers={"HX-Redirect": "/auth/ssh-keys/"})
196
197 return redirect("accounts:ssh_keys")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
--- accounts/views.py
+++ accounts/views.py
@@ -193,5 +193,31 @@
193
194 if request.headers.get("HX-Request"):
195 return HttpResponse(status=200, headers={"HX-Redirect": "/auth/ssh-keys/"})
196
197 return redirect("accounts:ssh_keys")
198
199
200 @login_required
201 def notification_preferences(request):
202 """User notification preferences page."""
203 from fossil.notifications import NotificationPreference
204
205 prefs, _ = NotificationPreference.objects.get_or_create(user=request.user)
206
207 if request.method == "POST":
208 prefs.delivery_mode = request.POST.get("delivery_mode", "immediate")
209 prefs.notify_checkins = "notify_checkins" in request.POST
210 prefs.notify_tickets = "notify_tickets" in request.POST
211 prefs.notify_wiki = "notify_wiki" in request.POST
212 prefs.notify_releases = "notify_releases" in request.POST
213 prefs.notify_forum = "notify_forum" in request.POST
214 prefs.save()
215
216 messages.success(request, "Notification preferences updated.")
217
218 if request.headers.get("HX-Request"):
219 return HttpResponse(status=200, headers={"HX-Redirect": "/auth/notifications/"})
220
221 return redirect("accounts:notification_prefs")
222
223 return render(request, "accounts/notification_prefs.html", {"prefs": prefs})
224
--- config/settings.py
+++ config/settings.py
@@ -200,10 +200,20 @@
200200
},
201201
"fossil-dispatch-notifications": {
202202
"task": "fossil.dispatch_notifications",
203203
"schedule": 300.0, # every 5 minutes
204204
},
205
+ "fossil-daily-digest": {
206
+ "task": "fossil.send_digest",
207
+ "schedule": 86400.0, # daily
208
+ "kwargs": {"mode": "daily"},
209
+ },
210
+ "fossil-weekly-digest": {
211
+ "task": "fossil.send_digest",
212
+ "schedule": 604800.0, # weekly
213
+ "kwargs": {"mode": "weekly"},
214
+ },
205215
}
206216
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
207217
208218
# --- CORS ---
209219
210220
--- config/settings.py
+++ config/settings.py
@@ -200,10 +200,20 @@
200 },
201 "fossil-dispatch-notifications": {
202 "task": "fossil.dispatch_notifications",
203 "schedule": 300.0, # every 5 minutes
204 },
 
 
 
 
 
 
 
 
 
 
205 }
206 CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
207
208 # --- CORS ---
209
210
--- config/settings.py
+++ config/settings.py
@@ -200,10 +200,20 @@
200 },
201 "fossil-dispatch-notifications": {
202 "task": "fossil.dispatch_notifications",
203 "schedule": 300.0, # every 5 minutes
204 },
205 "fossil-daily-digest": {
206 "task": "fossil.send_digest",
207 "schedule": 86400.0, # daily
208 "kwargs": {"mode": "daily"},
209 },
210 "fossil-weekly-digest": {
211 "task": "fossil.send_digest",
212 "schedule": 604800.0, # weekly
213 "kwargs": {"mode": "weekly"},
214 },
215 }
216 CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
217
218 # --- CORS ---
219
220
+105
--- ctl/main.py
+++ ctl/main.py
@@ -297,5 +297,110 @@
297297
@click.argument("path")
298298
def backup_restore(path: str) -> None:
299299
"""Restore from a backup."""
300300
console.print(f"[bold]Restoring from:[/bold] {path}")
301301
raise NotImplementedError("Restore not yet implemented")
302
+
303
+
304
+# ---------------------------------------------------------------------------
305
+# Bundle commands
306
+# ---------------------------------------------------------------------------
307
+
308
+
309
+@cli.group()
310
+def bundle() -> None:
311
+ """Export and import Fossil repository bundles."""
312
+
313
+
314
+@bundle.command(name="export")
315
+@click.argument("project_slug")
316
+@click.argument("output_path")
317
+def bundle_export(project_slug: str, output_path: str) -> None:
318
+ """Export a Fossil repo as a bundle file."""
319
+ import django
320
+
321
+ django.setup()
322
+
323
+ from fossil.cli import FossilCLI
324
+ from fossil.models import FossilRepository
325
+
326
+ repo = FossilRepository.objects.filter(project__slug=project_slug, deleted_at__isnull=True).first()
327
+ if not repo:
328
+ console.print(f"[red]No repository found for project: {project_slug}[/red]")
329
+ return
330
+
331
+ if not repo.exists_on_disk:
332
+ console.print(f"[red]Repository file not found on disk: {repo.full_path}[/red]")
333
+ return
334
+
335
+ fossil_cli = FossilCLI()
336
+ if not fossil_cli.is_available():
337
+ console.print("[red]Fossil binary not found.[/red]")
338
+ return
339
+
340
+ output = Path(output_path)
341
+ output.parent.mkdir(parents=True, exist_ok=True)
342
+
343
+ console.print(f"[bold]Exporting bundle:[/bold] {repo.filename} -> {output}")
344
+ try:
345
+ result = subprocess.run(
346
+ [fossil_cli.binary, "bundle", "export", str(output), "-R", str(repo.full_path)],
347
+ capture_output=True,
348
+ text=True,
349
+ timeout=300,
350
+ env=fossil_cli._env,
351
+ )
352
+ if result.returncode == 0:
353
+ size_kb = output.stat().st_size / 1024
354
+ console.print(f" [green]Success[/green] — {size_kb:.0f} KB written to {output}")
355
+ else:
356
+ console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}")
357
+ except subprocess.TimeoutExpired:
358
+ console.print("[red]Export timed out after 5 minutes.[/red]")
359
+
360
+
361
+@bundle.command(name="import")
362
+@click.argument("project_slug")
363
+@click.argument("input_path")
364
+def bundle_import(project_slug: str, input_path: str) -> None:
365
+ """Import a Fossil bundle into an existing repo."""
366
+ import django
367
+
368
+ django.setup()
369
+
370
+ from fossil.cli import FossilCLI
371
+ from fossil.models import FossilRepository
372
+
373
+ repo = FossilRepository.objects.filter(project__slug=project_slug, deleted_at__isnull=True).first()
374
+ if not repo:
375
+ console.print(f"[red]No repository found for project: {project_slug}[/red]")
376
+ return
377
+
378
+ if not repo.exists_on_disk:
379
+ console.print(f"[red]Repository file not found on disk: {repo.full_path}[/red]")
380
+ return
381
+
382
+ input_file = Path(input_path)
383
+ if not input_file.exists():
384
+ console.print(f"[red]Bundle file not found: {input_file}[/red]")
385
+ return
386
+
387
+ fossil_cli = FossilCLI()
388
+ if not fossil_cli.is_available():
389
+ console.print("[red]Fossil binary not found.[/red]")
390
+ return
391
+
392
+ console.print(f"[bold]Importing bundle:[/bold] {input_file} -> {repo.filename}")
393
+ try:
394
+ result = subprocess.run(
395
+ [fossil_cli.binary, "bundle", "import", str(input_file), "-R", str(repo.full_path)],
396
+ capture_output=True,
397
+ text=True,
398
+ timeout=300,
399
+ env=fossil_cli._env,
400
+ )
401
+ if result.returncode == 0:
402
+ console.print(f" [green]Success[/green] — {result.stdout.strip()}")
403
+ else:
404
+ console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}")
405
+ except subprocess.TimeoutExpired:
406
+ console.print("[red]Import timed out after 5 minutes.[/red]")
302407
--- ctl/main.py
+++ ctl/main.py
@@ -297,5 +297,110 @@
297 @click.argument("path")
298 def backup_restore(path: str) -> None:
299 """Restore from a backup."""
300 console.print(f"[bold]Restoring from:[/bold] {path}")
301 raise NotImplementedError("Restore not yet implemented")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
--- ctl/main.py
+++ ctl/main.py
@@ -297,5 +297,110 @@
297 @click.argument("path")
298 def backup_restore(path: str) -> None:
299 """Restore from a backup."""
300 console.print(f"[bold]Restoring from:[/bold] {path}")
301 raise NotImplementedError("Restore not yet implemented")
302
303
304 # ---------------------------------------------------------------------------
305 # Bundle commands
306 # ---------------------------------------------------------------------------
307
308
309 @cli.group()
310 def bundle() -> None:
311 """Export and import Fossil repository bundles."""
312
313
314 @bundle.command(name="export")
315 @click.argument("project_slug")
316 @click.argument("output_path")
317 def bundle_export(project_slug: str, output_path: str) -> None:
318 """Export a Fossil repo as a bundle file."""
319 import django
320
321 django.setup()
322
323 from fossil.cli import FossilCLI
324 from fossil.models import FossilRepository
325
326 repo = FossilRepository.objects.filter(project__slug=project_slug, deleted_at__isnull=True).first()
327 if not repo:
328 console.print(f"[red]No repository found for project: {project_slug}[/red]")
329 return
330
331 if not repo.exists_on_disk:
332 console.print(f"[red]Repository file not found on disk: {repo.full_path}[/red]")
333 return
334
335 fossil_cli = FossilCLI()
336 if not fossil_cli.is_available():
337 console.print("[red]Fossil binary not found.[/red]")
338 return
339
340 output = Path(output_path)
341 output.parent.mkdir(parents=True, exist_ok=True)
342
343 console.print(f"[bold]Exporting bundle:[/bold] {repo.filename} -> {output}")
344 try:
345 result = subprocess.run(
346 [fossil_cli.binary, "bundle", "export", str(output), "-R", str(repo.full_path)],
347 capture_output=True,
348 text=True,
349 timeout=300,
350 env=fossil_cli._env,
351 )
352 if result.returncode == 0:
353 size_kb = output.stat().st_size / 1024
354 console.print(f" [green]Success[/green] — {size_kb:.0f} KB written to {output}")
355 else:
356 console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}")
357 except subprocess.TimeoutExpired:
358 console.print("[red]Export timed out after 5 minutes.[/red]")
359
360
361 @bundle.command(name="import")
362 @click.argument("project_slug")
363 @click.argument("input_path")
364 def bundle_import(project_slug: str, input_path: str) -> None:
365 """Import a Fossil bundle into an existing repo."""
366 import django
367
368 django.setup()
369
370 from fossil.cli import FossilCLI
371 from fossil.models import FossilRepository
372
373 repo = FossilRepository.objects.filter(project__slug=project_slug, deleted_at__isnull=True).first()
374 if not repo:
375 console.print(f"[red]No repository found for project: {project_slug}[/red]")
376 return
377
378 if not repo.exists_on_disk:
379 console.print(f"[red]Repository file not found on disk: {repo.full_path}[/red]")
380 return
381
382 input_file = Path(input_path)
383 if not input_file.exists():
384 console.print(f"[red]Bundle file not found: {input_file}[/red]")
385 return
386
387 fossil_cli = FossilCLI()
388 if not fossil_cli.is_available():
389 console.print("[red]Fossil binary not found.[/red]")
390 return
391
392 console.print(f"[bold]Importing bundle:[/bold] {input_file} -> {repo.filename}")
393 try:
394 result = subprocess.run(
395 [fossil_cli.binary, "bundle", "import", str(input_file), "-R", str(repo.full_path)],
396 capture_output=True,
397 text=True,
398 timeout=300,
399 env=fossil_cli._env,
400 )
401 if result.returncode == 0:
402 console.print(f" [green]Success[/green] — {result.stdout.strip()}")
403 else:
404 console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}")
405 except subprocess.TimeoutExpired:
406 console.print("[red]Import timed out after 5 minutes.[/red]")
407
+27 -1
--- fossil/admin.py
+++ fossil/admin.py
@@ -5,13 +5,15 @@
55
from .api_tokens import APIToken
66
from .branch_protection import BranchProtection
77
from .ci import StatusCheck
88
from .forum import ForumPost
99
from .models import FossilRepository, FossilSnapshot
10
-from .notifications import Notification, ProjectWatch
10
+from .notifications import Notification, NotificationPreference, ProjectWatch
1111
from .releases import Release, ReleaseAsset
1212
from .sync_models import GitMirror, SSHKey, SyncLog
13
+from .ticket_fields import TicketFieldDefinition
14
+from .ticket_reports import TicketReport
1315
from .user_keys import UserSSHKey
1416
from .webhooks import Webhook, WebhookDelivery
1517
1618
1719
class FossilSnapshotInline(admin.TabularInline):
@@ -75,10 +77,18 @@
7577
list_display = ("user", "project", "event_filter", "email_enabled", "created_at")
7678
list_filter = ("event_filter", "email_enabled")
7779
search_fields = ("user__username", "project__name")
7880
raw_id_fields = ("user", "project")
7981
82
+
83
+@admin.register(NotificationPreference)
84
+class NotificationPreferenceAdmin(admin.ModelAdmin):
85
+ list_display = ("user", "delivery_mode", "notify_checkins", "notify_tickets", "notify_wiki", "notify_releases", "notify_forum")
86
+ list_filter = ("delivery_mode",)
87
+ search_fields = ("user__username",)
88
+ raw_id_fields = ("user",)
89
+
8090
8191
class ReleaseAssetInline(admin.TabularInline):
8292
model = ReleaseAsset
8393
extra = 0
8494
@@ -153,5 +163,21 @@
153163
class BranchProtectionAdmin(BaseCoreAdmin):
154164
list_display = ("branch_pattern", "repository", "require_status_checks", "restrict_push", "created_at")
155165
list_filter = ("require_status_checks", "restrict_push")
156166
search_fields = ("branch_pattern",)
157167
raw_id_fields = ("repository",)
168
+
169
+
170
+@admin.register(TicketFieldDefinition)
171
+class TicketFieldDefinitionAdmin(BaseCoreAdmin):
172
+ list_display = ("name", "label", "repository", "field_type", "is_required", "sort_order")
173
+ list_filter = ("field_type", "is_required")
174
+ search_fields = ("name", "label")
175
+ raw_id_fields = ("repository",)
176
+
177
+
178
+@admin.register(TicketReport)
179
+class TicketReportAdmin(BaseCoreAdmin):
180
+ list_display = ("title", "repository", "is_public", "created_at")
181
+ list_filter = ("is_public",)
182
+ search_fields = ("title", "description")
183
+ raw_id_fields = ("repository",)
158184
--- fossil/admin.py
+++ fossil/admin.py
@@ -5,13 +5,15 @@
5 from .api_tokens import APIToken
6 from .branch_protection import BranchProtection
7 from .ci import StatusCheck
8 from .forum import ForumPost
9 from .models import FossilRepository, FossilSnapshot
10 from .notifications import Notification, ProjectWatch
11 from .releases import Release, ReleaseAsset
12 from .sync_models import GitMirror, SSHKey, SyncLog
 
 
13 from .user_keys import UserSSHKey
14 from .webhooks import Webhook, WebhookDelivery
15
16
17 class FossilSnapshotInline(admin.TabularInline):
@@ -75,10 +77,18 @@
75 list_display = ("user", "project", "event_filter", "email_enabled", "created_at")
76 list_filter = ("event_filter", "email_enabled")
77 search_fields = ("user__username", "project__name")
78 raw_id_fields = ("user", "project")
79
 
 
 
 
 
 
 
 
80
81 class ReleaseAssetInline(admin.TabularInline):
82 model = ReleaseAsset
83 extra = 0
84
@@ -153,5 +163,21 @@
153 class BranchProtectionAdmin(BaseCoreAdmin):
154 list_display = ("branch_pattern", "repository", "require_status_checks", "restrict_push", "created_at")
155 list_filter = ("require_status_checks", "restrict_push")
156 search_fields = ("branch_pattern",)
157 raw_id_fields = ("repository",)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
--- fossil/admin.py
+++ fossil/admin.py
@@ -5,13 +5,15 @@
5 from .api_tokens import APIToken
6 from .branch_protection import BranchProtection
7 from .ci import StatusCheck
8 from .forum import ForumPost
9 from .models import FossilRepository, FossilSnapshot
10 from .notifications import Notification, NotificationPreference, ProjectWatch
11 from .releases import Release, ReleaseAsset
12 from .sync_models import GitMirror, SSHKey, SyncLog
13 from .ticket_fields import TicketFieldDefinition
14 from .ticket_reports import TicketReport
15 from .user_keys import UserSSHKey
16 from .webhooks import Webhook, WebhookDelivery
17
18
19 class FossilSnapshotInline(admin.TabularInline):
@@ -75,10 +77,18 @@
77 list_display = ("user", "project", "event_filter", "email_enabled", "created_at")
78 list_filter = ("event_filter", "email_enabled")
79 search_fields = ("user__username", "project__name")
80 raw_id_fields = ("user", "project")
81
82
83 @admin.register(NotificationPreference)
84 class NotificationPreferenceAdmin(admin.ModelAdmin):
85 list_display = ("user", "delivery_mode", "notify_checkins", "notify_tickets", "notify_wiki", "notify_releases", "notify_forum")
86 list_filter = ("delivery_mode",)
87 search_fields = ("user__username",)
88 raw_id_fields = ("user",)
89
90
91 class ReleaseAssetInline(admin.TabularInline):
92 model = ReleaseAsset
93 extra = 0
94
@@ -153,5 +163,21 @@
163 class BranchProtectionAdmin(BaseCoreAdmin):
164 list_display = ("branch_pattern", "repository", "require_status_checks", "restrict_push", "created_at")
165 list_filter = ("require_status_checks", "restrict_push")
166 search_fields = ("branch_pattern",)
167 raw_id_fields = ("repository",)
168
169
170 @admin.register(TicketFieldDefinition)
171 class TicketFieldDefinitionAdmin(BaseCoreAdmin):
172 list_display = ("name", "label", "repository", "field_type", "is_required", "sort_order")
173 list_filter = ("field_type", "is_required")
174 search_fields = ("name", "label")
175 raw_id_fields = ("repository",)
176
177
178 @admin.register(TicketReport)
179 class TicketReportAdmin(BaseCoreAdmin):
180 list_display = ("title", "repository", "is_public", "created_at")
181 list_filter = ("is_public",)
182 search_fields = ("title", "description")
183 raw_id_fields = ("repository",)
184
--- fossil/cli.py
+++ fossil/cli.py
@@ -384,5 +384,22 @@
384384
if line.lower().startswith(b"content-type:"):
385385
response_content_type = line.split(b":", 1)[1].strip().decode("utf-8", errors="replace")
386386
break
387387
388388
return body, response_content_type
389
+
390
+ def shun(self, repo_path: Path, artifact_uuid: str, reason: str = "") -> dict:
391
+ """Shun (permanently remove) an artifact from the repo.
392
+
393
+ This is IRREVERSIBLE. The artifact is permanently expunged from the repository.
394
+ """
395
+ cmd = [self.binary, "shun", artifact_uuid, "-R", str(repo_path)]
396
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env)
397
+ return {"success": result.returncode == 0, "message": (result.stdout + result.stderr).strip()}
398
+
399
+ def shun_list(self, repo_path: Path) -> list[str]:
400
+ """List currently shunned artifact UUIDs."""
401
+ cmd = [self.binary, "shun", "--list", "-R", str(repo_path)]
402
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env)
403
+ if result.returncode == 0:
404
+ return [line.strip() for line in result.stdout.strip().splitlines() if line.strip()]
405
+ return []
389406
390407
ADDED fossil/migrations/0009_historicalticketfielddefinition_and_more.py
--- fossil/cli.py
+++ fossil/cli.py
@@ -384,5 +384,22 @@
384 if line.lower().startswith(b"content-type:"):
385 response_content_type = line.split(b":", 1)[1].strip().decode("utf-8", errors="replace")
386 break
387
388 return body, response_content_type
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
390 DDED fossil/migrations/0009_historicalticketfielddefinition_and_more.py
--- fossil/cli.py
+++ fossil/cli.py
@@ -384,5 +384,22 @@
384 if line.lower().startswith(b"content-type:"):
385 response_content_type = line.split(b":", 1)[1].strip().decode("utf-8", errors="replace")
386 break
387
388 return body, response_content_type
389
390 def shun(self, repo_path: Path, artifact_uuid: str, reason: str = "") -> dict:
391 """Shun (permanently remove) an artifact from the repo.
392
393 This is IRREVERSIBLE. The artifact is permanently expunged from the repository.
394 """
395 cmd = [self.binary, "shun", artifact_uuid, "-R", str(repo_path)]
396 result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env)
397 return {"success": result.returncode == 0, "message": (result.stdout + result.stderr).strip()}
398
399 def shun_list(self, repo_path: Path) -> list[str]:
400 """List currently shunned artifact UUIDs."""
401 cmd = [self.binary, "shun", "--list", "-R", str(repo_path)]
402 result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env)
403 if result.returncode == 0:
404 return [line.strip() for line in result.stdout.strip().splitlines() if line.strip()]
405 return []
406
407 DDED fossil/migrations/0009_historicalticketfielddefinition_and_more.py
--- a/fossil/migrations/0009_historicalticketfielddefinition_and_more.py
+++ b/fossil/migrations/0009_historicalticketfielddefinition_and_more.py
@@ -0,0 +1,430 @@
1
+# Generated by Django 5.2.12 on 2026-04-07 15:23
2
+
3
+import django.db.models.deletion
4
+import simple_history.models
5
+from django.conf import settings
6
+from django.db import migrations, models
7
+
8
+
9
+class Migration(migrations.Migration):
10
+ dependencies = [
11
+ ("fossil", "0008_apitoken_historicalapitoken_and_more"),
12
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name="HistoricalTicketFieldDefinition",
18
+ fields=[
19
+ (
20
+ "id",
21
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
22
+ ),
23
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
24
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
25
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
26
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
27
+ (
28
+ "name",
29
+ models.CharField(
30
+ help_text="Field name (used in Fossil ticket system)",
31
+ max_length=100,
32
+ ),
33
+ ),
34
+ ("label", models.CharField(help_text="Display label", max_length=200)),
35
+ (
36
+ "field_type",
37
+ models.CharField(
38
+ choices=[
39
+ ("text", "Text"),
40
+ ("textarea", "Multi-line Text"),
41
+ ("select", "Select (dropdown)"),
42
+ ("checkbox", "Checkbox"),
43
+ ("date", "Date"),
44
+ ("url", "URL"),
45
+ ],
46
+ default="text",
47
+ max_length=20,
48
+ ),
49
+ ),
50
+ (
51
+ "choices",
52
+ models.TextField(
53
+ blank=True,
54
+ default="",
55
+ help_text="Options for select fields, one per line",
56
+ ),
57
+ ),
58
+ ("is_required", models.BooleanField(default=False)),
59
+ ("sort_order", models.IntegerField(default=0)),
60
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
61
+ ("history_date", models.DateTimeField(db_index=True)),
62
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
63
+ (
64
+ "history_type",
65
+ models.CharField(
66
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
67
+ max_length=1,
68
+ ),
69
+ ),
70
+ (
71
+ "created_by",
72
+ models.ForeignKey(
73
+ blank=True,
74
+ db_constraint=False,
75
+ null=True,
76
+ on_delete=django.db.models.deletion.DO_NOTHING,
77
+ related_name="+",
78
+ to=settings.AUTH_USER_MODEL,
79
+ ),
80
+ ),
81
+ (
82
+ "deleted_by",
83
+ models.ForeignKey(
84
+ blank=True,
85
+ db_constraint=False,
86
+ null=True,
87
+ on_delete=django.db.models.deletion.DO_NOTHING,
88
+ related_name="+",
89
+ to=settings.AUTH_USER_MODEL,
90
+ ),
91
+ ),
92
+ (
93
+ "history_user",
94
+ models.ForeignKey(
95
+ null=True,
96
+ on_delete=django.db.models.deletion.SET_NULL,
97
+ related_name="+",
98
+ to=settings.AUTH_USER_MODEL,
99
+ ),
100
+ ),
101
+ (
102
+ "repository",
103
+ models.ForeignKey(
104
+ blank=True,
105
+ db_constraint=False,
106
+ null=True,
107
+ on_delete=django.db.models.deletion.DO_NOTHING,
108
+ related_name="+",
109
+ to="fossil.fossilrepository",
110
+ ),
111
+ ),
112
+ (
113
+ "updated_by",
114
+ models.ForeignKey(
115
+ blank=True,
116
+ db_constraint=False,
117
+ null=True,
118
+ on_delete=django.db.models.deletion.DO_NOTHING,
119
+ related_name="+",
120
+ to=settings.AUTH_USER_MODEL,
121
+ ),
122
+ ),
123
+ ],
124
+ options={
125
+ "verbose_name": "historical ticket field definition",
126
+ "verbose_name_plural": "historical ticket field definitions",
127
+ "ordering": ("-history_date", "-history_id"),
128
+ "get_latest_by": ("history_date", "history_id"),
129
+ },
130
+ bases=(simple_history.models.HistoricalChanges, models.Model),
131
+ ),
132
+ migrations.CreateModel(
133
+ name="HistoricalTicketReport",
134
+ fields=[
135
+ (
136
+ "id",
137
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
138
+ ),
139
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
140
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
141
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
142
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
143
+ ("title", models.CharField(max_length=200)),
144
+ ("description", models.TextField(blank=True, default="")),
145
+ (
146
+ "sql_query",
147
+ models.TextField(help_text="SQL query against the Fossil ticket table. Use {status}, {type} as placeholders."),
148
+ ),
149
+ (
150
+ "is_public",
151
+ models.BooleanField(default=True, help_text="Visible to all project members"),
152
+ ),
153
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
154
+ ("history_date", models.DateTimeField(db_index=True)),
155
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
156
+ (
157
+ "history_type",
158
+ models.CharField(
159
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
160
+ max_length=1,
161
+ ),
162
+ ),
163
+ (
164
+ "created_by",
165
+ models.ForeignKey(
166
+ blank=True,
167
+ db_constraint=False,
168
+ null=True,
169
+ on_delete=django.db.models.deletion.DO_NOTHING,
170
+ related_name="+",
171
+ to=settings.AUTH_USER_MODEL,
172
+ ),
173
+ ),
174
+ (
175
+ "deleted_by",
176
+ models.ForeignKey(
177
+ blank=True,
178
+ db_constraint=False,
179
+ null=True,
180
+ on_delete=django.db.models.deletion.DO_NOTHING,
181
+ related_name="+",
182
+ to=settings.AUTH_USER_MODEL,
183
+ ),
184
+ ),
185
+ (
186
+ "history_user",
187
+ models.ForeignKey(
188
+ null=True,
189
+ on_delete=django.db.models.deletion.SET_NULL,
190
+ related_name="+",
191
+ to=settings.AUTH_USER_MODEL,
192
+ ),
193
+ ),
194
+ (
195
+ "repository",
196
+ models.ForeignKey(
197
+ blank=True,
198
+ db_constraint=False,
199
+ null=True,
200
+ on_delete=django.db.models.deletion.DO_NOTHING,
201
+ related_name="+",
202
+ to="fossil.fossilrepository",
203
+ ),
204
+ ),
205
+ (
206
+ "updated_by",
207
+ models.ForeignKey(
208
+ blank=True,
209
+ db_constraint=False,
210
+ null=True,
211
+ on_delete=django.db.models.deletion.DO_NOTHING,
212
+ related_name="+",
213
+ to=settings.AUTH_USER_MODEL,
214
+ ),
215
+ ),
216
+ ],
217
+ options={
218
+ "verbose_name": "historical ticket report",
219
+ "verbose_name_plural": "historical ticket reports",
220
+ "ordering": ("-history_date", "-history_id"),
221
+ "get_latest_by": ("history_date", "history_id"),
222
+ },
223
+ bases=(simple_history.models.HistoricalChanges, models.Model),
224
+ ),
225
+ migrations.CreateModel(
226
+ name="NotificationPreference",
227
+ fields=[
228
+ (
229
+ "id",
230
+ models.BigAutoField(
231
+ auto_created=True,
232
+ primary_key=True,
233
+ serialize=False,
234
+ verbose_name="ID",
235
+ ),
236
+ ),
237
+ (
238
+ "delivery_mode",
239
+ models.CharField(
240
+ choices=[
241
+ ("immediate", "Immediate (per event)"),
242
+ ("daily", "Daily Digest"),
243
+ ("weekly", "Weekly Digest"),
244
+ ("off", "Off"),
245
+ ],
246
+ default="immediate",
247
+ max_length=20,
248
+ ),
249
+ ),
250
+ ("notify_checkins", models.BooleanField(default=True)),
251
+ ("notify_tickets", models.BooleanField(default=True)),
252
+ ("notify_wiki", models.BooleanField(default=True)),
253
+ ("notify_releases", models.BooleanField(default=True)),
254
+ ("notify_forum", models.BooleanField(default=False)),
255
+ (
256
+ "user",
257
+ models.OneToOneField(
258
+ on_delete=django.db.models.deletion.CASCADE,
259
+ related_name="notification_prefs",
260
+ to=settings.AUTH_USER_MODEL,
261
+ ),
262
+ ),
263
+ ],
264
+ options={
265
+ "verbose_name": "Notification Preference",
266
+ },
267
+ ),
268
+ migrations.CreateModel(
269
+ name="TicketReport",
270
+ fields=[
271
+ (
272
+ "id",
273
+ models.BigAutoField(
274
+ auto_created=True,
275
+ primary_key=True,
276
+ serialize=False,
277
+ verbose_name="ID",
278
+ ),
279
+ ),
280
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
281
+ ("created_at", models.DateTimeField(auto_now_add=True)),
282
+ ("updated_at", models.DateTimeField(auto_now=True)),
283
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
284
+ ("title", models.CharField(max_length=200)),
285
+ ("description", models.TextField(blank=True, default="")),
286
+ (
287
+ "sql_query",
288
+ models.TextField(help_text="SQL query against the Fossil ticket table. Use {status}, {type} as placeholders."),
289
+ ),
290
+ (
291
+ "is_public",
292
+ models.BooleanField(default=True, help_text="Visible to all project members"),
293
+ ),
294
+ (
295
+ "created_by",
296
+ models.ForeignKey(
297
+ blank=True,
298
+ null=True,
299
+ on_delete=django.db.models.deletion.SET_NULL,
300
+ related_name="+",
301
+ to=settings.AUTH_USER_MODEL,
302
+ ),
303
+ ),
304
+ (
305
+ "deleted_by",
306
+ models.ForeignKey(
307
+ blank=True,
308
+ null=True,
309
+ on_delete=django.db.models.deletion.SET_NULL,
310
+ related_name="+",
311
+ to=settings.AUTH_USER_MODEL,
312
+ ),
313
+ ),
314
+ (
315
+ "repository",
316
+ models.ForeignKey(
317
+ on_delete=django.db.models.deletion.CASCADE,
318
+ related_name="ticket_reports",
319
+ to="fossil.fossilrepository",
320
+ ),
321
+ ),
322
+ (
323
+ "updated_by",
324
+ models.ForeignKey(
325
+ blank=True,
326
+ null=True,
327
+ on_delete=django.db.models.deletion.SET_NULL,
328
+ related_name="+",
329
+ to=settings.AUTH_USER_MODEL,
330
+ ),
331
+ ),
332
+ ],
333
+ options={
334
+ "ordering": ["title"],
335
+ },
336
+ ),
337
+ migrations.CreateModel(
338
+ name="TicketFieldDefinition",
339
+ fields=[
340
+ (
341
+ "id",
342
+ models.BigAutoField(
343
+ auto_created=True,
344
+ primary_key=True,
345
+ serialize=False,
346
+ verbose_name="ID",
347
+ ),
348
+ ),
349
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
350
+ ("created_at", models.DateTimeField(auto_now_add=True)),
351
+ ("updated_at", models.DateTimeField(auto_now=True)),
352
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
353
+ (
354
+ "name",
355
+ models.CharField(
356
+ help_text="Field name (used in Fossil ticket system)",
357
+ max_length=100,
358
+ ),
359
+ ),
360
+ ("label", models.CharField(help_text="Display label", max_length=200)),
361
+ (
362
+ "field_type",
363
+ models.CharField(
364
+ choices=[
365
+ ("text", "Text"),
366
+ ("textarea", "Multi-line Text"),
367
+ ("select", "Select (dropdown)"),
368
+ ("checkbox", "Checkbox"),
369
+ ("date", "Date"),
370
+ ("url", "URL"),
371
+ ],
372
+ default="text",
373
+ max_length=20,
374
+ ),
375
+ ),
376
+ (
377
+ "choices",
378
+ models.TextField(
379
+ blank=True,
380
+ default="",
381
+ help_text="Options for select fields, one per line",
382
+ ),
383
+ ),
384
+ ("is_required", models.BooleanField(default=False)),
385
+ ("sort_order", models.IntegerField(default=0)),
386
+ (
387
+ "created_by",
388
+ models.ForeignKey(
389
+ blank=True,
390
+ null=True,
391
+ on_delete=django.db.models.deletion.SET_NULL,
392
+ related_name="+",
393
+ to=settings.AUTH_USER_MODEL,
394
+ ),
395
+ ),
396
+ (
397
+ "deleted_by",
398
+ models.ForeignKey(
399
+ blank=True,
400
+ null=True,
401
+ on_delete=django.db.models.deletion.SET_NULL,
402
+ related_name="+",
403
+ to=settings.AUTH_USER_MODEL,
404
+ ),
405
+ ),
406
+ (
407
+ "repository",
408
+ models.ForeignKey(
409
+ on_delete=django.db.models.deletion.CASCADE,
410
+ related_name="ticket_fields",
411
+ to="fossil.fossilrepository",
412
+ ),
413
+ ),
414
+ (
415
+ "updated_by",
416
+ models.ForeignKey(
417
+ blank=True,
418
+ null=True,
419
+ on_delete=django.db.models.deletion.SET_NULL,
420
+ related_name="+",
421
+ to=settings.AUTH_USER_MODEL,
422
+ ),
423
+ ),
424
+ ],
425
+ options={
426
+ "ordering": ["sort_order", "name"],
427
+ "unique_together": {("repository", "name")},
428
+ },
429
+ ),
430
+ ]
--- a/fossil/migrations/0009_historicalticketfielddefinition_and_more.py
+++ b/fossil/migrations/0009_historicalticketfielddefinition_and_more.py
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/migrations/0009_historicalticketfielddefinition_and_more.py
+++ b/fossil/migrations/0009_historicalticketfielddefinition_and_more.py
@@ -0,0 +1,430 @@
1 # Generated by Django 5.2.12 on 2026-04-07 15:23
2
3 import django.db.models.deletion
4 import simple_history.models
5 from django.conf import settings
6 from django.db import migrations, models
7
8
9 class Migration(migrations.Migration):
10 dependencies = [
11 ("fossil", "0008_apitoken_historicalapitoken_and_more"),
12 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13 ]
14
15 operations = [
16 migrations.CreateModel(
17 name="HistoricalTicketFieldDefinition",
18 fields=[
19 (
20 "id",
21 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
22 ),
23 ("version", models.PositiveIntegerField(default=1, editable=False)),
24 ("created_at", models.DateTimeField(blank=True, editable=False)),
25 ("updated_at", models.DateTimeField(blank=True, editable=False)),
26 ("deleted_at", models.DateTimeField(blank=True, null=True)),
27 (
28 "name",
29 models.CharField(
30 help_text="Field name (used in Fossil ticket system)",
31 max_length=100,
32 ),
33 ),
34 ("label", models.CharField(help_text="Display label", max_length=200)),
35 (
36 "field_type",
37 models.CharField(
38 choices=[
39 ("text", "Text"),
40 ("textarea", "Multi-line Text"),
41 ("select", "Select (dropdown)"),
42 ("checkbox", "Checkbox"),
43 ("date", "Date"),
44 ("url", "URL"),
45 ],
46 default="text",
47 max_length=20,
48 ),
49 ),
50 (
51 "choices",
52 models.TextField(
53 blank=True,
54 default="",
55 help_text="Options for select fields, one per line",
56 ),
57 ),
58 ("is_required", models.BooleanField(default=False)),
59 ("sort_order", models.IntegerField(default=0)),
60 ("history_id", models.AutoField(primary_key=True, serialize=False)),
61 ("history_date", models.DateTimeField(db_index=True)),
62 ("history_change_reason", models.CharField(max_length=100, null=True)),
63 (
64 "history_type",
65 models.CharField(
66 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
67 max_length=1,
68 ),
69 ),
70 (
71 "created_by",
72 models.ForeignKey(
73 blank=True,
74 db_constraint=False,
75 null=True,
76 on_delete=django.db.models.deletion.DO_NOTHING,
77 related_name="+",
78 to=settings.AUTH_USER_MODEL,
79 ),
80 ),
81 (
82 "deleted_by",
83 models.ForeignKey(
84 blank=True,
85 db_constraint=False,
86 null=True,
87 on_delete=django.db.models.deletion.DO_NOTHING,
88 related_name="+",
89 to=settings.AUTH_USER_MODEL,
90 ),
91 ),
92 (
93 "history_user",
94 models.ForeignKey(
95 null=True,
96 on_delete=django.db.models.deletion.SET_NULL,
97 related_name="+",
98 to=settings.AUTH_USER_MODEL,
99 ),
100 ),
101 (
102 "repository",
103 models.ForeignKey(
104 blank=True,
105 db_constraint=False,
106 null=True,
107 on_delete=django.db.models.deletion.DO_NOTHING,
108 related_name="+",
109 to="fossil.fossilrepository",
110 ),
111 ),
112 (
113 "updated_by",
114 models.ForeignKey(
115 blank=True,
116 db_constraint=False,
117 null=True,
118 on_delete=django.db.models.deletion.DO_NOTHING,
119 related_name="+",
120 to=settings.AUTH_USER_MODEL,
121 ),
122 ),
123 ],
124 options={
125 "verbose_name": "historical ticket field definition",
126 "verbose_name_plural": "historical ticket field definitions",
127 "ordering": ("-history_date", "-history_id"),
128 "get_latest_by": ("history_date", "history_id"),
129 },
130 bases=(simple_history.models.HistoricalChanges, models.Model),
131 ),
132 migrations.CreateModel(
133 name="HistoricalTicketReport",
134 fields=[
135 (
136 "id",
137 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
138 ),
139 ("version", models.PositiveIntegerField(default=1, editable=False)),
140 ("created_at", models.DateTimeField(blank=True, editable=False)),
141 ("updated_at", models.DateTimeField(blank=True, editable=False)),
142 ("deleted_at", models.DateTimeField(blank=True, null=True)),
143 ("title", models.CharField(max_length=200)),
144 ("description", models.TextField(blank=True, default="")),
145 (
146 "sql_query",
147 models.TextField(help_text="SQL query against the Fossil ticket table. Use {status}, {type} as placeholders."),
148 ),
149 (
150 "is_public",
151 models.BooleanField(default=True, help_text="Visible to all project members"),
152 ),
153 ("history_id", models.AutoField(primary_key=True, serialize=False)),
154 ("history_date", models.DateTimeField(db_index=True)),
155 ("history_change_reason", models.CharField(max_length=100, null=True)),
156 (
157 "history_type",
158 models.CharField(
159 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
160 max_length=1,
161 ),
162 ),
163 (
164 "created_by",
165 models.ForeignKey(
166 blank=True,
167 db_constraint=False,
168 null=True,
169 on_delete=django.db.models.deletion.DO_NOTHING,
170 related_name="+",
171 to=settings.AUTH_USER_MODEL,
172 ),
173 ),
174 (
175 "deleted_by",
176 models.ForeignKey(
177 blank=True,
178 db_constraint=False,
179 null=True,
180 on_delete=django.db.models.deletion.DO_NOTHING,
181 related_name="+",
182 to=settings.AUTH_USER_MODEL,
183 ),
184 ),
185 (
186 "history_user",
187 models.ForeignKey(
188 null=True,
189 on_delete=django.db.models.deletion.SET_NULL,
190 related_name="+",
191 to=settings.AUTH_USER_MODEL,
192 ),
193 ),
194 (
195 "repository",
196 models.ForeignKey(
197 blank=True,
198 db_constraint=False,
199 null=True,
200 on_delete=django.db.models.deletion.DO_NOTHING,
201 related_name="+",
202 to="fossil.fossilrepository",
203 ),
204 ),
205 (
206 "updated_by",
207 models.ForeignKey(
208 blank=True,
209 db_constraint=False,
210 null=True,
211 on_delete=django.db.models.deletion.DO_NOTHING,
212 related_name="+",
213 to=settings.AUTH_USER_MODEL,
214 ),
215 ),
216 ],
217 options={
218 "verbose_name": "historical ticket report",
219 "verbose_name_plural": "historical ticket reports",
220 "ordering": ("-history_date", "-history_id"),
221 "get_latest_by": ("history_date", "history_id"),
222 },
223 bases=(simple_history.models.HistoricalChanges, models.Model),
224 ),
225 migrations.CreateModel(
226 name="NotificationPreference",
227 fields=[
228 (
229 "id",
230 models.BigAutoField(
231 auto_created=True,
232 primary_key=True,
233 serialize=False,
234 verbose_name="ID",
235 ),
236 ),
237 (
238 "delivery_mode",
239 models.CharField(
240 choices=[
241 ("immediate", "Immediate (per event)"),
242 ("daily", "Daily Digest"),
243 ("weekly", "Weekly Digest"),
244 ("off", "Off"),
245 ],
246 default="immediate",
247 max_length=20,
248 ),
249 ),
250 ("notify_checkins", models.BooleanField(default=True)),
251 ("notify_tickets", models.BooleanField(default=True)),
252 ("notify_wiki", models.BooleanField(default=True)),
253 ("notify_releases", models.BooleanField(default=True)),
254 ("notify_forum", models.BooleanField(default=False)),
255 (
256 "user",
257 models.OneToOneField(
258 on_delete=django.db.models.deletion.CASCADE,
259 related_name="notification_prefs",
260 to=settings.AUTH_USER_MODEL,
261 ),
262 ),
263 ],
264 options={
265 "verbose_name": "Notification Preference",
266 },
267 ),
268 migrations.CreateModel(
269 name="TicketReport",
270 fields=[
271 (
272 "id",
273 models.BigAutoField(
274 auto_created=True,
275 primary_key=True,
276 serialize=False,
277 verbose_name="ID",
278 ),
279 ),
280 ("version", models.PositiveIntegerField(default=1, editable=False)),
281 ("created_at", models.DateTimeField(auto_now_add=True)),
282 ("updated_at", models.DateTimeField(auto_now=True)),
283 ("deleted_at", models.DateTimeField(blank=True, null=True)),
284 ("title", models.CharField(max_length=200)),
285 ("description", models.TextField(blank=True, default="")),
286 (
287 "sql_query",
288 models.TextField(help_text="SQL query against the Fossil ticket table. Use {status}, {type} as placeholders."),
289 ),
290 (
291 "is_public",
292 models.BooleanField(default=True, help_text="Visible to all project members"),
293 ),
294 (
295 "created_by",
296 models.ForeignKey(
297 blank=True,
298 null=True,
299 on_delete=django.db.models.deletion.SET_NULL,
300 related_name="+",
301 to=settings.AUTH_USER_MODEL,
302 ),
303 ),
304 (
305 "deleted_by",
306 models.ForeignKey(
307 blank=True,
308 null=True,
309 on_delete=django.db.models.deletion.SET_NULL,
310 related_name="+",
311 to=settings.AUTH_USER_MODEL,
312 ),
313 ),
314 (
315 "repository",
316 models.ForeignKey(
317 on_delete=django.db.models.deletion.CASCADE,
318 related_name="ticket_reports",
319 to="fossil.fossilrepository",
320 ),
321 ),
322 (
323 "updated_by",
324 models.ForeignKey(
325 blank=True,
326 null=True,
327 on_delete=django.db.models.deletion.SET_NULL,
328 related_name="+",
329 to=settings.AUTH_USER_MODEL,
330 ),
331 ),
332 ],
333 options={
334 "ordering": ["title"],
335 },
336 ),
337 migrations.CreateModel(
338 name="TicketFieldDefinition",
339 fields=[
340 (
341 "id",
342 models.BigAutoField(
343 auto_created=True,
344 primary_key=True,
345 serialize=False,
346 verbose_name="ID",
347 ),
348 ),
349 ("version", models.PositiveIntegerField(default=1, editable=False)),
350 ("created_at", models.DateTimeField(auto_now_add=True)),
351 ("updated_at", models.DateTimeField(auto_now=True)),
352 ("deleted_at", models.DateTimeField(blank=True, null=True)),
353 (
354 "name",
355 models.CharField(
356 help_text="Field name (used in Fossil ticket system)",
357 max_length=100,
358 ),
359 ),
360 ("label", models.CharField(help_text="Display label", max_length=200)),
361 (
362 "field_type",
363 models.CharField(
364 choices=[
365 ("text", "Text"),
366 ("textarea", "Multi-line Text"),
367 ("select", "Select (dropdown)"),
368 ("checkbox", "Checkbox"),
369 ("date", "Date"),
370 ("url", "URL"),
371 ],
372 default="text",
373 max_length=20,
374 ),
375 ),
376 (
377 "choices",
378 models.TextField(
379 blank=True,
380 default="",
381 help_text="Options for select fields, one per line",
382 ),
383 ),
384 ("is_required", models.BooleanField(default=False)),
385 ("sort_order", models.IntegerField(default=0)),
386 (
387 "created_by",
388 models.ForeignKey(
389 blank=True,
390 null=True,
391 on_delete=django.db.models.deletion.SET_NULL,
392 related_name="+",
393 to=settings.AUTH_USER_MODEL,
394 ),
395 ),
396 (
397 "deleted_by",
398 models.ForeignKey(
399 blank=True,
400 null=True,
401 on_delete=django.db.models.deletion.SET_NULL,
402 related_name="+",
403 to=settings.AUTH_USER_MODEL,
404 ),
405 ),
406 (
407 "repository",
408 models.ForeignKey(
409 on_delete=django.db.models.deletion.CASCADE,
410 related_name="ticket_fields",
411 to="fossil.fossilrepository",
412 ),
413 ),
414 (
415 "updated_by",
416 models.ForeignKey(
417 blank=True,
418 null=True,
419 on_delete=django.db.models.deletion.SET_NULL,
420 related_name="+",
421 to=settings.AUTH_USER_MODEL,
422 ),
423 ),
424 ],
425 options={
426 "ordering": ["sort_order", "name"],
427 "unique_together": {("repository", "name")},
428 },
429 ),
430 ]
--- fossil/models.py
+++ fossil/models.py
@@ -69,10 +69,12 @@
6969
# Import related models so they're discoverable by Django
7070
from fossil.api_tokens import APIToken # noqa: E402, F401
7171
from fossil.branch_protection import BranchProtection # noqa: E402, F401
7272
from fossil.ci import StatusCheck # noqa: E402, F401
7373
from fossil.forum import ForumPost # noqa: E402, F401
74
-from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401
74
+from fossil.notifications import Notification, NotificationPreference, ProjectWatch # noqa: E402, F401
7575
from fossil.releases import Release, ReleaseAsset # noqa: E402, F401
7676
from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
77
+from fossil.ticket_fields import TicketFieldDefinition # noqa: E402, F401
78
+from fossil.ticket_reports import TicketReport # noqa: E402, F401
7779
from fossil.user_keys import UserSSHKey # noqa: E402, F401
7880
from fossil.webhooks import Webhook, WebhookDelivery # noqa: E402, F401
7981
--- fossil/models.py
+++ fossil/models.py
@@ -69,10 +69,12 @@
69 # Import related models so they're discoverable by Django
70 from fossil.api_tokens import APIToken # noqa: E402, F401
71 from fossil.branch_protection import BranchProtection # noqa: E402, F401
72 from fossil.ci import StatusCheck # noqa: E402, F401
73 from fossil.forum import ForumPost # noqa: E402, F401
74 from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401
75 from fossil.releases import Release, ReleaseAsset # noqa: E402, F401
76 from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
 
 
77 from fossil.user_keys import UserSSHKey # noqa: E402, F401
78 from fossil.webhooks import Webhook, WebhookDelivery # noqa: E402, F401
79
--- fossil/models.py
+++ fossil/models.py
@@ -69,10 +69,12 @@
69 # Import related models so they're discoverable by Django
70 from fossil.api_tokens import APIToken # noqa: E402, F401
71 from fossil.branch_protection import BranchProtection # noqa: E402, F401
72 from fossil.ci import StatusCheck # noqa: E402, F401
73 from fossil.forum import ForumPost # noqa: E402, F401
74 from fossil.notifications import Notification, NotificationPreference, ProjectWatch # noqa: E402, F401
75 from fossil.releases import Release, ReleaseAsset # noqa: E402, F401
76 from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
77 from fossil.ticket_fields import TicketFieldDefinition # noqa: E402, F401
78 from fossil.ticket_reports import TicketReport # noqa: E402, F401
79 from fossil.user_keys import UserSSHKey # noqa: E402, F401
80 from fossil.webhooks import Webhook, WebhookDelivery # noqa: E402, F401
81
--- fossil/notifications.py
+++ fossil/notifications.py
@@ -57,10 +57,36 @@
5757
ordering = ["-created_at"]
5858
5959
def __str__(self):
6060
return f"{self.title} → {self.user.username}"
6161
62
+
63
+class NotificationPreference(models.Model):
64
+ """Per-user notification delivery preferences."""
65
+
66
+ class DeliveryMode(models.TextChoices):
67
+ IMMEDIATE = "immediate", "Immediate (per event)"
68
+ DAILY = "daily", "Daily Digest"
69
+ WEEKLY = "weekly", "Weekly Digest"
70
+ OFF = "off", "Off"
71
+
72
+ user = models.OneToOneField("auth.User", on_delete=models.CASCADE, related_name="notification_prefs")
73
+ delivery_mode = models.CharField(max_length=20, choices=DeliveryMode.choices, default=DeliveryMode.IMMEDIATE)
74
+
75
+ # Event type toggles
76
+ notify_checkins = models.BooleanField(default=True)
77
+ notify_tickets = models.BooleanField(default=True)
78
+ notify_wiki = models.BooleanField(default=True)
79
+ notify_releases = models.BooleanField(default=True)
80
+ notify_forum = models.BooleanField(default=False)
81
+
82
+ class Meta:
83
+ verbose_name = "Notification Preference"
84
+
85
+ def __str__(self):
86
+ return f"{self.user.username}: {self.delivery_mode}"
87
+
6288
6389
def notify_project_event(project, event_type: str, title: str, body: str = "", url: str = "", exclude_user=None):
6490
"""Create notifications for all watchers of a project.
6591
6692
Args:
6793
--- fossil/notifications.py
+++ fossil/notifications.py
@@ -57,10 +57,36 @@
57 ordering = ["-created_at"]
58
59 def __str__(self):
60 return f"{self.title} → {self.user.username}"
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
63 def notify_project_event(project, event_type: str, title: str, body: str = "", url: str = "", exclude_user=None):
64 """Create notifications for all watchers of a project.
65
66 Args:
67
--- fossil/notifications.py
+++ fossil/notifications.py
@@ -57,10 +57,36 @@
57 ordering = ["-created_at"]
58
59 def __str__(self):
60 return f"{self.title} → {self.user.username}"
61
62
63 class NotificationPreference(models.Model):
64 """Per-user notification delivery preferences."""
65
66 class DeliveryMode(models.TextChoices):
67 IMMEDIATE = "immediate", "Immediate (per event)"
68 DAILY = "daily", "Daily Digest"
69 WEEKLY = "weekly", "Weekly Digest"
70 OFF = "off", "Off"
71
72 user = models.OneToOneField("auth.User", on_delete=models.CASCADE, related_name="notification_prefs")
73 delivery_mode = models.CharField(max_length=20, choices=DeliveryMode.choices, default=DeliveryMode.IMMEDIATE)
74
75 # Event type toggles
76 notify_checkins = models.BooleanField(default=True)
77 notify_tickets = models.BooleanField(default=True)
78 notify_wiki = models.BooleanField(default=True)
79 notify_releases = models.BooleanField(default=True)
80 notify_forum = models.BooleanField(default=False)
81
82 class Meta:
83 verbose_name = "Notification Preference"
84
85 def __str__(self):
86 return f"{self.user.username}: {self.delivery_mode}"
87
88
89 def notify_project_event(project, event_type: str, title: str, body: str = "", url: str = "", exclude_user=None):
90 """Create notifications for all watchers of a project.
91
92 Args:
93
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -195,10 +195,52 @@
195195
log.status = "failed"
196196
log.message = "Unexpected error"
197197
log.completed_at = timezone.now()
198198
log.save()
199199
200
+
201
+@shared_task(name="fossil.send_digest")
202
+def send_digest(mode="daily"):
203
+ """Send digest emails to users who prefer batch delivery.
204
+
205
+ Collects unread notifications for users with the given delivery mode
206
+ and sends a single summary email. Marks those notifications as read
207
+ after sending.
208
+ """
209
+ from django.conf import settings
210
+ from django.core.mail import send_mail
211
+
212
+ from fossil.notifications import Notification, NotificationPreference
213
+
214
+ prefs = NotificationPreference.objects.filter(delivery_mode=mode).select_related("user")
215
+ for pref in prefs:
216
+ unread = Notification.objects.filter(user=pref.user, read=False)
217
+ if not unread.exists():
218
+ continue
219
+
220
+ count = unread.count()
221
+ lines = [f"You have {count} new notification{'s' if count != 1 else ''}:\n"]
222
+ for notif in unread[:50]:
223
+ lines.append(f"- [{notif.event_type}] {notif.project.name}: {notif.title}")
224
+
225
+ if count > 50:
226
+ lines.append(f"\n... and {count - 50} more.")
227
+
228
+ try:
229
+ send_mail(
230
+ subject=f"Fossilrepo {mode.title()} Digest - {count} update{'s' if count != 1 else ''}",
231
+ message="\n".join(lines),
232
+ from_email=settings.DEFAULT_FROM_EMAIL,
233
+ recipient_list=[pref.user.email],
234
+ fail_silently=True,
235
+ )
236
+ except Exception:
237
+ logger.exception("Failed to send %s digest to %s", mode, pref.user.email)
238
+ continue
239
+
240
+ unread.update(read=True)
241
+
200242
201243
@shared_task(name="fossil.dispatch_webhook", bind=True, max_retries=3)
202244
def dispatch_webhook(self, webhook_id, event_type, payload):
203245
"""Deliver a webhook with retry and logging."""
204246
import hashlib
205247
206248
ADDED fossil/ticket_fields.py
207249
ADDED fossil/ticket_reports.py
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -195,10 +195,52 @@
195 log.status = "failed"
196 log.message = "Unexpected error"
197 log.completed_at = timezone.now()
198 log.save()
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
201 @shared_task(name="fossil.dispatch_webhook", bind=True, max_retries=3)
202 def dispatch_webhook(self, webhook_id, event_type, payload):
203 """Deliver a webhook with retry and logging."""
204 import hashlib
205
206 DDED fossil/ticket_fields.py
207 DDED fossil/ticket_reports.py
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -195,10 +195,52 @@
195 log.status = "failed"
196 log.message = "Unexpected error"
197 log.completed_at = timezone.now()
198 log.save()
199
200
201 @shared_task(name="fossil.send_digest")
202 def send_digest(mode="daily"):
203 """Send digest emails to users who prefer batch delivery.
204
205 Collects unread notifications for users with the given delivery mode
206 and sends a single summary email. Marks those notifications as read
207 after sending.
208 """
209 from django.conf import settings
210 from django.core.mail import send_mail
211
212 from fossil.notifications import Notification, NotificationPreference
213
214 prefs = NotificationPreference.objects.filter(delivery_mode=mode).select_related("user")
215 for pref in prefs:
216 unread = Notification.objects.filter(user=pref.user, read=False)
217 if not unread.exists():
218 continue
219
220 count = unread.count()
221 lines = [f"You have {count} new notification{'s' if count != 1 else ''}:\n"]
222 for notif in unread[:50]:
223 lines.append(f"- [{notif.event_type}] {notif.project.name}: {notif.title}")
224
225 if count > 50:
226 lines.append(f"\n... and {count - 50} more.")
227
228 try:
229 send_mail(
230 subject=f"Fossilrepo {mode.title()} Digest - {count} update{'s' if count != 1 else ''}",
231 message="\n".join(lines),
232 from_email=settings.DEFAULT_FROM_EMAIL,
233 recipient_list=[pref.user.email],
234 fail_silently=True,
235 )
236 except Exception:
237 logger.exception("Failed to send %s digest to %s", mode, pref.user.email)
238 continue
239
240 unread.update(read=True)
241
242
243 @shared_task(name="fossil.dispatch_webhook", bind=True, max_retries=3)
244 def dispatch_webhook(self, webhook_id, event_type, payload):
245 """Deliver a webhook with retry and logging."""
246 import hashlib
247
248 DDED fossil/ticket_fields.py
249 DDED fossil/ticket_reports.py
--- a/fossil/ticket_fields.py
+++ b/fossil/ticket_fields.py
@@ -0,0 +1,48 @@
1
+"""Custom field definitions for project tickets.
2
+
3
+Fossil's ticket system is schema-flexible -- the ``ticket`` table can
4
+have arbitrary columns. This model lets admins define extra fields
5
+per repository so the Django UI can render them in create/edit forms
6
+and pass values through to the Fossil CLI.
7
+"""
8
+
9
+from django.db import models
10
+
11
+from core.models import ActiveManager, Tracking
12
+
13
+
14
+class TicketFieldDefinition(Tracking):
15
+ """Custom field definition for project tickets."""
16
+
17
+ class FieldType(models.TextChoices):
18
+ TEXT = "text", "Text"
19
+ TEXTAREA = "textarea", "Multi-line Text"
20
+ SELECT = "select", "Select (dropdown)"
21
+ CHECKBOX = "checkbox", "Checkbox"
22
+ DATE = "date", "Date"
23
+ URL = "url", "URL"
24
+
25
+ repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="ticket_fields")
26
+ name = models.CharField(max_length=100, help_text="Field name (used in Fossil ticket system)")
27
+ label = models.CharField(max_length=200, help_text="Display label")
28
+ field_type = models.CharField(max_length=20, choices=FieldType.choices, default=FieldType.TEXT)
29
+ choices = models.TextField(blank=True, default="", help_text="Options for select fields, one per line")
30
+ is_required = models.BooleanField(default=False)
31
+ sort_order = models.IntegerField(default=0)
32
+
33
+ objects = ActiveManager()
34
+ all_objects = models.Manager()
35
+
36
+ class Meta:
37
+ ordering = ["sort_order", "name"]
38
+ unique_together = [("repository", "name")]
39
+
40
+ def __str__(self):
41
+ return f"{self.label} ({self.name})"
42
+
43
+ @property
44
+ def choices_list(self):
45
+ """Return choices as a list, filtering out blank lines."""
46
+ if not self.choices:
47
+ return []
48
+ return [c.strip() for c in self.choices.splitlines() if c.strip()]
--- a/fossil/ticket_fields.py
+++ b/fossil/ticket_fields.py
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/ticket_fields.py
+++ b/fossil/ticket_fields.py
@@ -0,0 +1,48 @@
1 """Custom field definitions for project tickets.
2
3 Fossil's ticket system is schema-flexible -- the ``ticket`` table can
4 have arbitrary columns. This model lets admins define extra fields
5 per repository so the Django UI can render them in create/edit forms
6 and pass values through to the Fossil CLI.
7 """
8
9 from django.db import models
10
11 from core.models import ActiveManager, Tracking
12
13
14 class TicketFieldDefinition(Tracking):
15 """Custom field definition for project tickets."""
16
17 class FieldType(models.TextChoices):
18 TEXT = "text", "Text"
19 TEXTAREA = "textarea", "Multi-line Text"
20 SELECT = "select", "Select (dropdown)"
21 CHECKBOX = "checkbox", "Checkbox"
22 DATE = "date", "Date"
23 URL = "url", "URL"
24
25 repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="ticket_fields")
26 name = models.CharField(max_length=100, help_text="Field name (used in Fossil ticket system)")
27 label = models.CharField(max_length=200, help_text="Display label")
28 field_type = models.CharField(max_length=20, choices=FieldType.choices, default=FieldType.TEXT)
29 choices = models.TextField(blank=True, default="", help_text="Options for select fields, one per line")
30 is_required = models.BooleanField(default=False)
31 sort_order = models.IntegerField(default=0)
32
33 objects = ActiveManager()
34 all_objects = models.Manager()
35
36 class Meta:
37 ordering = ["sort_order", "name"]
38 unique_together = [("repository", "name")]
39
40 def __str__(self):
41 return f"{self.label} ({self.name})"
42
43 @property
44 def choices_list(self):
45 """Return choices as a list, filtering out blank lines."""
46 if not self.choices:
47 return []
48 return [c.strip() for c in self.choices.splitlines() if c.strip()]
--- a/fossil/ticket_reports.py
+++ b/fossil/ticket_reports.py
@@ -0,0 +1,63 @@
1
+"""Custom SQL-based ticket reports.
2
+
3
+Fossil supports running SQL queries against its built-in ticket table.
4
+This model lets admins define reusable reports that project members can
5
+execute. Queries run in read-only mode against the Fossil SQLite file.
6
+"""
7
+
8
+import re
9
+
10
+from django.db import models
11
+
12
+from core.models import ActiveManager, Tracking
13
+
14
+# Statements that are never allowed in a report query.
15
+_FORBIDDEN_KEYWORDS = re.compile(
16
+ r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|REPLACE|ATTACH|DETACH|REINDEX|VACUUM|PRAGMA)\b",
17
+ re.IGNORECASE,
18
+)
19
+
20
+
21
+class TicketReport(Tracking):
22
+ """Custom SQL-based ticket report."""
23
+
24
+ repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="ticket_reports")
25
+ title = models.CharField(max_length=200)
26
+ description = models.TextField(blank=True, default="")
27
+ sql_query = models.TextField(help_text="SQL query against the Fossil ticket table. Use {status}, {type} as placeholders.")
28
+ is_public = models.BooleanField(default=True, help_text="Visible to all project members")
29
+
30
+ objects = ActiveManager()
31
+ all_objects = models.Manager()
32
+
33
+ class Meta:
34
+ ordering = ["title"]
35
+
36
+ def __str__(self):
37
+ return self.title
38
+
39
+ @staticmethod
40
+ def validate_sql(sql: str) -> str | None:
41
+ """Return an error message if *sql* is unsafe, or None if it passes.
42
+
43
+ Rules:
44
+ - Must start with SELECT (after stripping whitespace/comments).
45
+ - Must not contain any write/DDL keywords.
46
+ - Must not contain multiple statements (semicolons aside from trailing).
47
+ """
48
+ stripped = sql.strip().rstrip(";").strip()
49
+ if not stripped:
50
+ return "Query cannot be empty."
51
+
52
+ if not re.match(r"(?i)^\s*SELECT\b", stripped):
53
+ return "Query must start with SELECT."
54
+
55
+ if _FORBIDDEN_KEYWORDS.search(stripped):
56
+ return "Query contains forbidden keywords (INSERT, UPDATE, DELETE, DROP, ALTER, etc.)."
57
+
58
+ # Reject multiple statements: strip string literals then check for semicolons.
59
+ no_strings = re.sub(r"'[^']*'", "", stripped)
60
+ if ";" in no_strings:
61
+ return "Query must not contain multiple statements."
62
+
63
+ return None
--- a/fossil/ticket_reports.py
+++ b/fossil/ticket_reports.py
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/ticket_reports.py
+++ b/fossil/ticket_reports.py
@@ -0,0 +1,63 @@
1 """Custom SQL-based ticket reports.
2
3 Fossil supports running SQL queries against its built-in ticket table.
4 This model lets admins define reusable reports that project members can
5 execute. Queries run in read-only mode against the Fossil SQLite file.
6 """
7
8 import re
9
10 from django.db import models
11
12 from core.models import ActiveManager, Tracking
13
14 # Statements that are never allowed in a report query.
15 _FORBIDDEN_KEYWORDS = re.compile(
16 r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|REPLACE|ATTACH|DETACH|REINDEX|VACUUM|PRAGMA)\b",
17 re.IGNORECASE,
18 )
19
20
21 class TicketReport(Tracking):
22 """Custom SQL-based ticket report."""
23
24 repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="ticket_reports")
25 title = models.CharField(max_length=200)
26 description = models.TextField(blank=True, default="")
27 sql_query = models.TextField(help_text="SQL query against the Fossil ticket table. Use {status}, {type} as placeholders.")
28 is_public = models.BooleanField(default=True, help_text="Visible to all project members")
29
30 objects = ActiveManager()
31 all_objects = models.Manager()
32
33 class Meta:
34 ordering = ["title"]
35
36 def __str__(self):
37 return self.title
38
39 @staticmethod
40 def validate_sql(sql: str) -> str | None:
41 """Return an error message if *sql* is unsafe, or None if it passes.
42
43 Rules:
44 - Must start with SELECT (after stripping whitespace/comments).
45 - Must not contain any write/DDL keywords.
46 - Must not contain multiple statements (semicolons aside from trailing).
47 """
48 stripped = sql.strip().rstrip(";").strip()
49 if not stripped:
50 return "Query cannot be empty."
51
52 if not re.match(r"(?i)^\s*SELECT\b", stripped):
53 return "Query must start with SELECT."
54
55 if _FORBIDDEN_KEYWORDS.search(stripped):
56 return "Query contains forbidden keywords (INSERT, UPDATE, DELETE, DROP, ALTER, etc.)."
57
58 # Reject multiple statements: strip string literals then check for semicolons.
59 no_strings = re.sub(r"'[^']*'", "", stripped)
60 if ";" in no_strings:
61 return "Query must not contain multiple statements."
62
63 return None
+15 -2
--- fossil/urls.py
+++ fossil/urls.py
@@ -9,18 +9,29 @@
99
path("code/tree/<path:dirpath>/", views.code_browser, name="code_dir"),
1010
path("code/file/<path:filepath>", views.code_file, name="code_file"),
1111
path("timeline/", views.timeline, name="timeline"),
1212
path("checkin/<str:checkin_uuid>/", views.checkin_detail, name="checkin_detail"),
1313
path("tickets/", views.ticket_list, name="tickets"),
14
+ path("tickets/create/", views.ticket_create, name="ticket_create"),
15
+ path("tickets/export/", views.tickets_csv, name="tickets_csv"),
16
+ # Custom Ticket Fields (must be before tickets/<str:ticket_uuid>/ to avoid str match)
17
+ path("tickets/fields/", views.ticket_fields_list, name="ticket_fields"),
18
+ path("tickets/fields/create/", views.ticket_fields_create, name="ticket_field_create"),
19
+ path("tickets/fields/<int:pk>/edit/", views.ticket_fields_edit, name="ticket_field_edit"),
20
+ path("tickets/fields/<int:pk>/delete/", views.ticket_fields_delete, name="ticket_field_delete"),
21
+ # Custom Ticket Reports (must be before tickets/<str:ticket_uuid>/ to avoid str match)
22
+ path("tickets/reports/", views.ticket_reports_list, name="ticket_reports"),
23
+ path("tickets/reports/create/", views.ticket_report_create, name="ticket_report_create"),
24
+ path("tickets/reports/<int:pk>/", views.ticket_report_run, name="ticket_report_run"),
25
+ path("tickets/reports/<int:pk>/edit/", views.ticket_report_edit, name="ticket_report_edit"),
1426
path("tickets/<str:ticket_uuid>/", views.ticket_detail, name="ticket_detail"),
1527
path("tickets/<str:ticket_uuid>/edit/", views.ticket_edit, name="ticket_edit"),
1628
path("tickets/<str:ticket_uuid>/comment/", views.ticket_comment, name="ticket_comment"),
1729
path("wiki/", views.wiki_list, name="wiki"),
1830
path("wiki/create/", views.wiki_create, name="wiki_create"),
1931
path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"),
2032
path("wiki/edit/<path:page_name>", views.wiki_edit, name="wiki_edit"),
21
- path("tickets/create/", views.ticket_create, name="ticket_create"),
2233
path("forum/", views.forum_list, name="forum"),
2334
path("forum/create/", views.forum_create, name="forum_create"),
2435
path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"),
2536
path("forum/<int:post_id>/reply/", views.forum_reply, name="forum_reply"),
2637
# Webhooks
@@ -54,11 +65,10 @@
5465
path("code/raw/<path:filepath>", views.code_raw, name="code_raw"),
5566
path("code/blame/<path:filepath>", views.code_blame, name="code_blame"),
5667
path("code/history/<path:filepath>", views.file_history, name="file_history"),
5768
path("watch/", views.toggle_watch, name="toggle_watch"),
5869
path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
59
- path("tickets/export/", views.tickets_csv, name="tickets_csv"),
6070
path("docs/", views.fossil_docs, name="docs"),
6171
path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"),
6272
path("xfer", views.fossil_xfer, name="xfer"),
6373
# Releases
6474
path("releases/", views.release_list, name="releases"),
@@ -78,6 +88,9 @@
7888
# Branch Protection
7989
path("branches/protect/", views.branch_protection_list, name="branch_protections"),
8090
path("branches/protect/create/", views.branch_protection_create, name="branch_protection_create"),
8191
path("branches/protect/<int:pk>/edit/", views.branch_protection_edit, name="branch_protection_edit"),
8292
path("branches/protect/<int:pk>/delete/", views.branch_protection_delete, name="branch_protection_delete"),
93
+ # Artifact Shunning
94
+ path("admin/shun/", views.shun_list_view, name="shun_list"),
95
+ path("admin/shun/add/", views.shun_artifact, name="shun_artifact"),
8396
]
8497
--- fossil/urls.py
+++ fossil/urls.py
@@ -9,18 +9,29 @@
9 path("code/tree/<path:dirpath>/", views.code_browser, name="code_dir"),
10 path("code/file/<path:filepath>", views.code_file, name="code_file"),
11 path("timeline/", views.timeline, name="timeline"),
12 path("checkin/<str:checkin_uuid>/", views.checkin_detail, name="checkin_detail"),
13 path("tickets/", views.ticket_list, name="tickets"),
 
 
 
 
 
 
 
 
 
 
 
 
14 path("tickets/<str:ticket_uuid>/", views.ticket_detail, name="ticket_detail"),
15 path("tickets/<str:ticket_uuid>/edit/", views.ticket_edit, name="ticket_edit"),
16 path("tickets/<str:ticket_uuid>/comment/", views.ticket_comment, name="ticket_comment"),
17 path("wiki/", views.wiki_list, name="wiki"),
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
@@ -54,11 +65,10 @@
54 path("code/raw/<path:filepath>", views.code_raw, name="code_raw"),
55 path("code/blame/<path:filepath>", views.code_blame, name="code_blame"),
56 path("code/history/<path:filepath>", views.file_history, name="file_history"),
57 path("watch/", views.toggle_watch, name="toggle_watch"),
58 path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
59 path("tickets/export/", views.tickets_csv, name="tickets_csv"),
60 path("docs/", views.fossil_docs, name="docs"),
61 path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"),
62 path("xfer", views.fossil_xfer, name="xfer"),
63 # Releases
64 path("releases/", views.release_list, name="releases"),
@@ -78,6 +88,9 @@
78 # Branch Protection
79 path("branches/protect/", views.branch_protection_list, name="branch_protections"),
80 path("branches/protect/create/", views.branch_protection_create, name="branch_protection_create"),
81 path("branches/protect/<int:pk>/edit/", views.branch_protection_edit, name="branch_protection_edit"),
82 path("branches/protect/<int:pk>/delete/", views.branch_protection_delete, name="branch_protection_delete"),
 
 
 
83 ]
84
--- fossil/urls.py
+++ fossil/urls.py
@@ -9,18 +9,29 @@
9 path("code/tree/<path:dirpath>/", views.code_browser, name="code_dir"),
10 path("code/file/<path:filepath>", views.code_file, name="code_file"),
11 path("timeline/", views.timeline, name="timeline"),
12 path("checkin/<str:checkin_uuid>/", views.checkin_detail, name="checkin_detail"),
13 path("tickets/", views.ticket_list, name="tickets"),
14 path("tickets/create/", views.ticket_create, name="ticket_create"),
15 path("tickets/export/", views.tickets_csv, name="tickets_csv"),
16 # Custom Ticket Fields (must be before tickets/<str:ticket_uuid>/ to avoid str match)
17 path("tickets/fields/", views.ticket_fields_list, name="ticket_fields"),
18 path("tickets/fields/create/", views.ticket_fields_create, name="ticket_field_create"),
19 path("tickets/fields/<int:pk>/edit/", views.ticket_fields_edit, name="ticket_field_edit"),
20 path("tickets/fields/<int:pk>/delete/", views.ticket_fields_delete, name="ticket_field_delete"),
21 # Custom Ticket Reports (must be before tickets/<str:ticket_uuid>/ to avoid str match)
22 path("tickets/reports/", views.ticket_reports_list, name="ticket_reports"),
23 path("tickets/reports/create/", views.ticket_report_create, name="ticket_report_create"),
24 path("tickets/reports/<int:pk>/", views.ticket_report_run, name="ticket_report_run"),
25 path("tickets/reports/<int:pk>/edit/", views.ticket_report_edit, name="ticket_report_edit"),
26 path("tickets/<str:ticket_uuid>/", views.ticket_detail, name="ticket_detail"),
27 path("tickets/<str:ticket_uuid>/edit/", views.ticket_edit, name="ticket_edit"),
28 path("tickets/<str:ticket_uuid>/comment/", views.ticket_comment, name="ticket_comment"),
29 path("wiki/", views.wiki_list, name="wiki"),
30 path("wiki/create/", views.wiki_create, name="wiki_create"),
31 path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"),
32 path("wiki/edit/<path:page_name>", views.wiki_edit, name="wiki_edit"),
 
33 path("forum/", views.forum_list, name="forum"),
34 path("forum/create/", views.forum_create, name="forum_create"),
35 path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"),
36 path("forum/<int:post_id>/reply/", views.forum_reply, name="forum_reply"),
37 # Webhooks
@@ -54,11 +65,10 @@
65 path("code/raw/<path:filepath>", views.code_raw, name="code_raw"),
66 path("code/blame/<path:filepath>", views.code_blame, name="code_blame"),
67 path("code/history/<path:filepath>", views.file_history, name="file_history"),
68 path("watch/", views.toggle_watch, name="toggle_watch"),
69 path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
 
70 path("docs/", views.fossil_docs, name="docs"),
71 path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"),
72 path("xfer", views.fossil_xfer, name="xfer"),
73 # Releases
74 path("releases/", views.release_list, name="releases"),
@@ -78,6 +88,9 @@
88 # Branch Protection
89 path("branches/protect/", views.branch_protection_list, name="branch_protections"),
90 path("branches/protect/create/", views.branch_protection_create, name="branch_protection_create"),
91 path("branches/protect/<int:pk>/edit/", views.branch_protection_edit, name="branch_protection_edit"),
92 path("branches/protect/<int:pk>/delete/", views.branch_protection_delete, name="branch_protection_delete"),
93 # Artifact Shunning
94 path("admin/shun/", views.shun_list_view, name="shun_list"),
95 path("admin/shun/add/", views.shun_artifact, name="shun_artifact"),
96 ]
97
+431 -2
--- fossil/views.py
+++ fossil/views.py
@@ -1233,10 +1233,14 @@
12331233
12341234
@login_required
12351235
def ticket_create(request, slug):
12361236
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
12371237
1238
+ from fossil.ticket_fields import TicketFieldDefinition
1239
+
1240
+ custom_fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
1241
+
12381242
if request.method == "POST":
12391243
title = request.POST.get("title", "").strip()
12401244
body = request.POST.get("body", "")
12411245
ticket_type = request.POST.get("type", "Code_Defect")
12421246
severity = request.POST.get("severity", "")
@@ -1245,26 +1249,42 @@
12451249
12461250
cli = FossilCLI()
12471251
fields = {"title": title, "type": ticket_type, "comment": body, "status": "Open"}
12481252
if severity:
12491253
fields["severity"] = severity
1254
+ # Collect custom field values
1255
+ for cf in custom_fields:
1256
+ if cf.field_type == "checkbox":
1257
+ val = "1" if request.POST.get(f"custom_{cf.name}") == "on" else "0"
1258
+ else:
1259
+ val = request.POST.get(f"custom_{cf.name}", "").strip()
1260
+ if val:
1261
+ fields[cf.name] = val
12501262
success = cli.ticket_add(fossil_repo.full_path, fields)
12511263
if success:
12521264
from django.contrib import messages
12531265
12541266
messages.success(request, f'Ticket "{title}" created.')
12551267
from django.shortcuts import redirect
12561268
12571269
return redirect("fossil:tickets", slug=slug)
12581270
1259
- return render(request, "fossil/ticket_form.html", {"project": project, "active_tab": "tickets", "title": "New Ticket"})
1271
+ return render(
1272
+ request,
1273
+ "fossil/ticket_form.html",
1274
+ {"project": project, "active_tab": "tickets", "title": "New Ticket", "custom_fields": custom_fields},
1275
+ )
12601276
12611277
12621278
@login_required
12631279
def ticket_edit(request, slug, ticket_uuid):
12641280
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
12651281
1282
+ from fossil.ticket_fields import TicketFieldDefinition
1283
+
1284
+ custom_fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
1285
+
12661286
with reader:
12671287
ticket = reader.get_ticket_detail(ticket_uuid)
12681288
if not ticket:
12691289
raise Http404("Ticket not found")
12701290
@@ -1275,10 +1295,18 @@
12751295
fields = {}
12761296
for field in ["title", "status", "type", "severity", "priority", "resolution", "subsystem"]:
12771297
val = request.POST.get(field, "").strip()
12781298
if val:
12791299
fields[field] = val
1300
+ # Collect custom field values
1301
+ for cf in custom_fields:
1302
+ if cf.field_type == "checkbox":
1303
+ val = "1" if request.POST.get(f"custom_{cf.name}") == "on" else "0"
1304
+ else:
1305
+ val = request.POST.get(f"custom_{cf.name}", "").strip()
1306
+ if val:
1307
+ fields[cf.name] = val
12801308
if fields:
12811309
success = cli.ticket_change(fossil_repo.full_path, ticket.uuid, fields)
12821310
if success:
12831311
from django.contrib import messages
12841312
@@ -1288,11 +1316,11 @@
12881316
return redirect("fossil:ticket_detail", slug=slug, ticket_uuid=ticket.uuid)
12891317
12901318
return render(
12911319
request,
12921320
"fossil/ticket_edit.html",
1293
- {"project": project, "ticket": ticket, "active_tab": "tickets"},
1321
+ {"project": project, "ticket": ticket, "custom_fields": custom_fields, "active_tab": "tickets"},
12941322
)
12951323
12961324
12971325
@login_required
12981326
def ticket_comment(request, slug, ticket_uuid):
@@ -3318,5 +3346,406 @@
33183346
rule.soft_delete(user=request.user)
33193347
messages.success(request, f'Branch protection rule for "{rule.branch_pattern}" deleted.')
33203348
return redirect("fossil:branch_protections", slug=slug)
33213349
33223350
return redirect("fossil:branch_protections", slug=slug)
3351
+
3352
+
3353
+# ---------------------------------------------------------------------------
3354
+# Custom Ticket Fields
3355
+# ---------------------------------------------------------------------------
3356
+
3357
+
3358
+@login_required
3359
+def ticket_fields_list(request, slug):
3360
+ """List custom ticket field definitions for a project. Admin only."""
3361
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3362
+
3363
+ from fossil.ticket_fields import TicketFieldDefinition
3364
+
3365
+ fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
3366
+
3367
+ return render(
3368
+ request,
3369
+ "fossil/ticket_fields_list.html",
3370
+ {
3371
+ "project": project,
3372
+ "fossil_repo": fossil_repo,
3373
+ "fields": fields,
3374
+ "active_tab": "settings",
3375
+ },
3376
+ )
3377
+
3378
+
3379
+@login_required
3380
+def ticket_fields_create(request, slug):
3381
+ """Create a new custom ticket field."""
3382
+ from django.contrib import messages
3383
+
3384
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3385
+
3386
+ from fossil.ticket_fields import TicketFieldDefinition
3387
+
3388
+ if request.method == "POST":
3389
+ name = request.POST.get("name", "").strip()
3390
+ label = request.POST.get("label", "").strip()
3391
+ field_type = request.POST.get("field_type", "text")
3392
+ choices_text = request.POST.get("choices", "").strip()
3393
+ is_required = request.POST.get("is_required") == "on"
3394
+ sort_order = int(request.POST.get("sort_order", "0") or "0")
3395
+
3396
+ if name and label:
3397
+ if TicketFieldDefinition.objects.filter(repository=fossil_repo, name=name).exists():
3398
+ messages.error(request, f'A field named "{name}" already exists.')
3399
+ else:
3400
+ TicketFieldDefinition.objects.create(
3401
+ repository=fossil_repo,
3402
+ name=name,
3403
+ label=label,
3404
+ field_type=field_type,
3405
+ choices=choices_text,
3406
+ is_required=is_required,
3407
+ sort_order=sort_order,
3408
+ created_by=request.user,
3409
+ )
3410
+ messages.success(request, f'Custom field "{label}" created.')
3411
+ return redirect("fossil:ticket_fields", slug=slug)
3412
+
3413
+ return render(
3414
+ request,
3415
+ "fossil/ticket_fields_form.html",
3416
+ {
3417
+ "project": project,
3418
+ "form_title": "Add Custom Ticket Field",
3419
+ "submit_label": "Create Field",
3420
+ "field_type_choices": TicketFieldDefinition.FieldType.choices,
3421
+ "active_tab": "settings",
3422
+ },
3423
+ )
3424
+
3425
+
3426
+@login_required
3427
+def ticket_fields_edit(request, slug, pk):
3428
+ """Edit an existing custom ticket field."""
3429
+ from django.contrib import messages
3430
+
3431
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3432
+
3433
+ from fossil.ticket_fields import TicketFieldDefinition
3434
+
3435
+ field_def = get_object_or_404(TicketFieldDefinition, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3436
+
3437
+ if request.method == "POST":
3438
+ name = request.POST.get("name", "").strip()
3439
+ label = request.POST.get("label", "").strip()
3440
+ field_type = request.POST.get("field_type", "text")
3441
+ choices_text = request.POST.get("choices", "").strip()
3442
+ is_required = request.POST.get("is_required") == "on"
3443
+ sort_order = int(request.POST.get("sort_order", "0") or "0")
3444
+
3445
+ if name and label:
3446
+ dupe = TicketFieldDefinition.objects.filter(repository=fossil_repo, name=name).exclude(pk=field_def.pk).exists()
3447
+ if dupe:
3448
+ messages.error(request, f'A field named "{name}" already exists.')
3449
+ else:
3450
+ field_def.name = name
3451
+ field_def.label = label
3452
+ field_def.field_type = field_type
3453
+ field_def.choices = choices_text
3454
+ field_def.is_required = is_required
3455
+ field_def.sort_order = sort_order
3456
+ field_def.updated_by = request.user
3457
+ field_def.save()
3458
+ messages.success(request, f'Custom field "{label}" updated.')
3459
+ return redirect("fossil:ticket_fields", slug=slug)
3460
+
3461
+ return render(
3462
+ request,
3463
+ "fossil/ticket_fields_form.html",
3464
+ {
3465
+ "project": project,
3466
+ "field_def": field_def,
3467
+ "form_title": f"Edit Field: {field_def.label}",
3468
+ "submit_label": "Save Changes",
3469
+ "field_type_choices": TicketFieldDefinition.FieldType.choices,
3470
+ "active_tab": "settings",
3471
+ },
3472
+ )
3473
+
3474
+
3475
+@login_required
3476
+def ticket_fields_delete(request, slug, pk):
3477
+ """Soft-delete a custom ticket field."""
3478
+ from django.contrib import messages
3479
+
3480
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3481
+
3482
+ from fossil.ticket_fields import TicketFieldDefinition
3483
+
3484
+ field_def = get_object_or_404(TicketFieldDefinition, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3485
+
3486
+ if request.method == "POST":
3487
+ field_def.soft_delete(user=request.user)
3488
+ messages.success(request, f'Custom field "{field_def.label}" deleted.')
3489
+ return redirect("fossil:ticket_fields", slug=slug)
3490
+
3491
+ return redirect("fossil:ticket_fields", slug=slug)
3492
+
3493
+
3494
+# ---------------------------------------------------------------------------
3495
+# Custom Ticket Reports
3496
+# ---------------------------------------------------------------------------
3497
+
3498
+
3499
+@login_required
3500
+def ticket_reports_list(request, slug):
3501
+ """List available ticket reports for a project."""
3502
+ from projects.access import can_admin_project
3503
+
3504
+ project, fossil_repo = _get_project_and_repo(slug, request, "read")
3505
+
3506
+ from fossil.ticket_reports import TicketReport
3507
+
3508
+ reports = TicketReport.objects.filter(repository=fossil_repo)
3509
+ if not can_admin_project(request.user, project):
3510
+ reports = reports.filter(is_public=True)
3511
+
3512
+ return render(
3513
+ request,
3514
+ "fossil/ticket_reports_list.html",
3515
+ {
3516
+ "project": project,
3517
+ "fossil_repo": fossil_repo,
3518
+ "reports": reports,
3519
+ "can_admin": can_admin_project(request.user, project),
3520
+ "active_tab": "tickets",
3521
+ },
3522
+ )
3523
+
3524
+
3525
+@login_required
3526
+def ticket_report_create(request, slug):
3527
+ """Create a new ticket report. Admin only."""
3528
+ from django.contrib import messages
3529
+
3530
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3531
+
3532
+ from fossil.ticket_reports import TicketReport
3533
+
3534
+ if request.method == "POST":
3535
+ title = request.POST.get("title", "").strip()
3536
+ description = request.POST.get("description", "").strip()
3537
+ sql_query = request.POST.get("sql_query", "").strip()
3538
+ is_public = request.POST.get("is_public") == "on"
3539
+
3540
+ if title and sql_query:
3541
+ error = TicketReport.validate_sql(sql_query)
3542
+ if error:
3543
+ messages.error(request, f"Invalid SQL: {error}")
3544
+ else:
3545
+ TicketReport.objects.create(
3546
+ repository=fossil_repo,
3547
+ title=title,
3548
+ description=description,
3549
+ sql_query=sql_query,
3550
+ is_public=is_public,
3551
+ created_by=request.user,
3552
+ )
3553
+ messages.success(request, f'Report "{title}" created.')
3554
+ return redirect("fossil:ticket_reports", slug=slug)
3555
+
3556
+ return render(
3557
+ request,
3558
+ "fossil/ticket_report_form.html",
3559
+ {
3560
+ "project": project,
3561
+ "form_title": "Create Ticket Report",
3562
+ "submit_label": "Create Report",
3563
+ "active_tab": "tickets",
3564
+ },
3565
+ )
3566
+
3567
+
3568
+@login_required
3569
+def ticket_report_edit(request, slug, pk):
3570
+ """Edit an existing ticket report. Admin only."""
3571
+ from django.contrib import messages
3572
+
3573
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3574
+
3575
+ from fossil.ticket_reports import TicketReport
3576
+
3577
+ report = get_object_or_404(TicketReport, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3578
+
3579
+ if request.method == "POST":
3580
+ title = request.POST.get("title", "").strip()
3581
+ description = request.POST.get("description", "").strip()
3582
+ sql_query = request.POST.get("sql_query", "").strip()
3583
+ is_public = request.POST.get("is_public") == "on"
3584
+
3585
+ if title and sql_query:
3586
+ error = TicketReport.validate_sql(sql_query)
3587
+ if error:
3588
+ messages.error(request, f"Invalid SQL: {error}")
3589
+ else:
3590
+ report.title = title
3591
+ report.description = description
3592
+ report.sql_query = sql_query
3593
+ report.is_public = is_public
3594
+ report.updated_by = request.user
3595
+ report.save()
3596
+ messages.success(request, f'Report "{title}" updated.')
3597
+ return redirect("fossil:ticket_reports", slug=slug)
3598
+
3599
+ return render(
3600
+ request,
3601
+ "fossil/ticket_report_form.html",
3602
+ {
3603
+ "project": project,
3604
+ "report": report,
3605
+ "form_title": f"Edit Report: {report.title}",
3606
+ "submit_label": "Save Changes",
3607
+ "active_tab": "tickets",
3608
+ },
3609
+ )
3610
+
3611
+
3612
+@login_required
3613
+def ticket_report_run(request, slug, pk):
3614
+ """Execute a ticket report and display results."""
3615
+ import sqlite3
3616
+
3617
+ from projects.access import can_admin_project
3618
+
3619
+ project, fossil_repo = _get_project_and_repo(slug, request, "read")
3620
+
3621
+ from fossil.ticket_reports import TicketReport
3622
+
3623
+ report = get_object_or_404(TicketReport, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3624
+
3625
+ # Non-public reports require admin access
3626
+ if not report.is_public and not can_admin_project(request.user, project):
3627
+ from django.core.exceptions import PermissionDenied
3628
+
3629
+ raise PermissionDenied("This report is not public.")
3630
+
3631
+ # Re-validate the SQL at execution time (defense in depth)
3632
+ error = TicketReport.validate_sql(report.sql_query)
3633
+ columns = []
3634
+ rows = []
3635
+
3636
+ if error:
3637
+ pass # error is shown in template
3638
+ else:
3639
+ # Replace placeholders with request params
3640
+ sql = report.sql_query
3641
+ status_param = request.GET.get("status", "")
3642
+ type_param = request.GET.get("type", "")
3643
+ sql = sql.replace("{status}", status_param)
3644
+ sql = sql.replace("{type}", type_param)
3645
+
3646
+ # Execute against the Fossil SQLite file in read-only mode
3647
+ repo_path = fossil_repo.full_path
3648
+ uri = f"file:{repo_path}?mode=ro"
3649
+ try:
3650
+ conn = sqlite3.connect(uri, uri=True)
3651
+ try:
3652
+ cursor = conn.execute(sql)
3653
+ columns = [desc[0] for desc in cursor.description] if cursor.description else []
3654
+ rows = [list(row) for row in cursor.fetchall()[:1000]]
3655
+ except sqlite3.OperationalError as e:
3656
+ error = f"SQL error: {e}"
3657
+ finally:
3658
+ conn.close()
3659
+ except sqlite3.Error as e:
3660
+ error = f"Database error: {e}"
3661
+
3662
+ return render(
3663
+ request,
3664
+ "fossil/ticket_report_results.html",
3665
+ {
3666
+ "project": project,
3667
+ "fossil_repo": fossil_repo,
3668
+ "report": report,
3669
+ "columns": columns,
3670
+ "rows": rows,
3671
+ "error": error,
3672
+ "active_tab": "tickets",
3673
+ },
3674
+ )
3675
+
3676
+
3677
+# --- Artifact Shunning ---
3678
+
3679
+
3680
+@login_required
3681
+def shun_list_view(request, slug):
3682
+ """List shunned artifacts and provide form to shun new ones. Admin only."""
3683
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3684
+
3685
+ shunned = []
3686
+ if fossil_repo.exists_on_disk:
3687
+ from fossil.cli import FossilCLI
3688
+
3689
+ cli = FossilCLI()
3690
+ if cli.is_available():
3691
+ shunned = cli.shun_list(fossil_repo.full_path)
3692
+
3693
+ return render(
3694
+ request,
3695
+ "fossil/shun_list.html",
3696
+ {
3697
+ "project": project,
3698
+ "fossil_repo": fossil_repo,
3699
+ "shunned": shunned,
3700
+ "active_tab": "settings",
3701
+ },
3702
+ )
3703
+
3704
+
3705
+@login_required
3706
+def shun_artifact(request, slug):
3707
+ """Shun (permanently remove) an artifact. POST only. Admin only."""
3708
+ from django.contrib import messages
3709
+
3710
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3711
+
3712
+ if request.method != "POST":
3713
+ return redirect("fossil:shun_list", slug=slug)
3714
+
3715
+ artifact_uuid = request.POST.get("artifact_uuid", "").strip()
3716
+ confirmation = request.POST.get("confirmation", "").strip()
3717
+ reason = request.POST.get("reason", "").strip()
3718
+
3719
+ if not artifact_uuid:
3720
+ messages.error(request, "Artifact UUID is required.")
3721
+ return redirect("fossil:shun_list", slug=slug)
3722
+
3723
+ # Validate UUID format: should be hex characters, 4-64 chars (Fossil uses SHA1/SHA3 hashes)
3724
+ if not re.match(r"^[0-9a-fA-F]{4,64}$", artifact_uuid):
3725
+ messages.error(request, "Invalid artifact UUID format. Must be a hex hash (4-64 characters).")
3726
+ return redirect("fossil:shun_list", slug=slug)
3727
+
3728
+ # Require the user to type the first 8 chars of the UUID to confirm
3729
+ expected_confirmation = artifact_uuid[:8].lower()
3730
+ if confirmation.lower() != expected_confirmation:
3731
+ messages.error(request, f'Confirmation failed. You must type "{expected_confirmation}" to confirm shunning.')
3732
+ return redirect("fossil:shun_list", slug=slug)
3733
+
3734
+ if not fossil_repo.exists_on_disk:
3735
+ messages.error(request, "Repository file not found on disk.")
3736
+ return redirect("fossil:shun_list", slug=slug)
3737
+
3738
+ from fossil.cli import FossilCLI
3739
+
3740
+ cli = FossilCLI()
3741
+ if not cli.is_available():
3742
+ messages.error(request, "Fossil binary is not available.")
3743
+ return redirect("fossil:shun_list", slug=slug)
3744
+
3745
+ result = cli.shun(fossil_repo.full_path, artifact_uuid, reason=reason)
3746
+ if result["success"]:
3747
+ messages.success(request, f"Artifact {artifact_uuid[:12]}... has been permanently shunned.")
3748
+ else:
3749
+ messages.error(request, f"Failed to shun artifact: {result['message']}")
3750
+
3751
+ return redirect("fossil:shun_list", slug=slug)
33233752
33243753
ADDED templates/accounts/notification_prefs.html
33253754
ADDED templates/fossil/shun_list.html
--- fossil/views.py
+++ fossil/views.py
@@ -1233,10 +1233,14 @@
1233
1234 @login_required
1235 def ticket_create(request, slug):
1236 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1237
 
 
 
 
1238 if request.method == "POST":
1239 title = request.POST.get("title", "").strip()
1240 body = request.POST.get("body", "")
1241 ticket_type = request.POST.get("type", "Code_Defect")
1242 severity = request.POST.get("severity", "")
@@ -1245,26 +1249,42 @@
1245
1246 cli = FossilCLI()
1247 fields = {"title": title, "type": ticket_type, "comment": body, "status": "Open"}
1248 if severity:
1249 fields["severity"] = severity
 
 
 
 
 
 
 
 
1250 success = cli.ticket_add(fossil_repo.full_path, fields)
1251 if success:
1252 from django.contrib import messages
1253
1254 messages.success(request, f'Ticket "{title}" created.')
1255 from django.shortcuts import redirect
1256
1257 return redirect("fossil:tickets", slug=slug)
1258
1259 return render(request, "fossil/ticket_form.html", {"project": project, "active_tab": "tickets", "title": "New Ticket"})
 
 
 
 
1260
1261
1262 @login_required
1263 def ticket_edit(request, slug, ticket_uuid):
1264 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1265
 
 
 
 
1266 with reader:
1267 ticket = reader.get_ticket_detail(ticket_uuid)
1268 if not ticket:
1269 raise Http404("Ticket not found")
1270
@@ -1275,10 +1295,18 @@
1275 fields = {}
1276 for field in ["title", "status", "type", "severity", "priority", "resolution", "subsystem"]:
1277 val = request.POST.get(field, "").strip()
1278 if val:
1279 fields[field] = val
 
 
 
 
 
 
 
 
1280 if fields:
1281 success = cli.ticket_change(fossil_repo.full_path, ticket.uuid, fields)
1282 if success:
1283 from django.contrib import messages
1284
@@ -1288,11 +1316,11 @@
1288 return redirect("fossil:ticket_detail", slug=slug, ticket_uuid=ticket.uuid)
1289
1290 return render(
1291 request,
1292 "fossil/ticket_edit.html",
1293 {"project": project, "ticket": ticket, "active_tab": "tickets"},
1294 )
1295
1296
1297 @login_required
1298 def ticket_comment(request, slug, ticket_uuid):
@@ -3318,5 +3346,406 @@
3318 rule.soft_delete(user=request.user)
3319 messages.success(request, f'Branch protection rule for "{rule.branch_pattern}" deleted.')
3320 return redirect("fossil:branch_protections", slug=slug)
3321
3322 return redirect("fossil:branch_protections", slug=slug)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3323
3324 DDED templates/accounts/notification_prefs.html
3325 DDED templates/fossil/shun_list.html
--- fossil/views.py
+++ fossil/views.py
@@ -1233,10 +1233,14 @@
1233
1234 @login_required
1235 def ticket_create(request, slug):
1236 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1237
1238 from fossil.ticket_fields import TicketFieldDefinition
1239
1240 custom_fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
1241
1242 if request.method == "POST":
1243 title = request.POST.get("title", "").strip()
1244 body = request.POST.get("body", "")
1245 ticket_type = request.POST.get("type", "Code_Defect")
1246 severity = request.POST.get("severity", "")
@@ -1245,26 +1249,42 @@
1249
1250 cli = FossilCLI()
1251 fields = {"title": title, "type": ticket_type, "comment": body, "status": "Open"}
1252 if severity:
1253 fields["severity"] = severity
1254 # Collect custom field values
1255 for cf in custom_fields:
1256 if cf.field_type == "checkbox":
1257 val = "1" if request.POST.get(f"custom_{cf.name}") == "on" else "0"
1258 else:
1259 val = request.POST.get(f"custom_{cf.name}", "").strip()
1260 if val:
1261 fields[cf.name] = val
1262 success = cli.ticket_add(fossil_repo.full_path, fields)
1263 if success:
1264 from django.contrib import messages
1265
1266 messages.success(request, f'Ticket "{title}" created.')
1267 from django.shortcuts import redirect
1268
1269 return redirect("fossil:tickets", slug=slug)
1270
1271 return render(
1272 request,
1273 "fossil/ticket_form.html",
1274 {"project": project, "active_tab": "tickets", "title": "New Ticket", "custom_fields": custom_fields},
1275 )
1276
1277
1278 @login_required
1279 def ticket_edit(request, slug, ticket_uuid):
1280 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1281
1282 from fossil.ticket_fields import TicketFieldDefinition
1283
1284 custom_fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
1285
1286 with reader:
1287 ticket = reader.get_ticket_detail(ticket_uuid)
1288 if not ticket:
1289 raise Http404("Ticket not found")
1290
@@ -1275,10 +1295,18 @@
1295 fields = {}
1296 for field in ["title", "status", "type", "severity", "priority", "resolution", "subsystem"]:
1297 val = request.POST.get(field, "").strip()
1298 if val:
1299 fields[field] = val
1300 # Collect custom field values
1301 for cf in custom_fields:
1302 if cf.field_type == "checkbox":
1303 val = "1" if request.POST.get(f"custom_{cf.name}") == "on" else "0"
1304 else:
1305 val = request.POST.get(f"custom_{cf.name}", "").strip()
1306 if val:
1307 fields[cf.name] = val
1308 if fields:
1309 success = cli.ticket_change(fossil_repo.full_path, ticket.uuid, fields)
1310 if success:
1311 from django.contrib import messages
1312
@@ -1288,11 +1316,11 @@
1316 return redirect("fossil:ticket_detail", slug=slug, ticket_uuid=ticket.uuid)
1317
1318 return render(
1319 request,
1320 "fossil/ticket_edit.html",
1321 {"project": project, "ticket": ticket, "custom_fields": custom_fields, "active_tab": "tickets"},
1322 )
1323
1324
1325 @login_required
1326 def ticket_comment(request, slug, ticket_uuid):
@@ -3318,5 +3346,406 @@
3346 rule.soft_delete(user=request.user)
3347 messages.success(request, f'Branch protection rule for "{rule.branch_pattern}" deleted.')
3348 return redirect("fossil:branch_protections", slug=slug)
3349
3350 return redirect("fossil:branch_protections", slug=slug)
3351
3352
3353 # ---------------------------------------------------------------------------
3354 # Custom Ticket Fields
3355 # ---------------------------------------------------------------------------
3356
3357
3358 @login_required
3359 def ticket_fields_list(request, slug):
3360 """List custom ticket field definitions for a project. Admin only."""
3361 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3362
3363 from fossil.ticket_fields import TicketFieldDefinition
3364
3365 fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
3366
3367 return render(
3368 request,
3369 "fossil/ticket_fields_list.html",
3370 {
3371 "project": project,
3372 "fossil_repo": fossil_repo,
3373 "fields": fields,
3374 "active_tab": "settings",
3375 },
3376 )
3377
3378
3379 @login_required
3380 def ticket_fields_create(request, slug):
3381 """Create a new custom ticket field."""
3382 from django.contrib import messages
3383
3384 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3385
3386 from fossil.ticket_fields import TicketFieldDefinition
3387
3388 if request.method == "POST":
3389 name = request.POST.get("name", "").strip()
3390 label = request.POST.get("label", "").strip()
3391 field_type = request.POST.get("field_type", "text")
3392 choices_text = request.POST.get("choices", "").strip()
3393 is_required = request.POST.get("is_required") == "on"
3394 sort_order = int(request.POST.get("sort_order", "0") or "0")
3395
3396 if name and label:
3397 if TicketFieldDefinition.objects.filter(repository=fossil_repo, name=name).exists():
3398 messages.error(request, f'A field named "{name}" already exists.')
3399 else:
3400 TicketFieldDefinition.objects.create(
3401 repository=fossil_repo,
3402 name=name,
3403 label=label,
3404 field_type=field_type,
3405 choices=choices_text,
3406 is_required=is_required,
3407 sort_order=sort_order,
3408 created_by=request.user,
3409 )
3410 messages.success(request, f'Custom field "{label}" created.')
3411 return redirect("fossil:ticket_fields", slug=slug)
3412
3413 return render(
3414 request,
3415 "fossil/ticket_fields_form.html",
3416 {
3417 "project": project,
3418 "form_title": "Add Custom Ticket Field",
3419 "submit_label": "Create Field",
3420 "field_type_choices": TicketFieldDefinition.FieldType.choices,
3421 "active_tab": "settings",
3422 },
3423 )
3424
3425
3426 @login_required
3427 def ticket_fields_edit(request, slug, pk):
3428 """Edit an existing custom ticket field."""
3429 from django.contrib import messages
3430
3431 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3432
3433 from fossil.ticket_fields import TicketFieldDefinition
3434
3435 field_def = get_object_or_404(TicketFieldDefinition, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3436
3437 if request.method == "POST":
3438 name = request.POST.get("name", "").strip()
3439 label = request.POST.get("label", "").strip()
3440 field_type = request.POST.get("field_type", "text")
3441 choices_text = request.POST.get("choices", "").strip()
3442 is_required = request.POST.get("is_required") == "on"
3443 sort_order = int(request.POST.get("sort_order", "0") or "0")
3444
3445 if name and label:
3446 dupe = TicketFieldDefinition.objects.filter(repository=fossil_repo, name=name).exclude(pk=field_def.pk).exists()
3447 if dupe:
3448 messages.error(request, f'A field named "{name}" already exists.')
3449 else:
3450 field_def.name = name
3451 field_def.label = label
3452 field_def.field_type = field_type
3453 field_def.choices = choices_text
3454 field_def.is_required = is_required
3455 field_def.sort_order = sort_order
3456 field_def.updated_by = request.user
3457 field_def.save()
3458 messages.success(request, f'Custom field "{label}" updated.')
3459 return redirect("fossil:ticket_fields", slug=slug)
3460
3461 return render(
3462 request,
3463 "fossil/ticket_fields_form.html",
3464 {
3465 "project": project,
3466 "field_def": field_def,
3467 "form_title": f"Edit Field: {field_def.label}",
3468 "submit_label": "Save Changes",
3469 "field_type_choices": TicketFieldDefinition.FieldType.choices,
3470 "active_tab": "settings",
3471 },
3472 )
3473
3474
3475 @login_required
3476 def ticket_fields_delete(request, slug, pk):
3477 """Soft-delete a custom ticket field."""
3478 from django.contrib import messages
3479
3480 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3481
3482 from fossil.ticket_fields import TicketFieldDefinition
3483
3484 field_def = get_object_or_404(TicketFieldDefinition, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3485
3486 if request.method == "POST":
3487 field_def.soft_delete(user=request.user)
3488 messages.success(request, f'Custom field "{field_def.label}" deleted.')
3489 return redirect("fossil:ticket_fields", slug=slug)
3490
3491 return redirect("fossil:ticket_fields", slug=slug)
3492
3493
3494 # ---------------------------------------------------------------------------
3495 # Custom Ticket Reports
3496 # ---------------------------------------------------------------------------
3497
3498
3499 @login_required
3500 def ticket_reports_list(request, slug):
3501 """List available ticket reports for a project."""
3502 from projects.access import can_admin_project
3503
3504 project, fossil_repo = _get_project_and_repo(slug, request, "read")
3505
3506 from fossil.ticket_reports import TicketReport
3507
3508 reports = TicketReport.objects.filter(repository=fossil_repo)
3509 if not can_admin_project(request.user, project):
3510 reports = reports.filter(is_public=True)
3511
3512 return render(
3513 request,
3514 "fossil/ticket_reports_list.html",
3515 {
3516 "project": project,
3517 "fossil_repo": fossil_repo,
3518 "reports": reports,
3519 "can_admin": can_admin_project(request.user, project),
3520 "active_tab": "tickets",
3521 },
3522 )
3523
3524
3525 @login_required
3526 def ticket_report_create(request, slug):
3527 """Create a new ticket report. Admin only."""
3528 from django.contrib import messages
3529
3530 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3531
3532 from fossil.ticket_reports import TicketReport
3533
3534 if request.method == "POST":
3535 title = request.POST.get("title", "").strip()
3536 description = request.POST.get("description", "").strip()
3537 sql_query = request.POST.get("sql_query", "").strip()
3538 is_public = request.POST.get("is_public") == "on"
3539
3540 if title and sql_query:
3541 error = TicketReport.validate_sql(sql_query)
3542 if error:
3543 messages.error(request, f"Invalid SQL: {error}")
3544 else:
3545 TicketReport.objects.create(
3546 repository=fossil_repo,
3547 title=title,
3548 description=description,
3549 sql_query=sql_query,
3550 is_public=is_public,
3551 created_by=request.user,
3552 )
3553 messages.success(request, f'Report "{title}" created.')
3554 return redirect("fossil:ticket_reports", slug=slug)
3555
3556 return render(
3557 request,
3558 "fossil/ticket_report_form.html",
3559 {
3560 "project": project,
3561 "form_title": "Create Ticket Report",
3562 "submit_label": "Create Report",
3563 "active_tab": "tickets",
3564 },
3565 )
3566
3567
3568 @login_required
3569 def ticket_report_edit(request, slug, pk):
3570 """Edit an existing ticket report. Admin only."""
3571 from django.contrib import messages
3572
3573 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3574
3575 from fossil.ticket_reports import TicketReport
3576
3577 report = get_object_or_404(TicketReport, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3578
3579 if request.method == "POST":
3580 title = request.POST.get("title", "").strip()
3581 description = request.POST.get("description", "").strip()
3582 sql_query = request.POST.get("sql_query", "").strip()
3583 is_public = request.POST.get("is_public") == "on"
3584
3585 if title and sql_query:
3586 error = TicketReport.validate_sql(sql_query)
3587 if error:
3588 messages.error(request, f"Invalid SQL: {error}")
3589 else:
3590 report.title = title
3591 report.description = description
3592 report.sql_query = sql_query
3593 report.is_public = is_public
3594 report.updated_by = request.user
3595 report.save()
3596 messages.success(request, f'Report "{title}" updated.')
3597 return redirect("fossil:ticket_reports", slug=slug)
3598
3599 return render(
3600 request,
3601 "fossil/ticket_report_form.html",
3602 {
3603 "project": project,
3604 "report": report,
3605 "form_title": f"Edit Report: {report.title}",
3606 "submit_label": "Save Changes",
3607 "active_tab": "tickets",
3608 },
3609 )
3610
3611
3612 @login_required
3613 def ticket_report_run(request, slug, pk):
3614 """Execute a ticket report and display results."""
3615 import sqlite3
3616
3617 from projects.access import can_admin_project
3618
3619 project, fossil_repo = _get_project_and_repo(slug, request, "read")
3620
3621 from fossil.ticket_reports import TicketReport
3622
3623 report = get_object_or_404(TicketReport, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3624
3625 # Non-public reports require admin access
3626 if not report.is_public and not can_admin_project(request.user, project):
3627 from django.core.exceptions import PermissionDenied
3628
3629 raise PermissionDenied("This report is not public.")
3630
3631 # Re-validate the SQL at execution time (defense in depth)
3632 error = TicketReport.validate_sql(report.sql_query)
3633 columns = []
3634 rows = []
3635
3636 if error:
3637 pass # error is shown in template
3638 else:
3639 # Replace placeholders with request params
3640 sql = report.sql_query
3641 status_param = request.GET.get("status", "")
3642 type_param = request.GET.get("type", "")
3643 sql = sql.replace("{status}", status_param)
3644 sql = sql.replace("{type}", type_param)
3645
3646 # Execute against the Fossil SQLite file in read-only mode
3647 repo_path = fossil_repo.full_path
3648 uri = f"file:{repo_path}?mode=ro"
3649 try:
3650 conn = sqlite3.connect(uri, uri=True)
3651 try:
3652 cursor = conn.execute(sql)
3653 columns = [desc[0] for desc in cursor.description] if cursor.description else []
3654 rows = [list(row) for row in cursor.fetchall()[:1000]]
3655 except sqlite3.OperationalError as e:
3656 error = f"SQL error: {e}"
3657 finally:
3658 conn.close()
3659 except sqlite3.Error as e:
3660 error = f"Database error: {e}"
3661
3662 return render(
3663 request,
3664 "fossil/ticket_report_results.html",
3665 {
3666 "project": project,
3667 "fossil_repo": fossil_repo,
3668 "report": report,
3669 "columns": columns,
3670 "rows": rows,
3671 "error": error,
3672 "active_tab": "tickets",
3673 },
3674 )
3675
3676
3677 # --- Artifact Shunning ---
3678
3679
3680 @login_required
3681 def shun_list_view(request, slug):
3682 """List shunned artifacts and provide form to shun new ones. Admin only."""
3683 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3684
3685 shunned = []
3686 if fossil_repo.exists_on_disk:
3687 from fossil.cli import FossilCLI
3688
3689 cli = FossilCLI()
3690 if cli.is_available():
3691 shunned = cli.shun_list(fossil_repo.full_path)
3692
3693 return render(
3694 request,
3695 "fossil/shun_list.html",
3696 {
3697 "project": project,
3698 "fossil_repo": fossil_repo,
3699 "shunned": shunned,
3700 "active_tab": "settings",
3701 },
3702 )
3703
3704
3705 @login_required
3706 def shun_artifact(request, slug):
3707 """Shun (permanently remove) an artifact. POST only. Admin only."""
3708 from django.contrib import messages
3709
3710 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3711
3712 if request.method != "POST":
3713 return redirect("fossil:shun_list", slug=slug)
3714
3715 artifact_uuid = request.POST.get("artifact_uuid", "").strip()
3716 confirmation = request.POST.get("confirmation", "").strip()
3717 reason = request.POST.get("reason", "").strip()
3718
3719 if not artifact_uuid:
3720 messages.error(request, "Artifact UUID is required.")
3721 return redirect("fossil:shun_list", slug=slug)
3722
3723 # Validate UUID format: should be hex characters, 4-64 chars (Fossil uses SHA1/SHA3 hashes)
3724 if not re.match(r"^[0-9a-fA-F]{4,64}$", artifact_uuid):
3725 messages.error(request, "Invalid artifact UUID format. Must be a hex hash (4-64 characters).")
3726 return redirect("fossil:shun_list", slug=slug)
3727
3728 # Require the user to type the first 8 chars of the UUID to confirm
3729 expected_confirmation = artifact_uuid[:8].lower()
3730 if confirmation.lower() != expected_confirmation:
3731 messages.error(request, f'Confirmation failed. You must type "{expected_confirmation}" to confirm shunning.')
3732 return redirect("fossil:shun_list", slug=slug)
3733
3734 if not fossil_repo.exists_on_disk:
3735 messages.error(request, "Repository file not found on disk.")
3736 return redirect("fossil:shun_list", slug=slug)
3737
3738 from fossil.cli import FossilCLI
3739
3740 cli = FossilCLI()
3741 if not cli.is_available():
3742 messages.error(request, "Fossil binary is not available.")
3743 return redirect("fossil:shun_list", slug=slug)
3744
3745 result = cli.shun(fossil_repo.full_path, artifact_uuid, reason=reason)
3746 if result["success"]:
3747 messages.success(request, f"Artifact {artifact_uuid[:12]}... has been permanently shunned.")
3748 else:
3749 messages.error(request, f"Failed to shun artifact: {result['message']}")
3750
3751 return redirect("fossil:shun_list", slug=slug)
3752
3753 DDED templates/accounts/notification_prefs.html
3754 DDED templates/fossil/shun_list.html
--- a/templates/accounts/notification_prefs.html
+++ b/templates/accounts/notification_prefs.html
@@ -0,0 +1,111 @@
1
+{% extends "base.html" %}
2
+{% block title %}Notification Preferences — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-6">Notification Preferences</h1>
6
+
7
+<form method="post" class="space-y-6">
8
+ {% csrf_token %}
9
+
10
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-6">
11
+ <h2 class="text-lg font-semibold text-gray-200 mb-4">Delivery Mode</h2>
12
+ <p class="text-sm text-gray-400 mb-4">Choose how you receive notifications.</p>
13
+ <div class="space-y-3">
14
+ <label class="flex items-start gap-3 cursor-pointer">
15
+ <input type="radio" name="delivery_mode" value="immediate"
16
+ {% if prefs.delivery_mode == "immediate" %}checked{% endif %}
17
+ class="mt-1 text-brand focus:ring-brand">
18
+ <div>
19
+ <span class="text-sm font-medium text-gray-200">Immediate (per event)</span>
20
+ <p class="text-xs text-gray-500">Get an email for each event as it happens.</p>
21
+ </div>
22
+ </label>
23
+ <label class="flex items-start gap-3 cursor-pointer">
24
+ <input type="radio" name="delivery_mode" value="daily"
25
+ {% if prefs.delivery_mode == "daily" %}checked{% endif %}
26
+ class="mt-1 text-brand focus:ring-brand">
27
+ <div>
28
+ <span class="text-sm font-medium text-gray-200">Daily Digest</span>
29
+ <p class="text-xs text-gray-500">Receive a single daily summary of all notifications.</p>
30
+ </div>
31
+ </label>
32
+ <label class="flex items-start gap-3 cursor-pointer">
33
+ <input type="radio" name="delivery_mode" value="weekly"
34
+ {% if prefs.delivery_mode == "weekly" %}checked{% endif %}
35
+ class="mt-1 text-brand focus:ring-brand">
36
+ <div>
37
+ <span class="text-sm font-medium text-gray-200">Weekly Digest</span>
38
+ <p class="text-xs text-gray-500">Receive a weekly summary of all notifications.</p>
39
+ </div>
40
+ </label>
41
+ <label class="flex items-start gap-3 cursor-pointer">
42
+ <input type="radio" name="delivery_mode" value="off"
43
+ {% if prefs.delivery_mode == "off" %}checked{% endif %}
44
+ class="mt-1 text-brand focus:ring-brand">
45
+ <div>
46
+ <span class="text-sm font-medium text-gray-200">Off</span>
47
+ <p class="text-xs text-gray-500">Do not send any email notifications.</p>
48
+ </div>
49
+ </label>
50
+ </div>
51
+ </div>
52
+
53
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-6">
54
+ <h2 class="text-lg font-semibold text-gray-200 mb-4">Event Types</h2>
55
+ <p class="text-sm text-gray-400 mb-4">Choose which types of events you want to be notified about.</p>
56
+ <div class="space-y-3">
57
+ <label class="flex items-center gap-3 cursor-pointer">
58
+ <input type="checkbox" name="notify_checkins"
59
+ {% if prefs.notify_checkins %}checked{% endif %}
60
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
61
+ <div>
62
+ <span class="text-sm font-medium text-gray-200">Checkins</span>
63
+ <span class="text-xs text-gray-500 ml-2">New commits and code changes</span>
64
+ </div>
65
+ </label>
66
+ <label class="flex items-center gap-3 cursor-pointer">
67
+ <input type="checkbox" name="notify_tickets"
68
+ {% if prefs.notify_tickets %}checked{% endif %}
69
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
70
+ <div>
71
+ <span class="text-sm font-medium text-gray-200">Tickets</span>
72
+ <span class="text-xs text-gray-500 ml-2">New and updated tickets</span>
73
+ </div>
74
+ </label>
75
+ <label class="flex items-center gap-3 cursor-pointer">
76
+ <input type="checkbox" name="notify_wiki"
77
+ {% if prefs.notify_wiki %}checked{% endif %}
78
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
79
+ <div>
80
+ <span class="text-sm font-medium text-gray-200">Wiki</span>
81
+ <span class="text-xs text-gray-500 ml-2">Wiki page changes</span>
82
+ </div>
83
+ </label>
84
+ <label class="flex items-center gap-3 cursor-pointer">
85
+ <input type="checkbox" name="notify_releases"
86
+ {% if prefs.notify_releases %}checked{% endif %}
87
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
88
+ <div>
89
+ <span class="text-sm font-medium text-gray-200">Releases</span>
90
+ <span class="text-xs text-gray-500 ml-2">New releases and tags</span>
91
+ </div>
92
+ </label>
93
+ <label class="flex items-center gap-3 cursor-pointer">
94
+ <input type="checkbox" name="notify_forum"
95
+ {% if prefs.notify_forum %}checked{% endif %}
96
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
97
+ <div>
98
+ <span class="text-sm font-medium text-gray-200">Forum</span>
99
+ <span class="text-xs text-gray-500 ml-2">Forum posts and replies</span>
100
+ </div>
101
+ </label>
102
+ </div>
103
+ </div>
104
+
105
+ <div>
106
+ <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">
107
+ Save Preferences
108
+ </button>
109
+ </div>
110
+</form>
111
+{% endblock %}
--- a/templates/accounts/notification_prefs.html
+++ b/templates/accounts/notification_prefs.html
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/accounts/notification_prefs.html
+++ b/templates/accounts/notification_prefs.html
@@ -0,0 +1,111 @@
1 {% extends "base.html" %}
2 {% block title %}Notification Preferences — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-6">Notification Preferences</h1>
6
7 <form method="post" class="space-y-6">
8 {% csrf_token %}
9
10 <div class="rounded-lg bg-gray-800 border border-gray-700 p-6">
11 <h2 class="text-lg font-semibold text-gray-200 mb-4">Delivery Mode</h2>
12 <p class="text-sm text-gray-400 mb-4">Choose how you receive notifications.</p>
13 <div class="space-y-3">
14 <label class="flex items-start gap-3 cursor-pointer">
15 <input type="radio" name="delivery_mode" value="immediate"
16 {% if prefs.delivery_mode == "immediate" %}checked{% endif %}
17 class="mt-1 text-brand focus:ring-brand">
18 <div>
19 <span class="text-sm font-medium text-gray-200">Immediate (per event)</span>
20 <p class="text-xs text-gray-500">Get an email for each event as it happens.</p>
21 </div>
22 </label>
23 <label class="flex items-start gap-3 cursor-pointer">
24 <input type="radio" name="delivery_mode" value="daily"
25 {% if prefs.delivery_mode == "daily" %}checked{% endif %}
26 class="mt-1 text-brand focus:ring-brand">
27 <div>
28 <span class="text-sm font-medium text-gray-200">Daily Digest</span>
29 <p class="text-xs text-gray-500">Receive a single daily summary of all notifications.</p>
30 </div>
31 </label>
32 <label class="flex items-start gap-3 cursor-pointer">
33 <input type="radio" name="delivery_mode" value="weekly"
34 {% if prefs.delivery_mode == "weekly" %}checked{% endif %}
35 class="mt-1 text-brand focus:ring-brand">
36 <div>
37 <span class="text-sm font-medium text-gray-200">Weekly Digest</span>
38 <p class="text-xs text-gray-500">Receive a weekly summary of all notifications.</p>
39 </div>
40 </label>
41 <label class="flex items-start gap-3 cursor-pointer">
42 <input type="radio" name="delivery_mode" value="off"
43 {% if prefs.delivery_mode == "off" %}checked{% endif %}
44 class="mt-1 text-brand focus:ring-brand">
45 <div>
46 <span class="text-sm font-medium text-gray-200">Off</span>
47 <p class="text-xs text-gray-500">Do not send any email notifications.</p>
48 </div>
49 </label>
50 </div>
51 </div>
52
53 <div class="rounded-lg bg-gray-800 border border-gray-700 p-6">
54 <h2 class="text-lg font-semibold text-gray-200 mb-4">Event Types</h2>
55 <p class="text-sm text-gray-400 mb-4">Choose which types of events you want to be notified about.</p>
56 <div class="space-y-3">
57 <label class="flex items-center gap-3 cursor-pointer">
58 <input type="checkbox" name="notify_checkins"
59 {% if prefs.notify_checkins %}checked{% endif %}
60 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
61 <div>
62 <span class="text-sm font-medium text-gray-200">Checkins</span>
63 <span class="text-xs text-gray-500 ml-2">New commits and code changes</span>
64 </div>
65 </label>
66 <label class="flex items-center gap-3 cursor-pointer">
67 <input type="checkbox" name="notify_tickets"
68 {% if prefs.notify_tickets %}checked{% endif %}
69 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
70 <div>
71 <span class="text-sm font-medium text-gray-200">Tickets</span>
72 <span class="text-xs text-gray-500 ml-2">New and updated tickets</span>
73 </div>
74 </label>
75 <label class="flex items-center gap-3 cursor-pointer">
76 <input type="checkbox" name="notify_wiki"
77 {% if prefs.notify_wiki %}checked{% endif %}
78 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
79 <div>
80 <span class="text-sm font-medium text-gray-200">Wiki</span>
81 <span class="text-xs text-gray-500 ml-2">Wiki page changes</span>
82 </div>
83 </label>
84 <label class="flex items-center gap-3 cursor-pointer">
85 <input type="checkbox" name="notify_releases"
86 {% if prefs.notify_releases %}checked{% endif %}
87 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
88 <div>
89 <span class="text-sm font-medium text-gray-200">Releases</span>
90 <span class="text-xs text-gray-500 ml-2">New releases and tags</span>
91 </div>
92 </label>
93 <label class="flex items-center gap-3 cursor-pointer">
94 <input type="checkbox" name="notify_forum"
95 {% if prefs.notify_forum %}checked{% endif %}
96 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
97 <div>
98 <span class="text-sm font-medium text-gray-200">Forum</span>
99 <span class="text-xs text-gray-500 ml-2">Forum posts and replies</span>
100 </div>
101 </label>
102 </div>
103 </div>
104
105 <div>
106 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">
107 Save Preferences
108 </button>
109 </div>
110 </form>
111 {% endblock %}
--- a/templates/fossil/shun_list.html
+++ b/templates/fossil/shun_list.html
@@ -0,0 +1,90 @@
1
+{% extends "base.html" %}
2
+{% block title %}Shunned Artifacts — {{ 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">Shunned Artifacts</h2>
10
+</div>
11
+
12
+<div class="rounded-lg bg-red-900/20 border border-red-800/50 p-4 mb-6">
13
+ <div class="flex items-start gap-3">
14
+ <svg class="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
15
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
16
+ </svg>
17
+ <div>
18
+ <h3 class="text-sm font-semibold text-red-300">Irreversible Operation</h3>
19
+ <p class="text-xs text-red-400 mt-1">
20
+ Shunning permanently removes an artifact from the repository. This cannot be undone.
21
+ Shunned artifacts are expunged from the database and will not be recovered through sync.
22
+ Use this for spam, accidentally committed secrets, or legal compliance.
23
+ </p>
24
+ </div>
25
+ </div>
26
+</div>
27
+
28
+<div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6" x-data="{ showForm: false, artifactUuid: '', confirmation: '' }">
29
+ <div class="flex items-center justify-between mb-4">
30
+ <h3 class="text-base font-semibold text-gray-200">Shun an Artifact</h3>
31
+ <button @click="showForm = !showForm"
32
+ class="text-sm text-brand-light hover:text-brand"
33
+ x-text="showForm ? 'Cancel' : 'Shun New Artifact'"></button>
34
+ </div>
35
+
36
+ <form method="post" action="{% url 'fossil:shun_artifact' slug=project.slug %}"
37
+ x-show="showForm" x-transition class="space-y-4">
38
+ {% csrf_token %}
39
+ <div>
40
+ <label for="artifact_uuid" class="block text-sm font-medium text-gray-300 mb-1">Artifact UUID (SHA hash)</label>
41
+ <input type="text" name="artifact_uuid" id="artifact_uuid" required
42
+ x-model="artifactUuid"
43
+ placeholder="e.g. a1b2c3d4e5f6..."
44
+ pattern="[0-9a-fA-F]{4,64}"
45
+ title="Must be a hex hash (4-64 characters)"
46
+ class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm font-mono px-3 py-2 focus:border-brand focus:ring-brand">
47
+ </div>
48
+ <div>
49
+ <label for="reason" class="block text-sm font-medium text-gray-300 mb-1">Reason (optional)</label>
50
+ <input type="text" name="reason" id="reason"
51
+ placeholder="e.g. Accidentally committed API key"
52
+ class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
53
+ </div>
54
+ <div class="rounded-md bg-red-900/30 border border-red-700/50 p-3">
55
+ <label for="confirmation" class="block text-sm font-medium text-red-300 mb-1">
56
+ Type the first 8 characters of the UUID to confirm:
57
+ <span class="font-mono text-red-200" x-text="artifactUuid.slice(0, 8).toLowerCase() || '________'"></span>
58
+ </label>
59
+ <input type="text" name="confirmation" id="confirmation" required
60
+ x-model="confirmation"
61
+ placeholder="Type to confirm..."
62
+ class="w-full rounded-md border-red-700/50 bg-gray-900 text-gray-100 text-sm font-mono px-3 py-2 focus:border-red-500 focus:ring-red-500">
63
+ </div>
64
+ <button type="submit"
65
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
66
+ :disabled="confirmation.toLowerCase() !== artifactUuid.slice(0, 8).toLowerCase() || artifactUuid.length < 4">
67
+ Permanently Shun Artifact
68
+ </button>
69
+ </form>
70
+</div>
71
+
72
+{% if shunned %}
73
+<div class="rounded-lg bg-gray-800 border border-gray-700">
74
+ <div class="p-4 border-b border-gray-700">
75
+ <h3 class="text-base font-semibold text-gray-200">Currently Shunned ({{ shunned|length }})</h3>
76
+ </div>
77
+ <div class="divide-y divide-gray-700">
78
+ {% for uuid in shunned %}
79
+ <div class="px-4 py-3">
80
+ <span class="text-sm font-mono text-gray-300">{{ uuid }}</span>
81
+ </div>
82
+ {% endfor %}
83
+ </div>
84
+</div>
85
+{% else %}
86
+<div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center">
87
+ <p class="text-sm text-gray-500">No artifacts have been shunned in this repository.</p>
88
+</div>
89
+{% endif %}
90
+{% endblock %}
--- a/templates/fossil/shun_list.html
+++ b/templates/fossil/shun_list.html
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/shun_list.html
+++ b/templates/fossil/shun_list.html
@@ -0,0 +1,90 @@
1 {% extends "base.html" %}
2 {% block title %}Shunned Artifacts — {{ 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">Shunned Artifacts</h2>
10 </div>
11
12 <div class="rounded-lg bg-red-900/20 border border-red-800/50 p-4 mb-6">
13 <div class="flex items-start gap-3">
14 <svg class="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
15 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
16 </svg>
17 <div>
18 <h3 class="text-sm font-semibold text-red-300">Irreversible Operation</h3>
19 <p class="text-xs text-red-400 mt-1">
20 Shunning permanently removes an artifact from the repository. This cannot be undone.
21 Shunned artifacts are expunged from the database and will not be recovered through sync.
22 Use this for spam, accidentally committed secrets, or legal compliance.
23 </p>
24 </div>
25 </div>
26 </div>
27
28 <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6" x-data="{ showForm: false, artifactUuid: '', confirmation: '' }">
29 <div class="flex items-center justify-between mb-4">
30 <h3 class="text-base font-semibold text-gray-200">Shun an Artifact</h3>
31 <button @click="showForm = !showForm"
32 class="text-sm text-brand-light hover:text-brand"
33 x-text="showForm ? 'Cancel' : 'Shun New Artifact'"></button>
34 </div>
35
36 <form method="post" action="{% url 'fossil:shun_artifact' slug=project.slug %}"
37 x-show="showForm" x-transition class="space-y-4">
38 {% csrf_token %}
39 <div>
40 <label for="artifact_uuid" class="block text-sm font-medium text-gray-300 mb-1">Artifact UUID (SHA hash)</label>
41 <input type="text" name="artifact_uuid" id="artifact_uuid" required
42 x-model="artifactUuid"
43 placeholder="e.g. a1b2c3d4e5f6..."
44 pattern="[0-9a-fA-F]{4,64}"
45 title="Must be a hex hash (4-64 characters)"
46 class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm font-mono px-3 py-2 focus:border-brand focus:ring-brand">
47 </div>
48 <div>
49 <label for="reason" class="block text-sm font-medium text-gray-300 mb-1">Reason (optional)</label>
50 <input type="text" name="reason" id="reason"
51 placeholder="e.g. Accidentally committed API key"
52 class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
53 </div>
54 <div class="rounded-md bg-red-900/30 border border-red-700/50 p-3">
55 <label for="confirmation" class="block text-sm font-medium text-red-300 mb-1">
56 Type the first 8 characters of the UUID to confirm:
57 <span class="font-mono text-red-200" x-text="artifactUuid.slice(0, 8).toLowerCase() || '________'"></span>
58 </label>
59 <input type="text" name="confirmation" id="confirmation" required
60 x-model="confirmation"
61 placeholder="Type to confirm..."
62 class="w-full rounded-md border-red-700/50 bg-gray-900 text-gray-100 text-sm font-mono px-3 py-2 focus:border-red-500 focus:ring-red-500">
63 </div>
64 <button type="submit"
65 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
66 :disabled="confirmation.toLowerCase() !== artifactUuid.slice(0, 8).toLowerCase() || artifactUuid.length < 4">
67 Permanently Shun Artifact
68 </button>
69 </form>
70 </div>
71
72 {% if shunned %}
73 <div class="rounded-lg bg-gray-800 border border-gray-700">
74 <div class="p-4 border-b border-gray-700">
75 <h3 class="text-base font-semibold text-gray-200">Currently Shunned ({{ shunned|length }})</h3>
76 </div>
77 <div class="divide-y divide-gray-700">
78 {% for uuid in shunned %}
79 <div class="px-4 py-3">
80 <span class="text-sm font-mono text-gray-300">{{ uuid }}</span>
81 </div>
82 {% endfor %}
83 </div>
84 </div>
85 {% else %}
86 <div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center">
87 <p class="text-sm text-gray-500">No artifacts have been shunned in this repository.</p>
88 </div>
89 {% endif %}
90 {% endblock %}
--- templates/fossil/ticket_edit.html
+++ templates/fossil/ticket_edit.html
@@ -65,10 +65,48 @@
6565
{% for r in "Fixed,Rejected,Overcome_By_Events,Works_As_Designed,Unable_To_Reproduce,Not_A_Bug,Duplicate".split %}
6666
<option value="{{ r }}" {% if r == ticket.resolution %}selected{% endif %}>{{ r }}</option>
6767
{% endfor %}
6868
</select>
6969
</div>
70
+
71
+ {% if custom_fields %}
72
+ <div class="border-t border-gray-700 pt-4 mt-4">
73
+ <h3 class="text-sm font-semibold text-gray-300 mb-3">Custom Fields</h3>
74
+ <div class="grid grid-cols-2 gap-4">
75
+ {% for cf in custom_fields %}
76
+ <div{% if cf.field_type == "textarea" %} class="col-span-2"{% endif %}>
77
+ <label class="block text-sm font-medium text-gray-300 mb-1">
78
+ {{ cf.label }}
79
+ {% if cf.is_required %}<span class="text-red-400">*</span>{% endif %}
80
+ </label>
81
+ {% if cf.field_type == "text" or cf.field_type == "url" or cf.field_type == "date" %}
82
+ <input type="{% if cf.field_type == "url" %}url{% elif cf.field_type == "date" %}date{% else %}text{% endif %}"
83
+ name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %}
84
+ 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">
85
+ {% elif cf.field_type == "textarea" %}
86
+ <textarea name="custom_{{ cf.name }}" rows="3" {% if cf.is_required %}required{% endif %}
87
+ 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"></textarea>
88
+ {% elif cf.field_type == "select" %}
89
+ <select name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %}
90
+ 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">
91
+ <option value="">--</option>
92
+ {% for choice in cf.choices_list %}
93
+ <option value="{{ choice }}">{{ choice }}</option>
94
+ {% endfor %}
95
+ </select>
96
+ {% elif cf.field_type == "checkbox" %}
97
+ <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer mt-1">
98
+ <input type="checkbox" name="custom_{{ cf.name }}"
99
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
100
+ {{ cf.label }}
101
+ </label>
102
+ {% endif %}
103
+ </div>
104
+ {% endfor %}
105
+ </div>
106
+ </div>
107
+ {% endif %}
70108
71109
<div class="flex justify-end gap-3 pt-2">
72110
<a href="{% url 'fossil:ticket_detail' slug=project.slug ticket_uuid=ticket.uuid %}"
73111
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">
74112
Cancel
75113
76114
ADDED templates/fossil/ticket_fields_form.html
77115
ADDED templates/fossil/ticket_fields_list.html
--- templates/fossil/ticket_edit.html
+++ templates/fossil/ticket_edit.html
@@ -65,10 +65,48 @@
65 {% for r in "Fixed,Rejected,Overcome_By_Events,Works_As_Designed,Unable_To_Reproduce,Not_A_Bug,Duplicate".split %}
66 <option value="{{ r }}" {% if r == ticket.resolution %}selected{% endif %}>{{ r }}</option>
67 {% endfor %}
68 </select>
69 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
71 <div class="flex justify-end gap-3 pt-2">
72 <a href="{% url 'fossil:ticket_detail' slug=project.slug ticket_uuid=ticket.uuid %}"
73 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">
74 Cancel
75
76 DDED templates/fossil/ticket_fields_form.html
77 DDED templates/fossil/ticket_fields_list.html
--- templates/fossil/ticket_edit.html
+++ templates/fossil/ticket_edit.html
@@ -65,10 +65,48 @@
65 {% for r in "Fixed,Rejected,Overcome_By_Events,Works_As_Designed,Unable_To_Reproduce,Not_A_Bug,Duplicate".split %}
66 <option value="{{ r }}" {% if r == ticket.resolution %}selected{% endif %}>{{ r }}</option>
67 {% endfor %}
68 </select>
69 </div>
70
71 {% if custom_fields %}
72 <div class="border-t border-gray-700 pt-4 mt-4">
73 <h3 class="text-sm font-semibold text-gray-300 mb-3">Custom Fields</h3>
74 <div class="grid grid-cols-2 gap-4">
75 {% for cf in custom_fields %}
76 <div{% if cf.field_type == "textarea" %} class="col-span-2"{% endif %}>
77 <label class="block text-sm font-medium text-gray-300 mb-1">
78 {{ cf.label }}
79 {% if cf.is_required %}<span class="text-red-400">*</span>{% endif %}
80 </label>
81 {% if cf.field_type == "text" or cf.field_type == "url" or cf.field_type == "date" %}
82 <input type="{% if cf.field_type == "url" %}url{% elif cf.field_type == "date" %}date{% else %}text{% endif %}"
83 name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %}
84 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">
85 {% elif cf.field_type == "textarea" %}
86 <textarea name="custom_{{ cf.name }}" rows="3" {% if cf.is_required %}required{% endif %}
87 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"></textarea>
88 {% elif cf.field_type == "select" %}
89 <select name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %}
90 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">
91 <option value="">--</option>
92 {% for choice in cf.choices_list %}
93 <option value="{{ choice }}">{{ choice }}</option>
94 {% endfor %}
95 </select>
96 {% elif cf.field_type == "checkbox" %}
97 <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer mt-1">
98 <input type="checkbox" name="custom_{{ cf.name }}"
99 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
100 {{ cf.label }}
101 </label>
102 {% endif %}
103 </div>
104 {% endfor %}
105 </div>
106 </div>
107 {% endif %}
108
109 <div class="flex justify-end gap-3 pt-2">
110 <a href="{% url 'fossil:ticket_detail' slug=project.slug ticket_uuid=ticket.uuid %}"
111 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">
112 Cancel
113
114 DDED templates/fossil/ticket_fields_form.html
115 DDED templates/fossil/ticket_fields_list.html
--- a/templates/fossil/ticket_fields_form.html
+++ b/templates/fossil/ticket_fields_form.html
@@ -0,0 +1,87 @@
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:ticket_fields' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Custom Fields</a>
10
+</div>
11
+
12
+<div class="mx-auto max-w-2xl">
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 class="grid grid-cols-2 gap-4">
19
+ <div>
20
+ <label class="block text-sm font-medium text-gray-300 mb-1">Field Name <span class="text-red-400">*</span></label>
21
+ <input type="text" name="name" required placeholder="e.g. component"
22
+ value="{% if field_def %}{{ field_def.name }}{% endif %}"
23
+ pattern="[a-zA-Z_][a-zA-Z0-9_]*"
24
+ title="Letters, numbers, and underscores only. Must start with a letter or underscore."
25
+ 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">
26
+ <p class="mt-1 text-xs text-gray-500">Internal name used in the Fossil ticket table. Alphanumeric and underscores only.</p>
27
+ </div>
28
+ <div>
29
+ <label class="block text-sm font-medium text-gray-300 mb-1">Display Label <span class="text-red-400">*</span></label>
30
+ <input type="text" name="label" required placeholder="e.g. Component"
31
+ value="{% if field_def %}{{ field_def.label }}{% endif %}"
32
+ 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">
33
+ </div>
34
+ </div>
35
+
36
+ <div class="grid grid-cols-2 gap-4">
37
+ <div>
38
+ <label class="block text-sm font-medium text-gray-300 mb-1">Field Type</label>
39
+ <select name="field_type" id="field-type-select"
40
+ 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">
41
+ {% for value, label in field_type_choices %}
42
+ <option value="{{ value }}" {% if field_def and field_def.field_type == value %}selected{% endif %}>{{ label }}</option>
43
+ {% endfor %}
44
+ </select>
45
+ </div>
46
+ <div>
47
+ <label class="block text-sm font-medium text-gray-300 mb-1">Sort Order</label>
48
+ <input type="number" name="sort_order" value="{% if field_def %}{{ field_def.sort_order }}{% else %}0{% endif %}"
49
+ 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">
50
+ <p class="mt-1 text-xs text-gray-500">Lower numbers appear first.</p>
51
+ </div>
52
+ </div>
53
+
54
+ <div x-data="{ showChoices: '{% if field_def %}{{ field_def.field_type }}{% else %}text{% endif %}' === 'select' }">
55
+ <div x-show="showChoices" x-transition>
56
+ <label class="block text-sm font-medium text-gray-300 mb-1">Choices</label>
57
+ <textarea name="choices" rows="5" placeholder="One option per line"
58
+ 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">{% if field_def %}{{ field_def.choices }}{% endif %}</textarea>
59
+ <p class="mt-1 text-xs text-gray-500">One option per line. Only used for Select fields.</p>
60
+ </div>
61
+ <script>
62
+ document.getElementById('field-type-select').addEventListener('change', function() {
63
+ const comp = document.querySelector('[x-data]').__x.$data;
64
+ comp.showChoices = this.value === 'select';
65
+ });
66
+ </script>
67
+ </div>
68
+
69
+ <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
70
+ <input type="checkbox" name="is_required"
71
+ {% if field_def and field_def.is_required %}checked{% endif %}
72
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
73
+ Required field
74
+ </label>
75
+
76
+ <div class="flex justify-end gap-3 pt-2">
77
+ <a href="{% url 'fossil:ticket_fields' slug=project.slug %}"
78
+ 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">
79
+ Cancel
80
+ </a>
81
+ <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
82
+ {{ submit_label }}
83
+ </button>
84
+ </div>
85
+ </form>
86
+</div>
87
+{% endblock %}
--- a/templates/fossil/ticket_fields_form.html
+++ b/templates/fossil/ticket_fields_form.html
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/ticket_fields_form.html
+++ b/templates/fossil/ticket_fields_form.html
@@ -0,0 +1,87 @@
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:ticket_fields' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Custom Fields</a>
10 </div>
11
12 <div class="mx-auto max-w-2xl">
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 class="grid grid-cols-2 gap-4">
19 <div>
20 <label class="block text-sm font-medium text-gray-300 mb-1">Field Name <span class="text-red-400">*</span></label>
21 <input type="text" name="name" required placeholder="e.g. component"
22 value="{% if field_def %}{{ field_def.name }}{% endif %}"
23 pattern="[a-zA-Z_][a-zA-Z0-9_]*"
24 title="Letters, numbers, and underscores only. Must start with a letter or underscore."
25 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">
26 <p class="mt-1 text-xs text-gray-500">Internal name used in the Fossil ticket table. Alphanumeric and underscores only.</p>
27 </div>
28 <div>
29 <label class="block text-sm font-medium text-gray-300 mb-1">Display Label <span class="text-red-400">*</span></label>
30 <input type="text" name="label" required placeholder="e.g. Component"
31 value="{% if field_def %}{{ field_def.label }}{% endif %}"
32 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">
33 </div>
34 </div>
35
36 <div class="grid grid-cols-2 gap-4">
37 <div>
38 <label class="block text-sm font-medium text-gray-300 mb-1">Field Type</label>
39 <select name="field_type" id="field-type-select"
40 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">
41 {% for value, label in field_type_choices %}
42 <option value="{{ value }}" {% if field_def and field_def.field_type == value %}selected{% endif %}>{{ label }}</option>
43 {% endfor %}
44 </select>
45 </div>
46 <div>
47 <label class="block text-sm font-medium text-gray-300 mb-1">Sort Order</label>
48 <input type="number" name="sort_order" value="{% if field_def %}{{ field_def.sort_order }}{% else %}0{% endif %}"
49 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">
50 <p class="mt-1 text-xs text-gray-500">Lower numbers appear first.</p>
51 </div>
52 </div>
53
54 <div x-data="{ showChoices: '{% if field_def %}{{ field_def.field_type }}{% else %}text{% endif %}' === 'select' }">
55 <div x-show="showChoices" x-transition>
56 <label class="block text-sm font-medium text-gray-300 mb-1">Choices</label>
57 <textarea name="choices" rows="5" placeholder="One option per line"
58 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">{% if field_def %}{{ field_def.choices }}{% endif %}</textarea>
59 <p class="mt-1 text-xs text-gray-500">One option per line. Only used for Select fields.</p>
60 </div>
61 <script>
62 document.getElementById('field-type-select').addEventListener('change', function() {
63 const comp = document.querySelector('[x-data]').__x.$data;
64 comp.showChoices = this.value === 'select';
65 });
66 </script>
67 </div>
68
69 <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
70 <input type="checkbox" name="is_required"
71 {% if field_def and field_def.is_required %}checked{% endif %}
72 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
73 Required field
74 </label>
75
76 <div class="flex justify-end gap-3 pt-2">
77 <a href="{% url 'fossil:ticket_fields' slug=project.slug %}"
78 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">
79 Cancel
80 </a>
81 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
82 {{ submit_label }}
83 </button>
84 </div>
85 </form>
86 </div>
87 {% endblock %}
--- a/templates/fossil/ticket_fields_list.html
+++ b/templates/fossil/ticket_fields_list.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Custom Ticket Fields — {{ 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">Custom Ticket Fields</h2>
10
+ <ull bg-gray-700 px-2 py-0.5 text-xs font-semibold text-gray-400">{{ field.get_Add Field
11
+ </a>
12
+</div>
13
+
14
+
15
+<div id="fields-content">
16
+{% if fields %}
17
+<div class="space-y-3">
18
+ {% for field in fields %}
19
+ <div class="rounded-lg bg-gray-800 border border-gray-700">
20
+ <div class="px-5 py-4">
21
+ <div class="flex items-start justify-between gap-4">
22
+ <div class="flex-1 min-w-0">
23
+ <div class="flex items-center gap-3 mb-1">
24
+ <span class="text-sm font-medium text-gray-100">{{ field.label }}</span>
25
+ <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5 text-xs font-semibold text-gray-400">{{ field.get_field_type_display }}</span>
26
+ {% if field.is_required %}
27
+ <span class="inline-flex rounded-full bg-red-900/50 px-2 py-0.5 text-xs font-semibold text-red-300">Required</span>
28
+
--- a/templates/fossil/ticket_fields_list.html
+++ b/templates/fossil/ticket_fields_list.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/ticket_fields_list.html
+++ b/templates/fossil/ticket_fields_list.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Custom Ticket Fields — {{ 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">Custom Ticket Fields</h2>
10 <ull bg-gray-700 px-2 py-0.5 text-xs font-semibold text-gray-400">{{ field.get_Add Field
11 </a>
12 </div>
13
14
15 <div id="fields-content">
16 {% if fields %}
17 <div class="space-y-3">
18 {% for field in fields %}
19 <div class="rounded-lg bg-gray-800 border border-gray-700">
20 <div class="px-5 py-4">
21 <div class="flex items-start justify-between gap-4">
22 <div class="flex-1 min-w-0">
23 <div class="flex items-center gap-3 mb-1">
24 <span class="text-sm font-medium text-gray-100">{{ field.label }}</span>
25 <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5 text-xs font-semibold text-gray-400">{{ field.get_field_type_display }}</span>
26 {% if field.is_required %}
27 <span class="inline-flex rounded-full bg-red-900/50 px-2 py-0.5 text-xs font-semibold text-red-300">Required</span>
28
--- templates/fossil/ticket_form.html
+++ templates/fossil/ticket_form.html
@@ -46,10 +46,48 @@
4646
<div>
4747
<label class="block text-sm font-medium text-gray-300 mb-1">Description</label>
4848
<textarea name="body" rows="10" placeholder="Describe the issue..."
4949
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"></textarea>
5050
</div>
51
+
52
+ {% if custom_fields %}
53
+ <div class="border-t border-gray-700 pt-4 mt-4">
54
+ <h3 class="text-sm font-semibold text-gray-300 mb-3">Custom Fields</h3>
55
+ <div class="grid grid-cols-2 gap-4">
56
+ {% for cf in custom_fields %}
57
+ <div{% if cf.field_type == "textarea" %} class="col-span-2"{% endif %}>
58
+ <label class="block text-sm font-medium text-gray-300 mb-1">
59
+ {{ cf.label }}
60
+ {% if cf.is_required %}<span class="text-red-400">*</span>{% endif %}
61
+ </label>
62
+ {% if cf.field_type == "text" or cf.field_type == "url" or cf.field_type == "date" %}
63
+ <input type="{% if cf.field_type == "url" %}url{% elif cf.field_type == "date" %}date{% else %}text{% endif %}"
64
+ name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %}
65
+ 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">
66
+ {% elif cf.field_type == "textarea" %}
67
+ <textarea name="custom_{{ cf.name }}" rows="3" {% if cf.is_required %}required{% endif %}
68
+ 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"></textarea>
69
+ {% elif cf.field_type == "select" %}
70
+ <select name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %}
71
+ 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">
72
+ <option value="">--</option>
73
+ {% for choice in cf.choices_list %}
74
+ <option value="{{ choice }}">{{ choice }}</option>
75
+ {% endfor %}
76
+ </select>
77
+ {% elif cf.field_type == "checkbox" %}
78
+ <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer mt-1">
79
+ <input type="checkbox" name="custom_{{ cf.name }}"
80
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
81
+ {{ cf.label }}
82
+ </label>
83
+ {% endif %}
84
+ </div>
85
+ {% endfor %}
86
+ </div>
87
+ </div>
88
+ {% endif %}
5189
5290
<div class="flex justify-end gap-3 pt-2">
5391
<a href="{% url 'fossil:tickets' slug=project.slug %}"
5492
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">
5593
Cancel
5694
5795
ADDED templates/fossil/ticket_report_form.html
5896
ADDED templates/fossil/ticket_report_results.html
5997
ADDED templates/fossil/ticket_reports_list.html
6098
ADDED tests/test_bundle_cli.py
6199
ADDED tests/test_notification_prefs.py
62100
ADDED tests/test_shunning.py
63101
ADDED tests/test_ticket_fields.py
64102
ADDED tests/test_ticket_reports.py
--- templates/fossil/ticket_form.html
+++ templates/fossil/ticket_form.html
@@ -46,10 +46,48 @@
46 <div>
47 <label class="block text-sm font-medium text-gray-300 mb-1">Description</label>
48 <textarea name="body" rows="10" placeholder="Describe the issue..."
49 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"></textarea>
50 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
52 <div class="flex justify-end gap-3 pt-2">
53 <a href="{% url 'fossil:tickets' slug=project.slug %}"
54 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">
55 Cancel
56
57 DDED templates/fossil/ticket_report_form.html
58 DDED templates/fossil/ticket_report_results.html
59 DDED templates/fossil/ticket_reports_list.html
60 DDED tests/test_bundle_cli.py
61 DDED tests/test_notification_prefs.py
62 DDED tests/test_shunning.py
63 DDED tests/test_ticket_fields.py
64 DDED tests/test_ticket_reports.py
--- templates/fossil/ticket_form.html
+++ templates/fossil/ticket_form.html
@@ -46,10 +46,48 @@
46 <div>
47 <label class="block text-sm font-medium text-gray-300 mb-1">Description</label>
48 <textarea name="body" rows="10" placeholder="Describe the issue..."
49 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"></textarea>
50 </div>
51
52 {% if custom_fields %}
53 <div class="border-t border-gray-700 pt-4 mt-4">
54 <h3 class="text-sm font-semibold text-gray-300 mb-3">Custom Fields</h3>
55 <div class="grid grid-cols-2 gap-4">
56 {% for cf in custom_fields %}
57 <div{% if cf.field_type == "textarea" %} class="col-span-2"{% endif %}>
58 <label class="block text-sm font-medium text-gray-300 mb-1">
59 {{ cf.label }}
60 {% if cf.is_required %}<span class="text-red-400">*</span>{% endif %}
61 </label>
62 {% if cf.field_type == "text" or cf.field_type == "url" or cf.field_type == "date" %}
63 <input type="{% if cf.field_type == "url" %}url{% elif cf.field_type == "date" %}date{% else %}text{% endif %}"
64 name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %}
65 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">
66 {% elif cf.field_type == "textarea" %}
67 <textarea name="custom_{{ cf.name }}" rows="3" {% if cf.is_required %}required{% endif %}
68 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"></textarea>
69 {% elif cf.field_type == "select" %}
70 <select name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %}
71 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">
72 <option value="">--</option>
73 {% for choice in cf.choices_list %}
74 <option value="{{ choice }}">{{ choice }}</option>
75 {% endfor %}
76 </select>
77 {% elif cf.field_type == "checkbox" %}
78 <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer mt-1">
79 <input type="checkbox" name="custom_{{ cf.name }}"
80 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
81 {{ cf.label }}
82 </label>
83 {% endif %}
84 </div>
85 {% endfor %}
86 </div>
87 </div>
88 {% endif %}
89
90 <div class="flex justify-end gap-3 pt-2">
91 <a href="{% url 'fossil:tickets' slug=project.slug %}"
92 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">
93 Cancel
94
95 DDED templates/fossil/ticket_report_form.html
96 DDED templates/fossil/ticket_report_results.html
97 DDED templates/fossil/ticket_reports_list.html
98 DDED tests/test_bundle_cli.py
99 DDED tests/test_notification_prefs.py
100 DDED tests/test_shunning.py
101 DDED tests/test_ticket_fields.py
102 DDED tests/test_ticket_reports.py
--- a/templates/fossil/ticket_report_form.html
+++ b/templates/fossil/ticket_report_form.html
@@ -0,0 +1,61 @@
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:ticket_reports' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Reports</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">Title <span class="text-red-400">*</span></label>
20
+ <input type="text" name="title" required placeholder="Report title"
21
+ value="{% if report %}{{ report.title }}{% 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">
23
+ </div>
24
+
25
+ <div>
26
+ <label class="block text-sm font-medium text-gray-300 mb-1">Description</label>
27
+ <textarea name="description" rows="2" placeholder="What does this report show?"
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">{% if report %}{{ report.description }}{% endif %}</textarea>
29
+ </div>
30
+
31
+ <div>
32
+ <label class="block text-sm font-medium text-gray-300 mb-1">SQL Query <span class="text-red-400">*</span></label>
33
+ <textarea name="sql_query" rows="10" required
34
+ placeholder="SELECT tkt_uuid, title, status, type FROM ticket WHERE status = 'Open' ORDER BY tkt_ctime DESC"
35
+ 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">{% if report %}{{ report.sql_query }}{% endif %}</textarea>
36
+ <div class="mt-2 text-xs text-gray-500 space-y-1">
37
+ <p>Must be a SELECT statement. INSERT, UPDATE, DELETE, DROP, and other write operations are not allowed.</p>
38
+ <p>Available placeholders: <code class="text-gray-400">{status}</code>, <code class="text-gray-400">{type}</code> (filled from query parameters).</p>
39
+ <p>Common columns: <code class="text-gray-400">tkt_uuid</code>, <code class="text-gray-400">title</code>, <code class="text-gray-400">status</code>, <code class="text-gray-400">type</code>, <code class="text-gray-400">priority</code>, <code class="text-gray-400">severity</code>, <code class="text-gray-400">subsystem</code>, <code class="text-gray-400">tkt_ctime</code>.</p>
40
+ </div>
41
+ </div>
42
+
43
+ <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
44
+ <input type="checkbox" name="is_public"
45
+ {% if report %}{% if report.is_public %}checked{% endif %}{% else %}checked{% endif %}
46
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
47
+ Public (visible to all project members)
48
+ </label>
49
+
50
+ <div class="flex justify-end gap-3 pt-2">
51
+ <a href="{% url 'fossil:ticket_reports' slug=project.slug %}"
52
+ 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">
53
+ Cancel
54
+ </a>
55
+ <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
56
+ {{ submit_label }}
57
+ </button>
58
+ </div>
59
+ </form>
60
+</div>
61
+{% endblock %}
--- a/templates/fossil/ticket_report_form.html
+++ b/templates/fossil/ticket_report_form.html
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/ticket_report_form.html
+++ b/templates/fossil/ticket_report_form.html
@@ -0,0 +1,61 @@
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:ticket_reports' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Reports</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">Title <span class="text-red-400">*</span></label>
20 <input type="text" name="title" required placeholder="Report title"
21 value="{% if report %}{{ report.title }}{% 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">
23 </div>
24
25 <div>
26 <label class="block text-sm font-medium text-gray-300 mb-1">Description</label>
27 <textarea name="description" rows="2" placeholder="What does this report show?"
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">{% if report %}{{ report.description }}{% endif %}</textarea>
29 </div>
30
31 <div>
32 <label class="block text-sm font-medium text-gray-300 mb-1">SQL Query <span class="text-red-400">*</span></label>
33 <textarea name="sql_query" rows="10" required
34 placeholder="SELECT tkt_uuid, title, status, type FROM ticket WHERE status = 'Open' ORDER BY tkt_ctime DESC"
35 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">{% if report %}{{ report.sql_query }}{% endif %}</textarea>
36 <div class="mt-2 text-xs text-gray-500 space-y-1">
37 <p>Must be a SELECT statement. INSERT, UPDATE, DELETE, DROP, and other write operations are not allowed.</p>
38 <p>Available placeholders: <code class="text-gray-400">{status}</code>, <code class="text-gray-400">{type}</code> (filled from query parameters).</p>
39 <p>Common columns: <code class="text-gray-400">tkt_uuid</code>, <code class="text-gray-400">title</code>, <code class="text-gray-400">status</code>, <code class="text-gray-400">type</code>, <code class="text-gray-400">priority</code>, <code class="text-gray-400">severity</code>, <code class="text-gray-400">subsystem</code>, <code class="text-gray-400">tkt_ctime</code>.</p>
40 </div>
41 </div>
42
43 <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
44 <input type="checkbox" name="is_public"
45 {% if report %}{% if report.is_public %}checked{% endif %}{% else %}checked{% endif %}
46 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
47 Public (visible to all project members)
48 </label>
49
50 <div class="flex justify-end gap-3 pt-2">
51 <a href="{% url 'fossil:ticket_reports' slug=project.slug %}"
52 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">
53 Cancel
54 </a>
55 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
56 {{ submit_label }}
57 </button>
58 </div>
59 </form>
60 </div>
61 {% endblock %}
--- a/templates/fossil/ticket_report_results.html
+++ b/templates/fossil/ticket_report_results.html
@@ -0,0 +1,56 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ report.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:ticket_reports' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Reports</a>
10
+</div>
11
+
12
+<div class="flex items-center justify-between mb-4">
13
+ <div>
14
+ <h2 class="text-xl font-bold text-gray-100">{{ report.title }}</h2>
15
+ {% if report.description %}
16
+ <p class="text-sm text-gray-500 mt-1">{{ report.description }}</p>
17
+ {% endif %}
18
+ </div>
19
+</div>
20
+
21
+{% if error %}
22
+<div class="rounded-lg bg-red-900/30 border border-red-700 p-4 mb-4">
23
+ <p class="text-sm text-red-300">{{ error }}</p>
24
+</div>
25
+{% endif %}
26
+
27
+{% if columns %}
28
+<div class="rounded-lg bg-gray-800 border border-gray-700 overflow-x-auto">
29
+ <table class="min-w-full divide-y divide-gray-700">
30
+ <thead>
31
+ <tr>
32
+ {% for col in columns %}
33
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">{{ col }}</th>
34
+ {% endfor %}
35
+ </tr>
36
+ </thead>
37
+ <tbody class="divide-y divide-gray-700">
38
+ {% for row in rows %}
39
+ <tr class="hover:bg-gray-750">
40
+ {% for cell in row %}
41
+ <td class="px-4 py-3 text-sm text-gray-300 whitespace-nowrap">{{ cell|default:"" }}</td>
42
+ {% endfor %}
43
+ </tr>
44
+ {% endfor %}
45
+ </tbody>
46
+ </table>
47
+</div>
48
+<div class="mt-2 text-xs text-gray-500">
49
+ {{ rows|length }} row{{ rows|length|pluralize }} returned{% if rows|length >= 1000 %} (limited to 1000){% endif %}
50
+</div>
51
+{% elif not error %}
52
+<div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center">
53
+ <p class="text-sm text-gray-500">No results.</p>
54
+</div>
55
+{% endif %}
56
+{% endblock %}
--- a/templates/fossil/ticket_report_results.html
+++ b/templates/fossil/ticket_report_results.html
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/ticket_report_results.html
+++ b/templates/fossil/ticket_report_results.html
@@ -0,0 +1,56 @@
1 {% extends "base.html" %}
2 {% block title %}{{ report.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:ticket_reports' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Reports</a>
10 </div>
11
12 <div class="flex items-center justify-between mb-4">
13 <div>
14 <h2 class="text-xl font-bold text-gray-100">{{ report.title }}</h2>
15 {% if report.description %}
16 <p class="text-sm text-gray-500 mt-1">{{ report.description }}</p>
17 {% endif %}
18 </div>
19 </div>
20
21 {% if error %}
22 <div class="rounded-lg bg-red-900/30 border border-red-700 p-4 mb-4">
23 <p class="text-sm text-red-300">{{ error }}</p>
24 </div>
25 {% endif %}
26
27 {% if columns %}
28 <div class="rounded-lg bg-gray-800 border border-gray-700 overflow-x-auto">
29 <table class="min-w-full divide-y divide-gray-700">
30 <thead>
31 <tr>
32 {% for col in columns %}
33 <th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">{{ col }}</th>
34 {% endfor %}
35 </tr>
36 </thead>
37 <tbody class="divide-y divide-gray-700">
38 {% for row in rows %}
39 <tr class="hover:bg-gray-750">
40 {% for cell in row %}
41 <td class="px-4 py-3 text-sm text-gray-300 whitespace-nowrap">{{ cell|default:"" }}</td>
42 {% endfor %}
43 </tr>
44 {% endfor %}
45 </tbody>
46 </table>
47 </div>
48 <div class="mt-2 text-xs text-gray-500">
49 {{ rows|length }} row{{ rows|length|pluralize }} returned{% if rows|length >= 1000 %} (limited to 1000){% endif %}
50 </div>
51 {% elif not error %}
52 <div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center">
53 <p class="text-sm text-gray-500">No results.</p>
54 </div>
55 {% endif %}
56 {% endblock %}
--- a/templates/fossil/ticket_reports_list.html
+++ b/templates/fossil/ticket_reports_list.html
@@ -0,0 +1,22 @@
1
+{% extends "base.html" %}
2
+{% block title %}Ticket Reports — {{ 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-ce-y-3">
10
+ {% for repR</div>
11
+</p>
12
+lock title %}Ticket Reports �{% extends "base.html" %}
13
+{% block title %}Ticket Reports — {{ project.name }} — Fossilrepo{% endblock %}
14
+
15
+{% block content %}
16
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
17
+{% include "fossil/_project_nav.html" %}
18
+
19
+<div class="flex items-center justify-between mb-6">
20
+ <h2 class="text-lg font-semibold text-gr</div>
21
+{% endif %}
22
+{% endblock %}
--- a/templates/fossil/ticket_reports_list.html
+++ b/templates/fossil/ticket_reports_list.html
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/ticket_reports_list.html
+++ b/templates/fossil/ticket_reports_list.html
@@ -0,0 +1,22 @@
1 {% extends "base.html" %}
2 {% block title %}Ticket Reports — {{ 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-ce-y-3">
10 {% for repR</div>
11 </p>
12 lock title %}Ticket Reports �{% extends "base.html" %}
13 {% block title %}Ticket Reports — {{ project.name }} — Fossilrepo{% endblock %}
14
15 {% block content %}
16 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
17 {% include "fossil/_project_nav.html" %}
18
19 <div class="flex items-center justify-between mb-6">
20 <h2 class="text-lg font-semibold text-gr</div>
21 {% endif %}
22 {% endblock %}
--- a/tests/test_bundle_cli.py
+++ b/tests/test_bundle_cli.py
@@ -0,0 +1,125 @@
1
+"""Tests for the fossilrepo-ctl bundle export/import commands."""
2
+
3
+from unittest.mock import MagicMock, patch
4
+
5
+import pytest
6
+from click.testing import CliRunner
7
+
8
+from ctl.main import cli
9
+
10
+
11
+@pytest.fixture
12
+def runner():
13
+ return CliRunner()
14
+
15
+
16
+@pytest.mark.django_db
17
+class TestBundleExport:
18
+ def test_export_missing_project(self, runner):
19
+ """Export with a non-existent project slug prints an error."""
20
+ result = runner.invoke(cli, ["bundle", "export", "nonexistent-project", "/tmp/out.bundle"])
21
+ assert result.exit_code == 0
22
+ assert "No repository found" in result.output
23
+
24
+ def test_export_repo_not_on_disk(self, runner, sample_project):
25
+ """Export when the .fossil file does notresult = runner.invoke(cli, ["bundle", "export", sample_project.slug, "/tmp/out.bundle"])
26
+ assert result.exit_code == 0
27
+ assert "not found on disk" in result.output
28
+
29
+ def test_export_fossil_not_available(self, runner, sample_project):
30
+ """Export when fossil binary is not found."""
31
+ from fossil.models import FossilRepository
32
+
33
+ repo = FossilRepository.objects.get(project=sample_project)
34
+
35
+ with (
36
+ patch.object(type(repo), "exists_on_disk", new_callable=lambda: property(lambda self: True)),
37
+ patch("fossil.cli.FossilCLI.is_available", return_value=False),
38
+ ):
39
+ result = runner.invoke(cli, ["bundle", "export", sample_project.slug, "/tmp/out.bundle"])
40
+ assert result.exit_code == 0
41
+ assert "Fossil binary not found" in result.output
42
+
43
+ def test_export_success(self, runner, sample_project, tmp_path):
44
+ """Export succeeds when fossil binary is available and returns 0."""
45
+ from fossil.models import FossilRepository
46
+
47
+ repo = FossilRepository.objects.get(project=sample_project)
48
+ output_path = tmp_path / "test.bundle"
49
+
50
+ mock_run = MagicMock()
51
+ mock_run.returncode = 0
52
+ mock_run.stdout = ""
53
+ mock_run.stderr = ""
54
+
55
+ with (
56
+ patch.object(type(repo), "exists_on_disk", new_callable=lambda: property(lambda self: True)),
57
+ patch("fossil.cli.FossilCLI.is_available", return_value=True),
58
+ patch("subprocess.run", return_value=mock_run),
59
+ ):
60
+ # Create a fake output file so size calculation works
61
+ output_path.write_bytes(b"x" * 1024)
62
+ result = runner.invoke(cli, ["bundle", "export", sample_project.slug, str(output_path)])
63
+ assert result.exit_code == 0
64
+ assert "Success" in result.output
65
+
66
+ def test_export_failure(self, runner, sample_project, tmp_path):
67
+ """Export reports failure when fossil returns non-zero."""
68
+ from fossil.models import FossilRepository
69
+
70
+ repo = FossilRepository.objects.get(project=sample_project)
71
+ output_path = tmp_path / "test.bundle"
72
+
73
+ mock_run = MagicMock()
74
+ mock_run.returncode = 1
75
+ mock_run.stdout = ""
76
+ mock_run.stderr = "bundle export failed"
77
+
78
+ with (
79
+ patch.object(type(repo), "exists_on_disk", new_callable=lambda: property(lambda self: True)),
80
+ patch("fossil.cli.FossilCLI.is_available", return_value=True),
81
+ patch("subprocess.run", return_value=mock_run),
82
+ ):
83
+ result = runner.invoke(cli, ["bundle", "export", sample_project.slug, str(output_path)])
84
+ assert result.exit_code == 0
85
+ assert "Failed" in result.output
86
+
87
+
88
+@pytest.mark.django_db
89
+class TestBundleImport:
90
+ def test_import_missing_project(self, runner):
91
+ """Import with a non-existent project slug prints an error."""
92
+ result = runner.invoke(cli, ["bundle", "import", "nonexistent-project", "/tmp/in.bundle"])
93
+ assert result.exit_code == 0
94
+ assert "No repository found" in result.output
95
+
96
+ def test_import_bundle_file_not_found(self, runner, sample_project):
97
+ """Import when the bundle file does not exist."""
98
+ from fossil.models import FossilRepository
99
+
100
+ repo = FossilRepository.objects.get(project=sample_project)
101
+
102
+ with patch.object(type(repo), "exists_on_disk", new_callable=lambda: property(lambda self: True)):
103
+ result = runner.invoke(cli, ["bundle", "import", sample_project.slug, "/tmp/definitely-not-a-file.bundle"])
104
+ assert result.exit_code == 0
105
+ assert "not found" in result.output.lower()
106
+
107
+ def test_import_success(self, runner, sample_project, tmp_path):
108
+ """Import succeeds when fossil binary is available and returns 0."""
109
+ from fossil.models import FossilRepository
110
+
111
+ repo = FossilRepository.objects.get(project=sample_project)
112
+ bundle_file = tmp_path / "test.bundle"
113
+ bundle_file.write_bytes(b"fake-bundle")
114
+
115
+ mock_run = MagicMock()
116
+ mock_run.returncode = 0
117
+ mock_run.stdout = "imported 42 artifacts"
118
+ mock_run.stderr = ""
119
+
120
+ with (
121
+ patch.object(type(repo), "exists_on_disk", new_callable=lambda: property(lambda self: True)),
122
+ patch("fossil.cli.FossilCLI.is_available", return_value=True),
123
+ patch("subprocess.run", return_value=mock_run),
124
+ ):
125
+ result = runner.invoke(cli, ["bundle", "import", sample_project.slug, str(bundle_file)])
--- a/tests/test_bundle_cli.py
+++ b/tests/test_bundle_cli.py
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_bundle_cli.py
+++ b/tests/test_bundle_cli.py
@@ -0,0 +1,125 @@
1 """Tests for the fossilrepo-ctl bundle export/import commands."""
2
3 from unittest.mock import MagicMock, patch
4
5 import pytest
6 from click.testing import CliRunner
7
8 from ctl.main import cli
9
10
11 @pytest.fixture
12 def runner():
13 return CliRunner()
14
15
16 @pytest.mark.django_db
17 class TestBundleExport:
18 def test_export_missing_project(self, runner):
19 """Export with a non-existent project slug prints an error."""
20 result = runner.invoke(cli, ["bundle", "export", "nonexistent-project", "/tmp/out.bundle"])
21 assert result.exit_code == 0
22 assert "No repository found" in result.output
23
24 def test_export_repo_not_on_disk(self, runner, sample_project):
25 """Export when the .fossil file does notresult = runner.invoke(cli, ["bundle", "export", sample_project.slug, "/tmp/out.bundle"])
26 assert result.exit_code == 0
27 assert "not found on disk" in result.output
28
29 def test_export_fossil_not_available(self, runner, sample_project):
30 """Export when fossil binary is not found."""
31 from fossil.models import FossilRepository
32
33 repo = FossilRepository.objects.get(project=sample_project)
34
35 with (
36 patch.object(type(repo), "exists_on_disk", new_callable=lambda: property(lambda self: True)),
37 patch("fossil.cli.FossilCLI.is_available", return_value=False),
38 ):
39 result = runner.invoke(cli, ["bundle", "export", sample_project.slug, "/tmp/out.bundle"])
40 assert result.exit_code == 0
41 assert "Fossil binary not found" in result.output
42
43 def test_export_success(self, runner, sample_project, tmp_path):
44 """Export succeeds when fossil binary is available and returns 0."""
45 from fossil.models import FossilRepository
46
47 repo = FossilRepository.objects.get(project=sample_project)
48 output_path = tmp_path / "test.bundle"
49
50 mock_run = MagicMock()
51 mock_run.returncode = 0
52 mock_run.stdout = ""
53 mock_run.stderr = ""
54
55 with (
56 patch.object(type(repo), "exists_on_disk", new_callable=lambda: property(lambda self: True)),
57 patch("fossil.cli.FossilCLI.is_available", return_value=True),
58 patch("subprocess.run", return_value=mock_run),
59 ):
60 # Create a fake output file so size calculation works
61 output_path.write_bytes(b"x" * 1024)
62 result = runner.invoke(cli, ["bundle", "export", sample_project.slug, str(output_path)])
63 assert result.exit_code == 0
64 assert "Success" in result.output
65
66 def test_export_failure(self, runner, sample_project, tmp_path):
67 """Export reports failure when fossil returns non-zero."""
68 from fossil.models import FossilRepository
69
70 repo = FossilRepository.objects.get(project=sample_project)
71 output_path = tmp_path / "test.bundle"
72
73 mock_run = MagicMock()
74 mock_run.returncode = 1
75 mock_run.stdout = ""
76 mock_run.stderr = "bundle export failed"
77
78 with (
79 patch.object(type(repo), "exists_on_disk", new_callable=lambda: property(lambda self: True)),
80 patch("fossil.cli.FossilCLI.is_available", return_value=True),
81 patch("subprocess.run", return_value=mock_run),
82 ):
83 result = runner.invoke(cli, ["bundle", "export", sample_project.slug, str(output_path)])
84 assert result.exit_code == 0
85 assert "Failed" in result.output
86
87
88 @pytest.mark.django_db
89 class TestBundleImport:
90 def test_import_missing_project(self, runner):
91 """Import with a non-existent project slug prints an error."""
92 result = runner.invoke(cli, ["bundle", "import", "nonexistent-project", "/tmp/in.bundle"])
93 assert result.exit_code == 0
94 assert "No repository found" in result.output
95
96 def test_import_bundle_file_not_found(self, runner, sample_project):
97 """Import when the bundle file does not exist."""
98 from fossil.models import FossilRepository
99
100 repo = FossilRepository.objects.get(project=sample_project)
101
102 with patch.object(type(repo), "exists_on_disk", new_callable=lambda: property(lambda self: True)):
103 result = runner.invoke(cli, ["bundle", "import", sample_project.slug, "/tmp/definitely-not-a-file.bundle"])
104 assert result.exit_code == 0
105 assert "not found" in result.output.lower()
106
107 def test_import_success(self, runner, sample_project, tmp_path):
108 """Import succeeds when fossil binary is available and returns 0."""
109 from fossil.models import FossilRepository
110
111 repo = FossilRepository.objects.get(project=sample_project)
112 bundle_file = tmp_path / "test.bundle"
113 bundle_file.write_bytes(b"fake-bundle")
114
115 mock_run = MagicMock()
116 mock_run.returncode = 0
117 mock_run.stdout = "imported 42 artifacts"
118 mock_run.stderr = ""
119
120 with (
121 patch.object(type(repo), "exists_on_disk", new_callable=lambda: property(lambda self: True)),
122 patch("fossil.cli.FossilCLI.is_available", return_value=True),
123 patch("subprocess.run", return_value=mock_run),
124 ):
125 result = runner.invoke(cli, ["bundle", "import", sample_project.slug, str(bundle_file)])
--- a/tests/test_notification_prefs.py
+++ b/tests/test_notification_prefs.py
@@ -0,0 +1,244 @@
1
+from unittest.mock import patch
2
+
3
+import pytest
4
+from django.contrib.auth.models import User
5
+
6
+from fossil.notifications import Notification, NotificationPreference
7
+
8
+# --- NotificationPreference Model Tests ---
9
+
10
+
11
+@pytest.mark.django_db
12
+class TestNotificationPreferenceModel:
13
+ def test_create_preference(self, admin_user):
14
+ pref = NotificationPreference.objects.create(user=admin_user)
15
+ assert pref.pk is not None
16
+ assert pref.delivery_mode == "immediate"
17
+ assert pref.notify_checkins is True
18
+ assert pref.notify_tickets is True
19
+ assert pref.notify_wiki is True
20
+ assert pref.notify_releases is True
21
+ assert pref.notify_forum is False
22
+
23
+ def test_str_repr(self, admin_user):
24
+ pref = NotificationPreference.objects.create(user=admin_user, delivery_mode="daily")
25
+ assert str(pref) == "admin: daily"
26
+
27
+ def test_one_to_one_constraint(self, admin_user):
28
+ NotificationPreference.objects.create(user=admin_user)
29
+ from django.db import IntegrityError
30
+
31
+ with pytest.raises(IntegrityError):
32
+ NotificationPreference.objects.create(user=admin_user)
33
+
34
+ def test_delivery_mode_choices(self, admin_user):
35
+ for mode in ["immediate", "daily", "weekly", "off"]:
36
+ pref, _ = NotificationPreference.objects.update_or_create(user=admin_user, defaults={"delivery_mode": mode})
37
+ pref.refresh_from_db()
38
+ assert pref.delivery_mode == mode
39
+
40
+
41
+# --- Notification Preferences View Tests ---
42
+
43
+
44
+@pytest.mark.django_db
45
+class TestNotificationPreferencesView:
46
+ def test_get_creates_default_prefs(self, admin_client, admin_user):
47
+ assert not NotificationPreference.objects.filter(user=admin_user).exists()
48
+ response = admin_client.get("/auth/notifications/")
49
+ assert response.status_code == 200
50
+ assert "Notification Preferences" in response.content.decode()
51
+ assert NotificationPreference.objects.filter(user=admin_user).exists()
52
+
53
+ def test_get_renders_existing_prefs(self, admin_client, admin_user):
54
+ NotificationPreference.objects.create(user=admin_user, delivery_mode="daily", notify_forum=True)
55
+ response = admin_client.get("/auth/notifications/")
56
+ assert response.status_code == 200
57
+ content = response.content.decode()
58
+ assert "Notification Preferences" in content
59
+
60
+ def test_post_updates_delivery_mode(self, admin_client, admin_user):
61
+ NotificationPreference.objects.create(user=admin_user)
62
+ response = admin_client.post(
63
+ "/auth/notifications/",
64
+ {
65
+ "delivery_mode": "daily",
66
+ "notify_checkins": "on",
67
+ "notify_tickets": "on",
68
+ },
69
+ )
70
+ assert response.status_code == 302
71
+ pref = NotificationPreference.objects.get(user=admin_user)
72
+ assert pref.delivery_mode == "daily"
73
+ assert pref.notify_checkins is True
74
+ assert pref.notify_tickets is True
75
+ assert pref.notify_wiki is False
76
+ assert pref.notify_releases is False
77
+ assert pref.notify_forum is False
78
+
79
+ def test_post_updates_event_toggles(self, admin_client, admin_user):
80
+ NotificationPreference.objects.create(user=admin_user)
81
+ response = admin_client.post(
82
+ "/auth/notifications/",
83
+ {
84
+ "delivery_mode": "weekly",
85
+ "notify_checkins": "on",
86
+ "notify_tickets": "on",
87
+ "notify_wiki": "on",
88
+ "notify_releases": "on",
89
+ "notify_forum": "on",
90
+ },
91
+ )
92
+ assert response.status_code == 302
93
+ pref = NotificationPreference.objects.get(user=admin_user)
94
+ assert pref.delivery_mode == "weekly"
95
+ assert pref.notify_checkins is True
96
+ assert pref.notify_tickets is True
97
+ assert pref.notify_wiki is True
98
+ assert pref.notify_releases is True
99
+ assert pref.notify_forum is True
100
+
101
+ def test_post_turn_off(self, admin_client, admin_user):
102
+ NotificationPreference.objects.create(user=admin_user, delivery_mode="daily")
103
+ response = admin_client.post(
104
+ "/auth/notifications/",
105
+ {
106
+ "delivery_mode": "off",
107
+ },
108
+ )
109
+ assert response.status_code == 302
110
+ pref = NotificationPreference.objects.get(user=admin_user)
111
+ assert pref.delivery_mode == "off"
112
+ # All unchecked checkboxes default to False
113
+ assert pref.notify_checkins is False
114
+ assert pref.notify_tickets is False
115
+
116
+ def test_denied_for_anon(self, client):
117
+ response = client.get("/auth/notifications/")
118
+ assert response.status_code == 302 # redirect to login
119
+
120
+
121
+# --- Digest Task Tests ---
122
+
123
+
124
+@pytest.mark.django_db
125
+class TestSendDigestTask:
126
+ @pytest.fixture
127
+ def daily_user(self, db):
128
+ user = User.objects.create_user(username="dailyuser", email="[email protected]", password="testpass123")
129
+ NotificationPreference.objects.create(user=user, delivery_mode="daily")
130
+ return user
131
+
132
+ @pytest.fixture
133
+ def weekly_user(self, db):
134
+ user = User.objects.create_user(username="weeklyuser", email="[email protected]", password="testpass123")
135
+ NotificationPreference.objects.create(user=user, delivery_mode="weekly")
136
+ return user
137
+
138
+ @pytest.fixture
139
+ def immediate_user(self, db):
140
+ user = User.objects.create_user(username="immediateuser", email="[email protected]", password="testpass123")
141
+ NotificationPreference.objects.create(user=user, delivery_mode="immediate")
142
+ return user
143
+
144
+ def test_daily_digest_sends_email(self, daily_user, sample_project):
145
+ # Create unread notifications
146
+ for i in range(3):
147
+ Notification.objects.create(
148
+ user=daily_user,
149
+ project=sample_project,
150
+ event_type="checkin",
151
+ title=f"Commit #{i}",
152
+ )
153
+
154
+ from fossil.tasks import send_digest
155
+
156
+ with patch("django.core.mail.send_mail") as mock_send:
157
+ send_digest.apply(kwargs={"mode": "daily"})
158
+
159
+ mock_send.assert_called_once()
160
+ call_kwargs = mock_send.call_args
161
+ assert "Daily" in call_kwargs[1]["subject"] or "Daily" in call_kwargs[0][0]
162
+ assert daily_user.email in (call_kwargs[1].get("recipient_list") or call_kwargs[0][3])
163
+
164
+ # Notifications marked as read
165
+ assert Notification.objects.filter(user=daily_user, read=False).count() == 0
166
+
167
+ def test_weekly_digest_sends_email(self, weekly_user, sample_project):
168
+ Notification.objects.create(
169
+ user=weekly_user,
170
+ project=sample_project,
171
+ event_type="ticket",
172
+ title="New ticket",
173
+ )
174
+
175
+ from fossil.tasks import send_digest
176
+
177
+ with patch("django.core.mail.send_mail") as mock_send:
178
+ send_digest.apply(kwargs={"mode": "weekly"})
179
+
180
+ mock_send.assert_called_once()
181
+
182
+ def test_no_email_for_immediate_users(self, immediate_user, sample_project):
183
+ Notification.objects.create(
184
+ user=immediate_user,
185
+ project=sample_project,
186
+ event_type="checkin",
187
+ title="Commit",
188
+ )
189
+
190
+ from fossil.tasks import send_digest
191
+
192
+ with patch("django.core.mail.send_mail") as mock_send:
193
+ send_digest.apply(kwargs={"mode": "daily"})
194
+
195
+ mock_send.assert_not_called()
196
+
197
+ def test_no_email_when_no_unread(self, daily_user, sample_project):
198
+ # Create read notifications
199
+ Notification.objects.create(
200
+ user=daily_user,
201
+ project=sample_project,
202
+ event_type="checkin",
203
+ title="Old commit",
204
+ read=True,
205
+ )
206
+
207
+ from fossil.tasks import send_digest
208
+
209
+ with patch("django.core.mail.send_mail") as mock_send:
210
+ send_digest.apply(kwargs={"mode": "daily"})
211
+
212
+ mock_send.assert_not_called()
213
+
214
+ def test_digest_limits_to_50_notifications(self, daily_user, sample_project):
215
+ for i in range(55):
216
+ Notification.objects.create(
217
+ user=daily_user,
218
+ project=sample_project,
219
+ event_type="checkin",
220
+ title=f"Commit #{i}",
221
+ )
222
+
223
+ from fossil.tasks import send_digest
224
+
225
+ with patch("django.core.mail.send_mail") as mock_send:
226
+ send_digest.apply(kwargs={"mode": "daily"})
227
+
228
+ mock_send.assert_called_once()
229
+ call_args = mock_send.call_args
230
+ message = call_args[1].get("message") or call_args[0][1]
231
+ assert "55 new notifications" in message
232
+ assert "and 5 more" in message
233
+
234
+ # All 55 marked as read
235
+ assert Notification.objects.filter(user=daily_user, read=False).count() == 0
236
+
237
+ def test_digest_no_users_with_mode(self):
238
+ """When no users have the requested delivery mode, task completes without error."""
239
+ from fossil.tasks import send_digest
240
+
241
+ with patch("django.core.mail.send_mail") as mock_send:
242
+ send_digest.apply(kwargs={"mode": "daily"})
243
+
244
+ mock_send.assert_not_called()
--- a/tests/test_notification_prefs.py
+++ b/tests/test_notification_prefs.py
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_notification_prefs.py
+++ b/tests/test_notification_prefs.py
@@ -0,0 +1,244 @@
1 from unittest.mock import patch
2
3 import pytest
4 from django.contrib.auth.models import User
5
6 from fossil.notifications import Notification, NotificationPreference
7
8 # --- NotificationPreference Model Tests ---
9
10
11 @pytest.mark.django_db
12 class TestNotificationPreferenceModel:
13 def test_create_preference(self, admin_user):
14 pref = NotificationPreference.objects.create(user=admin_user)
15 assert pref.pk is not None
16 assert pref.delivery_mode == "immediate"
17 assert pref.notify_checkins is True
18 assert pref.notify_tickets is True
19 assert pref.notify_wiki is True
20 assert pref.notify_releases is True
21 assert pref.notify_forum is False
22
23 def test_str_repr(self, admin_user):
24 pref = NotificationPreference.objects.create(user=admin_user, delivery_mode="daily")
25 assert str(pref) == "admin: daily"
26
27 def test_one_to_one_constraint(self, admin_user):
28 NotificationPreference.objects.create(user=admin_user)
29 from django.db import IntegrityError
30
31 with pytest.raises(IntegrityError):
32 NotificationPreference.objects.create(user=admin_user)
33
34 def test_delivery_mode_choices(self, admin_user):
35 for mode in ["immediate", "daily", "weekly", "off"]:
36 pref, _ = NotificationPreference.objects.update_or_create(user=admin_user, defaults={"delivery_mode": mode})
37 pref.refresh_from_db()
38 assert pref.delivery_mode == mode
39
40
41 # --- Notification Preferences View Tests ---
42
43
44 @pytest.mark.django_db
45 class TestNotificationPreferencesView:
46 def test_get_creates_default_prefs(self, admin_client, admin_user):
47 assert not NotificationPreference.objects.filter(user=admin_user).exists()
48 response = admin_client.get("/auth/notifications/")
49 assert response.status_code == 200
50 assert "Notification Preferences" in response.content.decode()
51 assert NotificationPreference.objects.filter(user=admin_user).exists()
52
53 def test_get_renders_existing_prefs(self, admin_client, admin_user):
54 NotificationPreference.objects.create(user=admin_user, delivery_mode="daily", notify_forum=True)
55 response = admin_client.get("/auth/notifications/")
56 assert response.status_code == 200
57 content = response.content.decode()
58 assert "Notification Preferences" in content
59
60 def test_post_updates_delivery_mode(self, admin_client, admin_user):
61 NotificationPreference.objects.create(user=admin_user)
62 response = admin_client.post(
63 "/auth/notifications/",
64 {
65 "delivery_mode": "daily",
66 "notify_checkins": "on",
67 "notify_tickets": "on",
68 },
69 )
70 assert response.status_code == 302
71 pref = NotificationPreference.objects.get(user=admin_user)
72 assert pref.delivery_mode == "daily"
73 assert pref.notify_checkins is True
74 assert pref.notify_tickets is True
75 assert pref.notify_wiki is False
76 assert pref.notify_releases is False
77 assert pref.notify_forum is False
78
79 def test_post_updates_event_toggles(self, admin_client, admin_user):
80 NotificationPreference.objects.create(user=admin_user)
81 response = admin_client.post(
82 "/auth/notifications/",
83 {
84 "delivery_mode": "weekly",
85 "notify_checkins": "on",
86 "notify_tickets": "on",
87 "notify_wiki": "on",
88 "notify_releases": "on",
89 "notify_forum": "on",
90 },
91 )
92 assert response.status_code == 302
93 pref = NotificationPreference.objects.get(user=admin_user)
94 assert pref.delivery_mode == "weekly"
95 assert pref.notify_checkins is True
96 assert pref.notify_tickets is True
97 assert pref.notify_wiki is True
98 assert pref.notify_releases is True
99 assert pref.notify_forum is True
100
101 def test_post_turn_off(self, admin_client, admin_user):
102 NotificationPreference.objects.create(user=admin_user, delivery_mode="daily")
103 response = admin_client.post(
104 "/auth/notifications/",
105 {
106 "delivery_mode": "off",
107 },
108 )
109 assert response.status_code == 302
110 pref = NotificationPreference.objects.get(user=admin_user)
111 assert pref.delivery_mode == "off"
112 # All unchecked checkboxes default to False
113 assert pref.notify_checkins is False
114 assert pref.notify_tickets is False
115
116 def test_denied_for_anon(self, client):
117 response = client.get("/auth/notifications/")
118 assert response.status_code == 302 # redirect to login
119
120
121 # --- Digest Task Tests ---
122
123
124 @pytest.mark.django_db
125 class TestSendDigestTask:
126 @pytest.fixture
127 def daily_user(self, db):
128 user = User.objects.create_user(username="dailyuser", email="[email protected]", password="testpass123")
129 NotificationPreference.objects.create(user=user, delivery_mode="daily")
130 return user
131
132 @pytest.fixture
133 def weekly_user(self, db):
134 user = User.objects.create_user(username="weeklyuser", email="[email protected]", password="testpass123")
135 NotificationPreference.objects.create(user=user, delivery_mode="weekly")
136 return user
137
138 @pytest.fixture
139 def immediate_user(self, db):
140 user = User.objects.create_user(username="immediateuser", email="[email protected]", password="testpass123")
141 NotificationPreference.objects.create(user=user, delivery_mode="immediate")
142 return user
143
144 def test_daily_digest_sends_email(self, daily_user, sample_project):
145 # Create unread notifications
146 for i in range(3):
147 Notification.objects.create(
148 user=daily_user,
149 project=sample_project,
150 event_type="checkin",
151 title=f"Commit #{i}",
152 )
153
154 from fossil.tasks import send_digest
155
156 with patch("django.core.mail.send_mail") as mock_send:
157 send_digest.apply(kwargs={"mode": "daily"})
158
159 mock_send.assert_called_once()
160 call_kwargs = mock_send.call_args
161 assert "Daily" in call_kwargs[1]["subject"] or "Daily" in call_kwargs[0][0]
162 assert daily_user.email in (call_kwargs[1].get("recipient_list") or call_kwargs[0][3])
163
164 # Notifications marked as read
165 assert Notification.objects.filter(user=daily_user, read=False).count() == 0
166
167 def test_weekly_digest_sends_email(self, weekly_user, sample_project):
168 Notification.objects.create(
169 user=weekly_user,
170 project=sample_project,
171 event_type="ticket",
172 title="New ticket",
173 )
174
175 from fossil.tasks import send_digest
176
177 with patch("django.core.mail.send_mail") as mock_send:
178 send_digest.apply(kwargs={"mode": "weekly"})
179
180 mock_send.assert_called_once()
181
182 def test_no_email_for_immediate_users(self, immediate_user, sample_project):
183 Notification.objects.create(
184 user=immediate_user,
185 project=sample_project,
186 event_type="checkin",
187 title="Commit",
188 )
189
190 from fossil.tasks import send_digest
191
192 with patch("django.core.mail.send_mail") as mock_send:
193 send_digest.apply(kwargs={"mode": "daily"})
194
195 mock_send.assert_not_called()
196
197 def test_no_email_when_no_unread(self, daily_user, sample_project):
198 # Create read notifications
199 Notification.objects.create(
200 user=daily_user,
201 project=sample_project,
202 event_type="checkin",
203 title="Old commit",
204 read=True,
205 )
206
207 from fossil.tasks import send_digest
208
209 with patch("django.core.mail.send_mail") as mock_send:
210 send_digest.apply(kwargs={"mode": "daily"})
211
212 mock_send.assert_not_called()
213
214 def test_digest_limits_to_50_notifications(self, daily_user, sample_project):
215 for i in range(55):
216 Notification.objects.create(
217 user=daily_user,
218 project=sample_project,
219 event_type="checkin",
220 title=f"Commit #{i}",
221 )
222
223 from fossil.tasks import send_digest
224
225 with patch("django.core.mail.send_mail") as mock_send:
226 send_digest.apply(kwargs={"mode": "daily"})
227
228 mock_send.assert_called_once()
229 call_args = mock_send.call_args
230 message = call_args[1].get("message") or call_args[0][1]
231 assert "55 new notifications" in message
232 assert "and 5 more" in message
233
234 # All 55 marked as read
235 assert Notification.objects.filter(user=daily_user, read=False).count() == 0
236
237 def test_digest_no_users_with_mode(self):
238 """When no users have the requested delivery mode, task completes without error."""
239 from fossil.tasks import send_digest
240
241 with patch("django.core.mail.send_mail") as mock_send:
242 send_digest.apply(kwargs={"mode": "daily"})
243
244 mock_send.assert_not_called()
--- a/tests/test_shunning.py
+++ b/tests/test_shunning.py
@@ -0,0 +1,226 @@
1
+from unittest.mock import MagicMock, patch
2
+
3
+import pytest
4
+from django.contrib.auth.models import User
5
+from django.test import Client
6
+
7
+from fossil.models import FossilRepository
8
+from organization.models import Team
9
+from projects.models import ProjectTeam
10
+
11
+
12
+@pytest.fixture
13
+def fossil_repo_obj(sample_project):
14
+ """Return the auto-created FossilRepository for sample_project."""
15
+ return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
16
+
17
+
18
+@pytest.fixture
19
+def writer_user(db, admin_user, sample_project):
20
+ """User with write access but not admin."""
21
+ writer = User.objects.create_user(username="writer", password="testpass123")
22
+ team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user)
23
+ team.members.add(writer)
24
+ ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
25
+ return writer
26
+
27
+
28
+@pytest.fixture
29
+def writer_client(writer_user):
30
+ client = Client()
31
+ client.login(username="writer", password="testpass123")
32
+ return client
33
+
34
+
35
+# --- Shun List View Tests ---
36
+
37
+
38
+@pytest.mark.django_db
39
+class TestShunListView:
40
+ def test_list_shunned_as_admin(self, admin_client, sample_project, fossil_repo_obj):
41
+ with patch("fossil.cli.FossilCLI") as mock_cli_cls:
42
+ cli_instance = MagicMock()
43
+ cli_instance.is_available.return_value = True
44
+ cli_instance.shun_list.return_value = ["abc123def456", "789012345678"]
45
+ mock_cli_cls.return_value = cli_instance
46
+ # Patch exists_on_disk
47
+ with patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=lambda: property(lambda self: True)):
48
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/admin/shun/")
49
+ assert response.status_code == 200
50
+ content = response.content.decode()
51
+ assert "Shunned Artifacts" in content
52
+
53
+ def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
54
+ with patch("fossil.cli.FossilCLI") as mock_cli_cls:
55
+ cli_instance = MagicMock()
56
+ cli_instance.is_available.return_value = True
57
+ cli_instance.shun_list.return_value = []
58
+ mock_cli_cls.return_value = cli_instance
59
+ with patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=lambda: property(lambda self: True)):
60
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/admin/shun/")
61
+ assert response.status_code == 200
62
+ assert "No artifacts have been shunned" in response.content.decode()
63
+
64
+ def test_list_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj):
65
+ response = writer_client.get(f"/projects/{sample_project.slug}/fossil/admin/shun/")
66
+ assert response.status_code == 403
67
+
68
+ def test_list_denied_for_no_perm(self, no_perm_client, sample_project):
69
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/admin/shun/")
70
+ assert response.status_code == 403
71
+
72
+ def test_list_denied_for_anon(self, client, sample_project):
73
+ response = client.get(f"/projects/{sample_project.slug}/fossil/admin/shun/")
74
+ assert response.status_code == 302 # redirect to login
75
+
76
+
77
+# --- Shun Artifact View Tests ---
78
+
79
+
80
+@pytest.mark.django_db
81
+class TestShunArtifactView:
82
+ def test_shun_artifact_success(self, admin_client, sample_project, fossil_repo_obj):
83
+ with patch("fossil.cli.FossilCLI") as mock_cli_cls:
84
+ cli_instance = MagicMock()
85
+ cli_instance.is_available.return_value = True
86
+ cli_instance.shun.return_value = {"success": True, "message": "Artifact shunned"}
87
+ mock_cli_cls.return_value = cli_instance
88
+ with patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=lambda: property(lambda self: True)):
89
+ response = admin_client.post(
90
+ f"/projects/{sample_project.slug}/fossil/admin/shun/add/",
91
+ {
92
+ "artifact_uuid": "a1b2c3d4e5f67890",
93
+ "confirmation": "a1b2c3d4",
94
+ "reason": "Leaked secret",
95
+ },
96
+ )
97
+ assert response.status_code == 302
98
+ assert response.url == f"/projects/{sample_project.slug}/fossil/admin/shun/"
99
+
100
+ def test_shun_requires_confirmation(self, admin_client, sample_project, fossil_repo_obj):
101
+ response = admin_client.post(
102
+ f"/projects/{sample_project.slug}/fossil/admin/shun/add/",
103
+ {
104
+ "artifact_uuid": "a1b2c3d4e5f67890",
105
+ "confirmation": "wrong",
106
+ "reason": "test",
107
+ },
108
+ )
109
+ assert response.status_code == 302 # redirects with error message
110
+
111
+ def test_shun_empty_uuid_rejected(self, admin_client, sample_project, fossil_repo_obj):
112
+ response = admin_client.post(
113
+ f"/projects/{sample_project.slug}/fossil/admin/shun/add/",
114
+ {
115
+ "artifact_uuid": "",
116
+ "confirmation": "",
117
+ },
118
+ )
119
+ assert response.status_code == 302
120
+
121
+ def test_shun_invalid_uuid_format(self, admin_client, sample_project, fossil_repo_obj):
122
+ response = admin_client.post(
123
+ f"/projects/{sample_project.slug}/fossil/admin/shun/add/",
124
+ {
125
+ "artifact_uuid": "not-a-hex-hash!!!",
126
+ "confirmation": "not-a-he",
127
+ },
128
+ )
129
+ assert response.status_code == 302
130
+
131
+ def test_shun_get_redirects(self, admin_client, sample_project, fossil_repo_obj):
132
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/admin/shun/add/")
133
+ assert response.status_code == 302
134
+
135
+ def test_shun_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj):
136
+ response = writer_client.post(
137
+ f"/projects/{sample_project.slug}/fossil/admin/shun/add/",
138
+ {
139
+ "artifact_uuid": "a1b2c3d4e5f67890",
140
+ "confirmation": "a1b2c3d4",
141
+ },
142
+ )
143
+ assert response.status_code == 403
144
+
145
+ def test_shun_denied_for_anon(self, client, sample_project):
146
+ response = client.post(
147
+ f"/projects/{sample_project.slug}/fossil/admin/shun/add/",
148
+ {
149
+ "artifact_uuid": "a1b2c3d4e5f67890",
150
+ "confirmation": "a1b2c3d4",
151
+ },
152
+ )
153
+ assert response.status_code == 302 # redirect to login
154
+
155
+ def test_shun_cli_failure(self, admin_client, sample_project, fossil_repo_obj):
156
+ with patch("fossil.cli.FossilCLI") as mock_cli_cls:
157
+ cli_instance = MagicMock()
158
+ cli_instance.is_available.return_value = True
159
+ cli_instance.shun.return_value = {"success": False, "message": "Unknown artifact"}
160
+ mock_cli_cls.return_value = cli_instance
161
+ with patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=lambda: property(lambda self: True)):
162
+ response = admin_client.post(
163
+ f"/projects/{sample_project.slug}/fossil/admin/shun/add/",
164
+ {
165
+ "artifact_uuid": "a1b2c3d4e5f67890",
166
+ "confirmation": "a1b2c3d4",
167
+ "reason": "test",
168
+ },
169
+ )
170
+ assert response.status_code == 302
171
+
172
+
173
+# --- CLI Shun Method Tests ---
174
+
175
+
176
+@pytest.mark.django_db
177
+class TestFossilCLIShun:
178
+ def test_shun_calls_fossil_binary(self):
179
+ from fossil.cli import FossilCLI
180
+
181
+ cli = FossilCLI(binary="/usr/bin/fossil")
182
+ with patch("subprocess.run") as mock_run:
183
+ mock_run.return_value = MagicMock(returncode=0, stdout="Artifact shunned\n", stderr="")
184
+ result = cli.shun("/tmp/test.fossil", "abc123def456", reason="test")
185
+ assert result["success"] is True
186
+ assert "Artifact shunned" in result["message"]
187
+ call_args = mock_run.call_args[0][0]
188
+ assert "shun" in call_args
189
+ assert "abc123def456" in call_args
190
+
191
+ def test_shun_returns_failure(self):
192
+ from fossil.cli import FossilCLI
193
+
194
+ cli = FossilCLI(binary="/usr/bin/fossil")
195
+ with patch("subprocess.run") as mock_run:
196
+ mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Not found")
197
+ result = cli.shun("/tmp/test.fossil", "nonexistent")
198
+ assert result["success"] is False
199
+ assert "Not found" in result["message"]
200
+
201
+ def test_shun_list_returns_uuids(self):
202
+ from fossil.cli import FossilCLI
203
+
204
+ cli = FossilCLI(binary="/usr/bin/fossil")
205
+ with patch("subprocess.run") as mock_run:
206
+ mock_run.return_value = MagicMock(returncode=0, stdout="abc123\ndef456\n", stderr="")
207
+ result = cli.shun_list("/tmp/test.fossil")
208
+ assert result == ["abc123", "def456"]
209
+
210
+ def test_shun_list_empty(self):
211
+ from fossil.cli import FossilCLI
212
+
213
+ cli = FossilCLI(binary="/usr/bin/fossil")
214
+ with patch("subprocess.run") as mock_run:
215
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
216
+ result = cli.shun_list("/tmp/test.fossil")
217
+ assert result == []
218
+
219
+ def test_shun_list_failure_returns_empty(self):
220
+ from fossil.cli import FossilCLI
221
+
222
+ cli = FossilCLI(binary="/usr/bin/fossil")
223
+ with patch("subprocess.run") as mock_run:
224
+ mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
225
+ result = cli.shun_list("/tmp/test.fossil")
226
+ assert result == []
--- a/tests/test_shunning.py
+++ b/tests/test_shunning.py
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_shunning.py
+++ b/tests/test_shunning.py
@@ -0,0 +1,226 @@
1 from unittest.mock import MagicMock, patch
2
3 import pytest
4 from django.contrib.auth.models import User
5 from django.test import Client
6
7 from fossil.models import FossilRepository
8 from organization.models import Team
9 from projects.models import ProjectTeam
10
11
12 @pytest.fixture
13 def fossil_repo_obj(sample_project):
14 """Return the auto-created FossilRepository for sample_project."""
15 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
16
17
18 @pytest.fixture
19 def writer_user(db, admin_user, sample_project):
20 """User with write access but not admin."""
21 writer = User.objects.create_user(username="writer", password="testpass123")
22 team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user)
23 team.members.add(writer)
24 ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
25 return writer
26
27
28 @pytest.fixture
29 def writer_client(writer_user):
30 client = Client()
31 client.login(username="writer", password="testpass123")
32 return client
33
34
35 # --- Shun List View Tests ---
36
37
38 @pytest.mark.django_db
39 class TestShunListView:
40 def test_list_shunned_as_admin(self, admin_client, sample_project, fossil_repo_obj):
41 with patch("fossil.cli.FossilCLI") as mock_cli_cls:
42 cli_instance = MagicMock()
43 cli_instance.is_available.return_value = True
44 cli_instance.shun_list.return_value = ["abc123def456", "789012345678"]
45 mock_cli_cls.return_value = cli_instance
46 # Patch exists_on_disk
47 with patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=lambda: property(lambda self: True)):
48 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/admin/shun/")
49 assert response.status_code == 200
50 content = response.content.decode()
51 assert "Shunned Artifacts" in content
52
53 def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
54 with patch("fossil.cli.FossilCLI") as mock_cli_cls:
55 cli_instance = MagicMock()
56 cli_instance.is_available.return_value = True
57 cli_instance.shun_list.return_value = []
58 mock_cli_cls.return_value = cli_instance
59 with patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=lambda: property(lambda self: True)):
60 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/admin/shun/")
61 assert response.status_code == 200
62 assert "No artifacts have been shunned" in response.content.decode()
63
64 def test_list_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj):
65 response = writer_client.get(f"/projects/{sample_project.slug}/fossil/admin/shun/")
66 assert response.status_code == 403
67
68 def test_list_denied_for_no_perm(self, no_perm_client, sample_project):
69 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/admin/shun/")
70 assert response.status_code == 403
71
72 def test_list_denied_for_anon(self, client, sample_project):
73 response = client.get(f"/projects/{sample_project.slug}/fossil/admin/shun/")
74 assert response.status_code == 302 # redirect to login
75
76
77 # --- Shun Artifact View Tests ---
78
79
80 @pytest.mark.django_db
81 class TestShunArtifactView:
82 def test_shun_artifact_success(self, admin_client, sample_project, fossil_repo_obj):
83 with patch("fossil.cli.FossilCLI") as mock_cli_cls:
84 cli_instance = MagicMock()
85 cli_instance.is_available.return_value = True
86 cli_instance.shun.return_value = {"success": True, "message": "Artifact shunned"}
87 mock_cli_cls.return_value = cli_instance
88 with patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=lambda: property(lambda self: True)):
89 response = admin_client.post(
90 f"/projects/{sample_project.slug}/fossil/admin/shun/add/",
91 {
92 "artifact_uuid": "a1b2c3d4e5f67890",
93 "confirmation": "a1b2c3d4",
94 "reason": "Leaked secret",
95 },
96 )
97 assert response.status_code == 302
98 assert response.url == f"/projects/{sample_project.slug}/fossil/admin/shun/"
99
100 def test_shun_requires_confirmation(self, admin_client, sample_project, fossil_repo_obj):
101 response = admin_client.post(
102 f"/projects/{sample_project.slug}/fossil/admin/shun/add/",
103 {
104 "artifact_uuid": "a1b2c3d4e5f67890",
105 "confirmation": "wrong",
106 "reason": "test",
107 },
108 )
109 assert response.status_code == 302 # redirects with error message
110
111 def test_shun_empty_uuid_rejected(self, admin_client, sample_project, fossil_repo_obj):
112 response = admin_client.post(
113 f"/projects/{sample_project.slug}/fossil/admin/shun/add/",
114 {
115 "artifact_uuid": "",
116 "confirmation": "",
117 },
118 )
119 assert response.status_code == 302
120
121 def test_shun_invalid_uuid_format(self, admin_client, sample_project, fossil_repo_obj):
122 response = admin_client.post(
123 f"/projects/{sample_project.slug}/fossil/admin/shun/add/",
124 {
125 "artifact_uuid": "not-a-hex-hash!!!",
126 "confirmation": "not-a-he",
127 },
128 )
129 assert response.status_code == 302
130
131 def test_shun_get_redirects(self, admin_client, sample_project, fossil_repo_obj):
132 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/admin/shun/add/")
133 assert response.status_code == 302
134
135 def test_shun_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj):
136 response = writer_client.post(
137 f"/projects/{sample_project.slug}/fossil/admin/shun/add/",
138 {
139 "artifact_uuid": "a1b2c3d4e5f67890",
140 "confirmation": "a1b2c3d4",
141 },
142 )
143 assert response.status_code == 403
144
145 def test_shun_denied_for_anon(self, client, sample_project):
146 response = client.post(
147 f"/projects/{sample_project.slug}/fossil/admin/shun/add/",
148 {
149 "artifact_uuid": "a1b2c3d4e5f67890",
150 "confirmation": "a1b2c3d4",
151 },
152 )
153 assert response.status_code == 302 # redirect to login
154
155 def test_shun_cli_failure(self, admin_client, sample_project, fossil_repo_obj):
156 with patch("fossil.cli.FossilCLI") as mock_cli_cls:
157 cli_instance = MagicMock()
158 cli_instance.is_available.return_value = True
159 cli_instance.shun.return_value = {"success": False, "message": "Unknown artifact"}
160 mock_cli_cls.return_value = cli_instance
161 with patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=lambda: property(lambda self: True)):
162 response = admin_client.post(
163 f"/projects/{sample_project.slug}/fossil/admin/shun/add/",
164 {
165 "artifact_uuid": "a1b2c3d4e5f67890",
166 "confirmation": "a1b2c3d4",
167 "reason": "test",
168 },
169 )
170 assert response.status_code == 302
171
172
173 # --- CLI Shun Method Tests ---
174
175
176 @pytest.mark.django_db
177 class TestFossilCLIShun:
178 def test_shun_calls_fossil_binary(self):
179 from fossil.cli import FossilCLI
180
181 cli = FossilCLI(binary="/usr/bin/fossil")
182 with patch("subprocess.run") as mock_run:
183 mock_run.return_value = MagicMock(returncode=0, stdout="Artifact shunned\n", stderr="")
184 result = cli.shun("/tmp/test.fossil", "abc123def456", reason="test")
185 assert result["success"] is True
186 assert "Artifact shunned" in result["message"]
187 call_args = mock_run.call_args[0][0]
188 assert "shun" in call_args
189 assert "abc123def456" in call_args
190
191 def test_shun_returns_failure(self):
192 from fossil.cli import FossilCLI
193
194 cli = FossilCLI(binary="/usr/bin/fossil")
195 with patch("subprocess.run") as mock_run:
196 mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Not found")
197 result = cli.shun("/tmp/test.fossil", "nonexistent")
198 assert result["success"] is False
199 assert "Not found" in result["message"]
200
201 def test_shun_list_returns_uuids(self):
202 from fossil.cli import FossilCLI
203
204 cli = FossilCLI(binary="/usr/bin/fossil")
205 with patch("subprocess.run") as mock_run:
206 mock_run.return_value = MagicMock(returncode=0, stdout="abc123\ndef456\n", stderr="")
207 result = cli.shun_list("/tmp/test.fossil")
208 assert result == ["abc123", "def456"]
209
210 def test_shun_list_empty(self):
211 from fossil.cli import FossilCLI
212
213 cli = FossilCLI(binary="/usr/bin/fossil")
214 with patch("subprocess.run") as mock_run:
215 mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
216 result = cli.shun_list("/tmp/test.fossil")
217 assert result == []
218
219 def test_shun_list_failure_returns_empty(self):
220 from fossil.cli import FossilCLI
221
222 cli = FossilCLI(binary="/usr/bin/fossil")
223 with patch("subprocess.run") as mock_run:
224 mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
225 result = cli.shun_list("/tmp/test.fossil")
226 assert result == []
--- a/tests/test_ticket_fields.py
+++ b/tests/test_ticket_fields.py
@@ -0,0 +1,237 @@
1
+import pytest
2
+from django.contrib.auth.models import User
3
+from django.test import Client
4
+
5
+from fossil.models import FossilRepository
6
+from fossil.ticket_fields import TicketFieldDefinition
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 text_field(fossil_repo_obj, admin_user):
19
+ return TicketFieldDefinition.objects.create(
20
+ repository=fossil_repo_obj,
21
+ name="component",
22
+ label="Component",
23
+ field_type="text",
24
+ is_required=False,
25
+ sort_order=1,
26
+ created_by=admin_user,
27
+ )
28
+
29
+
30
+@pytest.fixture
31
+def select_field(fossil_repo_obj, admin_user):
32
+ return TicketFieldDefinition.objects.create(
33
+ repository=fossil_repo_obj,
34
+ name="platform",
35
+ label="Platform",
36
+ field_type="select",
37
+ choices="Linux\nWindows\nmacOS",
38
+ is_required=True,
39
+ sort_order=2,
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
+# --- Model Tests ---
62
+
63
+
64
+@pytest.mark.django_db
65
+class TestTicketFieldDefinitionModel:
66
+ def test_create_field(self, text_field):
67
+ assert text_field.pk is not None
68
+ assert str(text_field) == "Component (component)"
69
+
70
+ def test_choices_list(self, select_field):
71
+ assert select_field.choices_list == ["Linux", "Windows", "macOS"]
72
+
73
+ def test_choices_list_empty(self, text_field):
74
+ assert text_field.choices_list == []
75
+
76
+ def test_soft_delete(self, text_field, admin_user):
77
+ text_field.soft_delete(user=admin_user)
78
+ assert text_field.is_deleted
79
+ assert TicketFieldDefinition.objects.filter(pk=text_field.pk).count() == 0
80
+ assert TicketFieldDefinition.all_objects.filter(pk=text_field.pk).count() == 1
81
+
82
+ def test_ordering(self, text_field, select_field):
83
+ fields = list(TicketFieldDefinition.objects.filter(repository=text_field.repository))
84
+ assert fields[0] == text_field # sort_order=1
85
+ assert fields[1] == select_field # sort_order=2
86
+
87
+ def test_unique_name_per_repo(self, fossil_repo_obj, admin_user, text_field):
88
+ from django.db import IntegrityError
89
+
90
+ with pytest.raises(IntegrityError):
91
+ TicketFieldDefinition.objects.create(
92
+ repository=fossil_repo_obj,
93
+ name="component",
94
+ label="Duplicate Component",
95
+ created_by=admin_user,
96
+ )
97
+
98
+
99
+# --- List View Tests ---
100
+
101
+
102
+@pytest.mark.django_db
103
+class TestTicketFieldsListView:
104
+ def test_list_fields(self, admin_client, sample_project, text_field, select_field):
105
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/")
106
+ assert response.status_code == 200
107
+ content = response.content.decode()
108
+ assert "Component" in content
109
+ assert "Platform" in content
110
+
111
+ def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
112
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/")
113
+ assert response.status_code == 200
114
+ assert "No custom ticket fields defined" in response.content.decode()
115
+
116
+ def test_list_denied_for_writer(self, writer_client, sample_project, text_field):
117
+ """Custom field management requires admin."""
118
+ response = writer_client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/")
119
+ assert response.status_code == 403
120
+
121
+ def test_list_denied_for_anon(self, client, sample_project):
122
+ response = client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/")
123
+ assert response.status_code == 302 # redirect to login
124
+
125
+
126
+# --- Create View Tests ---
127
+
128
+
129
+@pytest.mark.django_db
130
+class TestTicketFieldCreateView:
131
+ def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
132
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/create/")
133
+ assert response.status_code == 200
134
+ assert "Add Custom Ticket Field" in response.content.decode()
135
+
136
+ def test_create_field(self, admin_client, sample_project, fossil_repo_obj):
137
+ response = admin_client.post(
138
+ f"/projects/{sample_project.slug}/fossil/tickets/fields/create/",
139
+ {
140
+ "name": "affected_version",
141
+ "label": "Affected Version",
142
+ "field_type": "text",
143
+ "sort_order": "5",
144
+ },
145
+ )
146
+ assert response.status_code == 302
147
+ field = TicketFieldDefinition.objects.get(name="affected_version")
148
+ assert field.label == "Affected Version"
149
+ assert field.field_type == "text"
150
+ assert field.sort_order == 5
151
+ assert field.is_required is False
152
+
153
+ def test_create_select_field_with_choices(self, admin_client, sample_project, fossil_repo_obj):
154
+ response = admin_client.post(
155
+ f"/projects/{sample_project.slug}/fossil/tickets/fields/create/",
156
+ {
157
+ "name": "env",
158
+ "label": "Environment",
159
+ "field_type": "select",
160
+ "choices": "dev\nstaging\nprod",
161
+ "is_required": "on",
162
+ "sort_order": "0",
163
+ },
164
+ )
165
+ assert response.status_code == 302
166
+ field = TicketFieldDefinition.objects.get(name="env")
167
+ assert field.choices_list == ["dev", "staging", "prod"]
168
+ assert field.is_required is True
169
+
170
+ def test_create_duplicate_name_rejected(self, admin_client, sample_project, text_field):
171
+ response = admin_client.post(
172
+ f"/projects/{sample_project.slug}/fossil/tickets/fields/create/",
173
+ {"name": "component", "label": "Another Component", "field_type": "text", "sort_order": "0"},
174
+ )
175
+ assert response.status_code == 200 # re-renders form
176
+ assert TicketFieldDefinition.objects.filter(name="component").count() == 1
177
+
178
+ def test_create_denied_for_writer(self, writer_client, sample_project):
179
+ response = writer_client.post(
180
+ f"/projects/{sample_project.slug}/fossil/tickets/fields/create/",
181
+ {"name": "evil", "label": "Evil", "field_type": "text", "sort_order": "0"},
182
+ )
183
+ assert response.status_code == 403
184
+
185
+
186
+# --- Edit View Tests ---
187
+
188
+
189
+@pytest.mark.django_db
190
+class TestTicketFieldEditView:
191
+ def test_get_edit_form(self, admin_client, sample_project, text_field):
192
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/{text_field.pk}/edit/")
193
+ assert response.status_code == 200
194
+ content = response.content.decode()
195
+ assert "component" in content
196
+ assert "Component" in content
197
+
198
+ def test_edit_field(self, admin_client, sample_project, text_field):
199
+ response = admin_client.post(
200
+ f"/projects/{sample_project.slug}/fossil/tickets/fields/{text_field.pk}/edit/",
201
+ {"name": "component", "label": "SW Component", "field_type": "text", "sort_order": "10"},
202
+ )
203
+ assert response.status_code == 302
204
+ text_field.refresh_from_db()
205
+ assert text_field.label == "SW Component"
206
+ assert text_field.sort_order == 10
207
+
208
+ def test_edit_denied_for_writer(self, writer_client, sample_project, text_field):
209
+ response = writer_client.post(
210
+ f"/projects/{sample_project.slug}/fossil/tickets/fields/{text_field.pk}/edit/",
211
+ {"name": "component", "label": "Hacked", "field_type": "text", "sort_order": "0"},
212
+ )
213
+ assert response.status_code == 403
214
+
215
+ def test_edit_nonexistent(self, admin_client, sample_project, fossil_repo_obj):
216
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/99999/edit/")
217
+ assert response.status_code == 404
218
+
219
+
220
+# --- Delete View Tests ---
221
+
222
+
223
+@pytest.mark.django_db
224
+class TestTicketFieldDeleteView:
225
+ def test_delete_field(self, admin_client, sample_project, text_field):
226
+ response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tickets/fields/{text_field.pk}/delete/")
227
+ assert response.status_code == 302
228
+ text_field.refresh_from_db()
229
+ assert text_field.is_deleted
230
+
231
+ def test_delete_get_redirects(self, admin_client, sample_project, text_field):
232
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/{text_field.pk}/delete/")
233
+ assert response.status_code == 302
234
+
235
+ def test_delete_denied_for_writer(self, writer_client, sample_project, text_field):
236
+ response = writer_client.post(f"/projects/{sample_project.slug}/fossil/tickets/fields/{text_field.pk}/delete/")
237
+ assert response.status_code == 403
--- a/tests/test_ticket_fields.py
+++ b/tests/test_ticket_fields.py
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_ticket_fields.py
+++ b/tests/test_ticket_fields.py
@@ -0,0 +1,237 @@
1 import pytest
2 from django.contrib.auth.models import User
3 from django.test import Client
4
5 from fossil.models import FossilRepository
6 from fossil.ticket_fields import TicketFieldDefinition
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 text_field(fossil_repo_obj, admin_user):
19 return TicketFieldDefinition.objects.create(
20 repository=fossil_repo_obj,
21 name="component",
22 label="Component",
23 field_type="text",
24 is_required=False,
25 sort_order=1,
26 created_by=admin_user,
27 )
28
29
30 @pytest.fixture
31 def select_field(fossil_repo_obj, admin_user):
32 return TicketFieldDefinition.objects.create(
33 repository=fossil_repo_obj,
34 name="platform",
35 label="Platform",
36 field_type="select",
37 choices="Linux\nWindows\nmacOS",
38 is_required=True,
39 sort_order=2,
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 # --- Model Tests ---
62
63
64 @pytest.mark.django_db
65 class TestTicketFieldDefinitionModel:
66 def test_create_field(self, text_field):
67 assert text_field.pk is not None
68 assert str(text_field) == "Component (component)"
69
70 def test_choices_list(self, select_field):
71 assert select_field.choices_list == ["Linux", "Windows", "macOS"]
72
73 def test_choices_list_empty(self, text_field):
74 assert text_field.choices_list == []
75
76 def test_soft_delete(self, text_field, admin_user):
77 text_field.soft_delete(user=admin_user)
78 assert text_field.is_deleted
79 assert TicketFieldDefinition.objects.filter(pk=text_field.pk).count() == 0
80 assert TicketFieldDefinition.all_objects.filter(pk=text_field.pk).count() == 1
81
82 def test_ordering(self, text_field, select_field):
83 fields = list(TicketFieldDefinition.objects.filter(repository=text_field.repository))
84 assert fields[0] == text_field # sort_order=1
85 assert fields[1] == select_field # sort_order=2
86
87 def test_unique_name_per_repo(self, fossil_repo_obj, admin_user, text_field):
88 from django.db import IntegrityError
89
90 with pytest.raises(IntegrityError):
91 TicketFieldDefinition.objects.create(
92 repository=fossil_repo_obj,
93 name="component",
94 label="Duplicate Component",
95 created_by=admin_user,
96 )
97
98
99 # --- List View Tests ---
100
101
102 @pytest.mark.django_db
103 class TestTicketFieldsListView:
104 def test_list_fields(self, admin_client, sample_project, text_field, select_field):
105 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/")
106 assert response.status_code == 200
107 content = response.content.decode()
108 assert "Component" in content
109 assert "Platform" in content
110
111 def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
112 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/")
113 assert response.status_code == 200
114 assert "No custom ticket fields defined" in response.content.decode()
115
116 def test_list_denied_for_writer(self, writer_client, sample_project, text_field):
117 """Custom field management requires admin."""
118 response = writer_client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/")
119 assert response.status_code == 403
120
121 def test_list_denied_for_anon(self, client, sample_project):
122 response = client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/")
123 assert response.status_code == 302 # redirect to login
124
125
126 # --- Create View Tests ---
127
128
129 @pytest.mark.django_db
130 class TestTicketFieldCreateView:
131 def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
132 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/create/")
133 assert response.status_code == 200
134 assert "Add Custom Ticket Field" in response.content.decode()
135
136 def test_create_field(self, admin_client, sample_project, fossil_repo_obj):
137 response = admin_client.post(
138 f"/projects/{sample_project.slug}/fossil/tickets/fields/create/",
139 {
140 "name": "affected_version",
141 "label": "Affected Version",
142 "field_type": "text",
143 "sort_order": "5",
144 },
145 )
146 assert response.status_code == 302
147 field = TicketFieldDefinition.objects.get(name="affected_version")
148 assert field.label == "Affected Version"
149 assert field.field_type == "text"
150 assert field.sort_order == 5
151 assert field.is_required is False
152
153 def test_create_select_field_with_choices(self, admin_client, sample_project, fossil_repo_obj):
154 response = admin_client.post(
155 f"/projects/{sample_project.slug}/fossil/tickets/fields/create/",
156 {
157 "name": "env",
158 "label": "Environment",
159 "field_type": "select",
160 "choices": "dev\nstaging\nprod",
161 "is_required": "on",
162 "sort_order": "0",
163 },
164 )
165 assert response.status_code == 302
166 field = TicketFieldDefinition.objects.get(name="env")
167 assert field.choices_list == ["dev", "staging", "prod"]
168 assert field.is_required is True
169
170 def test_create_duplicate_name_rejected(self, admin_client, sample_project, text_field):
171 response = admin_client.post(
172 f"/projects/{sample_project.slug}/fossil/tickets/fields/create/",
173 {"name": "component", "label": "Another Component", "field_type": "text", "sort_order": "0"},
174 )
175 assert response.status_code == 200 # re-renders form
176 assert TicketFieldDefinition.objects.filter(name="component").count() == 1
177
178 def test_create_denied_for_writer(self, writer_client, sample_project):
179 response = writer_client.post(
180 f"/projects/{sample_project.slug}/fossil/tickets/fields/create/",
181 {"name": "evil", "label": "Evil", "field_type": "text", "sort_order": "0"},
182 )
183 assert response.status_code == 403
184
185
186 # --- Edit View Tests ---
187
188
189 @pytest.mark.django_db
190 class TestTicketFieldEditView:
191 def test_get_edit_form(self, admin_client, sample_project, text_field):
192 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/{text_field.pk}/edit/")
193 assert response.status_code == 200
194 content = response.content.decode()
195 assert "component" in content
196 assert "Component" in content
197
198 def test_edit_field(self, admin_client, sample_project, text_field):
199 response = admin_client.post(
200 f"/projects/{sample_project.slug}/fossil/tickets/fields/{text_field.pk}/edit/",
201 {"name": "component", "label": "SW Component", "field_type": "text", "sort_order": "10"},
202 )
203 assert response.status_code == 302
204 text_field.refresh_from_db()
205 assert text_field.label == "SW Component"
206 assert text_field.sort_order == 10
207
208 def test_edit_denied_for_writer(self, writer_client, sample_project, text_field):
209 response = writer_client.post(
210 f"/projects/{sample_project.slug}/fossil/tickets/fields/{text_field.pk}/edit/",
211 {"name": "component", "label": "Hacked", "field_type": "text", "sort_order": "0"},
212 )
213 assert response.status_code == 403
214
215 def test_edit_nonexistent(self, admin_client, sample_project, fossil_repo_obj):
216 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/99999/edit/")
217 assert response.status_code == 404
218
219
220 # --- Delete View Tests ---
221
222
223 @pytest.mark.django_db
224 class TestTicketFieldDeleteView:
225 def test_delete_field(self, admin_client, sample_project, text_field):
226 response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tickets/fields/{text_field.pk}/delete/")
227 assert response.status_code == 302
228 text_field.refresh_from_db()
229 assert text_field.is_deleted
230
231 def test_delete_get_redirects(self, admin_client, sample_project, text_field):
232 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/fields/{text_field.pk}/delete/")
233 assert response.status_code == 302
234
235 def test_delete_denied_for_writer(self, writer_client, sample_project, text_field):
236 response = writer_client.post(f"/projects/{sample_project.slug}/fossil/tickets/fields/{text_field.pk}/delete/")
237 assert response.status_code == 403
--- a/tests/test_ticket_reports.py
+++ b/tests/test_ticket_reports.py
@@ -0,0 +1,293 @@
1
+import pytest
2
+from django.contrib.auth.models import User
3
+from django.test import Client
4
+
5
+from fossil.models import FossilRepository
6
+from fossil.ticket_reports import TicketReport
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 public_report(fossil_repo_obj, admin_user):
19
+ return TicketReport.objects.create(
20
+ repository=fossil_repo_obj,
21
+ title="Open Tickets",
22
+ description="All open tickets",
23
+ sql_query="SELECT tkt_uuid, title, status FROM ticket WHERE status = 'Open'",
24
+ is_public=True,
25
+ created_by=admin_user,
26
+ )
27
+
28
+
29
+@pytest.fixture
30
+def private_report(fossil_repo_obj, admin_user):
31
+ return TicketReport.objects.create(
32
+ repository=fossil_repo_obj,
33
+ title="Internal Metrics",
34
+ description="Admin-only report",
35
+ sql_query="SELECT COUNT(*) as total FROM ticket",
36
+ is_public=False,
37
+ created_by=admin_user,
38
+ )
39
+
40
+
41
+@pytest.fixture
42
+def writer_user(db, admin_user, sample_project):
43
+ """User with write access but not admin."""
44
+ writer = User.objects.create_user(username="writer", password="testpass123")
45
+ team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user)
46
+ team.members.add(writer)
47
+ ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
48
+ return writer
49
+
50
+
51
+@pytest.fixture
52
+def writer_client(writer_user):
53
+ client = Client()
54
+ client.login(username="writer", password="testpass123")
55
+ return client
56
+
57
+
58
+@pytest.fixture
59
+def reader_user(db, admin_user, sample_project):
60
+ """User with read access only."""
61
+ reader = User.objects.create_user(username="reader", password="testpass123")
62
+ team = Team.objects.create(name="Readers", organization=sample_project.organization, created_by=admin_user)
63
+ team.members.add(reader)
64
+ ProjectTeam.objects.create(project=sample_project, team=team, role="read", created_by=admin_user)
65
+ return reader
66
+
67
+
68
+@pytest.fixture
69
+def reader_client(reader_user):
70
+ client = Client()
71
+ client.login(username="reader", password="testpass123")
72
+ return client
73
+
74
+
75
+# --- SQL Validation Tests ---
76
+
77
+
78
+@pytest.mark.django_db
79
+class TestTicketReportSQLValidation:
80
+ def test_valid_select(self):
81
+ assert TicketReport.validate_sql("SELECT * FROM ticket") is None
82
+
83
+ def test_valid_select_with_where(self):
84
+ assert TicketReport.validate_sql("SELECT title, status FROM ticket WHERE status = 'Open'") is None
85
+
86
+ def test_valid_select_with_join(self):
87
+ assert TicketReport.validate_sql("SELECT t.title, tc.icomment FROM ticket t JOIN ticketchng tc ON t.tkt_id = tc.tkt_id") is None
88
+
89
+ def test_reject_empty(self):
90
+ assert TicketReport.validate_sql("") is not None
91
+ assert "empty" in TicketReport.validate_sql("").lower()
92
+
93
+ def test_reject_insert(self):
94
+ error = TicketReport.validate_sql("INSERT INTO ticket (title) VALUES ('hack')")
95
+ assert error is not None
96
+ assert "select" in error.lower() or "forbidden" in error.lower()
97
+
98
+ def test_reject_update(self):
99
+ error = TicketReport.validate_sql("UPDATE ticket SET title = 'hacked'")
100
+ assert error is not None
101
+
102
+ def test_reject_delete(self):
103
+ error = TicketReport.validate_sql("DELETE FROM ticket")
104
+ assert error is not None
105
+
106
+ def test_reject_drop(self):
107
+ error = TicketReport.validate_sql("DROP TABLE ticket")
108
+ assert error is not None
109
+
110
+ def test_reject_alter(self):
111
+ error = TicketReport.validate_sql("ALTER TABLE ticket ADD COLUMN evil TEXT")
112
+ assert error is not None
113
+
114
+ def test_reject_create(self):
115
+ error = TicketReport.validate_sql("CREATE TABLE evil (id INTEGER)")
116
+ assert error is not None
117
+
118
+ def test_reject_attach(self):
119
+ error = TicketReport.validate_sql("ATTACH DATABASE ':memory:' AS evil")
120
+ assert error is not None
121
+
122
+ def test_reject_pragma(self):
123
+ error = TicketReport.validate_sql("PRAGMA table_info(ticket)")
124
+ assert error is not None
125
+
126
+ def test_reject_multiple_statements(self):
127
+ error = TicketReport.validate_sql("SELECT 1; DROP TABLE ticket")
128
+ assert error is not None
129
+ # May be caught by forbidden keyword or multiple statement check
130
+ assert "multiple" in error.lower() or "forbidden" in error.lower()
131
+
132
+ def test_reject_multiple_statements_pure(self):
133
+ """Semicolons without forbidden keywords should also be rejected."""
134
+ error = TicketReport.validate_sql("SELECT 1; SELECT 2")
135
+ assert error is not None
136
+ assert "multiple" in error.lower()
137
+
138
+ def test_reject_non_select_start(self):
139
+ error = TicketReport.validate_sql("WITH cte AS (DELETE FROM ticket) SELECT * FROM cte")
140
+ assert error is not None
141
+ assert "SELECT" in error
142
+
143
+
144
+# --- Model Tests ---
145
+
146
+
147
+@pytest.mark.django_db
148
+class TestTicketReportModel:
149
+ def test_create_report(self, public_report):
150
+ assert public_report.pk is not None
151
+ assert str(public_report) == "Open Tickets"
152
+
153
+ def test_soft_delete(self, public_report, admin_user):
154
+ public_report.soft_delete(user=admin_user)
155
+ assert public_report.is_deleted
156
+ assert TicketReport.objects.filter(pk=public_report.pk).count() == 0
157
+ assert TicketReport.all_objects.filter(pk=public_report.pk).count() == 1
158
+
159
+ def test_ordering(self, fossil_repo_obj, admin_user):
160
+ r_b = TicketReport.objects.create(repository=fossil_repo_obvate_report.pk}/")
161
+ asse302 # redirect to loginository=fossil_repo_obj, title="A Report", sql_query="SELECT 1", created_by=admin_user)
162
+ reports = list(TicketReport.objects.filter(repository=fossil_repo_obj))
163
+ assert reports[0] == r_a # alphabetical
164
+ assert reports[1] == r_b
165
+
166
+
167
+# --- List View Tests ---
168
+
169
+
170
+@pytest.mark.django_db
171
+class TestTicketReportsListView:
172
+ def test_list_reports_admin(self, admin_client, sample_project, public_report, private_report):
173
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
174
+ assert response.status_code == 200
175
+ content = response.content.decode()
176
+ assert "Open Tickets" in content
177
+ assert "Internal Metrics" in content # admin sees private reports
178
+
179
+ def test_list_reports_reader_hides_private(self, reader_client, sample_project, public_report, private_report):
180
+ response = reader_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
181
+ assert response.status_code == 200
182
+ content = response.content.decode()
183
+ assert "Open Tickets" in content
184
+ assert "Internal Metrics" not in content # reader cannot see private reports
185
+
186
+ def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
187
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
188
+ assert response.status_code == 200
189
+ assert "No ticket reports defined" in response.content.decode()
190
+
191
+ def test_list_denied_for_anon(self, client, sample_project):
192
+ response = client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
193
+ # Private project: anonymous user gets 403 from require_project_read
194
+ assert response.status_code == 403
195
+
196
+
197
+# --- Create View Tests ---
198
+
199
+
200
+@pytest.mark.django_db
201
+class TestTicketReportCreateView:
202
+ def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
203
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/create/")
204
+ assert response.status_code == 200
205
+ assert "Create Ticket Report" in response.content.decode()
206
+
207
+ def test_create_report(self, admin_client, sample_project, fossil_repo_obj):
208
+ response = admin_client.post(
209
+ f"/projects/{sample_project.slug}/fossil/tickets/reports/create/",
210
+ {
211
+ "title": "Critical Bugs",
212
+ "description": "All critical severity tickets",
213
+ "sql_query": "SELECT tkt_uuid, title FROM ticket WHERE severity = 'Critical'",
214
+ "is_public": "on",
215
+ },
216
+ )
217
+ assert response.status_code == 302
218
+ report = TicketReport.objects.get(title="Critical Bugs")
219
+ assert report.is_public is True
220
+ assert "Critical" in report.sql_query
221
+
222
+ def test_create_report_rejects_dangerous_sql(self, admin_client, sample_project, fossil_repo_obj):
223
+ response = admin_client.post(
224
+ f"/projects/{sample_project.slug}/fossil/tickets/reports/create/",
225
+ {
226
+ "title": "Evil Report",
227
+ "sql_query": "DROP TABLE ticket",
228
+ },
229
+ )
230
+ assert response.status_code == 200 # re-renders form with error
231
+ assert TicketReport.objects.filter(title="Evil Report").count() == 0
232
+
233
+ def test_create_denied_for_writer(self, writer_client, sample_project):
234
+ response = writer_client.post(
235
+ f"/projects/{sample_project.slug}/fossil/tickets/reports/create/",
236
+ {"title": "Hack", "sql_query": "SELECT 1"},
237
+ )
238
+ assert response.status_code == 403
239
+
240
+ def test_create_denied_for_reader(self, reader_client, sample_project):
241
+ response = reader_client.post(
242
+ f"/projects/{sample_project.slug}/fossil/tickets/reports/create/",
243
+ {"title": "Hack", "sql_query": "SELECT 1"},
244
+ )
245
+ assert response.status_code == 403
246
+
247
+
248
+# --- Edit View Tests ---
249
+
250
+
251
+@pytest.mark.django_db
252
+class TestTicketReportEditView:
253
+ def test_get_edit_form(self, admin_client, sample_project, public_report):
254
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/edit/")
255
+ assert response.status_code == 200
256
+ content = response.content.decode()
257
+ assert "Open Tickets" in content
258
+
259
+ def test_edit_report(self, admin_client, sample_project, public_report):
260
+ response = admin_client.post(
261
+ f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/edit/",
262
+ {
263
+ "title": "Open Tickets (Updated)",
264
+ "description": "Updated description",
265
+ "sql_query": "SELECT tkt_uuid, title, status FROM ticket WHERE status = 'Open' ORDER BY tkt_ctime DESC",
266
+ "is_public": "on",
267
+ },
268
+ )
269
+ assert response.status_code == 302
270
+ public_report.refresh_from_db()
271
+ assert public_report.title == "Open Tickets (Updated)"
272
+
273
+ def test_edit_rejects_dangerous_sql(self, admin_client, sample_project, public_report):
274
+ response = admin_client.post(
275
+ f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/edit/",
276
+ {
277
+ "title": "Open Tickets",
278
+ "sql_query": "DELETE FROM ticket",
279
+ },
280
+ )
281
+ assert response.status_code == 200 # re-renders form
282
+ public_report.refresh_from_db()
283
+ assert "DELETE" not in public_report.sql_query
284
+
285
+ def test_edit_denied_for_writer(self, writer_client, sample_project, public_report):
286
+ response = writer_client.post(
287
+ f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/edit/",
288
+ {"title": "Hacked", "sql_query": "SELECT 1"},
289
+ )
290
+ assert response.status_code == 403
291
+
292
+ def test_edit_nonexistent(self, admin_client, savate_report.pk}/")
293
+ asse302 # redirect to login
--- a/tests/test_ticket_reports.py
+++ b/tests/test_ticket_reports.py
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_ticket_reports.py
+++ b/tests/test_ticket_reports.py
@@ -0,0 +1,293 @@
1 import pytest
2 from django.contrib.auth.models import User
3 from django.test import Client
4
5 from fossil.models import FossilRepository
6 from fossil.ticket_reports import TicketReport
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 public_report(fossil_repo_obj, admin_user):
19 return TicketReport.objects.create(
20 repository=fossil_repo_obj,
21 title="Open Tickets",
22 description="All open tickets",
23 sql_query="SELECT tkt_uuid, title, status FROM ticket WHERE status = 'Open'",
24 is_public=True,
25 created_by=admin_user,
26 )
27
28
29 @pytest.fixture
30 def private_report(fossil_repo_obj, admin_user):
31 return TicketReport.objects.create(
32 repository=fossil_repo_obj,
33 title="Internal Metrics",
34 description="Admin-only report",
35 sql_query="SELECT COUNT(*) as total FROM ticket",
36 is_public=False,
37 created_by=admin_user,
38 )
39
40
41 @pytest.fixture
42 def writer_user(db, admin_user, sample_project):
43 """User with write access but not admin."""
44 writer = User.objects.create_user(username="writer", password="testpass123")
45 team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user)
46 team.members.add(writer)
47 ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
48 return writer
49
50
51 @pytest.fixture
52 def writer_client(writer_user):
53 client = Client()
54 client.login(username="writer", password="testpass123")
55 return client
56
57
58 @pytest.fixture
59 def reader_user(db, admin_user, sample_project):
60 """User with read access only."""
61 reader = User.objects.create_user(username="reader", password="testpass123")
62 team = Team.objects.create(name="Readers", organization=sample_project.organization, created_by=admin_user)
63 team.members.add(reader)
64 ProjectTeam.objects.create(project=sample_project, team=team, role="read", created_by=admin_user)
65 return reader
66
67
68 @pytest.fixture
69 def reader_client(reader_user):
70 client = Client()
71 client.login(username="reader", password="testpass123")
72 return client
73
74
75 # --- SQL Validation Tests ---
76
77
78 @pytest.mark.django_db
79 class TestTicketReportSQLValidation:
80 def test_valid_select(self):
81 assert TicketReport.validate_sql("SELECT * FROM ticket") is None
82
83 def test_valid_select_with_where(self):
84 assert TicketReport.validate_sql("SELECT title, status FROM ticket WHERE status = 'Open'") is None
85
86 def test_valid_select_with_join(self):
87 assert TicketReport.validate_sql("SELECT t.title, tc.icomment FROM ticket t JOIN ticketchng tc ON t.tkt_id = tc.tkt_id") is None
88
89 def test_reject_empty(self):
90 assert TicketReport.validate_sql("") is not None
91 assert "empty" in TicketReport.validate_sql("").lower()
92
93 def test_reject_insert(self):
94 error = TicketReport.validate_sql("INSERT INTO ticket (title) VALUES ('hack')")
95 assert error is not None
96 assert "select" in error.lower() or "forbidden" in error.lower()
97
98 def test_reject_update(self):
99 error = TicketReport.validate_sql("UPDATE ticket SET title = 'hacked'")
100 assert error is not None
101
102 def test_reject_delete(self):
103 error = TicketReport.validate_sql("DELETE FROM ticket")
104 assert error is not None
105
106 def test_reject_drop(self):
107 error = TicketReport.validate_sql("DROP TABLE ticket")
108 assert error is not None
109
110 def test_reject_alter(self):
111 error = TicketReport.validate_sql("ALTER TABLE ticket ADD COLUMN evil TEXT")
112 assert error is not None
113
114 def test_reject_create(self):
115 error = TicketReport.validate_sql("CREATE TABLE evil (id INTEGER)")
116 assert error is not None
117
118 def test_reject_attach(self):
119 error = TicketReport.validate_sql("ATTACH DATABASE ':memory:' AS evil")
120 assert error is not None
121
122 def test_reject_pragma(self):
123 error = TicketReport.validate_sql("PRAGMA table_info(ticket)")
124 assert error is not None
125
126 def test_reject_multiple_statements(self):
127 error = TicketReport.validate_sql("SELECT 1; DROP TABLE ticket")
128 assert error is not None
129 # May be caught by forbidden keyword or multiple statement check
130 assert "multiple" in error.lower() or "forbidden" in error.lower()
131
132 def test_reject_multiple_statements_pure(self):
133 """Semicolons without forbidden keywords should also be rejected."""
134 error = TicketReport.validate_sql("SELECT 1; SELECT 2")
135 assert error is not None
136 assert "multiple" in error.lower()
137
138 def test_reject_non_select_start(self):
139 error = TicketReport.validate_sql("WITH cte AS (DELETE FROM ticket) SELECT * FROM cte")
140 assert error is not None
141 assert "SELECT" in error
142
143
144 # --- Model Tests ---
145
146
147 @pytest.mark.django_db
148 class TestTicketReportModel:
149 def test_create_report(self, public_report):
150 assert public_report.pk is not None
151 assert str(public_report) == "Open Tickets"
152
153 def test_soft_delete(self, public_report, admin_user):
154 public_report.soft_delete(user=admin_user)
155 assert public_report.is_deleted
156 assert TicketReport.objects.filter(pk=public_report.pk).count() == 0
157 assert TicketReport.all_objects.filter(pk=public_report.pk).count() == 1
158
159 def test_ordering(self, fossil_repo_obj, admin_user):
160 r_b = TicketReport.objects.create(repository=fossil_repo_obvate_report.pk}/")
161 asse302 # redirect to loginository=fossil_repo_obj, title="A Report", sql_query="SELECT 1", created_by=admin_user)
162 reports = list(TicketReport.objects.filter(repository=fossil_repo_obj))
163 assert reports[0] == r_a # alphabetical
164 assert reports[1] == r_b
165
166
167 # --- List View Tests ---
168
169
170 @pytest.mark.django_db
171 class TestTicketReportsListView:
172 def test_list_reports_admin(self, admin_client, sample_project, public_report, private_report):
173 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
174 assert response.status_code == 200
175 content = response.content.decode()
176 assert "Open Tickets" in content
177 assert "Internal Metrics" in content # admin sees private reports
178
179 def test_list_reports_reader_hides_private(self, reader_client, sample_project, public_report, private_report):
180 response = reader_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
181 assert response.status_code == 200
182 content = response.content.decode()
183 assert "Open Tickets" in content
184 assert "Internal Metrics" not in content # reader cannot see private reports
185
186 def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
187 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
188 assert response.status_code == 200
189 assert "No ticket reports defined" in response.content.decode()
190
191 def test_list_denied_for_anon(self, client, sample_project):
192 response = client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
193 # Private project: anonymous user gets 403 from require_project_read
194 assert response.status_code == 403
195
196
197 # --- Create View Tests ---
198
199
200 @pytest.mark.django_db
201 class TestTicketReportCreateView:
202 def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
203 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/create/")
204 assert response.status_code == 200
205 assert "Create Ticket Report" in response.content.decode()
206
207 def test_create_report(self, admin_client, sample_project, fossil_repo_obj):
208 response = admin_client.post(
209 f"/projects/{sample_project.slug}/fossil/tickets/reports/create/",
210 {
211 "title": "Critical Bugs",
212 "description": "All critical severity tickets",
213 "sql_query": "SELECT tkt_uuid, title FROM ticket WHERE severity = 'Critical'",
214 "is_public": "on",
215 },
216 )
217 assert response.status_code == 302
218 report = TicketReport.objects.get(title="Critical Bugs")
219 assert report.is_public is True
220 assert "Critical" in report.sql_query
221
222 def test_create_report_rejects_dangerous_sql(self, admin_client, sample_project, fossil_repo_obj):
223 response = admin_client.post(
224 f"/projects/{sample_project.slug}/fossil/tickets/reports/create/",
225 {
226 "title": "Evil Report",
227 "sql_query": "DROP TABLE ticket",
228 },
229 )
230 assert response.status_code == 200 # re-renders form with error
231 assert TicketReport.objects.filter(title="Evil Report").count() == 0
232
233 def test_create_denied_for_writer(self, writer_client, sample_project):
234 response = writer_client.post(
235 f"/projects/{sample_project.slug}/fossil/tickets/reports/create/",
236 {"title": "Hack", "sql_query": "SELECT 1"},
237 )
238 assert response.status_code == 403
239
240 def test_create_denied_for_reader(self, reader_client, sample_project):
241 response = reader_client.post(
242 f"/projects/{sample_project.slug}/fossil/tickets/reports/create/",
243 {"title": "Hack", "sql_query": "SELECT 1"},
244 )
245 assert response.status_code == 403
246
247
248 # --- Edit View Tests ---
249
250
251 @pytest.mark.django_db
252 class TestTicketReportEditView:
253 def test_get_edit_form(self, admin_client, sample_project, public_report):
254 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/edit/")
255 assert response.status_code == 200
256 content = response.content.decode()
257 assert "Open Tickets" in content
258
259 def test_edit_report(self, admin_client, sample_project, public_report):
260 response = admin_client.post(
261 f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/edit/",
262 {
263 "title": "Open Tickets (Updated)",
264 "description": "Updated description",
265 "sql_query": "SELECT tkt_uuid, title, status FROM ticket WHERE status = 'Open' ORDER BY tkt_ctime DESC",
266 "is_public": "on",
267 },
268 )
269 assert response.status_code == 302
270 public_report.refresh_from_db()
271 assert public_report.title == "Open Tickets (Updated)"
272
273 def test_edit_rejects_dangerous_sql(self, admin_client, sample_project, public_report):
274 response = admin_client.post(
275 f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/edit/",
276 {
277 "title": "Open Tickets",
278 "sql_query": "DELETE FROM ticket",
279 },
280 )
281 assert response.status_code == 200 # re-renders form
282 public_report.refresh_from_db()
283 assert "DELETE" not in public_report.sql_query
284
285 def test_edit_denied_for_writer(self, writer_client, sample_project, public_report):
286 response = writer_client.post(
287 f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/edit/",
288 {"title": "Hacked", "sql_query": "SELECT 1"},
289 )
290 assert response.status_code == 403
291
292 def test_edit_nonexistent(self, admin_client, savate_report.pk}/")
293 asse302 # redirect to login

Keyboard Shortcuts

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