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.
8032e48b1ba8263d723a48f8f77891098b41412694f79deab9cf4ee2d35d5c17
| --- accounts/urls.py | ||
| +++ accounts/urls.py | ||
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | urlpatterns = [ |
| 8 | 8 | path("login/", views.login_view, name="login"), |
| 9 | 9 | path("logout/", views.logout_view, name="logout"), |
| 10 | 10 | path("ssh-keys/", views.ssh_keys, name="ssh_keys"), |
| 11 | 11 | path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"), |
| 12 | + path("notifications/", views.notification_preferences, name="notification_prefs"), | |
| 12 | 13 | ] |
| 13 | 14 |
| --- 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 @@ | ||
| 193 | 193 | |
| 194 | 194 | if request.headers.get("HX-Request"): |
| 195 | 195 | return HttpResponse(status=200, headers={"HX-Redirect": "/auth/ssh-keys/"}) |
| 196 | 196 | |
| 197 | 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}) | |
| 198 | 224 |
| --- 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 @@ | ||
| 200 | 200 | }, |
| 201 | 201 | "fossil-dispatch-notifications": { |
| 202 | 202 | "task": "fossil.dispatch_notifications", |
| 203 | 203 | "schedule": 300.0, # every 5 minutes |
| 204 | 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 | + }, | |
| 205 | 215 | } |
| 206 | 216 | CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True |
| 207 | 217 | |
| 208 | 218 | # --- CORS --- |
| 209 | 219 | |
| 210 | 220 |
| --- 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 |
| --- ctl/main.py | ||
| +++ ctl/main.py | ||
| @@ -297,5 +297,110 @@ | ||
| 297 | 297 | @click.argument("path") |
| 298 | 298 | def backup_restore(path: str) -> None: |
| 299 | 299 | """Restore from a backup.""" |
| 300 | 300 | console.print(f"[bold]Restoring from:[/bold] {path}") |
| 301 | 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]") | |
| 302 | 407 |
| --- 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 |
| --- fossil/admin.py | ||
| +++ fossil/admin.py | ||
| @@ -5,13 +5,15 @@ | ||
| 5 | 5 | from .api_tokens import APIToken |
| 6 | 6 | from .branch_protection import BranchProtection |
| 7 | 7 | from .ci import StatusCheck |
| 8 | 8 | from .forum import ForumPost |
| 9 | 9 | from .models import FossilRepository, FossilSnapshot |
| 10 | -from .notifications import Notification, ProjectWatch | |
| 10 | +from .notifications import Notification, NotificationPreference, ProjectWatch | |
| 11 | 11 | from .releases import Release, ReleaseAsset |
| 12 | 12 | from .sync_models import GitMirror, SSHKey, SyncLog |
| 13 | +from .ticket_fields import TicketFieldDefinition | |
| 14 | +from .ticket_reports import TicketReport | |
| 13 | 15 | from .user_keys import UserSSHKey |
| 14 | 16 | from .webhooks import Webhook, WebhookDelivery |
| 15 | 17 | |
| 16 | 18 | |
| 17 | 19 | class FossilSnapshotInline(admin.TabularInline): |
| @@ -75,10 +77,18 @@ | ||
| 75 | 77 | list_display = ("user", "project", "event_filter", "email_enabled", "created_at") |
| 76 | 78 | list_filter = ("event_filter", "email_enabled") |
| 77 | 79 | search_fields = ("user__username", "project__name") |
| 78 | 80 | raw_id_fields = ("user", "project") |
| 79 | 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 | + | |
| 80 | 90 | |
| 81 | 91 | class ReleaseAssetInline(admin.TabularInline): |
| 82 | 92 | model = ReleaseAsset |
| 83 | 93 | extra = 0 |
| 84 | 94 | |
| @@ -153,5 +163,21 @@ | ||
| 153 | 163 | class BranchProtectionAdmin(BaseCoreAdmin): |
| 154 | 164 | list_display = ("branch_pattern", "repository", "require_status_checks", "restrict_push", "created_at") |
| 155 | 165 | list_filter = ("require_status_checks", "restrict_push") |
| 156 | 166 | search_fields = ("branch_pattern",) |
| 157 | 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",) | |
| 158 | 184 |
| --- 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 @@ | ||
| 384 | 384 | if line.lower().startswith(b"content-type:"): |
| 385 | 385 | response_content_type = line.split(b":", 1)[1].strip().decode("utf-8", errors="replace") |
| 386 | 386 | break |
| 387 | 387 | |
| 388 | 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 [] | |
| 389 | 406 | |
| 390 | 407 | 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 @@ | ||
| 69 | 69 | # Import related models so they're discoverable by Django |
| 70 | 70 | from fossil.api_tokens import APIToken # noqa: E402, F401 |
| 71 | 71 | from fossil.branch_protection import BranchProtection # noqa: E402, F401 |
| 72 | 72 | from fossil.ci import StatusCheck # noqa: E402, F401 |
| 73 | 73 | 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 | |
| 75 | 75 | from fossil.releases import Release, ReleaseAsset # noqa: E402, F401 |
| 76 | 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 | |
| 77 | 79 | from fossil.user_keys import UserSSHKey # noqa: E402, F401 |
| 78 | 80 | from fossil.webhooks import Webhook, WebhookDelivery # noqa: E402, F401 |
| 79 | 81 |
| --- 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 @@ | ||
| 57 | 57 | ordering = ["-created_at"] |
| 58 | 58 | |
| 59 | 59 | def __str__(self): |
| 60 | 60 | return f"{self.title} → {self.user.username}" |
| 61 | 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 | + | |
| 62 | 88 | |
| 63 | 89 | def notify_project_event(project, event_type: str, title: str, body: str = "", url: str = "", exclude_user=None): |
| 64 | 90 | """Create notifications for all watchers of a project. |
| 65 | 91 | |
| 66 | 92 | Args: |
| 67 | 93 |
| --- 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 @@ | ||
| 195 | 195 | log.status = "failed" |
| 196 | 196 | log.message = "Unexpected error" |
| 197 | 197 | log.completed_at = timezone.now() |
| 198 | 198 | log.save() |
| 199 | 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 | + | |
| 200 | 242 | |
| 201 | 243 | @shared_task(name="fossil.dispatch_webhook", bind=True, max_retries=3) |
| 202 | 244 | def dispatch_webhook(self, webhook_id, event_type, payload): |
| 203 | 245 | """Deliver a webhook with retry and logging.""" |
| 204 | 246 | import hashlib |
| 205 | 247 | |
| 206 | 248 | ADDED fossil/ticket_fields.py |
| 207 | 249 | 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 |
| --- fossil/urls.py | ||
| +++ fossil/urls.py | ||
| @@ -9,18 +9,29 @@ | ||
| 9 | 9 | path("code/tree/<path:dirpath>/", views.code_browser, name="code_dir"), |
| 10 | 10 | path("code/file/<path:filepath>", views.code_file, name="code_file"), |
| 11 | 11 | path("timeline/", views.timeline, name="timeline"), |
| 12 | 12 | path("checkin/<str:checkin_uuid>/", views.checkin_detail, name="checkin_detail"), |
| 13 | 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"), | |
| 14 | 26 | path("tickets/<str:ticket_uuid>/", views.ticket_detail, name="ticket_detail"), |
| 15 | 27 | path("tickets/<str:ticket_uuid>/edit/", views.ticket_edit, name="ticket_edit"), |
| 16 | 28 | path("tickets/<str:ticket_uuid>/comment/", views.ticket_comment, name="ticket_comment"), |
| 17 | 29 | path("wiki/", views.wiki_list, name="wiki"), |
| 18 | 30 | path("wiki/create/", views.wiki_create, name="wiki_create"), |
| 19 | 31 | path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"), |
| 20 | 32 | path("wiki/edit/<path:page_name>", views.wiki_edit, name="wiki_edit"), |
| 21 | - path("tickets/create/", views.ticket_create, name="ticket_create"), | |
| 22 | 33 | path("forum/", views.forum_list, name="forum"), |
| 23 | 34 | path("forum/create/", views.forum_create, name="forum_create"), |
| 24 | 35 | path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"), |
| 25 | 36 | path("forum/<int:post_id>/reply/", views.forum_reply, name="forum_reply"), |
| 26 | 37 | # Webhooks |
| @@ -54,11 +65,10 @@ | ||
| 54 | 65 | path("code/raw/<path:filepath>", views.code_raw, name="code_raw"), |
| 55 | 66 | path("code/blame/<path:filepath>", views.code_blame, name="code_blame"), |
| 56 | 67 | path("code/history/<path:filepath>", views.file_history, name="file_history"), |
| 57 | 68 | path("watch/", views.toggle_watch, name="toggle_watch"), |
| 58 | 69 | path("timeline/rss/", views.timeline_rss, name="timeline_rss"), |
| 59 | - path("tickets/export/", views.tickets_csv, name="tickets_csv"), | |
| 60 | 70 | path("docs/", views.fossil_docs, name="docs"), |
| 61 | 71 | path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"), |
| 62 | 72 | path("xfer", views.fossil_xfer, name="xfer"), |
| 63 | 73 | # Releases |
| 64 | 74 | path("releases/", views.release_list, name="releases"), |
| @@ -78,6 +88,9 @@ | ||
| 78 | 88 | # Branch Protection |
| 79 | 89 | path("branches/protect/", views.branch_protection_list, name="branch_protections"), |
| 80 | 90 | path("branches/protect/create/", views.branch_protection_create, name="branch_protection_create"), |
| 81 | 91 | path("branches/protect/<int:pk>/edit/", views.branch_protection_edit, name="branch_protection_edit"), |
| 82 | 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"), | |
| 83 | 96 | ] |
| 84 | 97 |
| --- 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 |
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -1233,10 +1233,14 @@ | ||
| 1233 | 1233 | |
| 1234 | 1234 | @login_required |
| 1235 | 1235 | def ticket_create(request, slug): |
| 1236 | 1236 | project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write") |
| 1237 | 1237 | |
| 1238 | + from fossil.ticket_fields import TicketFieldDefinition | |
| 1239 | + | |
| 1240 | + custom_fields = TicketFieldDefinition.objects.filter(repository=fossil_repo) | |
| 1241 | + | |
| 1238 | 1242 | if request.method == "POST": |
| 1239 | 1243 | title = request.POST.get("title", "").strip() |
| 1240 | 1244 | body = request.POST.get("body", "") |
| 1241 | 1245 | ticket_type = request.POST.get("type", "Code_Defect") |
| 1242 | 1246 | severity = request.POST.get("severity", "") |
| @@ -1245,26 +1249,42 @@ | ||
| 1245 | 1249 | |
| 1246 | 1250 | cli = FossilCLI() |
| 1247 | 1251 | fields = {"title": title, "type": ticket_type, "comment": body, "status": "Open"} |
| 1248 | 1252 | if severity: |
| 1249 | 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 | |
| 1250 | 1262 | success = cli.ticket_add(fossil_repo.full_path, fields) |
| 1251 | 1263 | if success: |
| 1252 | 1264 | from django.contrib import messages |
| 1253 | 1265 | |
| 1254 | 1266 | messages.success(request, f'Ticket "{title}" created.') |
| 1255 | 1267 | from django.shortcuts import redirect |
| 1256 | 1268 | |
| 1257 | 1269 | return redirect("fossil:tickets", slug=slug) |
| 1258 | 1270 | |
| 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 | + ) | |
| 1260 | 1276 | |
| 1261 | 1277 | |
| 1262 | 1278 | @login_required |
| 1263 | 1279 | def ticket_edit(request, slug, ticket_uuid): |
| 1264 | 1280 | project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write") |
| 1265 | 1281 | |
| 1282 | + from fossil.ticket_fields import TicketFieldDefinition | |
| 1283 | + | |
| 1284 | + custom_fields = TicketFieldDefinition.objects.filter(repository=fossil_repo) | |
| 1285 | + | |
| 1266 | 1286 | with reader: |
| 1267 | 1287 | ticket = reader.get_ticket_detail(ticket_uuid) |
| 1268 | 1288 | if not ticket: |
| 1269 | 1289 | raise Http404("Ticket not found") |
| 1270 | 1290 | |
| @@ -1275,10 +1295,18 @@ | ||
| 1275 | 1295 | fields = {} |
| 1276 | 1296 | for field in ["title", "status", "type", "severity", "priority", "resolution", "subsystem"]: |
| 1277 | 1297 | val = request.POST.get(field, "").strip() |
| 1278 | 1298 | if val: |
| 1279 | 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 | |
| 1280 | 1308 | if fields: |
| 1281 | 1309 | success = cli.ticket_change(fossil_repo.full_path, ticket.uuid, fields) |
| 1282 | 1310 | if success: |
| 1283 | 1311 | from django.contrib import messages |
| 1284 | 1312 | |
| @@ -1288,11 +1316,11 @@ | ||
| 1288 | 1316 | return redirect("fossil:ticket_detail", slug=slug, ticket_uuid=ticket.uuid) |
| 1289 | 1317 | |
| 1290 | 1318 | return render( |
| 1291 | 1319 | request, |
| 1292 | 1320 | "fossil/ticket_edit.html", |
| 1293 | - {"project": project, "ticket": ticket, "active_tab": "tickets"}, | |
| 1321 | + {"project": project, "ticket": ticket, "custom_fields": custom_fields, "active_tab": "tickets"}, | |
| 1294 | 1322 | ) |
| 1295 | 1323 | |
| 1296 | 1324 | |
| 1297 | 1325 | @login_required |
| 1298 | 1326 | def ticket_comment(request, slug, ticket_uuid): |
| @@ -3318,5 +3346,406 @@ | ||
| 3318 | 3346 | rule.soft_delete(user=request.user) |
| 3319 | 3347 | messages.success(request, f'Branch protection rule for "{rule.branch_pattern}" deleted.') |
| 3320 | 3348 | return redirect("fossil:branch_protections", slug=slug) |
| 3321 | 3349 | |
| 3322 | 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) | |
| 3323 | 3752 | |
| 3324 | 3753 | ADDED templates/accounts/notification_prefs.html |
| 3325 | 3754 | 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 @@ | ||
| 65 | 65 | {% for r in "Fixed,Rejected,Overcome_By_Events,Works_As_Designed,Unable_To_Reproduce,Not_A_Bug,Duplicate".split %} |
| 66 | 66 | <option value="{{ r }}" {% if r == ticket.resolution %}selected{% endif %}>{{ r }}</option> |
| 67 | 67 | {% endfor %} |
| 68 | 68 | </select> |
| 69 | 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 %} | |
| 70 | 108 | |
| 71 | 109 | <div class="flex justify-end gap-3 pt-2"> |
| 72 | 110 | <a href="{% url 'fossil:ticket_detail' slug=project.slug ticket_uuid=ticket.uuid %}" |
| 73 | 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"> |
| 74 | 112 | Cancel |
| 75 | 113 | |
| 76 | 114 | ADDED templates/fossil/ticket_fields_form.html |
| 77 | 115 | 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">← 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">← 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 @@ | ||
| 46 | 46 | <div> |
| 47 | 47 | <label class="block text-sm font-medium text-gray-300 mb-1">Description</label> |
| 48 | 48 | <textarea name="body" rows="10" placeholder="Describe the issue..." |
| 49 | 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 | 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 %} | |
| 51 | 89 | |
| 52 | 90 | <div class="flex justify-end gap-3 pt-2"> |
| 53 | 91 | <a href="{% url 'fossil:tickets' slug=project.slug %}" |
| 54 | 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"> |
| 55 | 93 | Cancel |
| 56 | 94 | |
| 57 | 95 | ADDED templates/fossil/ticket_report_form.html |
| 58 | 96 | ADDED templates/fossil/ticket_report_results.html |
| 59 | 97 | ADDED templates/fossil/ticket_reports_list.html |
| 60 | 98 | ADDED tests/test_bundle_cli.py |
| 61 | 99 | ADDED tests/test_notification_prefs.py |
| 62 | 100 | ADDED tests/test_shunning.py |
| 63 | 101 | ADDED tests/test_ticket_fields.py |
| 64 | 102 | 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">← 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">← 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">← 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">← 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 |