FossilRepo
Add technotes CRUD, unversioned files, starring, explore page, audit log, CI status checks, API tokens, branch protection Technotes: create/edit via fossil CLI, detail view with markdown rendering. Unversioned content: file list, download, admin upload via fossil uv CLI. Files tab added to project nav. Starring: toggle star per project, star count on cards. Explore page with public project discovery, sort by stars/recent/name, search. Works for anonymous users (public repos only). Audit log: unified view of all model changes via django-simple-history, filterable by model type. Superuser/org-admin access. CI status checks: external API (Bearer token auth) for posting build status per checkin. Badges (SVG), status display on checkin detail. API tokens: project-scoped tokens with SHA-256 hashed storage, one-time raw token display on creation. Revoke UI. Branch protection: advisory rules per branch pattern with required CI contexts and push restrictions. CRUD management UI. 156 new tests across 8 test files.
93c2492bfaff7336d58e4976312406b76ce97c4f5100ab1d722be7c46c583fec
| --- config/urls.py | ||
| +++ config/urls.py | ||
| @@ -235,14 +235,21 @@ | ||
| 235 | 235 | </body> |
| 236 | 236 | </html>""" |
| 237 | 237 | |
| 238 | 238 | return HttpResponse(html) |
| 239 | 239 | |
| 240 | + | |
| 241 | +def _explore_view(request): | |
| 242 | + from projects.views import explore | |
| 243 | + | |
| 244 | + return explore(request) | |
| 245 | + | |
| 240 | 246 | |
| 241 | 247 | urlpatterns = [ |
| 242 | 248 | path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)), |
| 243 | 249 | path("status/", status_page, name="status"), |
| 250 | + path("explore/", _explore_view, name="explore"), | |
| 244 | 251 | path("dashboard/", include("core.urls")), |
| 245 | 252 | path("auth/", include("accounts.urls")), |
| 246 | 253 | path("settings/", include("organization.urls")), |
| 247 | 254 | path("projects/", include("projects.urls")), |
| 248 | 255 | path("projects/<slug:slug>/fossil/", include("fossil.urls")), |
| 249 | 256 |
| --- config/urls.py | |
| +++ config/urls.py | |
| @@ -235,14 +235,21 @@ | |
| 235 | </body> |
| 236 | </html>""" |
| 237 | |
| 238 | return HttpResponse(html) |
| 239 | |
| 240 | |
| 241 | urlpatterns = [ |
| 242 | path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)), |
| 243 | path("status/", status_page, name="status"), |
| 244 | path("dashboard/", include("core.urls")), |
| 245 | path("auth/", include("accounts.urls")), |
| 246 | path("settings/", include("organization.urls")), |
| 247 | path("projects/", include("projects.urls")), |
| 248 | path("projects/<slug:slug>/fossil/", include("fossil.urls")), |
| 249 |
| --- config/urls.py | |
| +++ config/urls.py | |
| @@ -235,14 +235,21 @@ | |
| 235 | </body> |
| 236 | </html>""" |
| 237 | |
| 238 | return HttpResponse(html) |
| 239 | |
| 240 | |
| 241 | def _explore_view(request): |
| 242 | from projects.views import explore |
| 243 | |
| 244 | return explore(request) |
| 245 | |
| 246 | |
| 247 | urlpatterns = [ |
| 248 | path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)), |
| 249 | path("status/", status_page, name="status"), |
| 250 | path("explore/", _explore_view, name="explore"), |
| 251 | path("dashboard/", include("core.urls")), |
| 252 | path("auth/", include("accounts.urls")), |
| 253 | path("settings/", include("organization.urls")), |
| 254 | path("projects/", include("projects.urls")), |
| 255 | path("projects/<slug:slug>/fossil/", include("fossil.urls")), |
| 256 |
| --- fossil/admin.py | ||
| +++ fossil/admin.py | ||
| @@ -1,9 +1,12 @@ | ||
| 1 | 1 | from django.contrib import admin |
| 2 | 2 | |
| 3 | 3 | from core.admin import BaseCoreAdmin |
| 4 | 4 | |
| 5 | +from .api_tokens import APIToken | |
| 6 | +from .branch_protection import BranchProtection | |
| 7 | +from .ci import StatusCheck | |
| 5 | 8 | from .forum import ForumPost |
| 6 | 9 | from .models import FossilRepository, FossilSnapshot |
| 7 | 10 | from .notifications import Notification, ProjectWatch |
| 8 | 11 | from .releases import Release, ReleaseAsset |
| 9 | 12 | from .sync_models import GitMirror, SSHKey, SyncLog |
| @@ -126,5 +129,29 @@ | ||
| 126 | 129 | @admin.register(WebhookDelivery) |
| 127 | 130 | class WebhookDeliveryAdmin(admin.ModelAdmin): |
| 128 | 131 | list_display = ("webhook", "event_type", "response_status", "success", "delivered_at", "duration_ms") |
| 129 | 132 | list_filter = ("success", "event_type") |
| 130 | 133 | raw_id_fields = ("webhook",) |
| 134 | + | |
| 135 | + | |
| 136 | +@admin.register(StatusCheck) | |
| 137 | +class StatusCheckAdmin(BaseCoreAdmin): | |
| 138 | + list_display = ("context", "state", "checkin_uuid", "repository", "created_at") | |
| 139 | + list_filter = ("state",) | |
| 140 | + search_fields = ("context", "checkin_uuid") | |
| 141 | + raw_id_fields = ("repository",) | |
| 142 | + | |
| 143 | + | |
| 144 | +@admin.register(APIToken) | |
| 145 | +class APITokenAdmin(BaseCoreAdmin): | |
| 146 | + list_display = ("name", "token_prefix", "repository", "permissions", "last_used_at", "expires_at", "created_at") | |
| 147 | + search_fields = ("name", "token_prefix") | |
| 148 | + raw_id_fields = ("repository",) | |
| 149 | + readonly_fields = ("token_hash", "token_prefix") | |
| 150 | + | |
| 151 | + | |
| 152 | +@admin.register(BranchProtection) | |
| 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",) | |
| 131 | 158 | |
| 132 | 159 | ADDED fossil/api_tokens.py |
| 133 | 160 | ADDED fossil/branch_protection.py |
| 134 | 161 | ADDED fossil/ci.py |
| --- fossil/admin.py | |
| +++ fossil/admin.py | |
| @@ -1,9 +1,12 @@ | |
| 1 | from django.contrib import admin |
| 2 | |
| 3 | from core.admin import BaseCoreAdmin |
| 4 | |
| 5 | from .forum import ForumPost |
| 6 | from .models import FossilRepository, FossilSnapshot |
| 7 | from .notifications import Notification, ProjectWatch |
| 8 | from .releases import Release, ReleaseAsset |
| 9 | from .sync_models import GitMirror, SSHKey, SyncLog |
| @@ -126,5 +129,29 @@ | |
| 126 | @admin.register(WebhookDelivery) |
| 127 | class WebhookDeliveryAdmin(admin.ModelAdmin): |
| 128 | list_display = ("webhook", "event_type", "response_status", "success", "delivered_at", "duration_ms") |
| 129 | list_filter = ("success", "event_type") |
| 130 | raw_id_fields = ("webhook",) |
| 131 | |
| 132 | DDED fossil/api_tokens.py |
| 133 | DDED fossil/branch_protection.py |
| 134 | DDED fossil/ci.py |
| --- fossil/admin.py | |
| +++ fossil/admin.py | |
| @@ -1,9 +1,12 @@ | |
| 1 | from django.contrib import admin |
| 2 | |
| 3 | from core.admin import BaseCoreAdmin |
| 4 | |
| 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 |
| @@ -126,5 +129,29 @@ | |
| 129 | @admin.register(WebhookDelivery) |
| 130 | class WebhookDeliveryAdmin(admin.ModelAdmin): |
| 131 | list_display = ("webhook", "event_type", "response_status", "success", "delivered_at", "duration_ms") |
| 132 | list_filter = ("success", "event_type") |
| 133 | raw_id_fields = ("webhook",) |
| 134 | |
| 135 | |
| 136 | @admin.register(StatusCheck) |
| 137 | class StatusCheckAdmin(BaseCoreAdmin): |
| 138 | list_display = ("context", "state", "checkin_uuid", "repository", "created_at") |
| 139 | list_filter = ("state",) |
| 140 | search_fields = ("context", "checkin_uuid") |
| 141 | raw_id_fields = ("repository",) |
| 142 | |
| 143 | |
| 144 | @admin.register(APIToken) |
| 145 | class APITokenAdmin(BaseCoreAdmin): |
| 146 | list_display = ("name", "token_prefix", "repository", "permissions", "last_used_at", "expires_at", "created_at") |
| 147 | search_fields = ("name", "token_prefix") |
| 148 | raw_id_fields = ("repository",) |
| 149 | readonly_fields = ("token_hash", "token_prefix") |
| 150 | |
| 151 | |
| 152 | @admin.register(BranchProtection) |
| 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 | |
| 159 | DDED fossil/api_tokens.py |
| 160 | DDED fossil/branch_protection.py |
| 161 | DDED fossil/ci.py |
| --- a/fossil/api_tokens.py | ||
| +++ b/fossil/api_tokens.py | ||
| @@ -0,0 +1,70 @@ | ||
| 1 | +"""API tokens scoped to a repository for CI/CD and automation. | |
| 2 | + | |
| 3 | +Tokens are stored as SHA-256 hashes -- the raw value is shown once on creation | |
| 4 | +and never stored in plaintext. | |
| 5 | +""" | |
| 6 | + | |
| 7 | +import hashlib | |
| 8 | +import secrets | |
| 9 | + | |
| 10 | +from django.db import models | |
| 11 | +from django.utils import timezone | |
| 12 | + | |
| 13 | +from core.models import ActiveManager, Tracking | |
| 14 | + | |
| 15 | + | |
| 16 | +class APIToken(Tracking): | |
| 17 | + """API token scoped to a repository for CI/CD and automation.""" | |
| 18 | + | |
| 19 | + repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="api_tokens") | |
| 20 | + name = models.CharField(max_length=200) | |
| 21 | + token_hash = models.CharField(max_length=64, unique=True, help_text="SHA-256 hash of the token") | |
| 22 | + token_prefix = models.CharField(max_length=12, help_text="First 12 chars for identification") | |
| 23 | + permissions = models.CharField(max_length=200, default="status:write", help_text="Comma-separated permissions") | |
| 24 | + expires_at = models.DateTimeField(null=True, blank=True) | |
| 25 | + last_used_at = models.DateTimeField(null=True, blank=True) | |
| 26 | + | |
| 27 | + objects = ActiveManager() | |
| 28 | + all_objects = models.Manager() | |
| 29 | + | |
| 30 | + class Meta: | |
| 31 | + ordering = ["-created_at"] | |
| 32 | + | |
| 33 | + @staticmethod | |
| 34 | + def generate(): | |
| 35 | + """Generate a new token. Returns (raw_token, token_hash, prefix).""" | |
| 36 | + raw = f"frp_{secrets.token_urlsafe(32)}" | |
| 37 | + hash_val = hashlib.sha256(raw.encode()).hexdigest() | |
| 38 | + prefix = raw[:12] | |
| 39 | + return raw, hash_val, prefix | |
| 40 | + | |
| 41 | + @staticmethod | |
| 42 | + def hash_token(raw_token): | |
| 43 | + return hashlib.sha256(raw_token.encode()).hexdigest() | |
| 44 | + | |
| 45 | + def has_permission(self, permission): | |
| 46 | + """Check if this token has a specific permission.""" | |
| 47 | + perms = [p.strip() for p in self.permissions.split(",")] | |
| 48 | + return permission in perms or "*" in perms | |
| 49 | + | |
| 50 | + def __str__(self): | |
| 51 | + return f"{self.name} ({self.token_prefix}...)" | |
| 52 | + | |
| 53 | + | |
| 54 | +def authenticate_api_token(request, repository): | |
| 55 | + """Check Bearer token auth. Returns APIToken or None.""" | |
| 56 | + auth = request.META.get("HTTP_AUTHORIZATION", "") | |
| 57 | + if not auth.startswith("Bearer "): | |
| 58 | + return None | |
| 59 | + raw_token = auth[7:] | |
| 60 | + token_hash = APIToken.hash_token(raw_token) | |
| 61 | + try: | |
| 62 | + token = APIToken.objects.get(token_hash=token_hash, repository=repository, deleted_at__isnull=True) | |
| 63 | + # Check expiry | |
| 64 | + if token.expires_at and token.expires_at < timezone.now(): | |
| 65 | + return None | |
| 66 | + token.last_used_at = timezone.now() | |
| 67 | + token.save(update_fields=["last_used_at"]) | |
| 68 | + return token | |
| 69 | + except APIToken.DoesNotExist: | |
| 70 | + return None |
| --- a/fossil/api_tokens.py | |
| +++ b/fossil/api_tokens.py | |
| @@ -0,0 +1,70 @@ | |
| --- a/fossil/api_tokens.py | |
| +++ b/fossil/api_tokens.py | |
| @@ -0,0 +1,70 @@ | |
| 1 | """API tokens scoped to a repository for CI/CD and automation. |
| 2 | |
| 3 | Tokens are stored as SHA-256 hashes -- the raw value is shown once on creation |
| 4 | and never stored in plaintext. |
| 5 | """ |
| 6 | |
| 7 | import hashlib |
| 8 | import secrets |
| 9 | |
| 10 | from django.db import models |
| 11 | from django.utils import timezone |
| 12 | |
| 13 | from core.models import ActiveManager, Tracking |
| 14 | |
| 15 | |
| 16 | class APIToken(Tracking): |
| 17 | """API token scoped to a repository for CI/CD and automation.""" |
| 18 | |
| 19 | repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="api_tokens") |
| 20 | name = models.CharField(max_length=200) |
| 21 | token_hash = models.CharField(max_length=64, unique=True, help_text="SHA-256 hash of the token") |
| 22 | token_prefix = models.CharField(max_length=12, help_text="First 12 chars for identification") |
| 23 | permissions = models.CharField(max_length=200, default="status:write", help_text="Comma-separated permissions") |
| 24 | expires_at = models.DateTimeField(null=True, blank=True) |
| 25 | last_used_at = models.DateTimeField(null=True, blank=True) |
| 26 | |
| 27 | objects = ActiveManager() |
| 28 | all_objects = models.Manager() |
| 29 | |
| 30 | class Meta: |
| 31 | ordering = ["-created_at"] |
| 32 | |
| 33 | @staticmethod |
| 34 | def generate(): |
| 35 | """Generate a new token. Returns (raw_token, token_hash, prefix).""" |
| 36 | raw = f"frp_{secrets.token_urlsafe(32)}" |
| 37 | hash_val = hashlib.sha256(raw.encode()).hexdigest() |
| 38 | prefix = raw[:12] |
| 39 | return raw, hash_val, prefix |
| 40 | |
| 41 | @staticmethod |
| 42 | def hash_token(raw_token): |
| 43 | return hashlib.sha256(raw_token.encode()).hexdigest() |
| 44 | |
| 45 | def has_permission(self, permission): |
| 46 | """Check if this token has a specific permission.""" |
| 47 | perms = [p.strip() for p in self.permissions.split(",")] |
| 48 | return permission in perms or "*" in perms |
| 49 | |
| 50 | def __str__(self): |
| 51 | return f"{self.name} ({self.token_prefix}...)" |
| 52 | |
| 53 | |
| 54 | def authenticate_api_token(request, repository): |
| 55 | """Check Bearer token auth. Returns APIToken or None.""" |
| 56 | auth = request.META.get("HTTP_AUTHORIZATION", "") |
| 57 | if not auth.startswith("Bearer "): |
| 58 | return None |
| 59 | raw_token = auth[7:] |
| 60 | token_hash = APIToken.hash_token(raw_token) |
| 61 | try: |
| 62 | token = APIToken.objects.get(token_hash=token_hash, repository=repository, deleted_at__isnull=True) |
| 63 | # Check expiry |
| 64 | if token.expires_at and token.expires_at < timezone.now(): |
| 65 | return None |
| 66 | token.last_used_at = timezone.now() |
| 67 | token.save(update_fields=["last_used_at"]) |
| 68 | return token |
| 69 | except APIToken.DoesNotExist: |
| 70 | return None |
| --- a/fossil/branch_protection.py | ||
| +++ b/fossil/branch_protection.py | ||
| @@ -0,0 +1,33 @@ | ||
| 1 | +"""Branch protection rules for Fossil repositories. | |
| 2 | + | |
| 3 | +Advisory for now -- the model and UI are ready, but enforcement via push hooks | |
| 4 | +is not yet implemented. | |
| 5 | +""" | |
| 6 | + | |
| 7 | +from django.db import models | |
| 8 | + | |
| 9 | +from core.models import ActiveManager, Tracking | |
| 10 | + | |
| 11 | + | |
| 12 | +class BranchProtection(Tracking): | |
| 13 | + """Branch protection rules for a repository.""" | |
| 14 | + | |
| 15 | + repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="branch_protections") | |
| 16 | + branch_pattern = models.CharField(max_length=200, help_text="Branch name or glob pattern (e.g., 'trunk', 'release-*')") | |
| 17 | + require_status_checks = models.BooleanField(default=False) | |
| 18 | + required_contexts = models.TextField(blank=True, default="", help_text="Required CI contexts, one per line") | |
| 19 | + restrict_push = models.BooleanField(default=True, help_text="Only admins can push") | |
| 20 | + | |
| 21 | + objects = ActiveManager() | |
| 22 | + all_objects = models.Manager() | |
| 23 | + | |
| 24 | + class Meta: | |
| 25 | + ordering = ["branch_pattern"] | |
| 26 | + unique_together = [("repository", "branch_pattern")] | |
| 27 | + | |
| 28 | + def get_required_contexts_list(self): | |
| 29 | + """Return required_contexts as a list, filtering blanks.""" | |
| 30 | + return [c.strip() for c in self.required_contexts.splitlines() if c.strip()] | |
| 31 | + | |
| 32 | + def __str__(self): | |
| 33 | + return f"{self.branch_pattern} ({self.repository})" |
| --- a/fossil/branch_protection.py | |
| +++ b/fossil/branch_protection.py | |
| @@ -0,0 +1,33 @@ | |
| --- a/fossil/branch_protection.py | |
| +++ b/fossil/branch_protection.py | |
| @@ -0,0 +1,33 @@ | |
| 1 | """Branch protection rules for Fossil repositories. |
| 2 | |
| 3 | Advisory for now -- the model and UI are ready, but enforcement via push hooks |
| 4 | is not yet implemented. |
| 5 | """ |
| 6 | |
| 7 | from django.db import models |
| 8 | |
| 9 | from core.models import ActiveManager, Tracking |
| 10 | |
| 11 | |
| 12 | class BranchProtection(Tracking): |
| 13 | """Branch protection rules for a repository.""" |
| 14 | |
| 15 | repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="branch_protections") |
| 16 | branch_pattern = models.CharField(max_length=200, help_text="Branch name or glob pattern (e.g., 'trunk', 'release-*')") |
| 17 | require_status_checks = models.BooleanField(default=False) |
| 18 | required_contexts = models.TextField(blank=True, default="", help_text="Required CI contexts, one per line") |
| 19 | restrict_push = models.BooleanField(default=True, help_text="Only admins can push") |
| 20 | |
| 21 | objects = ActiveManager() |
| 22 | all_objects = models.Manager() |
| 23 | |
| 24 | class Meta: |
| 25 | ordering = ["branch_pattern"] |
| 26 | unique_together = [("repository", "branch_pattern")] |
| 27 | |
| 28 | def get_required_contexts_list(self): |
| 29 | """Return required_contexts as a list, filtering blanks.""" |
| 30 | return [c.strip() for c in self.required_contexts.splitlines() if c.strip()] |
| 31 | |
| 32 | def __str__(self): |
| 33 | return f"{self.branch_pattern} ({self.repository})" |
| --- a/fossil/ci.py | ||
| +++ b/fossil/ci.py | ||
| @@ -0,0 +1,36 @@ | ||
| 1 | +"""CI status checks for Fossil checkins. | |
| 2 | + | |
| 3 | +External CI systems (GitHub Actions, Jenkins, etc.) POST status results | |
| 4 | +for specific checkins. Results are displayed as badges on the checkin detail view. | |
| 5 | +""" | |
| 6 | + | |
| 7 | +from django.db import models | |
| 8 | + | |
| 9 | +from core.models import ActiveManager, Tracking | |
| 10 | + | |
| 11 | + | |
| 12 | +class StatusCheck(Tracking): | |
| 13 | + """CI status check result for a specific checkin.""" | |
| 14 | + | |
| 15 | + class State(models.TextChoices): | |
| 16 | + PENDING = "pending", "Pending" | |
| 17 | + SUCCESS = "success", "Success" | |
| 18 | + FAILURE = "failure", "Failure" | |
| 19 | + ERROR = "error", "Error" | |
| 20 | + | |
| 21 | + repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="status_checks") | |
| 22 | + checkin_uuid = models.CharField(max_length=64, db_index=True) | |
| 23 | + context = models.CharField(max_length=200, help_text="CI context name (e.g., 'ci/tests', 'ci/lint')") | |
| 24 | + state = models.CharField(max_length=20, choices=State.choices, default=State.PENDING) | |
| 25 | + description = models.CharField(max_length=500, blank=True, default="") | |
| 26 | + target_url = models.URLField(blank=True, default="", help_text="Link to CI build details") | |
| 27 | + | |
| 28 | + objects = ActiveManager() | |
| 29 | + all_objects = models.Manager() | |
| 30 | + | |
| 31 | + class Meta: | |
| 32 | + ordering = ["-created_at"] | |
| 33 | + unique_together = [("repository", "checkin_uuid", "context")] | |
| 34 | + | |
| 35 | + def __str__(self): | |
| 36 | + return f"{self.context}: {self.state} @ {self.checkin_uuid[:10]}" |
| --- a/fossil/ci.py | |
| +++ b/fossil/ci.py | |
| @@ -0,0 +1,36 @@ | |
| --- a/fossil/ci.py | |
| +++ b/fossil/ci.py | |
| @@ -0,0 +1,36 @@ | |
| 1 | """CI status checks for Fossil checkins. |
| 2 | |
| 3 | External CI systems (GitHub Actions, Jenkins, etc.) POST status results |
| 4 | for specific checkins. Results are displayed as badges on the checkin detail view. |
| 5 | """ |
| 6 | |
| 7 | from django.db import models |
| 8 | |
| 9 | from core.models import ActiveManager, Tracking |
| 10 | |
| 11 | |
| 12 | class StatusCheck(Tracking): |
| 13 | """CI status check result for a specific checkin.""" |
| 14 | |
| 15 | class State(models.TextChoices): |
| 16 | PENDING = "pending", "Pending" |
| 17 | SUCCESS = "success", "Success" |
| 18 | FAILURE = "failure", "Failure" |
| 19 | ERROR = "error", "Error" |
| 20 | |
| 21 | repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="status_checks") |
| 22 | checkin_uuid = models.CharField(max_length=64, db_index=True) |
| 23 | context = models.CharField(max_length=200, help_text="CI context name (e.g., 'ci/tests', 'ci/lint')") |
| 24 | state = models.CharField(max_length=20, choices=State.choices, default=State.PENDING) |
| 25 | description = models.CharField(max_length=500, blank=True, default="") |
| 26 | target_url = models.URLField(blank=True, default="", help_text="Link to CI build details") |
| 27 | |
| 28 | objects = ActiveManager() |
| 29 | all_objects = models.Manager() |
| 30 | |
| 31 | class Meta: |
| 32 | ordering = ["-created_at"] |
| 33 | unique_together = [("repository", "checkin_uuid", "context")] |
| 34 | |
| 35 | def __str__(self): |
| 36 | return f"{self.context}: {self.state} @ {self.checkin_uuid[:10]}" |
| --- fossil/cli.py | ||
| +++ fossil/cli.py | ||
| @@ -201,10 +201,57 @@ | ||
| 201 | 201 | for key, value in fields.items(): |
| 202 | 202 | cmd.append(f"{key}") |
| 203 | 203 | cmd.append(f"{value}") |
| 204 | 204 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env) |
| 205 | 205 | return result.returncode == 0 |
| 206 | + | |
| 207 | + def technote_create(self, repo_path: Path, title: str, body: str, timestamp: str | None = None, user: str = "") -> bool: | |
| 208 | + """Create a new technote. | |
| 209 | + | |
| 210 | + Uses: fossil wiki create --technote <timestamp> <title> -R <repo> | |
| 211 | + with the body piped via stdin. | |
| 212 | + """ | |
| 213 | + if not timestamp: | |
| 214 | + from datetime import UTC, datetime | |
| 215 | + | |
| 216 | + timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") | |
| 217 | + | |
| 218 | + cmd = [self.binary, "wiki", "create", title, "--technote", timestamp, "-R", str(repo_path)] | |
| 219 | + if user: | |
| 220 | + cmd.extend(["--technote-user", user]) | |
| 221 | + result = subprocess.run(cmd, input=body, capture_output=True, text=True, timeout=30, env=self._env) | |
| 222 | + return result.returncode == 0 | |
| 223 | + | |
| 224 | + def technote_edit(self, repo_path: Path, technote_id: str, body: str, user: str = "") -> bool: | |
| 225 | + """Edit an existing technote body. | |
| 226 | + | |
| 227 | + Uses: fossil wiki commit <comment> --technote <technote_id> -R <repo> | |
| 228 | + with the new body piped via stdin. | |
| 229 | + """ | |
| 230 | + cmd = [self.binary, "wiki", "commit", "update", "--technote", technote_id, "-R", str(repo_path)] | |
| 231 | + if user: | |
| 232 | + cmd.extend(["--technote-user", user]) | |
| 233 | + result = subprocess.run(cmd, input=body, capture_output=True, text=True, timeout=30, env=self._env) | |
| 234 | + return result.returncode == 0 | |
| 235 | + | |
| 236 | + def uv_add(self, repo_path: Path, name: str, filepath: Path) -> bool: | |
| 237 | + """Add an unversioned file: fossil uv add <filepath> --as <name> -R <repo>.""" | |
| 238 | + cmd = [self.binary, "uv", "add", str(filepath), "--as", name, "-R", str(repo_path)] | |
| 239 | + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, env=self._env) | |
| 240 | + return result.returncode == 0 | |
| 241 | + | |
| 242 | + def uv_cat(self, repo_path: Path, name: str) -> bytes: | |
| 243 | + """Get unversioned file content: fossil uv cat <name> -R <repo>. | |
| 244 | + | |
| 245 | + Returns raw bytes. Raises FileNotFoundError if the file doesn't exist | |
| 246 | + or the command fails. | |
| 247 | + """ | |
| 248 | + cmd = [self.binary, "uv", "cat", name, "-R", str(repo_path)] | |
| 249 | + result = subprocess.run(cmd, capture_output=True, timeout=60, env=self._env) | |
| 250 | + if result.returncode != 0: | |
| 251 | + raise FileNotFoundError(f"Unversioned file not found: {name}") | |
| 252 | + return result.stdout | |
| 206 | 253 | |
| 207 | 254 | def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "", auth_token: str = "") -> dict: |
| 208 | 255 | """Export Fossil repo to a Git mirror directory. Incremental. |
| 209 | 256 | |
| 210 | 257 | When auth_token is provided, credentials are passed via Git environment |
| 211 | 258 | |
| 212 | 259 | ADDED fossil/migrations/0008_apitoken_historicalapitoken_and_more.py |
| --- fossil/cli.py | |
| +++ fossil/cli.py | |
| @@ -201,10 +201,57 @@ | |
| 201 | for key, value in fields.items(): |
| 202 | cmd.append(f"{key}") |
| 203 | cmd.append(f"{value}") |
| 204 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env) |
| 205 | return result.returncode == 0 |
| 206 | |
| 207 | def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "", auth_token: str = "") -> dict: |
| 208 | """Export Fossil repo to a Git mirror directory. Incremental. |
| 209 | |
| 210 | When auth_token is provided, credentials are passed via Git environment |
| 211 | |
| 212 | DDED fossil/migrations/0008_apitoken_historicalapitoken_and_more.py |
| --- fossil/cli.py | |
| +++ fossil/cli.py | |
| @@ -201,10 +201,57 @@ | |
| 201 | for key, value in fields.items(): |
| 202 | cmd.append(f"{key}") |
| 203 | cmd.append(f"{value}") |
| 204 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env) |
| 205 | return result.returncode == 0 |
| 206 | |
| 207 | def technote_create(self, repo_path: Path, title: str, body: str, timestamp: str | None = None, user: str = "") -> bool: |
| 208 | """Create a new technote. |
| 209 | |
| 210 | Uses: fossil wiki create --technote <timestamp> <title> -R <repo> |
| 211 | with the body piped via stdin. |
| 212 | """ |
| 213 | if not timestamp: |
| 214 | from datetime import UTC, datetime |
| 215 | |
| 216 | timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") |
| 217 | |
| 218 | cmd = [self.binary, "wiki", "create", title, "--technote", timestamp, "-R", str(repo_path)] |
| 219 | if user: |
| 220 | cmd.extend(["--technote-user", user]) |
| 221 | result = subprocess.run(cmd, input=body, capture_output=True, text=True, timeout=30, env=self._env) |
| 222 | return result.returncode == 0 |
| 223 | |
| 224 | def technote_edit(self, repo_path: Path, technote_id: str, body: str, user: str = "") -> bool: |
| 225 | """Edit an existing technote body. |
| 226 | |
| 227 | Uses: fossil wiki commit <comment> --technote <technote_id> -R <repo> |
| 228 | with the new body piped via stdin. |
| 229 | """ |
| 230 | cmd = [self.binary, "wiki", "commit", "update", "--technote", technote_id, "-R", str(repo_path)] |
| 231 | if user: |
| 232 | cmd.extend(["--technote-user", user]) |
| 233 | result = subprocess.run(cmd, input=body, capture_output=True, text=True, timeout=30, env=self._env) |
| 234 | return result.returncode == 0 |
| 235 | |
| 236 | def uv_add(self, repo_path: Path, name: str, filepath: Path) -> bool: |
| 237 | """Add an unversioned file: fossil uv add <filepath> --as <name> -R <repo>.""" |
| 238 | cmd = [self.binary, "uv", "add", str(filepath), "--as", name, "-R", str(repo_path)] |
| 239 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, env=self._env) |
| 240 | return result.returncode == 0 |
| 241 | |
| 242 | def uv_cat(self, repo_path: Path, name: str) -> bytes: |
| 243 | """Get unversioned file content: fossil uv cat <name> -R <repo>. |
| 244 | |
| 245 | Returns raw bytes. Raises FileNotFoundError if the file doesn't exist |
| 246 | or the command fails. |
| 247 | """ |
| 248 | cmd = [self.binary, "uv", "cat", name, "-R", str(repo_path)] |
| 249 | result = subprocess.run(cmd, capture_output=True, timeout=60, env=self._env) |
| 250 | if result.returncode != 0: |
| 251 | raise FileNotFoundError(f"Unversioned file not found: {name}") |
| 252 | return result.stdout |
| 253 | |
| 254 | def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "", auth_token: str = "") -> dict: |
| 255 | """Export Fossil repo to a Git mirror directory. Incremental. |
| 256 | |
| 257 | When auth_token is provided, credentials are passed via Git environment |
| 258 | |
| 259 | DDED fossil/migrations/0008_apitoken_historicalapitoken_and_more.py |
| --- a/fossil/migrations/0008_apitoken_historicalapitoken_and_more.py | ||
| +++ b/fossil/migrations/0008_apitoken_historicalapitoken_and_more.py | ||
| @@ -0,0 +1,588 @@ | ||
| 1 | +# Generated by Django 5.2.12 on 2026-04-07 15:02 | |
| 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", "0007_forumpost_historicalforumpost_historicalwebhook_and_more"), | |
| 12 | + migrations.swappable_dependency(settings.AUTH_USER_MODEL), | |
| 13 | + ] | |
| 14 | + | |
| 15 | + operations = [ | |
| 16 | + migrations.CreateModel( | |
| 17 | + name="APIToken", | |
| 18 | + fields=[ | |
| 19 | + ( | |
| 20 | + "id", | |
| 21 | + models.BigAutoField( | |
| 22 | + auto_created=True, | |
| 23 | + primary_key=True, | |
| 24 | + serialize=False, | |
| 25 | + verbose_name="ID", | |
| 26 | + ), | |
| 27 | + ), | |
| 28 | + ("version", models.PositiveIntegerField(default=1, editable=False)), | |
| 29 | + ("created_at", models.DateTimeField(auto_now_add=True)), | |
| 30 | + ("updated_at", models.DateTimeField(auto_now=True)), | |
| 31 | + ("deleted_at", models.DateTimeField(blank=True, null=True)), | |
| 32 | + ("name", models.CharField(max_length=200)), | |
| 33 | + ( | |
| 34 | + "token_hash", | |
| 35 | + models.CharField( | |
| 36 | + help_text="SHA-256 hash of the token", | |
| 37 | + max_length=64, | |
| 38 | + unique=True, | |
| 39 | + ), | |
| 40 | + ), | |
| 41 | + ( | |
| 42 | + "token_prefix", | |
| 43 | + models.CharField(help_text="First 12 chars for identification", max_length=12), | |
| 44 | + ), | |
| 45 | + ( | |
| 46 | + "permissions", | |
| 47 | + models.CharField( | |
| 48 | + default="status:write", | |
| 49 | + help_text="Comma-separated permissions", | |
| 50 | + max_length=200, | |
| 51 | + ), | |
| 52 | + ), | |
| 53 | + ("expires_at", models.DateTimeField(blank=True, null=True)), | |
| 54 | + ("last_used_at", models.DateTimeField(blank=True, null=True)), | |
| 55 | + ( | |
| 56 | + "created_by", | |
| 57 | + models.ForeignKey( | |
| 58 | + blank=True, | |
| 59 | + null=True, | |
| 60 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 61 | + related_name="+", | |
| 62 | + to=settings.AUTH_USER_MODEL, | |
| 63 | + ), | |
| 64 | + ), | |
| 65 | + ( | |
| 66 | + "deleted_by", | |
| 67 | + models.ForeignKey( | |
| 68 | + blank=True, | |
| 69 | + null=True, | |
| 70 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 71 | + related_name="+", | |
| 72 | + to=settings.AUTH_USER_MODEL, | |
| 73 | + ), | |
| 74 | + ), | |
| 75 | + ( | |
| 76 | + "repository", | |
| 77 | + models.ForeignKey( | |
| 78 | + on_delete=django.db.models.deletion.CASCADE, | |
| 79 | + related_name="api_tokens", | |
| 80 | + to="fossil.fossilrepository", | |
| 81 | + ), | |
| 82 | + ), | |
| 83 | + ( | |
| 84 | + "updated_by", | |
| 85 | + models.ForeignKey( | |
| 86 | + blank=True, | |
| 87 | + null=True, | |
| 88 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 89 | + related_name="+", | |
| 90 | + to=settings.AUTH_USER_MODEL, | |
| 91 | + ), | |
| 92 | + ), | |
| 93 | + ], | |
| 94 | + options={ | |
| 95 | + "ordering": ["-created_at"], | |
| 96 | + }, | |
| 97 | + ), | |
| 98 | + migrations.CreateModel( | |
| 99 | + name="HistoricalAPIToken", | |
| 100 | + fields=[ | |
| 101 | + ( | |
| 102 | + "id", | |
| 103 | + models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"), | |
| 104 | + ), | |
| 105 | + ("version", models.PositiveIntegerField(default=1, editable=False)), | |
| 106 | + ("created_at", models.DateTimeField(blank=True, editable=False)), | |
| 107 | + ("updated_at", models.DateTimeField(blank=True, editable=False)), | |
| 108 | + ("deleted_at", models.DateTimeField(blank=True, null=True)), | |
| 109 | + ("name", models.CharField(max_length=200)), | |
| 110 | + ( | |
| 111 | + "token_hash", | |
| 112 | + models.CharField( | |
| 113 | + db_index=True, | |
| 114 | + help_text="SHA-256 hash of the token", | |
| 115 | + max_length=64, | |
| 116 | + ), | |
| 117 | + ), | |
| 118 | + ( | |
| 119 | + "token_prefix", | |
| 120 | + models.CharField(help_text="First 12 chars for identification", max_length=12), | |
| 121 | + ), | |
| 122 | + ( | |
| 123 | + "permissions", | |
| 124 | + models.CharField( | |
| 125 | + default="status:write", | |
| 126 | + help_text="Comma-separated permissions", | |
| 127 | + max_length=200, | |
| 128 | + ), | |
| 129 | + ), | |
| 130 | + ("expires_at", models.DateTimeField(blank=True, null=True)), | |
| 131 | + ("last_used_at", models.DateTimeField(blank=True, null=True)), | |
| 132 | + ("history_id", models.AutoField(primary_key=True, serialize=False)), | |
| 133 | + ("history_date", models.DateTimeField(db_index=True)), | |
| 134 | + ("history_change_reason", models.CharField(max_length=100, null=True)), | |
| 135 | + ( | |
| 136 | + "history_type", | |
| 137 | + models.CharField( | |
| 138 | + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], | |
| 139 | + max_length=1, | |
| 140 | + ), | |
| 141 | + ), | |
| 142 | + ( | |
| 143 | + "created_by", | |
| 144 | + models.ForeignKey( | |
| 145 | + blank=True, | |
| 146 | + db_constraint=False, | |
| 147 | + null=True, | |
| 148 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 149 | + related_name="+", | |
| 150 | + to=settings.AUTH_USER_MODEL, | |
| 151 | + ), | |
| 152 | + ), | |
| 153 | + ( | |
| 154 | + "deleted_by", | |
| 155 | + models.ForeignKey( | |
| 156 | + blank=True, | |
| 157 | + db_constraint=False, | |
| 158 | + null=True, | |
| 159 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 160 | + related_name="+", | |
| 161 | + to=settings.AUTH_USER_MODEL, | |
| 162 | + ), | |
| 163 | + ), | |
| 164 | + ( | |
| 165 | + "history_user", | |
| 166 | + models.ForeignKey( | |
| 167 | + null=True, | |
| 168 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 169 | + related_name="+", | |
| 170 | + to=settings.AUTH_USER_MODEL, | |
| 171 | + ), | |
| 172 | + ), | |
| 173 | + ( | |
| 174 | + "repository", | |
| 175 | + models.ForeignKey( | |
| 176 | + blank=True, | |
| 177 | + db_constraint=False, | |
| 178 | + null=True, | |
| 179 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 180 | + related_name="+", | |
| 181 | + to="fossil.fossilrepository", | |
| 182 | + ), | |
| 183 | + ), | |
| 184 | + ( | |
| 185 | + "updated_by", | |
| 186 | + models.ForeignKey( | |
| 187 | + blank=True, | |
| 188 | + db_constraint=False, | |
| 189 | + null=True, | |
| 190 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 191 | + related_name="+", | |
| 192 | + to=settings.AUTH_USER_MODEL, | |
| 193 | + ), | |
| 194 | + ), | |
| 195 | + ], | |
| 196 | + options={ | |
| 197 | + "verbose_name": "historical api token", | |
| 198 | + "verbose_name_plural": "historical api tokens", | |
| 199 | + "ordering": ("-history_date", "-history_id"), | |
| 200 | + "get_latest_by": ("history_date", "history_id"), | |
| 201 | + }, | |
| 202 | + bases=(simple_history.models.HistoricalChanges, models.Model), | |
| 203 | + ), | |
| 204 | + migrations.CreateModel( | |
| 205 | + name="HistoricalBranchProtection", | |
| 206 | + fields=[ | |
| 207 | + ( | |
| 208 | + "id", | |
| 209 | + models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"), | |
| 210 | + ), | |
| 211 | + ("version", models.PositiveIntegerField(default=1, editable=False)), | |
| 212 | + ("created_at", models.DateTimeField(blank=True, editable=False)), | |
| 213 | + ("updated_at", models.DateTimeField(blank=True, editable=False)), | |
| 214 | + ("deleted_at", models.DateTimeField(blank=True, null=True)), | |
| 215 | + ( | |
| 216 | + "branch_pattern", | |
| 217 | + models.CharField( | |
| 218 | + help_text="Branch name or glob pattern (e.g., 'trunk', 'release-*')", | |
| 219 | + max_length=200, | |
| 220 | + ), | |
| 221 | + ), | |
| 222 | + ("require_status_checks", models.BooleanField(default=False)), | |
| 223 | + ( | |
| 224 | + "required_contexts", | |
| 225 | + models.TextField( | |
| 226 | + blank=True, | |
| 227 | + default="", | |
| 228 | + help_text="Required CI contexts, one per line", | |
| 229 | + ), | |
| 230 | + ), | |
| 231 | + ( | |
| 232 | + "restrict_push", | |
| 233 | + models.BooleanField(default=True, help_text="Only admins can push"), | |
| 234 | + ), | |
| 235 | + ("history_id", models.AutoField(primary_key=True, serialize=False)), | |
| 236 | + ("history_date", models.DateTimeField(db_index=True)), | |
| 237 | + ("history_change_reason", models.CharField(max_length=100, null=True)), | |
| 238 | + ( | |
| 239 | + "history_type", | |
| 240 | + models.CharField( | |
| 241 | + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], | |
| 242 | + max_length=1, | |
| 243 | + ), | |
| 244 | + ), | |
| 245 | + ( | |
| 246 | + "created_by", | |
| 247 | + models.ForeignKey( | |
| 248 | + blank=True, | |
| 249 | + db_constraint=False, | |
| 250 | + null=True, | |
| 251 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 252 | + related_name="+", | |
| 253 | + to=settings.AUTH_USER_MODEL, | |
| 254 | + ), | |
| 255 | + ), | |
| 256 | + ( | |
| 257 | + "deleted_by", | |
| 258 | + models.ForeignKey( | |
| 259 | + blank=True, | |
| 260 | + db_constraint=False, | |
| 261 | + null=True, | |
| 262 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 263 | + related_name="+", | |
| 264 | + to=settings.AUTH_USER_MODEL, | |
| 265 | + ), | |
| 266 | + ), | |
| 267 | + ( | |
| 268 | + "history_user", | |
| 269 | + models.ForeignKey( | |
| 270 | + null=True, | |
| 271 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 272 | + related_name="+", | |
| 273 | + to=settings.AUTH_USER_MODEL, | |
| 274 | + ), | |
| 275 | + ), | |
| 276 | + ( | |
| 277 | + "repository", | |
| 278 | + models.ForeignKey( | |
| 279 | + blank=True, | |
| 280 | + db_constraint=False, | |
| 281 | + null=True, | |
| 282 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 283 | + related_name="+", | |
| 284 | + to="fossil.fossilrepository", | |
| 285 | + ), | |
| 286 | + ), | |
| 287 | + ( | |
| 288 | + "updated_by", | |
| 289 | + models.ForeignKey( | |
| 290 | + blank=True, | |
| 291 | + db_constraint=False, | |
| 292 | + null=True, | |
| 293 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 294 | + related_name="+", | |
| 295 | + to=settings.AUTH_USER_MODEL, | |
| 296 | + ), | |
| 297 | + ), | |
| 298 | + ], | |
| 299 | + options={ | |
| 300 | + "verbose_name": "historical branch protection", | |
| 301 | + "verbose_name_plural": "historical branch protections", | |
| 302 | + "ordering": ("-history_date", "-history_id"), | |
| 303 | + "get_latest_by": ("history_date", "history_id"), | |
| 304 | + }, | |
| 305 | + bases=(simple_history.models.HistoricalChanges, models.Model), | |
| 306 | + ), | |
| 307 | + migrations.CreateModel( | |
| 308 | + name="HistoricalStatusCheck", | |
| 309 | + fields=[ | |
| 310 | + ( | |
| 311 | + "id", | |
| 312 | + models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"), | |
| 313 | + ), | |
| 314 | + ("version", models.PositiveIntegerField(default=1, editable=False)), | |
| 315 | + ("created_at", models.DateTimeField(blank=True, editable=False)), | |
| 316 | + ("updated_at", models.DateTimeField(blank=True, editable=False)), | |
| 317 | + ("deleted_at", models.DateTimeField(blank=True, null=True)), | |
| 318 | + ("checkin_uuid", models.CharField(db_index=True, max_length=64)), | |
| 319 | + ( | |
| 320 | + "context", | |
| 321 | + models.CharField( | |
| 322 | + help_text="CI context name (e.g., 'ci/tests', 'ci/lint')", | |
| 323 | + max_length=200, | |
| 324 | + ), | |
| 325 | + ), | |
| 326 | + ( | |
| 327 | + "state", | |
| 328 | + models.CharField( | |
| 329 | + choices=[ | |
| 330 | + ("pending", "Pending"), | |
| 331 | + ("success", "Success"), | |
| 332 | + ("failure", "Failure"), | |
| 333 | + ("error", "Error"), | |
| 334 | + ], | |
| 335 | + default="pending", | |
| 336 | + max_length=20, | |
| 337 | + ), | |
| 338 | + ), | |
| 339 | + ( | |
| 340 | + "description", | |
| 341 | + models.CharField(blank=True, default="", max_length=500), | |
| 342 | + ), | |
| 343 | + ( | |
| 344 | + "target_url", | |
| 345 | + models.URLField(blank=True, default="", help_text="Link to CI build details"), | |
| 346 | + ), | |
| 347 | + ("history_id", models.AutoField(primary_key=True, serialize=False)), | |
| 348 | + ("history_date", models.DateTimeField(db_index=True)), | |
| 349 | + ("history_change_reason", models.CharField(max_length=100, null=True)), | |
| 350 | + ( | |
| 351 | + "history_type", | |
| 352 | + models.CharField( | |
| 353 | + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], | |
| 354 | + max_length=1, | |
| 355 | + ), | |
| 356 | + ), | |
| 357 | + ( | |
| 358 | + "created_by", | |
| 359 | + models.ForeignKey( | |
| 360 | + blank=True, | |
| 361 | + db_constraint=False, | |
| 362 | + null=True, | |
| 363 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 364 | + related_name="+", | |
| 365 | + to=settings.AUTH_USER_MODEL, | |
| 366 | + ), | |
| 367 | + ), | |
| 368 | + ( | |
| 369 | + "deleted_by", | |
| 370 | + models.ForeignKey( | |
| 371 | + blank=True, | |
| 372 | + db_constraint=False, | |
| 373 | + null=True, | |
| 374 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 375 | + related_name="+", | |
| 376 | + to=settings.AUTH_USER_MODEL, | |
| 377 | + ), | |
| 378 | + ), | |
| 379 | + ( | |
| 380 | + "history_user", | |
| 381 | + models.ForeignKey( | |
| 382 | + null=True, | |
| 383 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 384 | + related_name="+", | |
| 385 | + to=settings.AUTH_USER_MODEL, | |
| 386 | + ), | |
| 387 | + ), | |
| 388 | + ( | |
| 389 | + "repository", | |
| 390 | + models.ForeignKey( | |
| 391 | + blank=True, | |
| 392 | + db_constraint=False, | |
| 393 | + null=True, | |
| 394 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 395 | + related_name="+", | |
| 396 | + to="fossil.fossilrepository", | |
| 397 | + ), | |
| 398 | + ), | |
| 399 | + ( | |
| 400 | + "updated_by", | |
| 401 | + models.ForeignKey( | |
| 402 | + blank=True, | |
| 403 | + db_constraint=False, | |
| 404 | + null=True, | |
| 405 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 406 | + related_name="+", | |
| 407 | + to=settings.AUTH_USER_MODEL, | |
| 408 | + ), | |
| 409 | + ), | |
| 410 | + ], | |
| 411 | + options={ | |
| 412 | + "verbose_name": "historical status check", | |
| 413 | + "verbose_name_plural": "historical status checks", | |
| 414 | + "ordering": ("-history_date", "-history_id"), | |
| 415 | + "get_latest_by": ("history_date", "history_id"), | |
| 416 | + }, | |
| 417 | + bases=(simple_history.models.HistoricalChanges, models.Model), | |
| 418 | + ), | |
| 419 | + migrations.CreateModel( | |
| 420 | + name="BranchProtection", | |
| 421 | + fields=[ | |
| 422 | + ( | |
| 423 | + "id", | |
| 424 | + models.BigAutoField( | |
| 425 | + auto_created=True, | |
| 426 | + primary_key=True, | |
| 427 | + serialize=False, | |
| 428 | + verbose_name="ID", | |
| 429 | + ), | |
| 430 | + ), | |
| 431 | + ("version", models.PositiveIntegerField(default=1, editable=False)), | |
| 432 | + ("created_at", models.DateTimeField(auto_now_add=True)), | |
| 433 | + ("updated_at", models.DateTimeField(auto_now=True)), | |
| 434 | + ("deleted_at", models.DateTimeField(blank=True, null=True)), | |
| 435 | + ( | |
| 436 | + "branch_pattern", | |
| 437 | + models.CharField( | |
| 438 | + help_text="Branch name or glob pattern (e.g., 'trunk', 'release-*')", | |
| 439 | + max_length=200, | |
| 440 | + ), | |
| 441 | + ), | |
| 442 | + ("require_status_checks", models.BooleanField(default=False)), | |
| 443 | + ( | |
| 444 | + "required_contexts", | |
| 445 | + models.TextField( | |
| 446 | + blank=True, | |
| 447 | + default="", | |
| 448 | + help_text="Required CI contexts, one per line", | |
| 449 | + ), | |
| 450 | + ), | |
| 451 | + ( | |
| 452 | + "restrict_push", | |
| 453 | + models.BooleanField(default=True, help_text="Only admins can push"), | |
| 454 | + ), | |
| 455 | + ( | |
| 456 | + "created_by", | |
| 457 | + models.ForeignKey( | |
| 458 | + blank=True, | |
| 459 | + null=True, | |
| 460 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 461 | + related_name="+", | |
| 462 | + to=settings.AUTH_USER_MODEL, | |
| 463 | + ), | |
| 464 | + ), | |
| 465 | + ( | |
| 466 | + "deleted_by", | |
| 467 | + models.ForeignKey( | |
| 468 | + blank=True, | |
| 469 | + null=True, | |
| 470 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 471 | + related_name="+", | |
| 472 | + to=settings.AUTH_USER_MODEL, | |
| 473 | + ), | |
| 474 | + ), | |
| 475 | + ( | |
| 476 | + "repository", | |
| 477 | + models.ForeignKey( | |
| 478 | + on_delete=django.db.models.deletion.CASCADE, | |
| 479 | + related_name="branch_protections", | |
| 480 | + to="fossil.fossilrepository", | |
| 481 | + ), | |
| 482 | + ), | |
| 483 | + ( | |
| 484 | + "updated_by", | |
| 485 | + models.ForeignKey( | |
| 486 | + blank=True, | |
| 487 | + null=True, | |
| 488 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 489 | + related_name="+", | |
| 490 | + to=settings.AUTH_USER_MODEL, | |
| 491 | + ), | |
| 492 | + ), | |
| 493 | + ], | |
| 494 | + options={ | |
| 495 | + "ordering": ["branch_pattern"], | |
| 496 | + "unique_together": {("repository", "branch_pattern")}, | |
| 497 | + }, | |
| 498 | + ), | |
| 499 | + migrations.CreateModel( | |
| 500 | + name="StatusCheck", | |
| 501 | + fields=[ | |
| 502 | + ( | |
| 503 | + "id", | |
| 504 | + models.BigAutoField( | |
| 505 | + auto_created=True, | |
| 506 | + primary_key=True, | |
| 507 | + serialize=False, | |
| 508 | + verbose_name="ID", | |
| 509 | + ), | |
| 510 | + ), | |
| 511 | + ("version", models.PositiveIntegerField(default=1, editable=False)), | |
| 512 | + ("created_at", models.DateTimeField(auto_now_add=True)), | |
| 513 | + ("updated_at", models.DateTimeField(auto_now=True)), | |
| 514 | + ("deleted_at", models.DateTimeField(blank=True, null=True)), | |
| 515 | + ("checkin_uuid", models.CharField(db_index=True, max_length=64)), | |
| 516 | + ( | |
| 517 | + "context", | |
| 518 | + models.CharField( | |
| 519 | + help_text="CI context name (e.g., 'ci/tests', 'ci/lint')", | |
| 520 | + max_length=200, | |
| 521 | + ), | |
| 522 | + ), | |
| 523 | + ( | |
| 524 | + "state", | |
| 525 | + models.CharField( | |
| 526 | + choices=[ | |
| 527 | + ("pending", "Pending"), | |
| 528 | + ("success", "Success"), | |
| 529 | + ("failure", "Failure"), | |
| 530 | + ("error", "Error"), | |
| 531 | + ], | |
| 532 | + default="pending", | |
| 533 | + max_length=20, | |
| 534 | + ), | |
| 535 | + ), | |
| 536 | + ( | |
| 537 | + "description", | |
| 538 | + models.CharField(blank=True, default="", max_length=500), | |
| 539 | + ), | |
| 540 | + ( | |
| 541 | + "target_url", | |
| 542 | + models.URLField(blank=True, default="", help_text="Link to CI build details"), | |
| 543 | + ), | |
| 544 | + ( | |
| 545 | + "created_by", | |
| 546 | + models.ForeignKey( | |
| 547 | + blank=True, | |
| 548 | + null=True, | |
| 549 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 550 | + related_name="+", | |
| 551 | + to=settings.AUTH_USER_MODEL, | |
| 552 | + ), | |
| 553 | + ), | |
| 554 | + ( | |
| 555 | + "deleted_by", | |
| 556 | + models.ForeignKey( | |
| 557 | + blank=True, | |
| 558 | + null=True, | |
| 559 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 560 | + related_name="+", | |
| 561 | + to=settings.AUTH_USER_MODEL, | |
| 562 | + ), | |
| 563 | + ), | |
| 564 | + ( | |
| 565 | + "repository", | |
| 566 | + models.ForeignKey( | |
| 567 | + on_delete=django.db.models.deletion.CASCADE, | |
| 568 | + related_name="status_checks", | |
| 569 | + to="fossil.fossilrepository", | |
| 570 | + ), | |
| 571 | + ), | |
| 572 | + ( | |
| 573 | + "updated_by", | |
| 574 | + models.ForeignKey( | |
| 575 | + blank=True, | |
| 576 | + null=True, | |
| 577 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 578 | + related_name="+", | |
| 579 | + to=settings.AUTH_USER_MODEL, | |
| 580 | + ), | |
| 581 | + ), | |
| 582 | + ], | |
| 583 | + options={ | |
| 584 | + "ordering": ["-created_at"], | |
| 585 | + "unique_together": {("repository", "checkin_uuid", "context")}, | |
| 586 | + }, | |
| 587 | + ), | |
| 588 | + ] |
| --- a/fossil/migrations/0008_apitoken_historicalapitoken_and_more.py | |
| +++ b/fossil/migrations/0008_apitoken_historicalapitoken_and_more.py | |
| @@ -0,0 +1,588 @@ | |
| --- a/fossil/migrations/0008_apitoken_historicalapitoken_and_more.py | |
| +++ b/fossil/migrations/0008_apitoken_historicalapitoken_and_more.py | |
| @@ -0,0 +1,588 @@ | |
| 1 | # Generated by Django 5.2.12 on 2026-04-07 15:02 |
| 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", "0007_forumpost_historicalforumpost_historicalwebhook_and_more"), |
| 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
| 13 | ] |
| 14 | |
| 15 | operations = [ |
| 16 | migrations.CreateModel( |
| 17 | name="APIToken", |
| 18 | fields=[ |
| 19 | ( |
| 20 | "id", |
| 21 | models.BigAutoField( |
| 22 | auto_created=True, |
| 23 | primary_key=True, |
| 24 | serialize=False, |
| 25 | verbose_name="ID", |
| 26 | ), |
| 27 | ), |
| 28 | ("version", models.PositiveIntegerField(default=1, editable=False)), |
| 29 | ("created_at", models.DateTimeField(auto_now_add=True)), |
| 30 | ("updated_at", models.DateTimeField(auto_now=True)), |
| 31 | ("deleted_at", models.DateTimeField(blank=True, null=True)), |
| 32 | ("name", models.CharField(max_length=200)), |
| 33 | ( |
| 34 | "token_hash", |
| 35 | models.CharField( |
| 36 | help_text="SHA-256 hash of the token", |
| 37 | max_length=64, |
| 38 | unique=True, |
| 39 | ), |
| 40 | ), |
| 41 | ( |
| 42 | "token_prefix", |
| 43 | models.CharField(help_text="First 12 chars for identification", max_length=12), |
| 44 | ), |
| 45 | ( |
| 46 | "permissions", |
| 47 | models.CharField( |
| 48 | default="status:write", |
| 49 | help_text="Comma-separated permissions", |
| 50 | max_length=200, |
| 51 | ), |
| 52 | ), |
| 53 | ("expires_at", models.DateTimeField(blank=True, null=True)), |
| 54 | ("last_used_at", models.DateTimeField(blank=True, null=True)), |
| 55 | ( |
| 56 | "created_by", |
| 57 | models.ForeignKey( |
| 58 | blank=True, |
| 59 | null=True, |
| 60 | on_delete=django.db.models.deletion.SET_NULL, |
| 61 | related_name="+", |
| 62 | to=settings.AUTH_USER_MODEL, |
| 63 | ), |
| 64 | ), |
| 65 | ( |
| 66 | "deleted_by", |
| 67 | models.ForeignKey( |
| 68 | blank=True, |
| 69 | null=True, |
| 70 | on_delete=django.db.models.deletion.SET_NULL, |
| 71 | related_name="+", |
| 72 | to=settings.AUTH_USER_MODEL, |
| 73 | ), |
| 74 | ), |
| 75 | ( |
| 76 | "repository", |
| 77 | models.ForeignKey( |
| 78 | on_delete=django.db.models.deletion.CASCADE, |
| 79 | related_name="api_tokens", |
| 80 | to="fossil.fossilrepository", |
| 81 | ), |
| 82 | ), |
| 83 | ( |
| 84 | "updated_by", |
| 85 | models.ForeignKey( |
| 86 | blank=True, |
| 87 | null=True, |
| 88 | on_delete=django.db.models.deletion.SET_NULL, |
| 89 | related_name="+", |
| 90 | to=settings.AUTH_USER_MODEL, |
| 91 | ), |
| 92 | ), |
| 93 | ], |
| 94 | options={ |
| 95 | "ordering": ["-created_at"], |
| 96 | }, |
| 97 | ), |
| 98 | migrations.CreateModel( |
| 99 | name="HistoricalAPIToken", |
| 100 | fields=[ |
| 101 | ( |
| 102 | "id", |
| 103 | models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"), |
| 104 | ), |
| 105 | ("version", models.PositiveIntegerField(default=1, editable=False)), |
| 106 | ("created_at", models.DateTimeField(blank=True, editable=False)), |
| 107 | ("updated_at", models.DateTimeField(blank=True, editable=False)), |
| 108 | ("deleted_at", models.DateTimeField(blank=True, null=True)), |
| 109 | ("name", models.CharField(max_length=200)), |
| 110 | ( |
| 111 | "token_hash", |
| 112 | models.CharField( |
| 113 | db_index=True, |
| 114 | help_text="SHA-256 hash of the token", |
| 115 | max_length=64, |
| 116 | ), |
| 117 | ), |
| 118 | ( |
| 119 | "token_prefix", |
| 120 | models.CharField(help_text="First 12 chars for identification", max_length=12), |
| 121 | ), |
| 122 | ( |
| 123 | "permissions", |
| 124 | models.CharField( |
| 125 | default="status:write", |
| 126 | help_text="Comma-separated permissions", |
| 127 | max_length=200, |
| 128 | ), |
| 129 | ), |
| 130 | ("expires_at", models.DateTimeField(blank=True, null=True)), |
| 131 | ("last_used_at", models.DateTimeField(blank=True, null=True)), |
| 132 | ("history_id", models.AutoField(primary_key=True, serialize=False)), |
| 133 | ("history_date", models.DateTimeField(db_index=True)), |
| 134 | ("history_change_reason", models.CharField(max_length=100, null=True)), |
| 135 | ( |
| 136 | "history_type", |
| 137 | models.CharField( |
| 138 | choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], |
| 139 | max_length=1, |
| 140 | ), |
| 141 | ), |
| 142 | ( |
| 143 | "created_by", |
| 144 | models.ForeignKey( |
| 145 | blank=True, |
| 146 | db_constraint=False, |
| 147 | null=True, |
| 148 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 149 | related_name="+", |
| 150 | to=settings.AUTH_USER_MODEL, |
| 151 | ), |
| 152 | ), |
| 153 | ( |
| 154 | "deleted_by", |
| 155 | models.ForeignKey( |
| 156 | blank=True, |
| 157 | db_constraint=False, |
| 158 | null=True, |
| 159 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 160 | related_name="+", |
| 161 | to=settings.AUTH_USER_MODEL, |
| 162 | ), |
| 163 | ), |
| 164 | ( |
| 165 | "history_user", |
| 166 | models.ForeignKey( |
| 167 | null=True, |
| 168 | on_delete=django.db.models.deletion.SET_NULL, |
| 169 | related_name="+", |
| 170 | to=settings.AUTH_USER_MODEL, |
| 171 | ), |
| 172 | ), |
| 173 | ( |
| 174 | "repository", |
| 175 | models.ForeignKey( |
| 176 | blank=True, |
| 177 | db_constraint=False, |
| 178 | null=True, |
| 179 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 180 | related_name="+", |
| 181 | to="fossil.fossilrepository", |
| 182 | ), |
| 183 | ), |
| 184 | ( |
| 185 | "updated_by", |
| 186 | models.ForeignKey( |
| 187 | blank=True, |
| 188 | db_constraint=False, |
| 189 | null=True, |
| 190 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 191 | related_name="+", |
| 192 | to=settings.AUTH_USER_MODEL, |
| 193 | ), |
| 194 | ), |
| 195 | ], |
| 196 | options={ |
| 197 | "verbose_name": "historical api token", |
| 198 | "verbose_name_plural": "historical api tokens", |
| 199 | "ordering": ("-history_date", "-history_id"), |
| 200 | "get_latest_by": ("history_date", "history_id"), |
| 201 | }, |
| 202 | bases=(simple_history.models.HistoricalChanges, models.Model), |
| 203 | ), |
| 204 | migrations.CreateModel( |
| 205 | name="HistoricalBranchProtection", |
| 206 | fields=[ |
| 207 | ( |
| 208 | "id", |
| 209 | models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"), |
| 210 | ), |
| 211 | ("version", models.PositiveIntegerField(default=1, editable=False)), |
| 212 | ("created_at", models.DateTimeField(blank=True, editable=False)), |
| 213 | ("updated_at", models.DateTimeField(blank=True, editable=False)), |
| 214 | ("deleted_at", models.DateTimeField(blank=True, null=True)), |
| 215 | ( |
| 216 | "branch_pattern", |
| 217 | models.CharField( |
| 218 | help_text="Branch name or glob pattern (e.g., 'trunk', 'release-*')", |
| 219 | max_length=200, |
| 220 | ), |
| 221 | ), |
| 222 | ("require_status_checks", models.BooleanField(default=False)), |
| 223 | ( |
| 224 | "required_contexts", |
| 225 | models.TextField( |
| 226 | blank=True, |
| 227 | default="", |
| 228 | help_text="Required CI contexts, one per line", |
| 229 | ), |
| 230 | ), |
| 231 | ( |
| 232 | "restrict_push", |
| 233 | models.BooleanField(default=True, help_text="Only admins can push"), |
| 234 | ), |
| 235 | ("history_id", models.AutoField(primary_key=True, serialize=False)), |
| 236 | ("history_date", models.DateTimeField(db_index=True)), |
| 237 | ("history_change_reason", models.CharField(max_length=100, null=True)), |
| 238 | ( |
| 239 | "history_type", |
| 240 | models.CharField( |
| 241 | choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], |
| 242 | max_length=1, |
| 243 | ), |
| 244 | ), |
| 245 | ( |
| 246 | "created_by", |
| 247 | models.ForeignKey( |
| 248 | blank=True, |
| 249 | db_constraint=False, |
| 250 | null=True, |
| 251 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 252 | related_name="+", |
| 253 | to=settings.AUTH_USER_MODEL, |
| 254 | ), |
| 255 | ), |
| 256 | ( |
| 257 | "deleted_by", |
| 258 | models.ForeignKey( |
| 259 | blank=True, |
| 260 | db_constraint=False, |
| 261 | null=True, |
| 262 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 263 | related_name="+", |
| 264 | to=settings.AUTH_USER_MODEL, |
| 265 | ), |
| 266 | ), |
| 267 | ( |
| 268 | "history_user", |
| 269 | models.ForeignKey( |
| 270 | null=True, |
| 271 | on_delete=django.db.models.deletion.SET_NULL, |
| 272 | related_name="+", |
| 273 | to=settings.AUTH_USER_MODEL, |
| 274 | ), |
| 275 | ), |
| 276 | ( |
| 277 | "repository", |
| 278 | models.ForeignKey( |
| 279 | blank=True, |
| 280 | db_constraint=False, |
| 281 | null=True, |
| 282 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 283 | related_name="+", |
| 284 | to="fossil.fossilrepository", |
| 285 | ), |
| 286 | ), |
| 287 | ( |
| 288 | "updated_by", |
| 289 | models.ForeignKey( |
| 290 | blank=True, |
| 291 | db_constraint=False, |
| 292 | null=True, |
| 293 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 294 | related_name="+", |
| 295 | to=settings.AUTH_USER_MODEL, |
| 296 | ), |
| 297 | ), |
| 298 | ], |
| 299 | options={ |
| 300 | "verbose_name": "historical branch protection", |
| 301 | "verbose_name_plural": "historical branch protections", |
| 302 | "ordering": ("-history_date", "-history_id"), |
| 303 | "get_latest_by": ("history_date", "history_id"), |
| 304 | }, |
| 305 | bases=(simple_history.models.HistoricalChanges, models.Model), |
| 306 | ), |
| 307 | migrations.CreateModel( |
| 308 | name="HistoricalStatusCheck", |
| 309 | fields=[ |
| 310 | ( |
| 311 | "id", |
| 312 | models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"), |
| 313 | ), |
| 314 | ("version", models.PositiveIntegerField(default=1, editable=False)), |
| 315 | ("created_at", models.DateTimeField(blank=True, editable=False)), |
| 316 | ("updated_at", models.DateTimeField(blank=True, editable=False)), |
| 317 | ("deleted_at", models.DateTimeField(blank=True, null=True)), |
| 318 | ("checkin_uuid", models.CharField(db_index=True, max_length=64)), |
| 319 | ( |
| 320 | "context", |
| 321 | models.CharField( |
| 322 | help_text="CI context name (e.g., 'ci/tests', 'ci/lint')", |
| 323 | max_length=200, |
| 324 | ), |
| 325 | ), |
| 326 | ( |
| 327 | "state", |
| 328 | models.CharField( |
| 329 | choices=[ |
| 330 | ("pending", "Pending"), |
| 331 | ("success", "Success"), |
| 332 | ("failure", "Failure"), |
| 333 | ("error", "Error"), |
| 334 | ], |
| 335 | default="pending", |
| 336 | max_length=20, |
| 337 | ), |
| 338 | ), |
| 339 | ( |
| 340 | "description", |
| 341 | models.CharField(blank=True, default="", max_length=500), |
| 342 | ), |
| 343 | ( |
| 344 | "target_url", |
| 345 | models.URLField(blank=True, default="", help_text="Link to CI build details"), |
| 346 | ), |
| 347 | ("history_id", models.AutoField(primary_key=True, serialize=False)), |
| 348 | ("history_date", models.DateTimeField(db_index=True)), |
| 349 | ("history_change_reason", models.CharField(max_length=100, null=True)), |
| 350 | ( |
| 351 | "history_type", |
| 352 | models.CharField( |
| 353 | choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], |
| 354 | max_length=1, |
| 355 | ), |
| 356 | ), |
| 357 | ( |
| 358 | "created_by", |
| 359 | models.ForeignKey( |
| 360 | blank=True, |
| 361 | db_constraint=False, |
| 362 | null=True, |
| 363 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 364 | related_name="+", |
| 365 | to=settings.AUTH_USER_MODEL, |
| 366 | ), |
| 367 | ), |
| 368 | ( |
| 369 | "deleted_by", |
| 370 | models.ForeignKey( |
| 371 | blank=True, |
| 372 | db_constraint=False, |
| 373 | null=True, |
| 374 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 375 | related_name="+", |
| 376 | to=settings.AUTH_USER_MODEL, |
| 377 | ), |
| 378 | ), |
| 379 | ( |
| 380 | "history_user", |
| 381 | models.ForeignKey( |
| 382 | null=True, |
| 383 | on_delete=django.db.models.deletion.SET_NULL, |
| 384 | related_name="+", |
| 385 | to=settings.AUTH_USER_MODEL, |
| 386 | ), |
| 387 | ), |
| 388 | ( |
| 389 | "repository", |
| 390 | models.ForeignKey( |
| 391 | blank=True, |
| 392 | db_constraint=False, |
| 393 | null=True, |
| 394 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 395 | related_name="+", |
| 396 | to="fossil.fossilrepository", |
| 397 | ), |
| 398 | ), |
| 399 | ( |
| 400 | "updated_by", |
| 401 | models.ForeignKey( |
| 402 | blank=True, |
| 403 | db_constraint=False, |
| 404 | null=True, |
| 405 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 406 | related_name="+", |
| 407 | to=settings.AUTH_USER_MODEL, |
| 408 | ), |
| 409 | ), |
| 410 | ], |
| 411 | options={ |
| 412 | "verbose_name": "historical status check", |
| 413 | "verbose_name_plural": "historical status checks", |
| 414 | "ordering": ("-history_date", "-history_id"), |
| 415 | "get_latest_by": ("history_date", "history_id"), |
| 416 | }, |
| 417 | bases=(simple_history.models.HistoricalChanges, models.Model), |
| 418 | ), |
| 419 | migrations.CreateModel( |
| 420 | name="BranchProtection", |
| 421 | fields=[ |
| 422 | ( |
| 423 | "id", |
| 424 | models.BigAutoField( |
| 425 | auto_created=True, |
| 426 | primary_key=True, |
| 427 | serialize=False, |
| 428 | verbose_name="ID", |
| 429 | ), |
| 430 | ), |
| 431 | ("version", models.PositiveIntegerField(default=1, editable=False)), |
| 432 | ("created_at", models.DateTimeField(auto_now_add=True)), |
| 433 | ("updated_at", models.DateTimeField(auto_now=True)), |
| 434 | ("deleted_at", models.DateTimeField(blank=True, null=True)), |
| 435 | ( |
| 436 | "branch_pattern", |
| 437 | models.CharField( |
| 438 | help_text="Branch name or glob pattern (e.g., 'trunk', 'release-*')", |
| 439 | max_length=200, |
| 440 | ), |
| 441 | ), |
| 442 | ("require_status_checks", models.BooleanField(default=False)), |
| 443 | ( |
| 444 | "required_contexts", |
| 445 | models.TextField( |
| 446 | blank=True, |
| 447 | default="", |
| 448 | help_text="Required CI contexts, one per line", |
| 449 | ), |
| 450 | ), |
| 451 | ( |
| 452 | "restrict_push", |
| 453 | models.BooleanField(default=True, help_text="Only admins can push"), |
| 454 | ), |
| 455 | ( |
| 456 | "created_by", |
| 457 | models.ForeignKey( |
| 458 | blank=True, |
| 459 | null=True, |
| 460 | on_delete=django.db.models.deletion.SET_NULL, |
| 461 | related_name="+", |
| 462 | to=settings.AUTH_USER_MODEL, |
| 463 | ), |
| 464 | ), |
| 465 | ( |
| 466 | "deleted_by", |
| 467 | models.ForeignKey( |
| 468 | blank=True, |
| 469 | null=True, |
| 470 | on_delete=django.db.models.deletion.SET_NULL, |
| 471 | related_name="+", |
| 472 | to=settings.AUTH_USER_MODEL, |
| 473 | ), |
| 474 | ), |
| 475 | ( |
| 476 | "repository", |
| 477 | models.ForeignKey( |
| 478 | on_delete=django.db.models.deletion.CASCADE, |
| 479 | related_name="branch_protections", |
| 480 | to="fossil.fossilrepository", |
| 481 | ), |
| 482 | ), |
| 483 | ( |
| 484 | "updated_by", |
| 485 | models.ForeignKey( |
| 486 | blank=True, |
| 487 | null=True, |
| 488 | on_delete=django.db.models.deletion.SET_NULL, |
| 489 | related_name="+", |
| 490 | to=settings.AUTH_USER_MODEL, |
| 491 | ), |
| 492 | ), |
| 493 | ], |
| 494 | options={ |
| 495 | "ordering": ["branch_pattern"], |
| 496 | "unique_together": {("repository", "branch_pattern")}, |
| 497 | }, |
| 498 | ), |
| 499 | migrations.CreateModel( |
| 500 | name="StatusCheck", |
| 501 | fields=[ |
| 502 | ( |
| 503 | "id", |
| 504 | models.BigAutoField( |
| 505 | auto_created=True, |
| 506 | primary_key=True, |
| 507 | serialize=False, |
| 508 | verbose_name="ID", |
| 509 | ), |
| 510 | ), |
| 511 | ("version", models.PositiveIntegerField(default=1, editable=False)), |
| 512 | ("created_at", models.DateTimeField(auto_now_add=True)), |
| 513 | ("updated_at", models.DateTimeField(auto_now=True)), |
| 514 | ("deleted_at", models.DateTimeField(blank=True, null=True)), |
| 515 | ("checkin_uuid", models.CharField(db_index=True, max_length=64)), |
| 516 | ( |
| 517 | "context", |
| 518 | models.CharField( |
| 519 | help_text="CI context name (e.g., 'ci/tests', 'ci/lint')", |
| 520 | max_length=200, |
| 521 | ), |
| 522 | ), |
| 523 | ( |
| 524 | "state", |
| 525 | models.CharField( |
| 526 | choices=[ |
| 527 | ("pending", "Pending"), |
| 528 | ("success", "Success"), |
| 529 | ("failure", "Failure"), |
| 530 | ("error", "Error"), |
| 531 | ], |
| 532 | default="pending", |
| 533 | max_length=20, |
| 534 | ), |
| 535 | ), |
| 536 | ( |
| 537 | "description", |
| 538 | models.CharField(blank=True, default="", max_length=500), |
| 539 | ), |
| 540 | ( |
| 541 | "target_url", |
| 542 | models.URLField(blank=True, default="", help_text="Link to CI build details"), |
| 543 | ), |
| 544 | ( |
| 545 | "created_by", |
| 546 | models.ForeignKey( |
| 547 | blank=True, |
| 548 | null=True, |
| 549 | on_delete=django.db.models.deletion.SET_NULL, |
| 550 | related_name="+", |
| 551 | to=settings.AUTH_USER_MODEL, |
| 552 | ), |
| 553 | ), |
| 554 | ( |
| 555 | "deleted_by", |
| 556 | models.ForeignKey( |
| 557 | blank=True, |
| 558 | null=True, |
| 559 | on_delete=django.db.models.deletion.SET_NULL, |
| 560 | related_name="+", |
| 561 | to=settings.AUTH_USER_MODEL, |
| 562 | ), |
| 563 | ), |
| 564 | ( |
| 565 | "repository", |
| 566 | models.ForeignKey( |
| 567 | on_delete=django.db.models.deletion.CASCADE, |
| 568 | related_name="status_checks", |
| 569 | to="fossil.fossilrepository", |
| 570 | ), |
| 571 | ), |
| 572 | ( |
| 573 | "updated_by", |
| 574 | models.ForeignKey( |
| 575 | blank=True, |
| 576 | null=True, |
| 577 | on_delete=django.db.models.deletion.SET_NULL, |
| 578 | related_name="+", |
| 579 | to=settings.AUTH_USER_MODEL, |
| 580 | ), |
| 581 | ), |
| 582 | ], |
| 583 | options={ |
| 584 | "ordering": ["-created_at"], |
| 585 | "unique_together": {("repository", "checkin_uuid", "context")}, |
| 586 | }, |
| 587 | ), |
| 588 | ] |
| --- fossil/models.py | ||
| +++ fossil/models.py | ||
| @@ -65,10 +65,13 @@ | ||
| 65 | 65 | def __str__(self): |
| 66 | 66 | return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename |
| 67 | 67 | |
| 68 | 68 | |
| 69 | 69 | # Import related models so they're discoverable by Django |
| 70 | +from fossil.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 | |
| 70 | 73 | from fossil.forum import ForumPost # noqa: E402, F401 |
| 71 | 74 | from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401 |
| 72 | 75 | from fossil.releases import Release, ReleaseAsset # noqa: E402, F401 |
| 73 | 76 | from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401 |
| 74 | 77 | from fossil.user_keys import UserSSHKey # noqa: E402, F401 |
| 75 | 78 |
| --- fossil/models.py | |
| +++ fossil/models.py | |
| @@ -65,10 +65,13 @@ | |
| 65 | def __str__(self): |
| 66 | return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename |
| 67 | |
| 68 | |
| 69 | # Import related models so they're discoverable by Django |
| 70 | from fossil.forum import ForumPost # noqa: E402, F401 |
| 71 | from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401 |
| 72 | from fossil.releases import Release, ReleaseAsset # noqa: E402, F401 |
| 73 | from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401 |
| 74 | from fossil.user_keys import UserSSHKey # noqa: E402, F401 |
| 75 |
| --- fossil/models.py | |
| +++ fossil/models.py | |
| @@ -65,10 +65,13 @@ | |
| 65 | def __str__(self): |
| 66 | return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename |
| 67 | |
| 68 | |
| 69 | # Import related models so they're discoverable by Django |
| 70 | from fossil.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 |
| --- fossil/reader.py | ||
| +++ fossil/reader.py | ||
| @@ -378,10 +378,78 @@ | ||
| 378 | 378 | } |
| 379 | 379 | ) |
| 380 | 380 | except sqlite3.OperationalError: |
| 381 | 381 | pass |
| 382 | 382 | return notes |
| 383 | + | |
| 384 | + def get_technote_detail(self, technote_uuid: str) -> dict | None: | |
| 385 | + """Get a single technote by UUID, including its body content.""" | |
| 386 | + try: | |
| 387 | + row = self.conn.execute( | |
| 388 | + """ | |
| 389 | + SELECT blob.rid, blob.uuid, event.mtime, event.user, event.comment | |
| 390 | + FROM event | |
| 391 | + JOIN blob ON event.objid = blob.rid | |
| 392 | + WHERE event.type = 'e' AND blob.uuid LIKE ? || '%' | |
| 393 | + ORDER BY event.mtime DESC | |
| 394 | + LIMIT 1 | |
| 395 | + """, | |
| 396 | + (technote_uuid,), | |
| 397 | + ).fetchone() | |
| 398 | + if not row: | |
| 399 | + return None | |
| 400 | + | |
| 401 | + # Read the technote body from the blob | |
| 402 | + blob_row = self.conn.execute("SELECT content FROM blob WHERE rid=?", (row["rid"],)).fetchone() | |
| 403 | + body = "" | |
| 404 | + if blob_row and blob_row[0]: | |
| 405 | + raw = _decompress_blob(blob_row[0]) | |
| 406 | + text = raw.decode("utf-8", errors="replace") | |
| 407 | + body = _extract_wiki_content(text) | |
| 408 | + | |
| 409 | + return { | |
| 410 | + "uuid": row["uuid"], | |
| 411 | + "timestamp": _julian_to_datetime(row["mtime"]), | |
| 412 | + "user": row["user"] or "", | |
| 413 | + "comment": row["comment"] or "", | |
| 414 | + "body": body, | |
| 415 | + } | |
| 416 | + except sqlite3.OperationalError: | |
| 417 | + return None | |
| 418 | + | |
| 419 | + def get_unversioned_files(self) -> list[dict]: | |
| 420 | + """List unversioned files. Returns [{name, size, mtime, hash}]. | |
| 421 | + | |
| 422 | + The unversioned table is created on demand by Fossil when the first | |
| 423 | + unversioned file is added, so it may not exist in every repo. | |
| 424 | + """ | |
| 425 | + files = [] | |
| 426 | + try: | |
| 427 | + rows = self.conn.execute( | |
| 428 | + """ | |
| 429 | + SELECT name, sz, mtime, hash | |
| 430 | + FROM unversioned | |
| 431 | + WHERE hash IS NOT NULL AND hash != '' | |
| 432 | + ORDER BY name | |
| 433 | + """ | |
| 434 | + ).fetchall() | |
| 435 | + for r in rows: | |
| 436 | + mtime_val = r["mtime"] | |
| 437 | + # mtime in the unversioned table is a Unix timestamp (integer seconds) | |
| 438 | + ts = datetime.fromtimestamp(int(mtime_val), tz=UTC) if mtime_val else None | |
| 439 | + files.append( | |
| 440 | + { | |
| 441 | + "name": r["name"], | |
| 442 | + "size": r["sz"] or 0, | |
| 443 | + "mtime": ts, | |
| 444 | + "hash": r["hash"] or "", | |
| 445 | + } | |
| 446 | + ) | |
| 447 | + except sqlite3.OperationalError: | |
| 448 | + # Table doesn't exist yet -- no unversioned files added | |
| 449 | + pass | |
| 450 | + return files | |
| 383 | 451 | |
| 384 | 452 | def get_commit_activity(self, weeks: int = 52) -> list[dict]: |
| 385 | 453 | """Get weekly commit counts for the last N weeks. Returns [{week, count}].""" |
| 386 | 454 | activity = [] |
| 387 | 455 | try: |
| 388 | 456 |
| --- fossil/reader.py | |
| +++ fossil/reader.py | |
| @@ -378,10 +378,78 @@ | |
| 378 | } |
| 379 | ) |
| 380 | except sqlite3.OperationalError: |
| 381 | pass |
| 382 | return notes |
| 383 | |
| 384 | def get_commit_activity(self, weeks: int = 52) -> list[dict]: |
| 385 | """Get weekly commit counts for the last N weeks. Returns [{week, count}].""" |
| 386 | activity = [] |
| 387 | try: |
| 388 |
| --- fossil/reader.py | |
| +++ fossil/reader.py | |
| @@ -378,10 +378,78 @@ | |
| 378 | } |
| 379 | ) |
| 380 | except sqlite3.OperationalError: |
| 381 | pass |
| 382 | return notes |
| 383 | |
| 384 | def get_technote_detail(self, technote_uuid: str) -> dict | None: |
| 385 | """Get a single technote by UUID, including its body content.""" |
| 386 | try: |
| 387 | row = self.conn.execute( |
| 388 | """ |
| 389 | SELECT blob.rid, blob.uuid, event.mtime, event.user, event.comment |
| 390 | FROM event |
| 391 | JOIN blob ON event.objid = blob.rid |
| 392 | WHERE event.type = 'e' AND blob.uuid LIKE ? || '%' |
| 393 | ORDER BY event.mtime DESC |
| 394 | LIMIT 1 |
| 395 | """, |
| 396 | (technote_uuid,), |
| 397 | ).fetchone() |
| 398 | if not row: |
| 399 | return None |
| 400 | |
| 401 | # Read the technote body from the blob |
| 402 | blob_row = self.conn.execute("SELECT content FROM blob WHERE rid=?", (row["rid"],)).fetchone() |
| 403 | body = "" |
| 404 | if blob_row and blob_row[0]: |
| 405 | raw = _decompress_blob(blob_row[0]) |
| 406 | text = raw.decode("utf-8", errors="replace") |
| 407 | body = _extract_wiki_content(text) |
| 408 | |
| 409 | return { |
| 410 | "uuid": row["uuid"], |
| 411 | "timestamp": _julian_to_datetime(row["mtime"]), |
| 412 | "user": row["user"] or "", |
| 413 | "comment": row["comment"] or "", |
| 414 | "body": body, |
| 415 | } |
| 416 | except sqlite3.OperationalError: |
| 417 | return None |
| 418 | |
| 419 | def get_unversioned_files(self) -> list[dict]: |
| 420 | """List unversioned files. Returns [{name, size, mtime, hash}]. |
| 421 | |
| 422 | The unversioned table is created on demand by Fossil when the first |
| 423 | unversioned file is added, so it may not exist in every repo. |
| 424 | """ |
| 425 | files = [] |
| 426 | try: |
| 427 | rows = self.conn.execute( |
| 428 | """ |
| 429 | SELECT name, sz, mtime, hash |
| 430 | FROM unversioned |
| 431 | WHERE hash IS NOT NULL AND hash != '' |
| 432 | ORDER BY name |
| 433 | """ |
| 434 | ).fetchall() |
| 435 | for r in rows: |
| 436 | mtime_val = r["mtime"] |
| 437 | # mtime in the unversioned table is a Unix timestamp (integer seconds) |
| 438 | ts = datetime.fromtimestamp(int(mtime_val), tz=UTC) if mtime_val else None |
| 439 | files.append( |
| 440 | { |
| 441 | "name": r["name"], |
| 442 | "size": r["sz"] or 0, |
| 443 | "mtime": ts, |
| 444 | "hash": r["hash"] or "", |
| 445 | } |
| 446 | ) |
| 447 | except sqlite3.OperationalError: |
| 448 | # Table doesn't exist yet -- no unversioned files added |
| 449 | pass |
| 450 | return files |
| 451 | |
| 452 | def get_commit_activity(self, weeks: int = 52) -> list[dict]: |
| 453 | """Get weekly commit counts for the last N weeks. Returns [{week, count}].""" |
| 454 | activity = [] |
| 455 | try: |
| 456 |
| --- fossil/urls.py | ||
| +++ fossil/urls.py | ||
| @@ -31,10 +31,17 @@ | ||
| 31 | 31 | path("webhooks/<int:webhook_id>/deliveries/", views.webhook_deliveries, name="webhook_deliveries"), |
| 32 | 32 | path("user/<str:username>/", views.user_activity, name="user_activity"), |
| 33 | 33 | path("branches/", views.branch_list, name="branches"), |
| 34 | 34 | path("tags/", views.tag_list, name="tags"), |
| 35 | 35 | path("technotes/", views.technote_list, name="technotes"), |
| 36 | + path("technotes/create/", views.technote_create, name="technote_create"), | |
| 37 | + path("technotes/<str:technote_id>/", views.technote_detail, name="technote_detail"), | |
| 38 | + path("technotes/<str:technote_id>/edit/", views.technote_edit, name="technote_edit"), | |
| 39 | + # Unversioned content | |
| 40 | + path("files/", views.unversioned_list, name="unversioned"), | |
| 41 | + path("files/upload/", views.unversioned_upload, name="unversioned_upload"), | |
| 42 | + path("files/download/<path:filename>", views.unversioned_download, name="unversioned_download"), | |
| 36 | 43 | path("search/", views.search, name="search"), |
| 37 | 44 | path("stats/", views.repo_stats, name="stats"), |
| 38 | 45 | path("compare/", views.compare_checkins, name="compare"), |
| 39 | 46 | path("settings/", views.repo_settings, name="repo_settings"), |
| 40 | 47 | path("sync/", views.sync_pull, name="sync"), |
| @@ -59,6 +66,18 @@ | ||
| 59 | 66 | path("releases/<str:tag_name>/", views.release_detail, name="release_detail"), |
| 60 | 67 | path("releases/<str:tag_name>/edit/", views.release_edit, name="release_edit"), |
| 61 | 68 | path("releases/<str:tag_name>/delete/", views.release_delete, name="release_delete"), |
| 62 | 69 | path("releases/<str:tag_name>/upload/", views.release_asset_upload, name="release_asset_upload"), |
| 63 | 70 | path("releases/<str:tag_name>/assets/<int:asset_id>/", views.release_asset_download, name="release_asset_download"), |
| 71 | + # CI Status API | |
| 72 | + path("api/status", views.status_check_api, name="status_check_api"), | |
| 73 | + path("api/status/<str:checkin_uuid>/badge.svg", views.status_badge, name="status_badge"), | |
| 74 | + # API Tokens | |
| 75 | + path("tokens/", views.api_token_list, name="api_tokens"), | |
| 76 | + path("tokens/create/", views.api_token_create, name="api_token_create"), | |
| 77 | + path("tokens/<int:token_id>/delete/", views.api_token_delete, name="api_token_delete"), | |
| 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"), | |
| 64 | 83 | ] |
| 65 | 84 |
| --- fossil/urls.py | |
| +++ fossil/urls.py | |
| @@ -31,10 +31,17 @@ | |
| 31 | path("webhooks/<int:webhook_id>/deliveries/", views.webhook_deliveries, name="webhook_deliveries"), |
| 32 | path("user/<str:username>/", views.user_activity, name="user_activity"), |
| 33 | path("branches/", views.branch_list, name="branches"), |
| 34 | path("tags/", views.tag_list, name="tags"), |
| 35 | path("technotes/", views.technote_list, name="technotes"), |
| 36 | path("search/", views.search, name="search"), |
| 37 | path("stats/", views.repo_stats, name="stats"), |
| 38 | path("compare/", views.compare_checkins, name="compare"), |
| 39 | path("settings/", views.repo_settings, name="repo_settings"), |
| 40 | path("sync/", views.sync_pull, name="sync"), |
| @@ -59,6 +66,18 @@ | |
| 59 | path("releases/<str:tag_name>/", views.release_detail, name="release_detail"), |
| 60 | path("releases/<str:tag_name>/edit/", views.release_edit, name="release_edit"), |
| 61 | path("releases/<str:tag_name>/delete/", views.release_delete, name="release_delete"), |
| 62 | path("releases/<str:tag_name>/upload/", views.release_asset_upload, name="release_asset_upload"), |
| 63 | path("releases/<str:tag_name>/assets/<int:asset_id>/", views.release_asset_download, name="release_asset_download"), |
| 64 | ] |
| 65 |
| --- fossil/urls.py | |
| +++ fossil/urls.py | |
| @@ -31,10 +31,17 @@ | |
| 31 | path("webhooks/<int:webhook_id>/deliveries/", views.webhook_deliveries, name="webhook_deliveries"), |
| 32 | path("user/<str:username>/", views.user_activity, name="user_activity"), |
| 33 | path("branches/", views.branch_list, name="branches"), |
| 34 | path("tags/", views.tag_list, name="tags"), |
| 35 | path("technotes/", views.technote_list, name="technotes"), |
| 36 | path("technotes/create/", views.technote_create, name="technote_create"), |
| 37 | path("technotes/<str:technote_id>/", views.technote_detail, name="technote_detail"), |
| 38 | path("technotes/<str:technote_id>/edit/", views.technote_edit, name="technote_edit"), |
| 39 | # Unversioned content |
| 40 | path("files/", views.unversioned_list, name="unversioned"), |
| 41 | path("files/upload/", views.unversioned_upload, name="unversioned_upload"), |
| 42 | path("files/download/<path:filename>", views.unversioned_download, name="unversioned_download"), |
| 43 | path("search/", views.search, name="search"), |
| 44 | path("stats/", views.repo_stats, name="stats"), |
| 45 | path("compare/", views.compare_checkins, name="compare"), |
| 46 | path("settings/", views.repo_settings, name="repo_settings"), |
| 47 | path("sync/", views.sync_pull, name="sync"), |
| @@ -59,6 +66,18 @@ | |
| 66 | path("releases/<str:tag_name>/", views.release_detail, name="release_detail"), |
| 67 | path("releases/<str:tag_name>/edit/", views.release_edit, name="release_edit"), |
| 68 | path("releases/<str:tag_name>/delete/", views.release_delete, name="release_delete"), |
| 69 | path("releases/<str:tag_name>/upload/", views.release_asset_upload, name="release_asset_upload"), |
| 70 | path("releases/<str:tag_name>/assets/<int:asset_id>/", views.release_asset_download, name="release_asset_download"), |
| 71 | # CI Status API |
| 72 | path("api/status", views.status_check_api, name="status_check_api"), |
| 73 | path("api/status/<str:checkin_uuid>/badge.svg", views.status_badge, name="status_badge"), |
| 74 | # API Tokens |
| 75 | path("tokens/", views.api_token_list, name="api_tokens"), |
| 76 | path("tokens/create/", views.api_token_create, name="api_token_create"), |
| 77 | path("tokens/<int:token_id>/delete/", views.api_token_delete, name="api_token_delete"), |
| 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/views.py | ||
| +++ fossil/views.py | ||
| @@ -2,11 +2,11 @@ | ||
| 2 | 2 | import re |
| 3 | 3 | from datetime import datetime |
| 4 | 4 | |
| 5 | 5 | import markdown as md |
| 6 | 6 | from django.contrib.auth.decorators import login_required |
| 7 | -from django.http import Http404, HttpResponse | |
| 7 | +from django.http import Http404, HttpResponse, JsonResponse | |
| 8 | 8 | from django.shortcuts import get_object_or_404, redirect, render |
| 9 | 9 | from django.utils.safestring import mark_safe |
| 10 | 10 | from django.views.decorators.csrf import csrf_exempt |
| 11 | 11 | |
| 12 | 12 | from core.sanitize import sanitize_html |
| @@ -610,18 +610,24 @@ | ||
| 610 | 610 | "deletions": deletions, |
| 611 | 611 | "language": ext, |
| 612 | 612 | } |
| 613 | 613 | ) |
| 614 | 614 | |
| 615 | + # Fetch CI status checks for this checkin | |
| 616 | + from fossil.ci import StatusCheck | |
| 617 | + | |
| 618 | + status_checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid) | |
| 619 | + | |
| 615 | 620 | return render( |
| 616 | 621 | request, |
| 617 | 622 | "fossil/checkin_detail.html", |
| 618 | 623 | { |
| 619 | 624 | "project": project, |
| 620 | 625 | "fossil_repo": fossil_repo, |
| 621 | 626 | "checkin": checkin, |
| 622 | 627 | "file_diffs": file_diffs, |
| 628 | + "status_checks": status_checks, | |
| 623 | 629 | "active_tab": "timeline", |
| 624 | 630 | }, |
| 625 | 631 | ) |
| 626 | 632 | |
| 627 | 633 | |
| @@ -1788,21 +1794,217 @@ | ||
| 1788 | 1794 | |
| 1789 | 1795 | # --- Technotes --- |
| 1790 | 1796 | |
| 1791 | 1797 | |
| 1792 | 1798 | def technote_list(request, slug): |
| 1799 | + from projects.access import can_write_project | |
| 1800 | + | |
| 1793 | 1801 | project, fossil_repo, reader = _get_repo_and_reader(slug, request) |
| 1794 | 1802 | |
| 1795 | 1803 | with reader: |
| 1796 | 1804 | notes = reader.get_technotes() |
| 1797 | 1805 | |
| 1806 | + has_write = can_write_project(request.user, project) | |
| 1807 | + | |
| 1798 | 1808 | return render( |
| 1799 | 1809 | request, |
| 1800 | 1810 | "fossil/technote_list.html", |
| 1801 | - {"project": project, "notes": notes, "active_tab": "wiki"}, | |
| 1811 | + {"project": project, "notes": notes, "has_write": has_write, "active_tab": "wiki"}, | |
| 1812 | + ) | |
| 1813 | + | |
| 1814 | + | |
| 1815 | +@login_required | |
| 1816 | +def technote_create(request, slug): | |
| 1817 | + project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write") | |
| 1818 | + | |
| 1819 | + if request.method == "POST": | |
| 1820 | + title = request.POST.get("title", "").strip() | |
| 1821 | + body = request.POST.get("body", "") | |
| 1822 | + timestamp = request.POST.get("timestamp", "").strip() | |
| 1823 | + if title: | |
| 1824 | + from fossil.cli import FossilCLI | |
| 1825 | + | |
| 1826 | + cli = FossilCLI() | |
| 1827 | + ts = timestamp if timestamp else None | |
| 1828 | + success = cli.technote_create( | |
| 1829 | + fossil_repo.full_path, | |
| 1830 | + title, | |
| 1831 | + body, | |
| 1832 | + timestamp=ts, | |
| 1833 | + user=request.user.username, | |
| 1834 | + ) | |
| 1835 | + if success: | |
| 1836 | + from django.contrib import messages | |
| 1837 | + | |
| 1838 | + messages.success(request, f'Technote "{title}" created.') | |
| 1839 | + return redirect("fossil:technotes", slug=slug) | |
| 1840 | + | |
| 1841 | + return render( | |
| 1842 | + request, | |
| 1843 | + "fossil/technote_form.html", | |
| 1844 | + {"project": project, "active_tab": "wiki", "form_title": "New Technote"}, | |
| 1845 | + ) | |
| 1846 | + | |
| 1847 | + | |
| 1848 | +def technote_detail(request, slug, technote_id): | |
| 1849 | + from projects.access import can_write_project | |
| 1850 | + | |
| 1851 | + project, fossil_repo, reader = _get_repo_and_reader(slug, request) | |
| 1852 | + | |
| 1853 | + with reader: | |
| 1854 | + note = reader.get_technote_detail(technote_id) | |
| 1855 | + | |
| 1856 | + if not note: | |
| 1857 | + raise Http404("Technote not found") | |
| 1858 | + | |
| 1859 | + body_html = "" | |
| 1860 | + if note["body"]: | |
| 1861 | + body_html = mark_safe(sanitize_html(md.markdown(note["body"], extensions=["footnotes", "tables", "fenced_code"]))) | |
| 1862 | + | |
| 1863 | + has_write = can_write_project(request.user, project) | |
| 1864 | + | |
| 1865 | + return render( | |
| 1866 | + request, | |
| 1867 | + "fossil/technote_detail.html", | |
| 1868 | + { | |
| 1869 | + "project": project, | |
| 1870 | + "note": note, | |
| 1871 | + "body_html": body_html, | |
| 1872 | + "has_write": has_write, | |
| 1873 | + "active_tab": "wiki", | |
| 1874 | + }, | |
| 1875 | + ) | |
| 1876 | + | |
| 1877 | + | |
| 1878 | +@login_required | |
| 1879 | +def technote_edit(request, slug, technote_id): | |
| 1880 | + project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write") | |
| 1881 | + | |
| 1882 | + with reader: | |
| 1883 | + note = reader.get_technote_detail(technote_id) | |
| 1884 | + | |
| 1885 | + if not note: | |
| 1886 | + raise Http404("Technote not found") | |
| 1887 | + | |
| 1888 | + if request.method == "POST": | |
| 1889 | + body = request.POST.get("body", "") | |
| 1890 | + from fossil.cli import FossilCLI | |
| 1891 | + | |
| 1892 | + cli = FossilCLI() | |
| 1893 | + success = cli.technote_edit( | |
| 1894 | + fossil_repo.full_path, | |
| 1895 | + technote_id, | |
| 1896 | + body, | |
| 1897 | + user=request.user.username, | |
| 1898 | + ) | |
| 1899 | + if success: | |
| 1900 | + from django.contrib import messages | |
| 1901 | + | |
| 1902 | + messages.success(request, "Technote updated.") | |
| 1903 | + return redirect("fossil:technote_detail", slug=slug, technote_id=technote_id) | |
| 1904 | + | |
| 1905 | + return render( | |
| 1906 | + request, | |
| 1907 | + "fossil/technote_form.html", | |
| 1908 | + { | |
| 1909 | + "project": project, | |
| 1910 | + "note": note, | |
| 1911 | + "form_title": f"Edit Technote: {note['comment'][:60]}", | |
| 1912 | + "active_tab": "wiki", | |
| 1913 | + }, | |
| 1914 | + ) | |
| 1915 | + | |
| 1916 | + | |
| 1917 | +# --- Unversioned Content --- | |
| 1918 | + | |
| 1919 | + | |
| 1920 | +def unversioned_list(request, slug): | |
| 1921 | + from projects.access import can_admin_project | |
| 1922 | + | |
| 1923 | + project, fossil_repo, reader = _get_repo_and_reader(slug, request) | |
| 1924 | + | |
| 1925 | + with reader: | |
| 1926 | + files = reader.get_unversioned_files() | |
| 1927 | + | |
| 1928 | + has_admin = can_admin_project(request.user, project) | |
| 1929 | + | |
| 1930 | + return render( | |
| 1931 | + request, | |
| 1932 | + "fossil/unversioned_list.html", | |
| 1933 | + { | |
| 1934 | + "project": project, | |
| 1935 | + "files": files, | |
| 1936 | + "has_admin": has_admin, | |
| 1937 | + "active_tab": "files", | |
| 1938 | + }, | |
| 1802 | 1939 | ) |
| 1803 | 1940 | |
| 1941 | + | |
| 1942 | +def unversioned_download(request, slug, filename): | |
| 1943 | + project, fossil_repo, reader = _get_repo_and_reader(slug, request) | |
| 1944 | + | |
| 1945 | + import mimetypes | |
| 1946 | + | |
| 1947 | + from fossil.cli import FossilCLI | |
| 1948 | + | |
| 1949 | + cli = FossilCLI() | |
| 1950 | + try: | |
| 1951 | + content = cli.uv_cat(fossil_repo.full_path, filename) | |
| 1952 | + except FileNotFoundError as exc: | |
| 1953 | + raise Http404(f"Unversioned file not found: {filename}") from exc | |
| 1954 | + | |
| 1955 | + content_type, _ = mimetypes.guess_type(filename) | |
| 1956 | + if not content_type: | |
| 1957 | + content_type = "application/octet-stream" | |
| 1958 | + | |
| 1959 | + response = HttpResponse(content, content_type=content_type) | |
| 1960 | + response["Content-Disposition"] = f'attachment; filename="{filename.split("/")[-1]}"' | |
| 1961 | + response["Content-Length"] = len(content) | |
| 1962 | + return response | |
| 1963 | + | |
| 1964 | + | |
| 1965 | +@login_required | |
| 1966 | +def unversioned_upload(request, slug): | |
| 1967 | + project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin") | |
| 1968 | + | |
| 1969 | + if request.method != "POST": | |
| 1970 | + return redirect("fossil:unversioned", slug=slug) | |
| 1971 | + | |
| 1972 | + uploaded_file = request.FILES.get("file") | |
| 1973 | + if not uploaded_file: | |
| 1974 | + from django.contrib import messages | |
| 1975 | + | |
| 1976 | + messages.error(request, "No file selected.") | |
| 1977 | + return redirect("fossil:unversioned", slug=slug) | |
| 1978 | + | |
| 1979 | + import tempfile | |
| 1980 | + | |
| 1981 | + from fossil.cli import FossilCLI | |
| 1982 | + | |
| 1983 | + cli = FossilCLI() | |
| 1984 | + | |
| 1985 | + # Write uploaded file to a temp location, then add via CLI | |
| 1986 | + with tempfile.NamedTemporaryFile(delete=False) as tmp: | |
| 1987 | + for chunk in uploaded_file.chunks(): | |
| 1988 | + tmp.write(chunk) | |
| 1989 | + tmp_path = tmp.name | |
| 1990 | + | |
| 1991 | + from pathlib import Path | |
| 1992 | + | |
| 1993 | + try: | |
| 1994 | + success = cli.uv_add(fossil_repo.full_path, uploaded_file.name, Path(tmp_path)) | |
| 1995 | + from django.contrib import messages | |
| 1996 | + | |
| 1997 | + if success: | |
| 1998 | + messages.success(request, f'File "{uploaded_file.name}" uploaded.') | |
| 1999 | + else: | |
| 2000 | + messages.error(request, f'Failed to upload "{uploaded_file.name}".') | |
| 2001 | + finally: | |
| 2002 | + Path(tmp_path).unlink(missing_ok=True) | |
| 2003 | + | |
| 2004 | + return redirect("fossil:unversioned", slug=slug) | |
| 2005 | + | |
| 1804 | 2006 | |
| 1805 | 2007 | # --- Compare Checkins --- |
| 1806 | 2008 | |
| 1807 | 2009 | |
| 1808 | 2010 | def compare_checkins(request, slug): |
| @@ -2725,5 +2927,396 @@ | ||
| 2725 | 2927 | |
| 2726 | 2928 | # Increment download count atomically |
| 2727 | 2929 | ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1) |
| 2728 | 2930 | |
| 2729 | 2931 | return FileResponse(asset.file.open("rb"), as_attachment=True, filename=asset.name) |
| 2932 | + | |
| 2933 | + | |
| 2934 | +# --- CI Status Check API --- | |
| 2935 | + | |
| 2936 | + | |
| 2937 | +@csrf_exempt | |
| 2938 | +def status_check_api(request, slug): | |
| 2939 | + """API endpoint for CI to report status checks. | |
| 2940 | + | |
| 2941 | + POST /projects/<slug>/fossil/api/status | |
| 2942 | + Authorization: Bearer <api_token> | |
| 2943 | + { | |
| 2944 | + "checkin": "abc123...", | |
| 2945 | + "context": "ci/tests", | |
| 2946 | + "state": "success", | |
| 2947 | + "description": "All 200 tests passed", | |
| 2948 | + "target_url": "https://ci.example.com/build/123" | |
| 2949 | + } | |
| 2950 | + | |
| 2951 | + GET /projects/<slug>/fossil/api/status?checkin=<uuid> | |
| 2952 | + Returns status checks for a specific checkin (public if project is public). | |
| 2953 | + """ | |
| 2954 | + import json | |
| 2955 | + | |
| 2956 | + from fossil.api_tokens import authenticate_api_token | |
| 2957 | + from fossil.ci import StatusCheck | |
| 2958 | + | |
| 2959 | + project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) | |
| 2960 | + fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True) | |
| 2961 | + | |
| 2962 | + if request.method == "GET": | |
| 2963 | + # Read access -- use normal project visibility rules | |
| 2964 | + from projects.access import can_read_project | |
| 2965 | + | |
| 2966 | + if not can_read_project(request.user, project): | |
| 2967 | + return JsonResponse({"error": "Access denied"}, status=403) | |
| 2968 | + | |
| 2969 | + checkin_uuid = request.GET.get("checkin", "") | |
| 2970 | + if not checkin_uuid: | |
| 2971 | + return JsonResponse({"error": "checkin parameter required"}, status=400) | |
| 2972 | + | |
| 2973 | + checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid) | |
| 2974 | + data = [ | |
| 2975 | + { | |
| 2976 | + "context": c.context, | |
| 2977 | + "state": c.state, | |
| 2978 | + "description": c.description, | |
| 2979 | + "target_url": c.target_url, | |
| 2980 | + "created_at": c.created_at.isoformat() if c.created_at else None, | |
| 2981 | + } | |
| 2982 | + for c in checks | |
| 2983 | + ] | |
| 2984 | + return JsonResponse({"checkin": checkin_uuid, "checks": data}) | |
| 2985 | + | |
| 2986 | + if request.method == "POST": | |
| 2987 | + token = authenticate_api_token(request, fossil_repo) | |
| 2988 | + if not token: | |
| 2989 | + return JsonResponse({"error": "Invalid or expired token"}, status=401) | |
| 2990 | + | |
| 2991 | + if not token.has_permission("status:write"): | |
| 2992 | + return JsonResponse({"error": "Token lacks status:write permission"}, status=403) | |
| 2993 | + | |
| 2994 | + try: | |
| 2995 | + body = json.loads(request.body) | |
| 2996 | + except (json.JSONDecodeError, ValueError): | |
| 2997 | + return JsonResponse({"error": "Invalid JSON"}, status=400) | |
| 2998 | + | |
| 2999 | + checkin_uuid = body.get("checkin", "").strip() | |
| 3000 | + context = body.get("context", "").strip() | |
| 3001 | + state = body.get("state", "").strip() | |
| 3002 | + description = body.get("description", "").strip() | |
| 3003 | + target_url = body.get("target_url", "").strip() | |
| 3004 | + | |
| 3005 | + if not checkin_uuid: | |
| 3006 | + return JsonResponse({"error": "checkin is required"}, status=400) | |
| 3007 | + if not context: | |
| 3008 | + return JsonResponse({"error": "context is required"}, status=400) | |
| 3009 | + if state not in StatusCheck.State.values: | |
| 3010 | + return JsonResponse({"error": f"state must be one of: {', '.join(StatusCheck.State.values)}"}, status=400) | |
| 3011 | + if len(context) > 200: | |
| 3012 | + return JsonResponse({"error": "context must be 200 characters or fewer"}, status=400) | |
| 3013 | + if len(description) > 500: | |
| 3014 | + return JsonResponse({"error": "description must be 500 characters or fewer"}, status=400) | |
| 3015 | + | |
| 3016 | + check, created = StatusCheck.objects.update_or_create( | |
| 3017 | + repository=fossil_repo, | |
| 3018 | + checkin_uuid=checkin_uuid, | |
| 3019 | + context=context, | |
| 3020 | + defaults={ | |
| 3021 | + "state": state, | |
| 3022 | + "description": description, | |
| 3023 | + "target_url": target_url, | |
| 3024 | + "created_by": None, | |
| 3025 | + }, | |
| 3026 | + ) | |
| 3027 | + | |
| 3028 | + return JsonResponse( | |
| 3029 | + { | |
| 3030 | + "id": check.pk, | |
| 3031 | + "context": check.context, | |
| 3032 | + "state": check.state, | |
| 3033 | + "description": check.description, | |
| 3034 | + "target_url": check.target_url, | |
| 3035 | + "created": created, | |
| 3036 | + }, | |
| 3037 | + status=201 if created else 200, | |
| 3038 | + ) | |
| 3039 | + | |
| 3040 | + return JsonResponse({"error": "Method not allowed"}, status=405) | |
| 3041 | + | |
| 3042 | + | |
| 3043 | +def status_badge(request, slug, checkin_uuid): | |
| 3044 | + """SVG badge for CI status (like shields.io). | |
| 3045 | + | |
| 3046 | + Returns an SVG image showing the aggregate status for all checks on a checkin. | |
| 3047 | + """ | |
| 3048 | + from fossil.ci import StatusCheck | |
| 3049 | + | |
| 3050 | + project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) | |
| 3051 | + | |
| 3052 | + # Badge endpoint is public for embeddability (like shields.io) | |
| 3053 | + fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True) | |
| 3054 | + | |
| 3055 | + checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid) | |
| 3056 | + | |
| 3057 | + if not checks.exists(): | |
| 3058 | + label = "build" | |
| 3059 | + message = "unknown" | |
| 3060 | + color = "#9ca3af" # gray | |
| 3061 | + else: | |
| 3062 | + states = set(checks.values_list("state", flat=True)) | |
| 3063 | + if "error" in states or "failure" in states: | |
| 3064 | + label = "build" | |
| 3065 | + message = "failing" | |
| 3066 | + color = "#ef4444" # red | |
| 3067 | + elif "pending" in states: | |
| 3068 | + label = "build" | |
| 3069 | + message = "pending" | |
| 3070 | + color = "#eab308" # yellow | |
| 3071 | + else: | |
| 3072 | + label = "build" | |
| 3073 | + message = "passing" | |
| 3074 | + color = "#22c55e" # green | |
| 3075 | + | |
| 3076 | + label_width = len(label) * 7 + 10 | |
| 3077 | + message_width = len(message) * 7 + 10 | |
| 3078 | + total_width = label_width + message_width | |
| 3079 | + | |
| 3080 | + svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="{total_width}" height="20" role="img" aria-label="{label}: {message}"> | |
| 3081 | + <title>{label}: {message}</title> | |
| 3082 | + <linearGradient id="s" x2="0" y2="100%"> | |
| 3083 | + <stop offset="0" stop-color="#bbb" stop-opacity=".1"/> | |
| 3084 | + <stop offset="1" stop-opacity=".1"/> | |
| 3085 | + </linearGradient> | |
| 3086 | + <clipPath id="r"><rect width="{total_width}" height="20" rx="3" fill="#fff"/></clipPath> | |
| 3087 | + <g clip-path="url(#r)"> | |
| 3088 | + <rect width="{label_width}" height="20" fill="#555"/> | |
| 3089 | + <rect x="{label_width}" width="{message_width}" height="20" fill="{color}"/> | |
| 3090 | + <rect width="{total_width}" height="20" fill="url(#s)"/> | |
| 3091 | + </g> | |
| 3092 | + <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> | |
| 3093 | + <text x="{label_width / 2}" y="14">{label}</text> | |
| 3094 | + <text x="{label_width + message_width / 2}" y="14">{message}</text> | |
| 3095 | + </g> | |
| 3096 | +</svg>""" | |
| 3097 | + | |
| 3098 | + response = HttpResponse(svg, content_type="image/svg+xml") | |
| 3099 | + response["Cache-Control"] = "no-cache, no-store, must-revalidate" | |
| 3100 | + return response | |
| 3101 | + | |
| 3102 | + | |
| 3103 | +# --- API Token Management --- | |
| 3104 | + | |
| 3105 | + | |
| 3106 | +@login_required | |
| 3107 | +def api_token_list(request, slug): | |
| 3108 | + """List API tokens for a project.""" | |
| 3109 | + project, fossil_repo = _get_project_and_repo(slug, request, "admin") | |
| 3110 | + | |
| 3111 | + from fossil.api_tokens import APIToken | |
| 3112 | + | |
| 3113 | + tokens = APIToken.objects.filter(repository=fossil_repo) | |
| 3114 | + | |
| 3115 | + return render( | |
| 3116 | + request, | |
| 3117 | + "fossil/api_token_list.html", | |
| 3118 | + { | |
| 3119 | + "project": project, | |
| 3120 | + "fossil_repo": fossil_repo, | |
| 3121 | + "tokens": tokens, | |
| 3122 | + "active_tab": "settings", | |
| 3123 | + }, | |
| 3124 | + ) | |
| 3125 | + | |
| 3126 | + | |
| 3127 | +@login_required | |
| 3128 | +def api_token_create(request, slug): | |
| 3129 | + """Generate a new API token. Shows the raw token once.""" | |
| 3130 | + from django.contrib import messages | |
| 3131 | + | |
| 3132 | + project, fossil_repo = _get_project_and_repo(slug, request, "admin") | |
| 3133 | + | |
| 3134 | + from fossil.api_tokens import APIToken | |
| 3135 | + | |
| 3136 | + raw_token = None | |
| 3137 | + | |
| 3138 | + if request.method == "POST": | |
| 3139 | + name = request.POST.get("name", "").strip() | |
| 3140 | + permissions = request.POST.get("permissions", "status:write").strip() | |
| 3141 | + expires_at = request.POST.get("expires_at", "").strip() or None | |
| 3142 | + | |
| 3143 | + if not name: | |
| 3144 | + messages.error(request, "Token name is required.") | |
| 3145 | + else: | |
| 3146 | + raw, token_hash, prefix = APIToken.generate() | |
| 3147 | + APIToken.objects.create( | |
| 3148 | + repository=fossil_repo, | |
| 3149 | + name=name, | |
| 3150 | + token_hash=token_hash, | |
| 3151 | + token_prefix=prefix, | |
| 3152 | + permissions=permissions, | |
| 3153 | + expires_at=expires_at, | |
| 3154 | + created_by=request.user, | |
| 3155 | + ) | |
| 3156 | + raw_token = raw | |
| 3157 | + messages.success(request, f'Token "{name}" created. Copy it now -- it won\'t be shown again.') | |
| 3158 | + | |
| 3159 | + return render( | |
| 3160 | + request, | |
| 3161 | + "fossil/api_token_create.html", | |
| 3162 | + { | |
| 3163 | + "project": project, | |
| 3164 | + "fossil_repo": fossil_repo, | |
| 3165 | + "raw_token": raw_token, | |
| 3166 | + "active_tab": "settings", | |
| 3167 | + }, | |
| 3168 | + ) | |
| 3169 | + | |
| 3170 | + | |
| 3171 | +@login_required | |
| 3172 | +def api_token_delete(request, slug, token_id): | |
| 3173 | + """Revoke (soft-delete) an API token.""" | |
| 3174 | + from django.contrib import messages | |
| 3175 | + | |
| 3176 | + project, fossil_repo = _get_project_and_repo(slug, request, "admin") | |
| 3177 | + | |
| 3178 | + from fossil.api_tokens import APIToken | |
| 3179 | + | |
| 3180 | + token = get_object_or_404(APIToken, pk=token_id, repository=fossil_repo, deleted_at__isnull=True) | |
| 3181 | + | |
| 3182 | + if request.method == "POST": | |
| 3183 | + token.soft_delete(user=request.user) | |
| 3184 | + messages.success(request, f'Token "{token.name}" revoked.') | |
| 3185 | + return redirect("fossil:api_tokens", slug=slug) | |
| 3186 | + | |
| 3187 | + return redirect("fossil:api_tokens", slug=slug) | |
| 3188 | + | |
| 3189 | + | |
| 3190 | +# --- Branch Protection --- | |
| 3191 | + | |
| 3192 | + | |
| 3193 | +@login_required | |
| 3194 | +def branch_protection_list(request, slug): | |
| 3195 | + """List branch protection rules.""" | |
| 3196 | + project, fossil_repo = _get_project_and_repo(slug, request, "admin") | |
| 3197 | + | |
| 3198 | + from fossil.branch_protection import BranchProtection | |
| 3199 | + | |
| 3200 | + rules = BranchProtection.objects.filter(repository=fossil_repo) | |
| 3201 | + | |
| 3202 | + return render( | |
| 3203 | + request, | |
| 3204 | + "fossil/branch_protection_list.html", | |
| 3205 | + { | |
| 3206 | + "project": project, | |
| 3207 | + "fossil_repo": fossil_repo, | |
| 3208 | + "rules": rules, | |
| 3209 | + "active_tab": "settings", | |
| 3210 | + }, | |
| 3211 | + ) | |
| 3212 | + | |
| 3213 | + | |
| 3214 | +@login_required | |
| 3215 | +def branch_protection_create(request, slug): | |
| 3216 | + """Create a new branch protection rule.""" | |
| 3217 | + from django.contrib import messages | |
| 3218 | + | |
| 3219 | + project, fossil_repo = _get_project_and_repo(slug, request, "admin") | |
| 3220 | + | |
| 3221 | + from fossil.branch_protection import BranchProtection | |
| 3222 | + | |
| 3223 | + if request.method == "POST": | |
| 3224 | + branch_pattern = request.POST.get("branch_pattern", "").strip() | |
| 3225 | + require_status_checks = request.POST.get("require_status_checks") == "on" | |
| 3226 | + required_contexts = request.POST.get("required_contexts", "").strip() | |
| 3227 | + restrict_push = request.POST.get("restrict_push") == "on" | |
| 3228 | + | |
| 3229 | + if not branch_pattern: | |
| 3230 | + messages.error(request, "Branch pattern is required.") | |
| 3231 | + elif BranchProtection.objects.filter(repository=fossil_repo, branch_pattern=branch_pattern).exists(): | |
| 3232 | + messages.error(request, f'A rule for "{branch_pattern}" already exists.') | |
| 3233 | + else: | |
| 3234 | + BranchProtection.objects.create( | |
| 3235 | + repository=fossil_repo, | |
| 3236 | + branch_pattern=branch_pattern, | |
| 3237 | + require_status_checks=require_status_checks, | |
| 3238 | + required_contexts=required_contexts, | |
| 3239 | + restrict_push=restrict_push, | |
| 3240 | + created_by=request.user, | |
| 3241 | + ) | |
| 3242 | + messages.success(request, f'Branch protection rule for "{branch_pattern}" created.') | |
| 3243 | + return redirect("fossil:branch_protections", slug=slug) | |
| 3244 | + | |
| 3245 | + return render( | |
| 3246 | + request, | |
| 3247 | + "fossil/branch_protection_form.html", | |
| 3248 | + { | |
| 3249 | + "project": project, | |
| 3250 | + "fossil_repo": fossil_repo, | |
| 3251 | + "form_title": "Create Branch Protection Rule", | |
| 3252 | + "submit_label": "Create Rule", | |
| 3253 | + "active_tab": "settings", | |
| 3254 | + }, | |
| 3255 | + ) | |
| 3256 | + | |
| 3257 | + | |
| 3258 | +@login_required | |
| 3259 | +def branch_protection_edit(request, slug, pk): | |
| 3260 | + """Edit a branch protection rule.""" | |
| 3261 | + from django.contrib import messages | |
| 3262 | + | |
| 3263 | + project, fossil_repo = _get_project_and_repo(slug, request, "admin") | |
| 3264 | + | |
| 3265 | + from fossil.branch_protection import BranchProtection | |
| 3266 | + | |
| 3267 | + rule = get_object_or_404(BranchProtection, pk=pk, repository=fossil_repo, deleted_at__isnull=True) | |
| 3268 | + | |
| 3269 | + if request.method == "POST": | |
| 3270 | + branch_pattern = request.POST.get("branch_pattern", "").strip() | |
| 3271 | + require_status_checks = request.POST.get("require_status_checks") == "on" | |
| 3272 | + required_contexts = request.POST.get("required_contexts", "").strip() | |
| 3273 | + restrict_push = request.POST.get("restrict_push") == "on" | |
| 3274 | + | |
| 3275 | + if not branch_pattern: | |
| 3276 | + messages.error(request, "Branch pattern is required.") | |
| 3277 | + else: | |
| 3278 | + # Check uniqueness if pattern changed | |
| 3279 | + conflict = BranchProtection.objects.filter(repository=fossil_repo, branch_pattern=branch_pattern).exclude(pk=rule.pk).exists() | |
| 3280 | + if conflict: | |
| 3281 | + messages.error(request, f'A rule for "{branch_pattern}" already exists.') | |
| 3282 | + else: | |
| 3283 | + rule.branch_pattern = branch_pattern | |
| 3284 | + rule.require_status_checks = require_status_checks | |
| 3285 | + rule.required_contexts = required_contexts | |
| 3286 | + rule.restrict_push = restrict_push | |
| 3287 | + rule.updated_by = request.user | |
| 3288 | + rule.save() | |
| 3289 | + messages.success(request, f'Branch protection rule for "{rule.branch_pattern}" updated.') | |
| 3290 | + return redirect("fossil:branch_protections", slug=slug) | |
| 3291 | + | |
| 3292 | + return render( | |
| 3293 | + request, | |
| 3294 | + "fossil/branch_protection_form.html", | |
| 3295 | + { | |
| 3296 | + "project": project, | |
| 3297 | + "fossil_repo": fossil_repo, | |
| 3298 | + "rule": rule, | |
| 3299 | + "form_title": f"Edit Rule: {rule.branch_pattern}", | |
| 3300 | + "submit_label": "Update Rule", | |
| 3301 | + "active_tab": "settings", | |
| 3302 | + }, | |
| 3303 | + ) | |
| 3304 | + | |
| 3305 | + | |
| 3306 | +@login_required | |
| 3307 | +def branch_protection_delete(request, slug, pk): | |
| 3308 | + """Soft-delete a branch protection rule.""" | |
| 3309 | + from django.contrib import messages | |
| 3310 | + | |
| 3311 | + project, fossil_repo = _get_project_and_repo(slug, request, "admin") | |
| 3312 | + | |
| 3313 | + from fossil.branch_protection import BranchProtection | |
| 3314 | + | |
| 3315 | + rule = get_object_or_404(BranchProtection, pk=pk, repository=fossil_repo, deleted_at__isnull=True) | |
| 3316 | + | |
| 3317 | + if request.method == "POST": | |
| 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) | |
| 2730 | 3323 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -2,11 +2,11 @@ | |
| 2 | import re |
| 3 | from datetime import datetime |
| 4 | |
| 5 | import markdown as md |
| 6 | from django.contrib.auth.decorators import login_required |
| 7 | from django.http import Http404, HttpResponse |
| 8 | from django.shortcuts import get_object_or_404, redirect, render |
| 9 | from django.utils.safestring import mark_safe |
| 10 | from django.views.decorators.csrf import csrf_exempt |
| 11 | |
| 12 | from core.sanitize import sanitize_html |
| @@ -610,18 +610,24 @@ | |
| 610 | "deletions": deletions, |
| 611 | "language": ext, |
| 612 | } |
| 613 | ) |
| 614 | |
| 615 | return render( |
| 616 | request, |
| 617 | "fossil/checkin_detail.html", |
| 618 | { |
| 619 | "project": project, |
| 620 | "fossil_repo": fossil_repo, |
| 621 | "checkin": checkin, |
| 622 | "file_diffs": file_diffs, |
| 623 | "active_tab": "timeline", |
| 624 | }, |
| 625 | ) |
| 626 | |
| 627 | |
| @@ -1788,21 +1794,217 @@ | |
| 1788 | |
| 1789 | # --- Technotes --- |
| 1790 | |
| 1791 | |
| 1792 | def technote_list(request, slug): |
| 1793 | project, fossil_repo, reader = _get_repo_and_reader(slug, request) |
| 1794 | |
| 1795 | with reader: |
| 1796 | notes = reader.get_technotes() |
| 1797 | |
| 1798 | return render( |
| 1799 | request, |
| 1800 | "fossil/technote_list.html", |
| 1801 | {"project": project, "notes": notes, "active_tab": "wiki"}, |
| 1802 | ) |
| 1803 | |
| 1804 | |
| 1805 | # --- Compare Checkins --- |
| 1806 | |
| 1807 | |
| 1808 | def compare_checkins(request, slug): |
| @@ -2725,5 +2927,396 @@ | |
| 2725 | |
| 2726 | # Increment download count atomically |
| 2727 | ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1) |
| 2728 | |
| 2729 | return FileResponse(asset.file.open("rb"), as_attachment=True, filename=asset.name) |
| 2730 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -2,11 +2,11 @@ | |
| 2 | import re |
| 3 | from datetime import datetime |
| 4 | |
| 5 | import markdown as md |
| 6 | from django.contrib.auth.decorators import login_required |
| 7 | from django.http import Http404, HttpResponse, JsonResponse |
| 8 | from django.shortcuts import get_object_or_404, redirect, render |
| 9 | from django.utils.safestring import mark_safe |
| 10 | from django.views.decorators.csrf import csrf_exempt |
| 11 | |
| 12 | from core.sanitize import sanitize_html |
| @@ -610,18 +610,24 @@ | |
| 610 | "deletions": deletions, |
| 611 | "language": ext, |
| 612 | } |
| 613 | ) |
| 614 | |
| 615 | # Fetch CI status checks for this checkin |
| 616 | from fossil.ci import StatusCheck |
| 617 | |
| 618 | status_checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid) |
| 619 | |
| 620 | return render( |
| 621 | request, |
| 622 | "fossil/checkin_detail.html", |
| 623 | { |
| 624 | "project": project, |
| 625 | "fossil_repo": fossil_repo, |
| 626 | "checkin": checkin, |
| 627 | "file_diffs": file_diffs, |
| 628 | "status_checks": status_checks, |
| 629 | "active_tab": "timeline", |
| 630 | }, |
| 631 | ) |
| 632 | |
| 633 | |
| @@ -1788,21 +1794,217 @@ | |
| 1794 | |
| 1795 | # --- Technotes --- |
| 1796 | |
| 1797 | |
| 1798 | def technote_list(request, slug): |
| 1799 | from projects.access import can_write_project |
| 1800 | |
| 1801 | project, fossil_repo, reader = _get_repo_and_reader(slug, request) |
| 1802 | |
| 1803 | with reader: |
| 1804 | notes = reader.get_technotes() |
| 1805 | |
| 1806 | has_write = can_write_project(request.user, project) |
| 1807 | |
| 1808 | return render( |
| 1809 | request, |
| 1810 | "fossil/technote_list.html", |
| 1811 | {"project": project, "notes": notes, "has_write": has_write, "active_tab": "wiki"}, |
| 1812 | ) |
| 1813 | |
| 1814 | |
| 1815 | @login_required |
| 1816 | def technote_create(request, slug): |
| 1817 | project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write") |
| 1818 | |
| 1819 | if request.method == "POST": |
| 1820 | title = request.POST.get("title", "").strip() |
| 1821 | body = request.POST.get("body", "") |
| 1822 | timestamp = request.POST.get("timestamp", "").strip() |
| 1823 | if title: |
| 1824 | from fossil.cli import FossilCLI |
| 1825 | |
| 1826 | cli = FossilCLI() |
| 1827 | ts = timestamp if timestamp else None |
| 1828 | success = cli.technote_create( |
| 1829 | fossil_repo.full_path, |
| 1830 | title, |
| 1831 | body, |
| 1832 | timestamp=ts, |
| 1833 | user=request.user.username, |
| 1834 | ) |
| 1835 | if success: |
| 1836 | from django.contrib import messages |
| 1837 | |
| 1838 | messages.success(request, f'Technote "{title}" created.') |
| 1839 | return redirect("fossil:technotes", slug=slug) |
| 1840 | |
| 1841 | return render( |
| 1842 | request, |
| 1843 | "fossil/technote_form.html", |
| 1844 | {"project": project, "active_tab": "wiki", "form_title": "New Technote"}, |
| 1845 | ) |
| 1846 | |
| 1847 | |
| 1848 | def technote_detail(request, slug, technote_id): |
| 1849 | from projects.access import can_write_project |
| 1850 | |
| 1851 | project, fossil_repo, reader = _get_repo_and_reader(slug, request) |
| 1852 | |
| 1853 | with reader: |
| 1854 | note = reader.get_technote_detail(technote_id) |
| 1855 | |
| 1856 | if not note: |
| 1857 | raise Http404("Technote not found") |
| 1858 | |
| 1859 | body_html = "" |
| 1860 | if note["body"]: |
| 1861 | body_html = mark_safe(sanitize_html(md.markdown(note["body"], extensions=["footnotes", "tables", "fenced_code"]))) |
| 1862 | |
| 1863 | has_write = can_write_project(request.user, project) |
| 1864 | |
| 1865 | return render( |
| 1866 | request, |
| 1867 | "fossil/technote_detail.html", |
| 1868 | { |
| 1869 | "project": project, |
| 1870 | "note": note, |
| 1871 | "body_html": body_html, |
| 1872 | "has_write": has_write, |
| 1873 | "active_tab": "wiki", |
| 1874 | }, |
| 1875 | ) |
| 1876 | |
| 1877 | |
| 1878 | @login_required |
| 1879 | def technote_edit(request, slug, technote_id): |
| 1880 | project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write") |
| 1881 | |
| 1882 | with reader: |
| 1883 | note = reader.get_technote_detail(technote_id) |
| 1884 | |
| 1885 | if not note: |
| 1886 | raise Http404("Technote not found") |
| 1887 | |
| 1888 | if request.method == "POST": |
| 1889 | body = request.POST.get("body", "") |
| 1890 | from fossil.cli import FossilCLI |
| 1891 | |
| 1892 | cli = FossilCLI() |
| 1893 | success = cli.technote_edit( |
| 1894 | fossil_repo.full_path, |
| 1895 | technote_id, |
| 1896 | body, |
| 1897 | user=request.user.username, |
| 1898 | ) |
| 1899 | if success: |
| 1900 | from django.contrib import messages |
| 1901 | |
| 1902 | messages.success(request, "Technote updated.") |
| 1903 | return redirect("fossil:technote_detail", slug=slug, technote_id=technote_id) |
| 1904 | |
| 1905 | return render( |
| 1906 | request, |
| 1907 | "fossil/technote_form.html", |
| 1908 | { |
| 1909 | "project": project, |
| 1910 | "note": note, |
| 1911 | "form_title": f"Edit Technote: {note['comment'][:60]}", |
| 1912 | "active_tab": "wiki", |
| 1913 | }, |
| 1914 | ) |
| 1915 | |
| 1916 | |
| 1917 | # --- Unversioned Content --- |
| 1918 | |
| 1919 | |
| 1920 | def unversioned_list(request, slug): |
| 1921 | from projects.access import can_admin_project |
| 1922 | |
| 1923 | project, fossil_repo, reader = _get_repo_and_reader(slug, request) |
| 1924 | |
| 1925 | with reader: |
| 1926 | files = reader.get_unversioned_files() |
| 1927 | |
| 1928 | has_admin = can_admin_project(request.user, project) |
| 1929 | |
| 1930 | return render( |
| 1931 | request, |
| 1932 | "fossil/unversioned_list.html", |
| 1933 | { |
| 1934 | "project": project, |
| 1935 | "files": files, |
| 1936 | "has_admin": has_admin, |
| 1937 | "active_tab": "files", |
| 1938 | }, |
| 1939 | ) |
| 1940 | |
| 1941 | |
| 1942 | def unversioned_download(request, slug, filename): |
| 1943 | project, fossil_repo, reader = _get_repo_and_reader(slug, request) |
| 1944 | |
| 1945 | import mimetypes |
| 1946 | |
| 1947 | from fossil.cli import FossilCLI |
| 1948 | |
| 1949 | cli = FossilCLI() |
| 1950 | try: |
| 1951 | content = cli.uv_cat(fossil_repo.full_path, filename) |
| 1952 | except FileNotFoundError as exc: |
| 1953 | raise Http404(f"Unversioned file not found: {filename}") from exc |
| 1954 | |
| 1955 | content_type, _ = mimetypes.guess_type(filename) |
| 1956 | if not content_type: |
| 1957 | content_type = "application/octet-stream" |
| 1958 | |
| 1959 | response = HttpResponse(content, content_type=content_type) |
| 1960 | response["Content-Disposition"] = f'attachment; filename="{filename.split("/")[-1]}"' |
| 1961 | response["Content-Length"] = len(content) |
| 1962 | return response |
| 1963 | |
| 1964 | |
| 1965 | @login_required |
| 1966 | def unversioned_upload(request, slug): |
| 1967 | project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin") |
| 1968 | |
| 1969 | if request.method != "POST": |
| 1970 | return redirect("fossil:unversioned", slug=slug) |
| 1971 | |
| 1972 | uploaded_file = request.FILES.get("file") |
| 1973 | if not uploaded_file: |
| 1974 | from django.contrib import messages |
| 1975 | |
| 1976 | messages.error(request, "No file selected.") |
| 1977 | return redirect("fossil:unversioned", slug=slug) |
| 1978 | |
| 1979 | import tempfile |
| 1980 | |
| 1981 | from fossil.cli import FossilCLI |
| 1982 | |
| 1983 | cli = FossilCLI() |
| 1984 | |
| 1985 | # Write uploaded file to a temp location, then add via CLI |
| 1986 | with tempfile.NamedTemporaryFile(delete=False) as tmp: |
| 1987 | for chunk in uploaded_file.chunks(): |
| 1988 | tmp.write(chunk) |
| 1989 | tmp_path = tmp.name |
| 1990 | |
| 1991 | from pathlib import Path |
| 1992 | |
| 1993 | try: |
| 1994 | success = cli.uv_add(fossil_repo.full_path, uploaded_file.name, Path(tmp_path)) |
| 1995 | from django.contrib import messages |
| 1996 | |
| 1997 | if success: |
| 1998 | messages.success(request, f'File "{uploaded_file.name}" uploaded.') |
| 1999 | else: |
| 2000 | messages.error(request, f'Failed to upload "{uploaded_file.name}".') |
| 2001 | finally: |
| 2002 | Path(tmp_path).unlink(missing_ok=True) |
| 2003 | |
| 2004 | return redirect("fossil:unversioned", slug=slug) |
| 2005 | |
| 2006 | |
| 2007 | # --- Compare Checkins --- |
| 2008 | |
| 2009 | |
| 2010 | def compare_checkins(request, slug): |
| @@ -2725,5 +2927,396 @@ | |
| 2927 | |
| 2928 | # Increment download count atomically |
| 2929 | ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1) |
| 2930 | |
| 2931 | return FileResponse(asset.file.open("rb"), as_attachment=True, filename=asset.name) |
| 2932 | |
| 2933 | |
| 2934 | # --- CI Status Check API --- |
| 2935 | |
| 2936 | |
| 2937 | @csrf_exempt |
| 2938 | def status_check_api(request, slug): |
| 2939 | """API endpoint for CI to report status checks. |
| 2940 | |
| 2941 | POST /projects/<slug>/fossil/api/status |
| 2942 | Authorization: Bearer <api_token> |
| 2943 | { |
| 2944 | "checkin": "abc123...", |
| 2945 | "context": "ci/tests", |
| 2946 | "state": "success", |
| 2947 | "description": "All 200 tests passed", |
| 2948 | "target_url": "https://ci.example.com/build/123" |
| 2949 | } |
| 2950 | |
| 2951 | GET /projects/<slug>/fossil/api/status?checkin=<uuid> |
| 2952 | Returns status checks for a specific checkin (public if project is public). |
| 2953 | """ |
| 2954 | import json |
| 2955 | |
| 2956 | from fossil.api_tokens import authenticate_api_token |
| 2957 | from fossil.ci import StatusCheck |
| 2958 | |
| 2959 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 2960 | fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True) |
| 2961 | |
| 2962 | if request.method == "GET": |
| 2963 | # Read access -- use normal project visibility rules |
| 2964 | from projects.access import can_read_project |
| 2965 | |
| 2966 | if not can_read_project(request.user, project): |
| 2967 | return JsonResponse({"error": "Access denied"}, status=403) |
| 2968 | |
| 2969 | checkin_uuid = request.GET.get("checkin", "") |
| 2970 | if not checkin_uuid: |
| 2971 | return JsonResponse({"error": "checkin parameter required"}, status=400) |
| 2972 | |
| 2973 | checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid) |
| 2974 | data = [ |
| 2975 | { |
| 2976 | "context": c.context, |
| 2977 | "state": c.state, |
| 2978 | "description": c.description, |
| 2979 | "target_url": c.target_url, |
| 2980 | "created_at": c.created_at.isoformat() if c.created_at else None, |
| 2981 | } |
| 2982 | for c in checks |
| 2983 | ] |
| 2984 | return JsonResponse({"checkin": checkin_uuid, "checks": data}) |
| 2985 | |
| 2986 | if request.method == "POST": |
| 2987 | token = authenticate_api_token(request, fossil_repo) |
| 2988 | if not token: |
| 2989 | return JsonResponse({"error": "Invalid or expired token"}, status=401) |
| 2990 | |
| 2991 | if not token.has_permission("status:write"): |
| 2992 | return JsonResponse({"error": "Token lacks status:write permission"}, status=403) |
| 2993 | |
| 2994 | try: |
| 2995 | body = json.loads(request.body) |
| 2996 | except (json.JSONDecodeError, ValueError): |
| 2997 | return JsonResponse({"error": "Invalid JSON"}, status=400) |
| 2998 | |
| 2999 | checkin_uuid = body.get("checkin", "").strip() |
| 3000 | context = body.get("context", "").strip() |
| 3001 | state = body.get("state", "").strip() |
| 3002 | description = body.get("description", "").strip() |
| 3003 | target_url = body.get("target_url", "").strip() |
| 3004 | |
| 3005 | if not checkin_uuid: |
| 3006 | return JsonResponse({"error": "checkin is required"}, status=400) |
| 3007 | if not context: |
| 3008 | return JsonResponse({"error": "context is required"}, status=400) |
| 3009 | if state not in StatusCheck.State.values: |
| 3010 | return JsonResponse({"error": f"state must be one of: {', '.join(StatusCheck.State.values)}"}, status=400) |
| 3011 | if len(context) > 200: |
| 3012 | return JsonResponse({"error": "context must be 200 characters or fewer"}, status=400) |
| 3013 | if len(description) > 500: |
| 3014 | return JsonResponse({"error": "description must be 500 characters or fewer"}, status=400) |
| 3015 | |
| 3016 | check, created = StatusCheck.objects.update_or_create( |
| 3017 | repository=fossil_repo, |
| 3018 | checkin_uuid=checkin_uuid, |
| 3019 | context=context, |
| 3020 | defaults={ |
| 3021 | "state": state, |
| 3022 | "description": description, |
| 3023 | "target_url": target_url, |
| 3024 | "created_by": None, |
| 3025 | }, |
| 3026 | ) |
| 3027 | |
| 3028 | return JsonResponse( |
| 3029 | { |
| 3030 | "id": check.pk, |
| 3031 | "context": check.context, |
| 3032 | "state": check.state, |
| 3033 | "description": check.description, |
| 3034 | "target_url": check.target_url, |
| 3035 | "created": created, |
| 3036 | }, |
| 3037 | status=201 if created else 200, |
| 3038 | ) |
| 3039 | |
| 3040 | return JsonResponse({"error": "Method not allowed"}, status=405) |
| 3041 | |
| 3042 | |
| 3043 | def status_badge(request, slug, checkin_uuid): |
| 3044 | """SVG badge for CI status (like shields.io). |
| 3045 | |
| 3046 | Returns an SVG image showing the aggregate status for all checks on a checkin. |
| 3047 | """ |
| 3048 | from fossil.ci import StatusCheck |
| 3049 | |
| 3050 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 3051 | |
| 3052 | # Badge endpoint is public for embeddability (like shields.io) |
| 3053 | fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True) |
| 3054 | |
| 3055 | checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid) |
| 3056 | |
| 3057 | if not checks.exists(): |
| 3058 | label = "build" |
| 3059 | message = "unknown" |
| 3060 | color = "#9ca3af" # gray |
| 3061 | else: |
| 3062 | states = set(checks.values_list("state", flat=True)) |
| 3063 | if "error" in states or "failure" in states: |
| 3064 | label = "build" |
| 3065 | message = "failing" |
| 3066 | color = "#ef4444" # red |
| 3067 | elif "pending" in states: |
| 3068 | label = "build" |
| 3069 | message = "pending" |
| 3070 | color = "#eab308" # yellow |
| 3071 | else: |
| 3072 | label = "build" |
| 3073 | message = "passing" |
| 3074 | color = "#22c55e" # green |
| 3075 | |
| 3076 | label_width = len(label) * 7 + 10 |
| 3077 | message_width = len(message) * 7 + 10 |
| 3078 | total_width = label_width + message_width |
| 3079 | |
| 3080 | svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="{total_width}" height="20" role="img" aria-label="{label}: {message}"> |
| 3081 | <title>{label}: {message}</title> |
| 3082 | <linearGradient id="s" x2="0" y2="100%"> |
| 3083 | <stop offset="0" stop-color="#bbb" stop-opacity=".1"/> |
| 3084 | <stop offset="1" stop-opacity=".1"/> |
| 3085 | </linearGradient> |
| 3086 | <clipPath id="r"><rect width="{total_width}" height="20" rx="3" fill="#fff"/></clipPath> |
| 3087 | <g clip-path="url(#r)"> |
| 3088 | <rect width="{label_width}" height="20" fill="#555"/> |
| 3089 | <rect x="{label_width}" width="{message_width}" height="20" fill="{color}"/> |
| 3090 | <rect width="{total_width}" height="20" fill="url(#s)"/> |
| 3091 | </g> |
| 3092 | <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> |
| 3093 | <text x="{label_width / 2}" y="14">{label}</text> |
| 3094 | <text x="{label_width + message_width / 2}" y="14">{message}</text> |
| 3095 | </g> |
| 3096 | </svg>""" |
| 3097 | |
| 3098 | response = HttpResponse(svg, content_type="image/svg+xml") |
| 3099 | response["Cache-Control"] = "no-cache, no-store, must-revalidate" |
| 3100 | return response |
| 3101 | |
| 3102 | |
| 3103 | # --- API Token Management --- |
| 3104 | |
| 3105 | |
| 3106 | @login_required |
| 3107 | def api_token_list(request, slug): |
| 3108 | """List API tokens for a project.""" |
| 3109 | project, fossil_repo = _get_project_and_repo(slug, request, "admin") |
| 3110 | |
| 3111 | from fossil.api_tokens import APIToken |
| 3112 | |
| 3113 | tokens = APIToken.objects.filter(repository=fossil_repo) |
| 3114 | |
| 3115 | return render( |
| 3116 | request, |
| 3117 | "fossil/api_token_list.html", |
| 3118 | { |
| 3119 | "project": project, |
| 3120 | "fossil_repo": fossil_repo, |
| 3121 | "tokens": tokens, |
| 3122 | "active_tab": "settings", |
| 3123 | }, |
| 3124 | ) |
| 3125 | |
| 3126 | |
| 3127 | @login_required |
| 3128 | def api_token_create(request, slug): |
| 3129 | """Generate a new API token. Shows the raw token once.""" |
| 3130 | from django.contrib import messages |
| 3131 | |
| 3132 | project, fossil_repo = _get_project_and_repo(slug, request, "admin") |
| 3133 | |
| 3134 | from fossil.api_tokens import APIToken |
| 3135 | |
| 3136 | raw_token = None |
| 3137 | |
| 3138 | if request.method == "POST": |
| 3139 | name = request.POST.get("name", "").strip() |
| 3140 | permissions = request.POST.get("permissions", "status:write").strip() |
| 3141 | expires_at = request.POST.get("expires_at", "").strip() or None |
| 3142 | |
| 3143 | if not name: |
| 3144 | messages.error(request, "Token name is required.") |
| 3145 | else: |
| 3146 | raw, token_hash, prefix = APIToken.generate() |
| 3147 | APIToken.objects.create( |
| 3148 | repository=fossil_repo, |
| 3149 | name=name, |
| 3150 | token_hash=token_hash, |
| 3151 | token_prefix=prefix, |
| 3152 | permissions=permissions, |
| 3153 | expires_at=expires_at, |
| 3154 | created_by=request.user, |
| 3155 | ) |
| 3156 | raw_token = raw |
| 3157 | messages.success(request, f'Token "{name}" created. Copy it now -- it won\'t be shown again.') |
| 3158 | |
| 3159 | return render( |
| 3160 | request, |
| 3161 | "fossil/api_token_create.html", |
| 3162 | { |
| 3163 | "project": project, |
| 3164 | "fossil_repo": fossil_repo, |
| 3165 | "raw_token": raw_token, |
| 3166 | "active_tab": "settings", |
| 3167 | }, |
| 3168 | ) |
| 3169 | |
| 3170 | |
| 3171 | @login_required |
| 3172 | def api_token_delete(request, slug, token_id): |
| 3173 | """Revoke (soft-delete) an API token.""" |
| 3174 | from django.contrib import messages |
| 3175 | |
| 3176 | project, fossil_repo = _get_project_and_repo(slug, request, "admin") |
| 3177 | |
| 3178 | from fossil.api_tokens import APIToken |
| 3179 | |
| 3180 | token = get_object_or_404(APIToken, pk=token_id, repository=fossil_repo, deleted_at__isnull=True) |
| 3181 | |
| 3182 | if request.method == "POST": |
| 3183 | token.soft_delete(user=request.user) |
| 3184 | messages.success(request, f'Token "{token.name}" revoked.') |
| 3185 | return redirect("fossil:api_tokens", slug=slug) |
| 3186 | |
| 3187 | return redirect("fossil:api_tokens", slug=slug) |
| 3188 | |
| 3189 | |
| 3190 | # --- Branch Protection --- |
| 3191 | |
| 3192 | |
| 3193 | @login_required |
| 3194 | def branch_protection_list(request, slug): |
| 3195 | """List branch protection rules.""" |
| 3196 | project, fossil_repo = _get_project_and_repo(slug, request, "admin") |
| 3197 | |
| 3198 | from fossil.branch_protection import BranchProtection |
| 3199 | |
| 3200 | rules = BranchProtection.objects.filter(repository=fossil_repo) |
| 3201 | |
| 3202 | return render( |
| 3203 | request, |
| 3204 | "fossil/branch_protection_list.html", |
| 3205 | { |
| 3206 | "project": project, |
| 3207 | "fossil_repo": fossil_repo, |
| 3208 | "rules": rules, |
| 3209 | "active_tab": "settings", |
| 3210 | }, |
| 3211 | ) |
| 3212 | |
| 3213 | |
| 3214 | @login_required |
| 3215 | def branch_protection_create(request, slug): |
| 3216 | """Create a new branch protection rule.""" |
| 3217 | from django.contrib import messages |
| 3218 | |
| 3219 | project, fossil_repo = _get_project_and_repo(slug, request, "admin") |
| 3220 | |
| 3221 | from fossil.branch_protection import BranchProtection |
| 3222 | |
| 3223 | if request.method == "POST": |
| 3224 | branch_pattern = request.POST.get("branch_pattern", "").strip() |
| 3225 | require_status_checks = request.POST.get("require_status_checks") == "on" |
| 3226 | required_contexts = request.POST.get("required_contexts", "").strip() |
| 3227 | restrict_push = request.POST.get("restrict_push") == "on" |
| 3228 | |
| 3229 | if not branch_pattern: |
| 3230 | messages.error(request, "Branch pattern is required.") |
| 3231 | elif BranchProtection.objects.filter(repository=fossil_repo, branch_pattern=branch_pattern).exists(): |
| 3232 | messages.error(request, f'A rule for "{branch_pattern}" already exists.') |
| 3233 | else: |
| 3234 | BranchProtection.objects.create( |
| 3235 | repository=fossil_repo, |
| 3236 | branch_pattern=branch_pattern, |
| 3237 | require_status_checks=require_status_checks, |
| 3238 | required_contexts=required_contexts, |
| 3239 | restrict_push=restrict_push, |
| 3240 | created_by=request.user, |
| 3241 | ) |
| 3242 | messages.success(request, f'Branch protection rule for "{branch_pattern}" created.') |
| 3243 | return redirect("fossil:branch_protections", slug=slug) |
| 3244 | |
| 3245 | return render( |
| 3246 | request, |
| 3247 | "fossil/branch_protection_form.html", |
| 3248 | { |
| 3249 | "project": project, |
| 3250 | "fossil_repo": fossil_repo, |
| 3251 | "form_title": "Create Branch Protection Rule", |
| 3252 | "submit_label": "Create Rule", |
| 3253 | "active_tab": "settings", |
| 3254 | }, |
| 3255 | ) |
| 3256 | |
| 3257 | |
| 3258 | @login_required |
| 3259 | def branch_protection_edit(request, slug, pk): |
| 3260 | """Edit a branch protection rule.""" |
| 3261 | from django.contrib import messages |
| 3262 | |
| 3263 | project, fossil_repo = _get_project_and_repo(slug, request, "admin") |
| 3264 | |
| 3265 | from fossil.branch_protection import BranchProtection |
| 3266 | |
| 3267 | rule = get_object_or_404(BranchProtection, pk=pk, repository=fossil_repo, deleted_at__isnull=True) |
| 3268 | |
| 3269 | if request.method == "POST": |
| 3270 | branch_pattern = request.POST.get("branch_pattern", "").strip() |
| 3271 | require_status_checks = request.POST.get("require_status_checks") == "on" |
| 3272 | required_contexts = request.POST.get("required_contexts", "").strip() |
| 3273 | restrict_push = request.POST.get("restrict_push") == "on" |
| 3274 | |
| 3275 | if not branch_pattern: |
| 3276 | messages.error(request, "Branch pattern is required.") |
| 3277 | else: |
| 3278 | # Check uniqueness if pattern changed |
| 3279 | conflict = BranchProtection.objects.filter(repository=fossil_repo, branch_pattern=branch_pattern).exclude(pk=rule.pk).exists() |
| 3280 | if conflict: |
| 3281 | messages.error(request, f'A rule for "{branch_pattern}" already exists.') |
| 3282 | else: |
| 3283 | rule.branch_pattern = branch_pattern |
| 3284 | rule.require_status_checks = require_status_checks |
| 3285 | rule.required_contexts = required_contexts |
| 3286 | rule.restrict_push = restrict_push |
| 3287 | rule.updated_by = request.user |
| 3288 | rule.save() |
| 3289 | messages.success(request, f'Branch protection rule for "{rule.branch_pattern}" updated.') |
| 3290 | return redirect("fossil:branch_protections", slug=slug) |
| 3291 | |
| 3292 | return render( |
| 3293 | request, |
| 3294 | "fossil/branch_protection_form.html", |
| 3295 | { |
| 3296 | "project": project, |
| 3297 | "fossil_repo": fossil_repo, |
| 3298 | "rule": rule, |
| 3299 | "form_title": f"Edit Rule: {rule.branch_pattern}", |
| 3300 | "submit_label": "Update Rule", |
| 3301 | "active_tab": "settings", |
| 3302 | }, |
| 3303 | ) |
| 3304 | |
| 3305 | |
| 3306 | @login_required |
| 3307 | def branch_protection_delete(request, slug, pk): |
| 3308 | """Soft-delete a branch protection rule.""" |
| 3309 | from django.contrib import messages |
| 3310 | |
| 3311 | project, fossil_repo = _get_project_and_repo(slug, request, "admin") |
| 3312 | |
| 3313 | from fossil.branch_protection import BranchProtection |
| 3314 | |
| 3315 | rule = get_object_or_404(BranchProtection, pk=pk, repository=fossil_repo, deleted_at__isnull=True) |
| 3316 | |
| 3317 | if request.method == "POST": |
| 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 |
| --- organization/urls.py | ||
| +++ organization/urls.py | ||
| @@ -18,10 +18,12 @@ | ||
| 18 | 18 | path("members/<str:username>/remove/", views.member_remove, name="member_remove"), |
| 19 | 19 | # Roles |
| 20 | 20 | path("roles/", views.role_list, name="role_list"), |
| 21 | 21 | path("roles/initialize/", views.role_initialize, name="role_initialize"), |
| 22 | 22 | path("roles/<slug:slug>/", views.role_detail, name="role_detail"), |
| 23 | + # Audit log | |
| 24 | + path("audit/", views.audit_log, name="audit_log"), | |
| 23 | 25 | # Teams |
| 24 | 26 | path("teams/", views.team_list, name="team_list"), |
| 25 | 27 | path("teams/create/", views.team_create, name="team_create"), |
| 26 | 28 | path("teams/<slug:slug>/", views.team_detail, name="team_detail"), |
| 27 | 29 | path("teams/<slug:slug>/edit/", views.team_update, name="team_update"), |
| 28 | 30 |
| --- organization/urls.py | |
| +++ organization/urls.py | |
| @@ -18,10 +18,12 @@ | |
| 18 | path("members/<str:username>/remove/", views.member_remove, name="member_remove"), |
| 19 | # Roles |
| 20 | path("roles/", views.role_list, name="role_list"), |
| 21 | path("roles/initialize/", views.role_initialize, name="role_initialize"), |
| 22 | path("roles/<slug:slug>/", views.role_detail, name="role_detail"), |
| 23 | # Teams |
| 24 | path("teams/", views.team_list, name="team_list"), |
| 25 | path("teams/create/", views.team_create, name="team_create"), |
| 26 | path("teams/<slug:slug>/", views.team_detail, name="team_detail"), |
| 27 | path("teams/<slug:slug>/edit/", views.team_update, name="team_update"), |
| 28 |
| --- organization/urls.py | |
| +++ organization/urls.py | |
| @@ -18,10 +18,12 @@ | |
| 18 | path("members/<str:username>/remove/", views.member_remove, name="member_remove"), |
| 19 | # Roles |
| 20 | path("roles/", views.role_list, name="role_list"), |
| 21 | path("roles/initialize/", views.role_initialize, name="role_initialize"), |
| 22 | path("roles/<slug:slug>/", views.role_detail, name="role_detail"), |
| 23 | # Audit log |
| 24 | path("audit/", views.audit_log, name="audit_log"), |
| 25 | # Teams |
| 26 | path("teams/", views.team_list, name="team_list"), |
| 27 | path("teams/create/", views.team_create, name="team_create"), |
| 28 | path("teams/<slug:slug>/", views.team_detail, name="team_detail"), |
| 29 | path("teams/<slug:slug>/edit/", views.team_update, name="team_update"), |
| 30 |
| --- organization/views.py | ||
| +++ organization/views.py | ||
| @@ -385,10 +385,62 @@ | ||
| 385 | 385 | request, |
| 386 | 386 | "organization/role_detail.html", |
| 387 | 387 | {"role": role, "grouped_permissions": grouped, "role_members": role_members}, |
| 388 | 388 | ) |
| 389 | 389 | |
| 390 | + | |
| 391 | +@login_required | |
| 392 | +def audit_log(request): | |
| 393 | + """Unified audit log across all tracked models. Requires superuser or org admin.""" | |
| 394 | + if not request.user.is_superuser: | |
| 395 | + P.ORGANIZATION_CHANGE.check(request.user) | |
| 396 | + | |
| 397 | + from fossil.models import FossilRepository | |
| 398 | + from projects.models import Project | |
| 399 | + | |
| 400 | + trackable_models = [ | |
| 401 | + ("Project", Project), | |
| 402 | + ("Organization", Organization), | |
| 403 | + ("Team", Team), | |
| 404 | + ("FossilRepository", FossilRepository), | |
| 405 | + ] | |
| 406 | + | |
| 407 | + entries = [] | |
| 408 | + model_filter = request.GET.get("model", "").strip() | |
| 409 | + | |
| 410 | + for label, model in trackable_models: | |
| 411 | + if model_filter and label.lower() != model_filter.lower(): | |
| 412 | + continue | |
| 413 | + history_model = model.history.model | |
| 414 | + qs = history_model.objects.all().select_related("history_user").order_by("-history_date")[:100] | |
| 415 | + for h in qs: | |
| 416 | + entries.append( | |
| 417 | + { | |
| 418 | + "date": h.history_date, | |
| 419 | + "user": h.history_user, | |
| 420 | + "action": h.get_history_type_display(), | |
| 421 | + "model": label, | |
| 422 | + "object_repr": str(h), | |
| 423 | + "object_id": h.pk, | |
| 424 | + } | |
| 425 | + ) | |
| 426 | + | |
| 427 | + entries.sort(key=lambda x: x["date"], reverse=True) | |
| 428 | + entries = entries[:200] | |
| 429 | + | |
| 430 | + available_models = [label for label, _ in trackable_models] | |
| 431 | + | |
| 432 | + return render( | |
| 433 | + request, | |
| 434 | + "organization/audit_log.html", | |
| 435 | + { | |
| 436 | + "entries": entries, | |
| 437 | + "model_filter": model_filter, | |
| 438 | + "available_models": available_models, | |
| 439 | + }, | |
| 440 | + ) | |
| 441 | + | |
| 390 | 442 | |
| 391 | 443 | @login_required |
| 392 | 444 | def role_initialize(request): |
| 393 | 445 | P.ORGANIZATION_CHANGE.check(request.user) |
| 394 | 446 | |
| 395 | 447 |
| --- organization/views.py | |
| +++ organization/views.py | |
| @@ -385,10 +385,62 @@ | |
| 385 | request, |
| 386 | "organization/role_detail.html", |
| 387 | {"role": role, "grouped_permissions": grouped, "role_members": role_members}, |
| 388 | ) |
| 389 | |
| 390 | |
| 391 | @login_required |
| 392 | def role_initialize(request): |
| 393 | P.ORGANIZATION_CHANGE.check(request.user) |
| 394 | |
| 395 |
| --- organization/views.py | |
| +++ organization/views.py | |
| @@ -385,10 +385,62 @@ | |
| 385 | request, |
| 386 | "organization/role_detail.html", |
| 387 | {"role": role, "grouped_permissions": grouped, "role_members": role_members}, |
| 388 | ) |
| 389 | |
| 390 | |
| 391 | @login_required |
| 392 | def audit_log(request): |
| 393 | """Unified audit log across all tracked models. Requires superuser or org admin.""" |
| 394 | if not request.user.is_superuser: |
| 395 | P.ORGANIZATION_CHANGE.check(request.user) |
| 396 | |
| 397 | from fossil.models import FossilRepository |
| 398 | from projects.models import Project |
| 399 | |
| 400 | trackable_models = [ |
| 401 | ("Project", Project), |
| 402 | ("Organization", Organization), |
| 403 | ("Team", Team), |
| 404 | ("FossilRepository", FossilRepository), |
| 405 | ] |
| 406 | |
| 407 | entries = [] |
| 408 | model_filter = request.GET.get("model", "").strip() |
| 409 | |
| 410 | for label, model in trackable_models: |
| 411 | if model_filter and label.lower() != model_filter.lower(): |
| 412 | continue |
| 413 | history_model = model.history.model |
| 414 | qs = history_model.objects.all().select_related("history_user").order_by("-history_date")[:100] |
| 415 | for h in qs: |
| 416 | entries.append( |
| 417 | { |
| 418 | "date": h.history_date, |
| 419 | "user": h.history_user, |
| 420 | "action": h.get_history_type_display(), |
| 421 | "model": label, |
| 422 | "object_repr": str(h), |
| 423 | "object_id": h.pk, |
| 424 | } |
| 425 | ) |
| 426 | |
| 427 | entries.sort(key=lambda x: x["date"], reverse=True) |
| 428 | entries = entries[:200] |
| 429 | |
| 430 | available_models = [label for label, _ in trackable_models] |
| 431 | |
| 432 | return render( |
| 433 | request, |
| 434 | "organization/audit_log.html", |
| 435 | { |
| 436 | "entries": entries, |
| 437 | "model_filter": model_filter, |
| 438 | "available_models": available_models, |
| 439 | }, |
| 440 | ) |
| 441 | |
| 442 | |
| 443 | @login_required |
| 444 | def role_initialize(request): |
| 445 | P.ORGANIZATION_CHANGE.check(request.user) |
| 446 | |
| 447 |
| --- projects/admin.py | ||
| +++ projects/admin.py | ||
| @@ -1,10 +1,10 @@ | ||
| 1 | 1 | from django.contrib import admin |
| 2 | 2 | |
| 3 | 3 | from core.admin import BaseCoreAdmin |
| 4 | 4 | |
| 5 | -from .models import Project, ProjectGroup, ProjectTeam | |
| 5 | +from .models import Project, ProjectGroup, ProjectStar, ProjectTeam | |
| 6 | 6 | |
| 7 | 7 | |
| 8 | 8 | @admin.register(ProjectGroup) |
| 9 | 9 | class ProjectGroupAdmin(BaseCoreAdmin): |
| 10 | 10 | list_display = ("name", "slug", "created_at") |
| @@ -29,5 +29,13 @@ | ||
| 29 | 29 | class ProjectTeamAdmin(BaseCoreAdmin): |
| 30 | 30 | list_display = ("project", "team", "role", "created_at") |
| 31 | 31 | list_filter = ("role", "team") |
| 32 | 32 | search_fields = ("project__name", "team__name") |
| 33 | 33 | raw_id_fields = ("project", "team") |
| 34 | + | |
| 35 | + | |
| 36 | +@admin.register(ProjectStar) | |
| 37 | +class ProjectStarAdmin(admin.ModelAdmin): | |
| 38 | + list_display = ("user", "project", "created_at") | |
| 39 | + list_filter = ("created_at",) | |
| 40 | + search_fields = ("user__username", "project__name") | |
| 41 | + raw_id_fields = ("user", "project") | |
| 34 | 42 | |
| 35 | 43 | ADDED projects/migrations/0003_add_projectstar.py |
| --- projects/admin.py | |
| +++ projects/admin.py | |
| @@ -1,10 +1,10 @@ | |
| 1 | from django.contrib import admin |
| 2 | |
| 3 | from core.admin import BaseCoreAdmin |
| 4 | |
| 5 | from .models import Project, ProjectGroup, ProjectTeam |
| 6 | |
| 7 | |
| 8 | @admin.register(ProjectGroup) |
| 9 | class ProjectGroupAdmin(BaseCoreAdmin): |
| 10 | list_display = ("name", "slug", "created_at") |
| @@ -29,5 +29,13 @@ | |
| 29 | class ProjectTeamAdmin(BaseCoreAdmin): |
| 30 | list_display = ("project", "team", "role", "created_at") |
| 31 | list_filter = ("role", "team") |
| 32 | search_fields = ("project__name", "team__name") |
| 33 | raw_id_fields = ("project", "team") |
| 34 | |
| 35 | DDED projects/migrations/0003_add_projectstar.py |
| --- projects/admin.py | |
| +++ projects/admin.py | |
| @@ -1,10 +1,10 @@ | |
| 1 | from django.contrib import admin |
| 2 | |
| 3 | from core.admin import BaseCoreAdmin |
| 4 | |
| 5 | from .models import Project, ProjectGroup, ProjectStar, ProjectTeam |
| 6 | |
| 7 | |
| 8 | @admin.register(ProjectGroup) |
| 9 | class ProjectGroupAdmin(BaseCoreAdmin): |
| 10 | list_display = ("name", "slug", "created_at") |
| @@ -29,5 +29,13 @@ | |
| 29 | class ProjectTeamAdmin(BaseCoreAdmin): |
| 30 | list_display = ("project", "team", "role", "created_at") |
| 31 | list_filter = ("role", "team") |
| 32 | search_fields = ("project__name", "team__name") |
| 33 | raw_id_fields = ("project", "team") |
| 34 | |
| 35 | |
| 36 | @admin.register(ProjectStar) |
| 37 | class ProjectStarAdmin(admin.ModelAdmin): |
| 38 | list_display = ("user", "project", "created_at") |
| 39 | list_filter = ("created_at",) |
| 40 | search_fields = ("user__username", "project__name") |
| 41 | raw_id_fields = ("user", "project") |
| 42 | |
| 43 | DDED projects/migrations/0003_add_projectstar.py |
| --- a/projects/migrations/0003_add_projectstar.py | ||
| +++ b/projects/migrations/0003_add_projectstar.py | ||
| @@ -0,0 +1,50 @@ | ||
| 1 | +# Generated by Django 5.2.12 on 2026-04-07 15:00 | |
| 2 | + | |
| 3 | +import django.db.models.deletion | |
| 4 | +from django.conf import settings | |
| 5 | +from django.db import migrations, models | |
| 6 | + | |
| 7 | + | |
| 8 | +class Migration(migrations.Migration): | |
| 9 | + dependencies = [ | |
| 10 | + ("projects", "0002_historicalprojectgroup_projectgroup_and_more"), | |
| 11 | + migrations.swappable_dependency(settings.AUTH_USER_MODEL), | |
| 12 | + ] | |
| 13 | + | |
| 14 | + operations = [ | |
| 15 | + migrations.CreateModel( | |
| 16 | + name="ProjectStar", | |
| 17 | + fields=[ | |
| 18 | + ( | |
| 19 | + "id", | |
| 20 | + models.BigAutoField( | |
| 21 | + auto_created=True, | |
| 22 | + primary_key=True, | |
| 23 | + serialize=False, | |
| 24 | + verbose_name="ID", | |
| 25 | + ), | |
| 26 | + ), | |
| 27 | + ("created_at", models.DateTimeField(auto_now_add=True)), | |
| 28 | + ( | |
| 29 | + "project", | |
| 30 | + models.ForeignKey( | |
| 31 | + on_delete=django.db.models.deletion.CASCADE, | |
| 32 | + related_name="stars", | |
| 33 | + to="projects.project", | |
| 34 | + ), | |
| 35 | + ), | |
| 36 | + ( | |
| 37 | + "user", | |
| 38 | + models.ForeignKey( | |
| 39 | + on_delete=django.db.models.deletion.CASCADE, | |
| 40 | + related_name="starred_projects", | |
| 41 | + to=settings.AUTH_USER_MODEL, | |
| 42 | + ), | |
| 43 | + ), | |
| 44 | + ], | |
| 45 | + options={ | |
| 46 | + "ordering": ["-created_at"], | |
| 47 | + "unique_together": {("user", "project")}, | |
| 48 | + }, | |
| 49 | + ), | |
| 50 | + ] |
| --- a/projects/migrations/0003_add_projectstar.py | |
| +++ b/projects/migrations/0003_add_projectstar.py | |
| @@ -0,0 +1,50 @@ | |
| --- a/projects/migrations/0003_add_projectstar.py | |
| +++ b/projects/migrations/0003_add_projectstar.py | |
| @@ -0,0 +1,50 @@ | |
| 1 | # Generated by Django 5.2.12 on 2026-04-07 15:00 |
| 2 | |
| 3 | import django.db.models.deletion |
| 4 | from django.conf import settings |
| 5 | from django.db import migrations, models |
| 6 | |
| 7 | |
| 8 | class Migration(migrations.Migration): |
| 9 | dependencies = [ |
| 10 | ("projects", "0002_historicalprojectgroup_projectgroup_and_more"), |
| 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
| 12 | ] |
| 13 | |
| 14 | operations = [ |
| 15 | migrations.CreateModel( |
| 16 | name="ProjectStar", |
| 17 | fields=[ |
| 18 | ( |
| 19 | "id", |
| 20 | models.BigAutoField( |
| 21 | auto_created=True, |
| 22 | primary_key=True, |
| 23 | serialize=False, |
| 24 | verbose_name="ID", |
| 25 | ), |
| 26 | ), |
| 27 | ("created_at", models.DateTimeField(auto_now_add=True)), |
| 28 | ( |
| 29 | "project", |
| 30 | models.ForeignKey( |
| 31 | on_delete=django.db.models.deletion.CASCADE, |
| 32 | related_name="stars", |
| 33 | to="projects.project", |
| 34 | ), |
| 35 | ), |
| 36 | ( |
| 37 | "user", |
| 38 | models.ForeignKey( |
| 39 | on_delete=django.db.models.deletion.CASCADE, |
| 40 | related_name="starred_projects", |
| 41 | to=settings.AUTH_USER_MODEL, |
| 42 | ), |
| 43 | ), |
| 44 | ], |
| 45 | options={ |
| 46 | "ordering": ["-created_at"], |
| 47 | "unique_together": {("user", "project")}, |
| 48 | }, |
| 49 | ), |
| 50 | ] |
| --- projects/models.py | ||
| +++ projects/models.py | ||
| @@ -1,5 +1,6 @@ | ||
| 1 | +from django.conf import settings | |
| 1 | 2 | from django.db import models |
| 2 | 3 | |
| 3 | 4 | from core.models import ActiveManager, BaseCoreModel, Tracking |
| 4 | 5 | |
| 5 | 6 | |
| @@ -38,10 +39,29 @@ | ||
| 38 | 39 | all_objects = models.Manager() |
| 39 | 40 | |
| 40 | 41 | class Meta: |
| 41 | 42 | ordering = ["name"] |
| 42 | 43 | |
| 44 | + @property | |
| 45 | + def star_count(self): | |
| 46 | + return self.stars.count() | |
| 47 | + | |
| 48 | + | |
| 49 | +class ProjectStar(models.Model): | |
| 50 | + """Tracks which users have starred a project.""" | |
| 51 | + | |
| 52 | + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="starred_projects") | |
| 53 | + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="stars") | |
| 54 | + created_at = models.DateTimeField(auto_now_add=True) | |
| 55 | + | |
| 56 | + class Meta: | |
| 57 | + unique_together = ("user", "project") | |
| 58 | + ordering = ["-created_at"] | |
| 59 | + | |
| 60 | + def __str__(self): | |
| 61 | + return f"{self.user} starred {self.project}" | |
| 62 | + | |
| 43 | 63 | |
| 44 | 64 | class ProjectTeam(Tracking): |
| 45 | 65 | class Role(models.TextChoices): |
| 46 | 66 | READ = "read", "Read" |
| 47 | 67 | WRITE = "write", "Write" |
| 48 | 68 |
| --- projects/models.py | |
| +++ projects/models.py | |
| @@ -1,5 +1,6 @@ | |
| 1 | from django.db import models |
| 2 | |
| 3 | from core.models import ActiveManager, BaseCoreModel, Tracking |
| 4 | |
| 5 | |
| @@ -38,10 +39,29 @@ | |
| 38 | all_objects = models.Manager() |
| 39 | |
| 40 | class Meta: |
| 41 | ordering = ["name"] |
| 42 | |
| 43 | |
| 44 | class ProjectTeam(Tracking): |
| 45 | class Role(models.TextChoices): |
| 46 | READ = "read", "Read" |
| 47 | WRITE = "write", "Write" |
| 48 |
| --- projects/models.py | |
| +++ projects/models.py | |
| @@ -1,5 +1,6 @@ | |
| 1 | from django.conf import settings |
| 2 | from django.db import models |
| 3 | |
| 4 | from core.models import ActiveManager, BaseCoreModel, Tracking |
| 5 | |
| 6 | |
| @@ -38,10 +39,29 @@ | |
| 39 | all_objects = models.Manager() |
| 40 | |
| 41 | class Meta: |
| 42 | ordering = ["name"] |
| 43 | |
| 44 | @property |
| 45 | def star_count(self): |
| 46 | return self.stars.count() |
| 47 | |
| 48 | |
| 49 | class ProjectStar(models.Model): |
| 50 | """Tracks which users have starred a project.""" |
| 51 | |
| 52 | user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="starred_projects") |
| 53 | project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="stars") |
| 54 | created_at = models.DateTimeField(auto_now_add=True) |
| 55 | |
| 56 | class Meta: |
| 57 | unique_together = ("user", "project") |
| 58 | ordering = ["-created_at"] |
| 59 | |
| 60 | def __str__(self): |
| 61 | return f"{self.user} starred {self.project}" |
| 62 | |
| 63 | |
| 64 | class ProjectTeam(Tracking): |
| 65 | class Role(models.TextChoices): |
| 66 | READ = "read", "Read" |
| 67 | WRITE = "write", "Write" |
| 68 |
| --- projects/urls.py | ||
| +++ projects/urls.py | ||
| @@ -12,10 +12,11 @@ | ||
| 12 | 12 | path("groups/create/", views.group_create, name="group_create"), |
| 13 | 13 | path("groups/<slug:slug>/", views.group_detail, name="group_detail"), |
| 14 | 14 | path("groups/<slug:slug>/edit/", views.group_edit, name="group_edit"), |
| 15 | 15 | path("groups/<slug:slug>/delete/", views.group_delete, name="group_delete"), |
| 16 | 16 | # Projects |
| 17 | + path("<slug:slug>/star/", views.toggle_star, name="toggle_star"), | |
| 17 | 18 | path("<slug:slug>/", views.project_detail, name="detail"), |
| 18 | 19 | path("<slug:slug>/edit/", views.project_update, name="update"), |
| 19 | 20 | path("<slug:slug>/delete/", views.project_delete, name="delete"), |
| 20 | 21 | path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"), |
| 21 | 22 | path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"), |
| 22 | 23 |
| --- projects/urls.py | |
| +++ projects/urls.py | |
| @@ -12,10 +12,11 @@ | |
| 12 | path("groups/create/", views.group_create, name="group_create"), |
| 13 | path("groups/<slug:slug>/", views.group_detail, name="group_detail"), |
| 14 | path("groups/<slug:slug>/edit/", views.group_edit, name="group_edit"), |
| 15 | path("groups/<slug:slug>/delete/", views.group_delete, name="group_delete"), |
| 16 | # Projects |
| 17 | path("<slug:slug>/", views.project_detail, name="detail"), |
| 18 | path("<slug:slug>/edit/", views.project_update, name="update"), |
| 19 | path("<slug:slug>/delete/", views.project_delete, name="delete"), |
| 20 | path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"), |
| 21 | path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"), |
| 22 |
| --- projects/urls.py | |
| +++ projects/urls.py | |
| @@ -12,10 +12,11 @@ | |
| 12 | path("groups/create/", views.group_create, name="group_create"), |
| 13 | path("groups/<slug:slug>/", views.group_detail, name="group_detail"), |
| 14 | path("groups/<slug:slug>/edit/", views.group_edit, name="group_edit"), |
| 15 | path("groups/<slug:slug>/delete/", views.group_delete, name="group_delete"), |
| 16 | # Projects |
| 17 | path("<slug:slug>/star/", views.toggle_star, name="toggle_star"), |
| 18 | path("<slug:slug>/", views.project_detail, name="detail"), |
| 19 | path("<slug:slug>/edit/", views.project_update, name="update"), |
| 20 | path("<slug:slug>/delete/", views.project_delete, name="delete"), |
| 21 | path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"), |
| 22 | path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"), |
| 23 |
| --- projects/views.py | ||
| +++ projects/views.py | ||
| @@ -1,16 +1,17 @@ | ||
| 1 | 1 | from django.contrib import messages |
| 2 | 2 | from django.contrib.auth.decorators import login_required |
| 3 | +from django.db.models import Count | |
| 3 | 4 | from django.http import HttpResponse |
| 4 | 5 | from django.shortcuts import get_object_or_404, redirect, render |
| 5 | 6 | |
| 6 | 7 | from core.permissions import P |
| 7 | 8 | from organization.models import Team |
| 8 | 9 | from organization.views import get_org |
| 9 | 10 | |
| 10 | 11 | from .forms import ProjectForm, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm |
| 11 | -from .models import Project, ProjectGroup, ProjectTeam | |
| 12 | +from .models import Project, ProjectGroup, ProjectStar, ProjectTeam | |
| 12 | 13 | |
| 13 | 14 | |
| 14 | 15 | @login_required |
| 15 | 16 | def project_list(request): |
| 16 | 17 | P.PROJECT_VIEW.check(request.user) |
| @@ -120,14 +121,16 @@ | ||
| 120 | 121 | |
| 121 | 122 | import json |
| 122 | 123 | |
| 123 | 124 | # Check if user is watching this project |
| 124 | 125 | is_watching = False |
| 126 | + is_starred = False | |
| 125 | 127 | if request.user.is_authenticated: |
| 126 | 128 | from fossil.notifications import ProjectWatch |
| 127 | 129 | |
| 128 | 130 | is_watching = ProjectWatch.objects.filter(user=request.user, project=project, deleted_at__isnull=True).exists() |
| 131 | + is_starred = ProjectStar.objects.filter(user=request.user, project=project).exists() | |
| 129 | 132 | |
| 130 | 133 | return render( |
| 131 | 134 | request, |
| 132 | 135 | "projects/project_detail.html", |
| 133 | 136 | { |
| @@ -136,10 +139,11 @@ | ||
| 136 | 139 | "repo_stats": repo_stats, |
| 137 | 140 | "recent_commits": recent_commits, |
| 138 | 141 | "commit_activity_json": json.dumps([c["count"] for c in commit_activity]), |
| 139 | 142 | "top_contributors": top_contributors, |
| 140 | 143 | "is_watching": is_watching, |
| 144 | + "is_starred": is_starred, | |
| 141 | 145 | }, |
| 142 | 146 | ) |
| 143 | 147 | |
| 144 | 148 | |
| 145 | 149 | @login_required |
| @@ -315,5 +319,69 @@ | ||
| 315 | 319 | return HttpResponse(status=200, headers={"HX-Redirect": "/projects/groups/"}) |
| 316 | 320 | |
| 317 | 321 | return redirect("projects:group_list") |
| 318 | 322 | |
| 319 | 323 | return render(request, "projects/group_confirm_delete.html", {"group": group}) |
| 324 | + | |
| 325 | + | |
| 326 | +# --- Project Starring --- | |
| 327 | + | |
| 328 | + | |
| 329 | +@login_required | |
| 330 | +def toggle_star(request, slug): | |
| 331 | + """Toggle star on/off for a project. POST only.""" | |
| 332 | + project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) | |
| 333 | + star, created = ProjectStar.objects.get_or_create(user=request.user, project=project) | |
| 334 | + if not created: | |
| 335 | + star.delete() | |
| 336 | + is_starred = created | |
| 337 | + | |
| 338 | + if request.headers.get("HX-Request"): | |
| 339 | + return render(request, "projects/partials/star_button.html", {"project": project, "is_starred": is_starred}) | |
| 340 | + | |
| 341 | + return redirect("projects:detail", slug=slug) | |
| 342 | + | |
| 343 | + | |
| 344 | +# --- Explore / Discover --- | |
| 345 | + | |
| 346 | + | |
| 347 | +def explore(request): | |
| 348 | + """Public project discovery page. Works for anonymous users (public only) and authenticated users (public + internal).""" | |
| 349 | + if request.user.is_authenticated: | |
| 350 | + allowed_visibility = [Project.Visibility.PUBLIC, Project.Visibility.INTERNAL] | |
| 351 | + else: | |
| 352 | + allowed_visibility = [Project.Visibility.PUBLIC] | |
| 353 | + | |
| 354 | + projects = ( | |
| 355 | + Project.objects.filter(deleted_at__isnull=True, visibility__in=allowed_visibility) | |
| 356 | + .annotate(star_count_annotated=Count("stars")) | |
| 357 | + .select_related("organization") | |
| 358 | + ) | |
| 359 | + | |
| 360 | + sort = request.GET.get("sort", "stars") | |
| 361 | + if sort == "recent": | |
| 362 | + projects = projects.order_by("-created_at") | |
| 363 | + elif sort == "name": | |
| 364 | + projects = projects.order_by("name") | |
| 365 | + else: | |
| 366 | + sort = "stars" | |
| 367 | + projects = projects.order_by("-star_count_annotated", "-created_at") | |
| 368 | + | |
| 369 | + search = request.GET.get("search", "").strip() | |
| 370 | + if search: | |
| 371 | + projects = projects.filter(name__icontains=search) | |
| 372 | + | |
| 373 | + # Check which projects the current user has starred | |
| 374 | + starred_ids = set() | |
| 375 | + if request.user.is_authenticated: | |
| 376 | + starred_ids = set(ProjectStar.objects.filter(user=request.user).values_list("project_id", flat=True)) | |
| 377 | + | |
| 378 | + return render( | |
| 379 | + request, | |
| 380 | + "projects/explore.html", | |
| 381 | + { | |
| 382 | + "projects": projects, | |
| 383 | + "sort": sort, | |
| 384 | + "search": search, | |
| 385 | + "starred_ids": starred_ids, | |
| 386 | + }, | |
| 387 | + ) | |
| 320 | 388 |
| --- projects/views.py | |
| +++ projects/views.py | |
| @@ -1,16 +1,17 @@ | |
| 1 | from django.contrib import messages |
| 2 | from django.contrib.auth.decorators import login_required |
| 3 | from django.http import HttpResponse |
| 4 | from django.shortcuts import get_object_or_404, redirect, render |
| 5 | |
| 6 | from core.permissions import P |
| 7 | from organization.models import Team |
| 8 | from organization.views import get_org |
| 9 | |
| 10 | from .forms import ProjectForm, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm |
| 11 | from .models import Project, ProjectGroup, ProjectTeam |
| 12 | |
| 13 | |
| 14 | @login_required |
| 15 | def project_list(request): |
| 16 | P.PROJECT_VIEW.check(request.user) |
| @@ -120,14 +121,16 @@ | |
| 120 | |
| 121 | import json |
| 122 | |
| 123 | # Check if user is watching this project |
| 124 | is_watching = False |
| 125 | if request.user.is_authenticated: |
| 126 | from fossil.notifications import ProjectWatch |
| 127 | |
| 128 | is_watching = ProjectWatch.objects.filter(user=request.user, project=project, deleted_at__isnull=True).exists() |
| 129 | |
| 130 | return render( |
| 131 | request, |
| 132 | "projects/project_detail.html", |
| 133 | { |
| @@ -136,10 +139,11 @@ | |
| 136 | "repo_stats": repo_stats, |
| 137 | "recent_commits": recent_commits, |
| 138 | "commit_activity_json": json.dumps([c["count"] for c in commit_activity]), |
| 139 | "top_contributors": top_contributors, |
| 140 | "is_watching": is_watching, |
| 141 | }, |
| 142 | ) |
| 143 | |
| 144 | |
| 145 | @login_required |
| @@ -315,5 +319,69 @@ | |
| 315 | return HttpResponse(status=200, headers={"HX-Redirect": "/projects/groups/"}) |
| 316 | |
| 317 | return redirect("projects:group_list") |
| 318 | |
| 319 | return render(request, "projects/group_confirm_delete.html", {"group": group}) |
| 320 |
| --- projects/views.py | |
| +++ projects/views.py | |
| @@ -1,16 +1,17 @@ | |
| 1 | from django.contrib import messages |
| 2 | from django.contrib.auth.decorators import login_required |
| 3 | from django.db.models import Count |
| 4 | from django.http import HttpResponse |
| 5 | from django.shortcuts import get_object_or_404, redirect, render |
| 6 | |
| 7 | from core.permissions import P |
| 8 | from organization.models import Team |
| 9 | from organization.views import get_org |
| 10 | |
| 11 | from .forms import ProjectForm, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm |
| 12 | from .models import Project, ProjectGroup, ProjectStar, ProjectTeam |
| 13 | |
| 14 | |
| 15 | @login_required |
| 16 | def project_list(request): |
| 17 | P.PROJECT_VIEW.check(request.user) |
| @@ -120,14 +121,16 @@ | |
| 121 | |
| 122 | import json |
| 123 | |
| 124 | # Check if user is watching this project |
| 125 | is_watching = False |
| 126 | is_starred = False |
| 127 | if request.user.is_authenticated: |
| 128 | from fossil.notifications import ProjectWatch |
| 129 | |
| 130 | is_watching = ProjectWatch.objects.filter(user=request.user, project=project, deleted_at__isnull=True).exists() |
| 131 | is_starred = ProjectStar.objects.filter(user=request.user, project=project).exists() |
| 132 | |
| 133 | return render( |
| 134 | request, |
| 135 | "projects/project_detail.html", |
| 136 | { |
| @@ -136,10 +139,11 @@ | |
| 139 | "repo_stats": repo_stats, |
| 140 | "recent_commits": recent_commits, |
| 141 | "commit_activity_json": json.dumps([c["count"] for c in commit_activity]), |
| 142 | "top_contributors": top_contributors, |
| 143 | "is_watching": is_watching, |
| 144 | "is_starred": is_starred, |
| 145 | }, |
| 146 | ) |
| 147 | |
| 148 | |
| 149 | @login_required |
| @@ -315,5 +319,69 @@ | |
| 319 | return HttpResponse(status=200, headers={"HX-Redirect": "/projects/groups/"}) |
| 320 | |
| 321 | return redirect("projects:group_list") |
| 322 | |
| 323 | return render(request, "projects/group_confirm_delete.html", {"group": group}) |
| 324 | |
| 325 | |
| 326 | # --- Project Starring --- |
| 327 | |
| 328 | |
| 329 | @login_required |
| 330 | def toggle_star(request, slug): |
| 331 | """Toggle star on/off for a project. POST only.""" |
| 332 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 333 | star, created = ProjectStar.objects.get_or_create(user=request.user, project=project) |
| 334 | if not created: |
| 335 | star.delete() |
| 336 | is_starred = created |
| 337 | |
| 338 | if request.headers.get("HX-Request"): |
| 339 | return render(request, "projects/partials/star_button.html", {"project": project, "is_starred": is_starred}) |
| 340 | |
| 341 | return redirect("projects:detail", slug=slug) |
| 342 | |
| 343 | |
| 344 | # --- Explore / Discover --- |
| 345 | |
| 346 | |
| 347 | def explore(request): |
| 348 | """Public project discovery page. Works for anonymous users (public only) and authenticated users (public + internal).""" |
| 349 | if request.user.is_authenticated: |
| 350 | allowed_visibility = [Project.Visibility.PUBLIC, Project.Visibility.INTERNAL] |
| 351 | else: |
| 352 | allowed_visibility = [Project.Visibility.PUBLIC] |
| 353 | |
| 354 | projects = ( |
| 355 | Project.objects.filter(deleted_at__isnull=True, visibility__in=allowed_visibility) |
| 356 | .annotate(star_count_annotated=Count("stars")) |
| 357 | .select_related("organization") |
| 358 | ) |
| 359 | |
| 360 | sort = request.GET.get("sort", "stars") |
| 361 | if sort == "recent": |
| 362 | projects = projects.order_by("-created_at") |
| 363 | elif sort == "name": |
| 364 | projects = projects.order_by("name") |
| 365 | else: |
| 366 | sort = "stars" |
| 367 | projects = projects.order_by("-star_count_annotated", "-created_at") |
| 368 | |
| 369 | search = request.GET.get("search", "").strip() |
| 370 | if search: |
| 371 | projects = projects.filter(name__icontains=search) |
| 372 | |
| 373 | # Check which projects the current user has starred |
| 374 | starred_ids = set() |
| 375 | if request.user.is_authenticated: |
| 376 | starred_ids = set(ProjectStar.objects.filter(user=request.user).values_list("project_id", flat=True)) |
| 377 | |
| 378 | return render( |
| 379 | request, |
| 380 | "projects/explore.html", |
| 381 | { |
| 382 | "projects": projects, |
| 383 | "sort": sort, |
| 384 | "search": search, |
| 385 | "starred_ids": starred_ids, |
| 386 | }, |
| 387 | ) |
| 388 |
| --- templates/fossil/_project_nav.html | ||
| +++ templates/fossil/_project_nav.html | ||
| @@ -28,10 +28,14 @@ | ||
| 28 | 28 | Forum |
| 29 | 29 | </a> |
| 30 | 30 | <a href="{% url 'fossil:releases' slug=project.slug %}" |
| 31 | 31 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'releases' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> |
| 32 | 32 | Releases |
| 33 | + </a> | |
| 34 | + <a href="{% url 'fossil:unversioned' slug=project.slug %}" | |
| 35 | + class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'files' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> | |
| 36 | + Files | |
| 33 | 37 | </a> |
| 34 | 38 | {% if perms.projects.change_project %} |
| 35 | 39 | <a href="{% url 'fossil:sync' slug=project.slug %}" |
| 36 | 40 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'sync' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> |
| 37 | 41 | {% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %} |
| 38 | 42 | |
| 39 | 43 | ADDED templates/fossil/api_token_create.html |
| 40 | 44 | ADDED templates/fossil/api_token_list.html |
| 41 | 45 | ADDED templates/fossil/branch_protection_form.html |
| 42 | 46 | ADDED templates/fossil/branch_protection_list.html |
| --- templates/fossil/_project_nav.html | |
| +++ templates/fossil/_project_nav.html | |
| @@ -28,10 +28,14 @@ | |
| 28 | Forum |
| 29 | </a> |
| 30 | <a href="{% url 'fossil:releases' slug=project.slug %}" |
| 31 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'releases' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> |
| 32 | Releases |
| 33 | </a> |
| 34 | {% if perms.projects.change_project %} |
| 35 | <a href="{% url 'fossil:sync' slug=project.slug %}" |
| 36 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'sync' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> |
| 37 | {% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %} |
| 38 | |
| 39 | DDED templates/fossil/api_token_create.html |
| 40 | DDED templates/fossil/api_token_list.html |
| 41 | DDED templates/fossil/branch_protection_form.html |
| 42 | DDED templates/fossil/branch_protection_list.html |
| --- templates/fossil/_project_nav.html | |
| +++ templates/fossil/_project_nav.html | |
| @@ -28,10 +28,14 @@ | |
| 28 | Forum |
| 29 | </a> |
| 30 | <a href="{% url 'fossil:releases' slug=project.slug %}" |
| 31 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'releases' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> |
| 32 | Releases |
| 33 | </a> |
| 34 | <a href="{% url 'fossil:unversioned' slug=project.slug %}" |
| 35 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'files' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> |
| 36 | Files |
| 37 | </a> |
| 38 | {% if perms.projects.change_project %} |
| 39 | <a href="{% url 'fossil:sync' slug=project.slug %}" |
| 40 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'sync' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> |
| 41 | {% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %} |
| 42 | |
| 43 | DDED templates/fossil/api_token_create.html |
| 44 | DDED templates/fossil/api_token_list.html |
| 45 | DDED templates/fossil/branch_protection_form.html |
| 46 | DDED templates/fossil/branch_protection_list.html |
| --- a/templates/fossil/api_token_create.html | ||
| +++ b/templates/fossil/api_token_create.html | ||
| @@ -0,0 +1,41 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Generate API Token — {{ 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:api_tokens' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to Tokens</a> | |
| 10 | +</div> | |
| 11 | + | |
| 12 | +{% if raw_token %} | |
| 13 | +<div class="mx-auto max-w-3xl"> | |
| 14 | + <div class="rounded-lg bg-green-900/20 border border-green-800 p-6 mb-6"> | |
| 15 | + <h2 class="text-lg font-bold text-green-300 mb-2">Token Generated</h2> | |
| 16 | + <p class="text-sm text-gray-300 mb-4">Copy this token now. It will not be shown again.</p> | |
| 17 | + <div class="flex items-center gap-2" x-data="{ copied: false }"> | |
| 18 | + <input type="text" readonly value="{{ raw_token }}" | |
| 19 | + class="flex-1 rounded-md border-gray-700 bg-gray-900 text-gray-100 text-sm px-3 py-2 font-mono" | |
| 20 | + id="token-value"> | |
| 21 | + <button @click="navigator.clipboard.writeText(document.getElementById('token-value').value); copied = true; setTimeout(() => status:write class="rounded-md px-3 py-2 text-sm font-medium transition-colors"> | |
| 22 | + <span x-show="!copied">Copy</span> | |
| 23 | + <span x-show="copied">Copied</span> | |
| 24 | + </button> | |
| 25 | + </div> | |
| 26 | + </div> | |
| 27 | + <a href="{% url 'fossil:api_tokens' slug=project.slug %}" | |
| 28 | + class="inline-flex items-center rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 29 | + Done | |
| 30 | + </a> | |
| 31 | +</div> | |
| 32 | +{% else %} | |
| 33 | +<div class="mx-auto max-w-3xl"> | |
| 34 | + <h2 class="text-xl font-bold text-gray-100 mb-4">Generate API Token</h2> | |
| 35 | + | |
| 36 | + <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 37 | + {% csrf_token %} | |
| 38 | + | |
| 39 | + <div> | |
| 40 | + <label class="block text-sm font-medium text-gray-300 mb-1">Name <span class="text-red-400">*</span></label> | |
| 41 | + <input type="text" name="name" required placeholder="e.g., GitHub Actio |
| --- a/templates/fossil/api_token_create.html | |
| +++ b/templates/fossil/api_token_create.html | |
| @@ -0,0 +1,41 @@ | |
| --- a/templates/fossil/api_token_create.html | |
| +++ b/templates/fossil/api_token_create.html | |
| @@ -0,0 +1,41 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Generate API Token — {{ 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:api_tokens' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to Tokens</a> |
| 10 | </div> |
| 11 | |
| 12 | {% if raw_token %} |
| 13 | <div class="mx-auto max-w-3xl"> |
| 14 | <div class="rounded-lg bg-green-900/20 border border-green-800 p-6 mb-6"> |
| 15 | <h2 class="text-lg font-bold text-green-300 mb-2">Token Generated</h2> |
| 16 | <p class="text-sm text-gray-300 mb-4">Copy this token now. It will not be shown again.</p> |
| 17 | <div class="flex items-center gap-2" x-data="{ copied: false }"> |
| 18 | <input type="text" readonly value="{{ raw_token }}" |
| 19 | class="flex-1 rounded-md border-gray-700 bg-gray-900 text-gray-100 text-sm px-3 py-2 font-mono" |
| 20 | id="token-value"> |
| 21 | <button @click="navigator.clipboard.writeText(document.getElementById('token-value').value); copied = true; setTimeout(() => status:write class="rounded-md px-3 py-2 text-sm font-medium transition-colors"> |
| 22 | <span x-show="!copied">Copy</span> |
| 23 | <span x-show="copied">Copied</span> |
| 24 | </button> |
| 25 | </div> |
| 26 | </div> |
| 27 | <a href="{% url 'fossil:api_tokens' slug=project.slug %}" |
| 28 | class="inline-flex items-center rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 29 | Done |
| 30 | </a> |
| 31 | </div> |
| 32 | {% else %} |
| 33 | <div class="mx-auto max-w-3xl"> |
| 34 | <h2 class="text-xl font-bold text-gray-100 mb-4">Generate API Token</h2> |
| 35 | |
| 36 | <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 37 | {% csrf_token %} |
| 38 | |
| 39 | <div> |
| 40 | <label class="block text-sm font-medium text-gray-300 mb-1">Name <span class="text-red-400">*</span></label> |
| 41 | <input type="text" name="name" required placeholder="e.g., GitHub Actio |
| --- a/templates/fossil/api_token_list.html | ||
| +++ b/templates/fossil/api_token_list.html | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}API Tokens — {{ 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 | + <div> | |
| 10 | + <h2 class="text-lg font-semibold text-gray-200">API Tokens</h2> | |
| 11 | + <p class="text-sm text-gray-500 mt-1">Tokens for CI/CD systems and automation. Used with Bearer autheth></svg> | |
| 12 | + </span> | |
| 13 | + <a href="{% url 'fossil:api_token_create' ug=project.slug %}" | |
| 14 | + class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm Generate Token | |
| 15 | + </a> | |
| 16 | +</div> | |
| 17 | +> | |
| 18 | + | |
| 19 | +<div id="token-content"> | |
| 20 | +{% if tokens %} | |
| 21 | +<div class="space-y-3"> | |
| 22 | + {% for token in tokens %} | |
| 23 | + <div class="rounded-lg bg-gray-800 border border-gray-700"> | |
| 24 | + <div class="px-5 py-4"> | |
| 25 | + <div class="flex items-start justify-between gap-4"> | |
| 26 | + <div class="flex-1 min-w-0"> | |
| 27 | + <div class="flex items-center gap-3 mb-1"> | |
| 28 | + <span class="text-sm font-medium text-gray-100">{{ token.name }}</span> | |
| 29 | + <code class="text-xs font-mono text-gray-500">{{ token.token_prefix }}...</code> | |
| 30 | + </div> | |
| 31 | + <div class="flex items-center gap-3 text-xs text-gray-500 mt-1 flex-wrap"> | |
| 32 | + <span>Permissions: {{ token.permissions }}</span> | |
| 33 | + <span>Created {{ token.created_at|timesince }} ago</span> | |
| 34 | + {% if token.last_used_at %} | |
| 35 | + <span>Last used {{ token.last_used_at|timesince }} ago</span> | |
| 36 | + {% else %} | |
| 37 | + <span>Never used</span> | |
| 38 | + {% endif %} | |
| 39 | + {% |
| --- a/templates/fossil/api_token_list.html | |
| +++ b/templates/fossil/api_token_list.html | |
| @@ -0,0 +1,39 @@ | |
| --- a/templates/fossil/api_token_list.html | |
| +++ b/templates/fossil/api_token_list.html | |
| @@ -0,0 +1,39 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}API Tokens — {{ 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 | <div> |
| 10 | <h2 class="text-lg font-semibold text-gray-200">API Tokens</h2> |
| 11 | <p class="text-sm text-gray-500 mt-1">Tokens for CI/CD systems and automation. Used with Bearer autheth></svg> |
| 12 | </span> |
| 13 | <a href="{% url 'fossil:api_token_create' ug=project.slug %}" |
| 14 | class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm Generate Token |
| 15 | </a> |
| 16 | </div> |
| 17 | > |
| 18 | |
| 19 | <div id="token-content"> |
| 20 | {% if tokens %} |
| 21 | <div class="space-y-3"> |
| 22 | {% for token in tokens %} |
| 23 | <div class="rounded-lg bg-gray-800 border border-gray-700"> |
| 24 | <div class="px-5 py-4"> |
| 25 | <div class="flex items-start justify-between gap-4"> |
| 26 | <div class="flex-1 min-w-0"> |
| 27 | <div class="flex items-center gap-3 mb-1"> |
| 28 | <span class="text-sm font-medium text-gray-100">{{ token.name }}</span> |
| 29 | <code class="text-xs font-mono text-gray-500">{{ token.token_prefix }}...</code> |
| 30 | </div> |
| 31 | <div class="flex items-center gap-3 text-xs text-gray-500 mt-1 flex-wrap"> |
| 32 | <span>Permissions: {{ token.permissions }}</span> |
| 33 | <span>Created {{ token.created_at|timesince }} ago</span> |
| 34 | {% if token.last_used_at %} |
| 35 | <span>Last used {{ token.last_used_at|timesince }} ago</span> |
| 36 | {% else %} |
| 37 | <span>Never used</span> |
| 38 | {% endif %} |
| 39 | {% |
| --- a/templates/fossil/branch_protection_form.html | ||
| +++ b/templates/fossil/branch_protection_form.html | ||
| @@ -0,0 +1,64 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ form_title }} — {{ project.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> | |
| 6 | +{% include "fossil/_project_nav.html" %} | |
| 7 | + | |
| 8 | +<div class="mb-4"> | |
| 9 | + <a href="{% url 'fossil:branch_protections' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to Branch Protection</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">Branch Pattern <span class="text-red-400">*</span></label> | |
| 20 | + <input type="text" name="branch_pattern" required | |
| 21 | + placeholder="e.g., trunk, release-*" | |
| 22 | + value="{% if rule %}{{ rule.branch_pattern }}{% endif %}" | |
| 23 | + 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"> | |
| 24 | + <p class="mt-1 text-xs text-gray-500">Branch name or glob pattern. Use <code class="text-gray-400">*</code> for wildcards.</p> | |
| 25 | + </div> | |
| 26 | + | |
| 27 | + <div class="space-y-3"> | |
| 28 | + <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer"> | |
| 29 | + <input type="checkbox" name="restrict_push" | |
| 30 | + {% if rule %}{% if rule.restrict_push %}checked{% endif %}{% else %}checked{% endif %} | |
| 31 | + class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand"> | |
| 32 | + Restrict push (admin only) | |
| 33 | + </label> | |
| 34 | + <p class="text-xs text-gray-500 ml-6">Only project admins can push to matching branches.</p> | |
| 35 | + | |
| 36 | + <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer"> | |
| 37 | + <input type="checkbox" name="require_status_checks" | |
| 38 | + {% if rule and rule.require_status_checks %}checked{% endif %} | |
| 39 | + class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand"> | |
| 40 | + Require status checks to pass | |
| 41 | + </label> | |
| 42 | + <p class="text-xs text-gray-500 ml-6">All required CI contexts must report success before merging.</p> | |
| 43 | + </div> | |
| 44 | + | |
| 45 | + <div> | |
| 46 | + <label class="block text-sm font-medium text-gray-300 mb-1">Required CI Contexts</label> | |
| 47 | + <textarea name="required_contexts" rows="3" | |
| 48 | + placeholder="ci/tests ci/lint ci/build" | |
| 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 font-mono">{% if rule %}{{ rule.required_contexts }}{% endif %}</textarea> | |
| 50 | + <p class="mt-1 text-xs text-gray-500">One CI context per line. These must report success before merging is allowed.</p> | |
| 51 | + </div> | |
| 52 | + | |
| 53 | + <div class="flex justify-end gap-3 pt-2"> | |
| 54 | + <a href="{% url 'fossil:branch_protections' slug=project.slug %}" | |
| 55 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 56 | + Cancel | |
| 57 | + </a> | |
| 58 | + <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 59 | + {{ submit_label }} | |
| 60 | + </button> | |
| 61 | + </div> | |
| 62 | + </form> | |
| 63 | +</div> | |
| 64 | +{% endblock %} |
| --- a/templates/fossil/branch_protection_form.html | |
| +++ b/templates/fossil/branch_protection_form.html | |
| @@ -0,0 +1,64 @@ | |
| --- a/templates/fossil/branch_protection_form.html | |
| +++ b/templates/fossil/branch_protection_form.html | |
| @@ -0,0 +1,64 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ form_title }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| 7 | |
| 8 | <div class="mb-4"> |
| 9 | <a href="{% url 'fossil:branch_protections' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to Branch Protection</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">Branch Pattern <span class="text-red-400">*</span></label> |
| 20 | <input type="text" name="branch_pattern" required |
| 21 | placeholder="e.g., trunk, release-*" |
| 22 | value="{% if rule %}{{ rule.branch_pattern }}{% endif %}" |
| 23 | 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"> |
| 24 | <p class="mt-1 text-xs text-gray-500">Branch name or glob pattern. Use <code class="text-gray-400">*</code> for wildcards.</p> |
| 25 | </div> |
| 26 | |
| 27 | <div class="space-y-3"> |
| 28 | <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer"> |
| 29 | <input type="checkbox" name="restrict_push" |
| 30 | {% if rule %}{% if rule.restrict_push %}checked{% endif %}{% else %}checked{% endif %} |
| 31 | class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand"> |
| 32 | Restrict push (admin only) |
| 33 | </label> |
| 34 | <p class="text-xs text-gray-500 ml-6">Only project admins can push to matching branches.</p> |
| 35 | |
| 36 | <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer"> |
| 37 | <input type="checkbox" name="require_status_checks" |
| 38 | {% if rule and rule.require_status_checks %}checked{% endif %} |
| 39 | class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand"> |
| 40 | Require status checks to pass |
| 41 | </label> |
| 42 | <p class="text-xs text-gray-500 ml-6">All required CI contexts must report success before merging.</p> |
| 43 | </div> |
| 44 | |
| 45 | <div> |
| 46 | <label class="block text-sm font-medium text-gray-300 mb-1">Required CI Contexts</label> |
| 47 | <textarea name="required_contexts" rows="3" |
| 48 | placeholder="ci/tests ci/lint ci/build" |
| 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 font-mono">{% if rule %}{{ rule.required_contexts }}{% endif %}</textarea> |
| 50 | <p class="mt-1 text-xs text-gray-500">One CI context per line. These must report success before merging is allowed.</p> |
| 51 | </div> |
| 52 | |
| 53 | <div class="flex justify-end gap-3 pt-2"> |
| 54 | <a href="{% url 'fossil:branch_protections' slug=project.slug %}" |
| 55 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 56 | Cancel |
| 57 | </a> |
| 58 | <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 59 | {{ submit_label }} |
| 60 | </button> |
| 61 | </div> |
| 62 | </form> |
| 63 | </div> |
| 64 | {% endblock %} |
| --- a/templates/fossil/branch_protection_list.html | ||
| +++ b/templates/fossil/branch_protection_list.html | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Branch Protection — {{ 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 | + <div> | |
| 10 | + <h2 class="text-lg font-semibold text-gray-200">Branch Protection Rules</h2> | |
| 11 | + <p class="text-sm text-gray-500 mt-1">Protect branches from unreviewed changes and require CI checks to pass.</p> | |
| 12 | + </div> | |
| 13 | + <t_push %} | |
| 14 | + <span class="inline-flex rounded-full bg-yellow-900/50 px-2 p{% extends "base.html" in rules %} | |
| 15 | + <div class="rounded-lg bg-gray-800 border border-gray-700"> | |
| 16 | + <div class="px-5 py-4"> | |
| 17 | + <div class="flex items-start justify-between gap-4"> | |
| 18 | + <div class="flex-1 min-w-0"> | |
| 19 | + <div class="flex items-center gap-3 mb-1"> | |
| 20 | + <code class="text-sm font-mono font-medium text-gray-100">{{ rule.branch_pattern }}</code> | |
| 21 | + {% if rule.restrict_push %} | |
| 22 | + <span class="inline-flex rounded-full bg-yellow-900/50 px-2 py-0.5 text-xs font-semibold text-yellow-300">Push restricted</span> | |
| 23 | + {% endif %} | |
| 24 | + {% if rule.require_status_checks %} | |
| 25 | + <span class="inline-flex rounded-full bg-blue-900/50 px-2 py-0.5 text-xs font-semibold text-blue-300">CI required</span>yellow-900/20 </div> | |
| 26 | + <div class="flex items-center gap-3 text-xs text-gray-500 mt-1 flex-wrap"> | |
| 27 | + {% if rule.require_status_checks and rule.required_contexts %} | |
| 28 | + yellow- |
| --- a/templates/fossil/branch_protection_list.html | |
| +++ b/templates/fossil/branch_protection_list.html | |
| @@ -0,0 +1,28 @@ | |
| --- a/templates/fossil/branch_protection_list.html | |
| +++ b/templates/fossil/branch_protection_list.html | |
| @@ -0,0 +1,28 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Branch Protection — {{ 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 | <div> |
| 10 | <h2 class="text-lg font-semibold text-gray-200">Branch Protection Rules</h2> |
| 11 | <p class="text-sm text-gray-500 mt-1">Protect branches from unreviewed changes and require CI checks to pass.</p> |
| 12 | </div> |
| 13 | <t_push %} |
| 14 | <span class="inline-flex rounded-full bg-yellow-900/50 px-2 p{% extends "base.html" in rules %} |
| 15 | <div class="rounded-lg bg-gray-800 border border-gray-700"> |
| 16 | <div class="px-5 py-4"> |
| 17 | <div class="flex items-start justify-between gap-4"> |
| 18 | <div class="flex-1 min-w-0"> |
| 19 | <div class="flex items-center gap-3 mb-1"> |
| 20 | <code class="text-sm font-mono font-medium text-gray-100">{{ rule.branch_pattern }}</code> |
| 21 | {% if rule.restrict_push %} |
| 22 | <span class="inline-flex rounded-full bg-yellow-900/50 px-2 py-0.5 text-xs font-semibold text-yellow-300">Push restricted</span> |
| 23 | {% endif %} |
| 24 | {% if rule.require_status_checks %} |
| 25 | <span class="inline-flex rounded-full bg-blue-900/50 px-2 py-0.5 text-xs font-semibold text-blue-300">CI required</span>yellow-900/20 </div> |
| 26 | <div class="flex items-center gap-3 text-xs text-gray-500 mt-1 flex-wrap"> |
| 27 | {% if rule.require_status_checks and rule.required_contexts %} |
| 28 | yellow- |
| --- templates/fossil/checkin_detail.html | ||
| +++ templates/fossil/checkin_detail.html | ||
| @@ -69,10 +69,44 @@ | ||
| 69 | 69 | {% if checkin.is_merge %} |
| 70 | 70 | <span class="inline-flex items-center rounded-full bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">merge</span> |
| 71 | 71 | {% endif %} |
| 72 | 72 | </div> |
| 73 | 73 | </div> |
| 74 | + {% if status_checks %} | |
| 75 | + <div class="px-6 py-3 border-t border-gray-700 bg-gray-800/50"> | |
| 76 | + <div class="flex items-center gap-2 mb-2"> | |
| 77 | + <span class="text-xs font-medium text-gray-400">CI Status</span> | |
| 78 | + <img src="{% url 'fossil:status_badge' slug=project.slug checkin_uuid=checkin.uuid %}" alt="CI Status" class="h-5"> | |
| 79 | + </div> | |
| 80 | + <div class="flex flex-wrap gap-2"> | |
| 81 | + {% for check in status_checks %} | |
| 82 | + <a {% if check.target_url %}href="{{ check.target_url }}" target="_blank" rel="noopener"{% endif %} | |
| 83 | + class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs | |
| 84 | + {% if check.state == 'success' %}border-green-800 bg-green-900/30 text-green-300 | |
| 85 | + {% elif check.state == 'failure' %}border-red-800 bg-red-900/30 text-red-300 | |
| 86 | + {% elif check.state == 'error' %}border-red-800 bg-red-900/30 text-red-300 | |
| 87 | + {% else %}border-yellow-800 bg-yellow-900/30 text-yellow-300{% endif %}" | |
| 88 | + {% if check.description %}title="{{ check.description }}"{% endif %}> | |
| 89 | + {% if check.state == 'success' %} | |
| 90 | + <svg class="h-3.5 w-3.5 text-green-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> | |
| 91 | + <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/> | |
| 92 | + </svg> | |
| 93 | + {% elif check.state == 'failure' or check.state == 'error' %} | |
| 94 | + <svg class="h-3.5 w-3.5 text-red-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> | |
| 95 | + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/> | |
| 96 | + </svg> | |
| 97 | + {% else %} | |
| 98 | + <svg class="h-3.5 w-3.5 text-yellow-400" fill="currentColor" viewBox="0 0 24 24"> | |
| 99 | + <circle cx="12" cy="12" r="6"/> | |
| 100 | + </svg> | |
| 101 | + {% endif %} | |
| 102 | + {{ check.context }} | |
| 103 | + </a> | |
| 104 | + {% endfor %} | |
| 105 | + </div> | |
| 106 | + </div> | |
| 107 | + {% endif %} | |
| 74 | 108 | <div class="px-6 py-3 border-t border-gray-700 bg-gray-800/50 flex items-center gap-6 flex-wrap text-xs"> |
| 75 | 109 | <div class="flex items-center gap-2"> |
| 76 | 110 | <span class="text-gray-500">Commit</span> |
| 77 | 111 | <code class="font-mono text-gray-300 break-all">{{ checkin.uuid }}</code> |
| 78 | 112 | </div> |
| 79 | 113 |
| --- templates/fossil/checkin_detail.html | |
| +++ templates/fossil/checkin_detail.html | |
| @@ -69,10 +69,44 @@ | |
| 69 | {% if checkin.is_merge %} |
| 70 | <span class="inline-flex items-center rounded-full bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">merge</span> |
| 71 | {% endif %} |
| 72 | </div> |
| 73 | </div> |
| 74 | <div class="px-6 py-3 border-t border-gray-700 bg-gray-800/50 flex items-center gap-6 flex-wrap text-xs"> |
| 75 | <div class="flex items-center gap-2"> |
| 76 | <span class="text-gray-500">Commit</span> |
| 77 | <code class="font-mono text-gray-300 break-all">{{ checkin.uuid }}</code> |
| 78 | </div> |
| 79 |
| --- templates/fossil/checkin_detail.html | |
| +++ templates/fossil/checkin_detail.html | |
| @@ -69,10 +69,44 @@ | |
| 69 | {% if checkin.is_merge %} |
| 70 | <span class="inline-flex items-center rounded-full bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">merge</span> |
| 71 | {% endif %} |
| 72 | </div> |
| 73 | </div> |
| 74 | {% if status_checks %} |
| 75 | <div class="px-6 py-3 border-t border-gray-700 bg-gray-800/50"> |
| 76 | <div class="flex items-center gap-2 mb-2"> |
| 77 | <span class="text-xs font-medium text-gray-400">CI Status</span> |
| 78 | <img src="{% url 'fossil:status_badge' slug=project.slug checkin_uuid=checkin.uuid %}" alt="CI Status" class="h-5"> |
| 79 | </div> |
| 80 | <div class="flex flex-wrap gap-2"> |
| 81 | {% for check in status_checks %} |
| 82 | <a {% if check.target_url %}href="{{ check.target_url }}" target="_blank" rel="noopener"{% endif %} |
| 83 | class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs |
| 84 | {% if check.state == 'success' %}border-green-800 bg-green-900/30 text-green-300 |
| 85 | {% elif check.state == 'failure' %}border-red-800 bg-red-900/30 text-red-300 |
| 86 | {% elif check.state == 'error' %}border-red-800 bg-red-900/30 text-red-300 |
| 87 | {% else %}border-yellow-800 bg-yellow-900/30 text-yellow-300{% endif %}" |
| 88 | {% if check.description %}title="{{ check.description }}"{% endif %}> |
| 89 | {% if check.state == 'success' %} |
| 90 | <svg class="h-3.5 w-3.5 text-green-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> |
| 91 | <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/> |
| 92 | </svg> |
| 93 | {% elif check.state == 'failure' or check.state == 'error' %} |
| 94 | <svg class="h-3.5 w-3.5 text-red-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> |
| 95 | <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/> |
| 96 | </svg> |
| 97 | {% else %} |
| 98 | <svg class="h-3.5 w-3.5 text-yellow-400" fill="currentColor" viewBox="0 0 24 24"> |
| 99 | <circle cx="12" cy="12" r="6"/> |
| 100 | </svg> |
| 101 | {% endif %} |
| 102 | {{ check.context }} |
| 103 | </a> |
| 104 | {% endfor %} |
| 105 | </div> |
| 106 | </div> |
| 107 | {% endif %} |
| 108 | <div class="px-6 py-3 border-t border-gray-700 bg-gray-800/50 flex items-center gap-6 flex-wrap text-xs"> |
| 109 | <div class="flex items-center gap-2"> |
| 110 | <span class="text-gray-500">Commit</span> |
| 111 | <code class="font-mono text-gray-300 break-all">{{ checkin.uuid }}</code> |
| 112 | </div> |
| 113 |
| --- templates/fossil/repo_settings.html | ||
| +++ templates/fossil/repo_settings.html | ||
| @@ -134,10 +134,47 @@ | ||
| 134 | 134 | </button> |
| 135 | 135 | </form> |
| 136 | 136 | {% endif %} |
| 137 | 137 | </div> |
| 138 | 138 | </div> |
| 139 | + | |
| 140 | + <!-- API Tokens & Branch Protection --> | |
| 141 | + <div class="rounded-lg bg-gray-800 border border-gray-700 p-5"> | |
| 142 | + <h2 class="text-lg font-semibold text-gray-200 mb-4">Management</h2> | |
| 143 | + <div class="space-y-3"> | |
| 144 | + <a href="{% url 'fossil:api_tokens' slug=project.slug %}" | |
| 145 | + class="flex items-center justify-between rounded-md bg-gray-700/50 px-4 py-3 hover:bg-gray-700 transition-colors"> | |
| 146 | + <div> | |
| 147 | + <p class="text-sm font-medium text-gray-200">API Tokens</p> | |
| 148 | + <p class="text-xs text-gray-500">Manage tokens for CI/CD systems and automation.</p> | |
| 149 | + </div> | |
| 150 | + <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 151 | + <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/> | |
| 152 | + </svg> | |
| 153 | + </a> | |
| 154 | + <a href="{% url 'fossil:branch_protections' slug=project.slug %}" | |
| 155 | + class="flex items-center justify-between rounded-md bg-gray-700/50 px-4 py-3 hover:bg-gray-700 transition-colors"> | |
| 156 | + <div> | |
| 157 | + <p class="text-sm font-medium text-gray-200">Branch Protection</p> | |
| 158 | + <p class="text-xs text-gray-500">Protect branches from unreviewed changes.</p> | |
| 159 | + </div> | |
| 160 | + <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 161 | + <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/> | |
| 162 | + </svg> | |
| 163 | + </a> | |
| 164 | + <a href="{% url 'fossil:webhooks' slug=project.slug %}" | |
| 165 | + class="flex items-center justify-between rounded-md bg-gray-700/50 px-4 py-3 hover:bg-gray-700 transition-colors"> | |
| 166 | + <div> | |
| 167 | + <p class="text-sm font-medium text-gray-200">Webhooks</p> | |
| 168 | + <p class="text-xs text-gray-500">Configure outbound event notifications.</p> | |
| 169 | + </div> | |
| 170 | + <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 171 | + <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/> | |
| 172 | + </svg> | |
| 173 | + </a> | |
| 174 | + </div> | |
| 175 | + </div> | |
| 139 | 176 | |
| 140 | 177 | <!-- Danger Zone --> |
| 141 | 178 | <div class="rounded-lg border-2 border-red-900/50 p-5"> |
| 142 | 179 | <h2 class="text-lg font-semibold text-red-400 mb-2">Danger Zone</h2> |
| 143 | 180 | <p class="text-sm text-gray-400 mb-4">Destructive operations that cannot be undone.</p> |
| 144 | 181 | |
| 145 | 182 | ADDED templates/fossil/technote_detail.html |
| 146 | 183 | ADDED templates/fossil/technote_form.html |
| --- templates/fossil/repo_settings.html | |
| +++ templates/fossil/repo_settings.html | |
| @@ -134,10 +134,47 @@ | |
| 134 | </button> |
| 135 | </form> |
| 136 | {% endif %} |
| 137 | </div> |
| 138 | </div> |
| 139 | |
| 140 | <!-- Danger Zone --> |
| 141 | <div class="rounded-lg border-2 border-red-900/50 p-5"> |
| 142 | <h2 class="text-lg font-semibold text-red-400 mb-2">Danger Zone</h2> |
| 143 | <p class="text-sm text-gray-400 mb-4">Destructive operations that cannot be undone.</p> |
| 144 | |
| 145 | DDED templates/fossil/technote_detail.html |
| 146 | DDED templates/fossil/technote_form.html |
| --- templates/fossil/repo_settings.html | |
| +++ templates/fossil/repo_settings.html | |
| @@ -134,10 +134,47 @@ | |
| 134 | </button> |
| 135 | </form> |
| 136 | {% endif %} |
| 137 | </div> |
| 138 | </div> |
| 139 | |
| 140 | <!-- API Tokens & Branch Protection --> |
| 141 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-5"> |
| 142 | <h2 class="text-lg font-semibold text-gray-200 mb-4">Management</h2> |
| 143 | <div class="space-y-3"> |
| 144 | <a href="{% url 'fossil:api_tokens' slug=project.slug %}" |
| 145 | class="flex items-center justify-between rounded-md bg-gray-700/50 px-4 py-3 hover:bg-gray-700 transition-colors"> |
| 146 | <div> |
| 147 | <p class="text-sm font-medium text-gray-200">API Tokens</p> |
| 148 | <p class="text-xs text-gray-500">Manage tokens for CI/CD systems and automation.</p> |
| 149 | </div> |
| 150 | <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 151 | <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/> |
| 152 | </svg> |
| 153 | </a> |
| 154 | <a href="{% url 'fossil:branch_protections' slug=project.slug %}" |
| 155 | class="flex items-center justify-between rounded-md bg-gray-700/50 px-4 py-3 hover:bg-gray-700 transition-colors"> |
| 156 | <div> |
| 157 | <p class="text-sm font-medium text-gray-200">Branch Protection</p> |
| 158 | <p class="text-xs text-gray-500">Protect branches from unreviewed changes.</p> |
| 159 | </div> |
| 160 | <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 161 | <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/> |
| 162 | </svg> |
| 163 | </a> |
| 164 | <a href="{% url 'fossil:webhooks' slug=project.slug %}" |
| 165 | class="flex items-center justify-between rounded-md bg-gray-700/50 px-4 py-3 hover:bg-gray-700 transition-colors"> |
| 166 | <div> |
| 167 | <p class="text-sm font-medium text-gray-200">Webhooks</p> |
| 168 | <p class="text-xs text-gray-500">Configure outbound event notifications.</p> |
| 169 | </div> |
| 170 | <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 171 | <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/> |
| 172 | </svg> |
| 173 | </a> |
| 174 | </div> |
| 175 | </div> |
| 176 | |
| 177 | <!-- Danger Zone --> |
| 178 | <div class="rounded-lg border-2 border-red-900/50 p-5"> |
| 179 | <h2 class="text-lg font-semibold text-red-400 mb-2">Danger Zone</h2> |
| 180 | <p class="text-sm text-gray-400 mb-4">Destructive operations that cannot be undone.</p> |
| 181 | |
| 182 | DDED templates/fossil/technote_detail.html |
| 183 | DDED templates/fossil/technote_form.html |
| --- a/templates/fossil/technote_detail.html | ||
| +++ b/templates/fossil/technote_detail.html | ||
| @@ -0,0 +1,46 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% load fossil_filters %} | |
| 3 | +{% block title %}{{ note.comment|truncatechars:60 }} — {{ project.name }} — Fossilrepo{% endblock %} | |
| 4 | + | |
| 5 | +{% block content %} | |
| 6 | +<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> | |
| 7 | +{% include "fossil/_project_nav.html" %} | |
| 8 | + | |
| 9 | +<div class="mb-4"> | |
| 10 | + <a href="{% url 'fossil:technotes' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to Technotes</a> | |
| 11 | +</div> | |
| 12 | + | |
| 13 | +<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> | |
| 14 | + <!-- Header --> | |
| 15 | + <div class="px-6 py-5 border-b border-gray-700"> | |
| 16 | + <div class="flex items-start justify-between gap-4"> | |
| 17 | + <div class="flex-1"> | |
| 18 | + <h2 class="text-xl font-bold text-gray-100 mb-1">{{ note.comment }}</h2> | |
| 19 | + <div class="flex items-center gap-3 text-xs text-gray-500"> | |
| 20 | + <a href="{% url 'fossil:user_activity' slug=project.slug username=note.user %}" | |
| 21 | + ">{{ note.user|display_user }}</a> | |
| 22 | + <span class="font-mono text-brand-light">{{ note.uuid|truncatechars:16 }}</span> | |
| 23 | + <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span> | |
| 24 | + </div> | |
| 25 | + </div> | |
| 26 | + {% if has_write %} | |
| 27 | + <div class="flex items-center gap-2 flex-shrink-0"> | |
| 28 | + <a href="{% url 'fossil:technote_edit' slug=project.slug technote_id=note.uuid %}" | |
| 29 | + class="rounded-md bg-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-300 hover:bg-gray-600">Edit</a> | |
| 30 | + </div> | |
| 31 | + {% endif %} | |
| 32 | + </div> | |
| 33 | + </div> | |
| 34 | + | |
| 35 | + <!-- Body --> | |
| 36 | + {% if body_html %} | |
| 37 | + <div class="px-6 py-5"> | |
| 38 | + <div class="prose prose-invert prose-gray prose-sm max-w-none"> | |
| 39 | + {{ body_html }} | |
| 40 | + </div> | |
| 41 | + </div> | |
| 42 | + {% else %} | |
| 43 | + <div class="px-6 py-5"> | |
| 44 | + <p class="text-sm text-gray-500 italic">No body content.</p> | |
| 45 | + </div> | |
| 46 | + {% endi |
| --- a/templates/fossil/technote_detail.html | |
| +++ b/templates/fossil/technote_detail.html | |
| @@ -0,0 +1,46 @@ | |
| --- a/templates/fossil/technote_detail.html | |
| +++ b/templates/fossil/technote_detail.html | |
| @@ -0,0 +1,46 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}{{ note.comment|truncatechars:60 }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| 8 | |
| 9 | <div class="mb-4"> |
| 10 | <a href="{% url 'fossil:technotes' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to Technotes</a> |
| 11 | </div> |
| 12 | |
| 13 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 14 | <!-- Header --> |
| 15 | <div class="px-6 py-5 border-b border-gray-700"> |
| 16 | <div class="flex items-start justify-between gap-4"> |
| 17 | <div class="flex-1"> |
| 18 | <h2 class="text-xl font-bold text-gray-100 mb-1">{{ note.comment }}</h2> |
| 19 | <div class="flex items-center gap-3 text-xs text-gray-500"> |
| 20 | <a href="{% url 'fossil:user_activity' slug=project.slug username=note.user %}" |
| 21 | ">{{ note.user|display_user }}</a> |
| 22 | <span class="font-mono text-brand-light">{{ note.uuid|truncatechars:16 }}</span> |
| 23 | <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span> |
| 24 | </div> |
| 25 | </div> |
| 26 | {% if has_write %} |
| 27 | <div class="flex items-center gap-2 flex-shrink-0"> |
| 28 | <a href="{% url 'fossil:technote_edit' slug=project.slug technote_id=note.uuid %}" |
| 29 | class="rounded-md bg-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-300 hover:bg-gray-600">Edit</a> |
| 30 | </div> |
| 31 | {% endif %} |
| 32 | </div> |
| 33 | </div> |
| 34 | |
| 35 | <!-- Body --> |
| 36 | {% if body_html %} |
| 37 | <div class="px-6 py-5"> |
| 38 | <div class="prose prose-invert prose-gray prose-sm max-w-none"> |
| 39 | {{ body_html }} |
| 40 | </div> |
| 41 | </div> |
| 42 | {% else %} |
| 43 | <div class="px-6 py-5"> |
| 44 | <p class="text-sm text-gray-500 italic">No body content.</p> |
| 45 | </div> |
| 46 | {% endi |
| --- a/templates/fossil/technote_form.html | ||
| +++ b/templates/fossil/technote_form.html | ||
| @@ -0,0 +1,21 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ form_title }} — {{ project.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block extra_head %} | |
| 5 | +<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| 6 | +{% endblock %} | |
| 7 | + | |
| 8 | +{% block content %} | |
| 9 | +<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> | |
| 10 | +{% include "fossil/_project_nav.html" %} | |
| 11 | + | |
| 12 | +<div class="mb-4"> | |
| 13 | + <a href="{% url 'fossil:technotes' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to Technotes</a> | |
| 14 | +</div> | |
| 15 | + | |
| 16 | +<div class="mx-auto max-w-6xl" x-data="{ tab: 'write' }"> | |
| 17 | + <div class="flex items-center justify-between mb-4"> | |
| 18 | + <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2> | |
| 19 | + <div class="flex items-center gap-1 text-xs"> | |
| 20 | + <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button> | |
| 21 | + <button @click="tab = 'preview'; document.getElementById('marked.parse(document.getElementB" |
| --- a/templates/fossil/technote_form.html | |
| +++ b/templates/fossil/technote_form.html | |
| @@ -0,0 +1,21 @@ | |
| --- a/templates/fossil/technote_form.html | |
| +++ b/templates/fossil/technote_form.html | |
| @@ -0,0 +1,21 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ form_title }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block extra_head %} |
| 5 | <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> |
| 6 | {% endblock %} |
| 7 | |
| 8 | {% block content %} |
| 9 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 10 | {% include "fossil/_project_nav.html" %} |
| 11 | |
| 12 | <div class="mb-4"> |
| 13 | <a href="{% url 'fossil:technotes' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to Technotes</a> |
| 14 | </div> |
| 15 | |
| 16 | <div class="mx-auto max-w-6xl" x-data="{ tab: 'write' }"> |
| 17 | <div class="flex items-center justify-between mb-4"> |
| 18 | <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2> |
| 19 | <div class="flex items-center gap-1 text-xs"> |
| 20 | <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button> |
| 21 | <button @click="tab = 'preview'; document.getElementById('marked.parse(document.getElementB" |
| --- templates/fossil/technote_list.html | ||
| +++ templates/fossil/technote_list.html | ||
| @@ -3,26 +3,45 @@ | ||
| 3 | 3 | |
| 4 | 4 | {% block content %} |
| 5 | 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 6 | {% include "fossil/_project_nav.html" %} |
| 7 | 7 | |
| 8 | -<h2 class="text-lg font-semibold text-gray-200 mb-4">Technotes</h2> | |
| 8 | +<div class="flex items-center justify-between mb-6"> | |
| 9 | + <h2 class="text-lg font-semibold text-gray-200">Technotes</h2> | |
| 10 | + {% if has_write %} | |
| 11 | + <a href="{% url 'fossil:technote_create' slug=project.slug %}" | |
| 12 | + class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 13 | + New Technote | |
| 14 | + </a> | |
| 15 | + {% endif %} | |
| 16 | +</div> | |
| 9 | 17 | |
| 18 | +{% if notes %} | |
| 10 | 19 | <div class="space-y-3"> |
| 11 | 20 | {% for note in notes %} |
| 12 | - <div class="rounded-lg bg-gray-800 border border-gray-700 px-5 py-4"> | |
| 21 | + <a href="{% url 'fossil:technote_detail' slug=project.slug technote_id=note.uuid %}" | |
| 22 | + class="block rounded-lg bg-gray-800 border border-gray-700 px-5 py-4 hover:bg-gray-700/50 transition-colors"> | |
| 13 | 23 | <div class="flex items-start justify-between gap-3"> |
| 14 | 24 | <div class="flex-1 min-w-0"> |
| 15 | 25 | <p class="text-sm text-gray-200">{{ note.comment|truncatechars:200 }}</p> |
| 16 | 26 | <div class="mt-2 flex items-center gap-3 text-xs text-gray-500"> |
| 17 | - <a href="{% url 'fossil:user_activity' slug=project.slug username=note.user %}" class="hover:text-brand-light">{{ note.user }}</a> | |
| 18 | - <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=note.uuid %}" class="font-mono text-brand-light hover:text-brand">{{ note.uuid|truncatechars:10 }}</a> | |
| 27 | + <span class="hover:text-brand-light">{{ note.user }}</span> | |
| 28 | + <span class="font-mono text-brand-light">{{ note.uuid|truncatechars:10 }}</span> | |
| 19 | 29 | <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span> |
| 20 | 30 | </div> |
| 21 | 31 | </div> |
| 22 | 32 | </div> |
| 23 | - </div> | |
| 24 | - {% empty %} | |
| 25 | - <p class="text-sm text-gray-500 py-8 text-center">No technotes.</p> | |
| 33 | + </a> | |
| 26 | 34 | {% endfor %} |
| 27 | 35 | </div> |
| 36 | +{% else %} | |
| 37 | +<div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center"> | |
| 38 | + <p class="text-sm text-gray-500">No technotes.</p> | |
| 39 | + {% if has_write %} | |
| 40 | + <a href="{% url 'fossil:technote_create' slug=project.slug %}" | |
| 41 | + class="mt-3 inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 42 | + Create the first technote | |
| 43 | + </a> | |
| 44 | + {% endif %} | |
| 45 | +</div> | |
| 46 | +{% endif %} | |
| 28 | 47 | {% endblock %} |
| 29 | 48 | |
| 30 | 49 | ADDED templates/fossil/unversioned_list.html |
| --- templates/fossil/technote_list.html | |
| +++ templates/fossil/technote_list.html | |
| @@ -3,26 +3,45 @@ | |
| 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 | <h2 class="text-lg font-semibold text-gray-200 mb-4">Technotes</h2> |
| 9 | |
| 10 | <div class="space-y-3"> |
| 11 | {% for note in notes %} |
| 12 | <div class="rounded-lg bg-gray-800 border border-gray-700 px-5 py-4"> |
| 13 | <div class="flex items-start justify-between gap-3"> |
| 14 | <div class="flex-1 min-w-0"> |
| 15 | <p class="text-sm text-gray-200">{{ note.comment|truncatechars:200 }}</p> |
| 16 | <div class="mt-2 flex items-center gap-3 text-xs text-gray-500"> |
| 17 | <a href="{% url 'fossil:user_activity' slug=project.slug username=note.user %}" class="hover:text-brand-light">{{ note.user }}</a> |
| 18 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=note.uuid %}" class="font-mono text-brand-light hover:text-brand">{{ note.uuid|truncatechars:10 }}</a> |
| 19 | <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span> |
| 20 | </div> |
| 21 | </div> |
| 22 | </div> |
| 23 | </div> |
| 24 | {% empty %} |
| 25 | <p class="text-sm text-gray-500 py-8 text-center">No technotes.</p> |
| 26 | {% endfor %} |
| 27 | </div> |
| 28 | {% endblock %} |
| 29 | |
| 30 | DDED templates/fossil/unversioned_list.html |
| --- templates/fossil/technote_list.html | |
| +++ templates/fossil/technote_list.html | |
| @@ -3,26 +3,45 @@ | |
| 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">Technotes</h2> |
| 10 | {% if has_write %} |
| 11 | <a href="{% url 'fossil:technote_create' slug=project.slug %}" |
| 12 | class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 13 | New Technote |
| 14 | </a> |
| 15 | {% endif %} |
| 16 | </div> |
| 17 | |
| 18 | {% if notes %} |
| 19 | <div class="space-y-3"> |
| 20 | {% for note in notes %} |
| 21 | <a href="{% url 'fossil:technote_detail' slug=project.slug technote_id=note.uuid %}" |
| 22 | class="block rounded-lg bg-gray-800 border border-gray-700 px-5 py-4 hover:bg-gray-700/50 transition-colors"> |
| 23 | <div class="flex items-start justify-between gap-3"> |
| 24 | <div class="flex-1 min-w-0"> |
| 25 | <p class="text-sm text-gray-200">{{ note.comment|truncatechars:200 }}</p> |
| 26 | <div class="mt-2 flex items-center gap-3 text-xs text-gray-500"> |
| 27 | <span class="hover:text-brand-light">{{ note.user }}</span> |
| 28 | <span class="font-mono text-brand-light">{{ note.uuid|truncatechars:10 }}</span> |
| 29 | <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span> |
| 30 | </div> |
| 31 | </div> |
| 32 | </div> |
| 33 | </a> |
| 34 | {% endfor %} |
| 35 | </div> |
| 36 | {% else %} |
| 37 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center"> |
| 38 | <p class="text-sm text-gray-500">No technotes.</p> |
| 39 | {% if has_write %} |
| 40 | <a href="{% url 'fossil:technote_create' slug=project.slug %}" |
| 41 | class="mt-3 inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 42 | Create the first technote |
| 43 | </a> |
| 44 | {% endif %} |
| 45 | </div> |
| 46 | {% endif %} |
| 47 | {% endblock %} |
| 48 | |
| 49 | DDED templates/fossil/unversioned_list.html |
| --- a/templates/fossil/unversioned_list.html | ||
| +++ b/templates/fossil/unversioned_list.html | ||
| @@ -0,0 +1,25 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Files — {{ 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-2</div> | |
| 10 | + | |
| 11 | +{% if files <table class="overflow-hidden ro border border-gray-700"> | |
| 12 | + <table class="min-w-full divide-y divide-gray-700"> | |
| 13 | + thead class="bg-gray-900/80/50"> | |
| 14 | + <tr> | |
| 15 | + <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Name</th> | |
| 16 | + <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Size</th> | |
| 17 | + <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Modified</th> | |
| 18 | + <th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider"></th> | |
| 19 | + </tr> | |
| 20 | + </thead> | |
| 21 | + <tbody clasdivide-y divide-gray-700/70"> | |
| 22 | + {% for file in files %} | |
| 23 | + <tr ".size|filesizeformat }200 font-mono">{{ file.name }}</td> | |
| 24 | + <td class="px-6 py-3 text-sm text-gray-400">{{ file.size|filesizeformat }}</td> | |
| 25 | + <td class="px-6 py-3 text-sm tex |
| --- a/templates/fossil/unversioned_list.html | |
| +++ b/templates/fossil/unversioned_list.html | |
| @@ -0,0 +1,25 @@ | |
| --- a/templates/fossil/unversioned_list.html | |
| +++ b/templates/fossil/unversioned_list.html | |
| @@ -0,0 +1,25 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Files — {{ 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-2</div> |
| 10 | |
| 11 | {% if files <table class="overflow-hidden ro border border-gray-700"> |
| 12 | <table class="min-w-full divide-y divide-gray-700"> |
| 13 | thead class="bg-gray-900/80/50"> |
| 14 | <tr> |
| 15 | <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Name</th> |
| 16 | <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Size</th> |
| 17 | <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Modified</th> |
| 18 | <th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider"></th> |
| 19 | </tr> |
| 20 | </thead> |
| 21 | <tbody clasdivide-y divide-gray-700/70"> |
| 22 | {% for file in files %} |
| 23 | <tr ".size|filesizeformat }200 font-mono">{{ file.name }}</td> |
| 24 | <td class="px-6 py-3 text-sm text-gray-400">{{ file.size|filesizeformat }}</td> |
| 25 | <td class="px-6 py-3 text-sm tex |
| --- templates/includes/sidebar.html | ||
| +++ templates/includes/sidebar.html | ||
| @@ -22,10 +22,20 @@ | ||
| 22 | 22 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 23 | 23 | <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> |
| 24 | 24 | </svg> |
| 25 | 25 | <span x-show="!collapsed" class="truncate">Dashboard</span> |
| 26 | 26 | </a> |
| 27 | + | |
| 28 | + <!-- Explore --> | |
| 29 | + <a href="{% url 'explore' %}" | |
| 30 | + class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if request.path == '/explore/' %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" | |
| 31 | + :title="collapsed ? 'Explore' : ''"> | |
| 32 | + <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 33 | + <path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" /> | |
| 34 | + </svg> | |
| 35 | + <span x-show="!collapsed" class="truncate">Explore</span> | |
| 36 | + </a> | |
| 27 | 37 | |
| 28 | 38 | <!-- Projects section --> |
| 29 | 39 | {% if perms.projects.view_project %} |
| 30 | 40 | <div> |
| 31 | 41 | <button @click="collapsed ? (collapsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)" |
| @@ -157,10 +167,22 @@ | ||
| 157 | 167 | <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> |
| 158 | 168 | </svg> |
| 159 | 169 | <span x-show="!collapsed" class="truncate">Settings</span> |
| 160 | 170 | </a> |
| 161 | 171 | {% endif %} |
| 172 | + | |
| 173 | + <!-- Audit Log --> | |
| 174 | + {% if user.is_superuser or perms.organization.change_organization %} | |
| 175 | + <a href="{% url 'organization:audit_log' %}" | |
| 176 | + class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/audit/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" | |
| 177 | + :title="collapsed ? 'Audit Log' : ''"> | |
| 178 | + <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 179 | + <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /> | |
| 180 | + </svg> | |
| 181 | + <span x-show="!collapsed" class="truncate">Audit Log</span> | |
| 182 | + </a> | |
| 183 | + {% endif %} | |
| 162 | 184 | |
| 163 | 185 | <!-- Admin --> |
| 164 | 186 | {% if user.is_staff %} |
| 165 | 187 | <a href="{% url 'admin:index' %}" |
| 166 | 188 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white" |
| 167 | 189 | |
| 168 | 190 | ADDED templates/organization/audit_log.html |
| 169 | 191 | ADDED templates/projects/explore.html |
| 170 | 192 | ADDED templates/projects/partials/star_button.html |
| --- templates/includes/sidebar.html | |
| +++ templates/includes/sidebar.html | |
| @@ -22,10 +22,20 @@ | |
| 22 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 23 | <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> |
| 24 | </svg> |
| 25 | <span x-show="!collapsed" class="truncate">Dashboard</span> |
| 26 | </a> |
| 27 | |
| 28 | <!-- Projects section --> |
| 29 | {% if perms.projects.view_project %} |
| 30 | <div> |
| 31 | <button @click="collapsed ? (collapsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)" |
| @@ -157,10 +167,22 @@ | |
| 157 | <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> |
| 158 | </svg> |
| 159 | <span x-show="!collapsed" class="truncate">Settings</span> |
| 160 | </a> |
| 161 | {% endif %} |
| 162 | |
| 163 | <!-- Admin --> |
| 164 | {% if user.is_staff %} |
| 165 | <a href="{% url 'admin:index' %}" |
| 166 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white" |
| 167 | |
| 168 | DDED templates/organization/audit_log.html |
| 169 | DDED templates/projects/explore.html |
| 170 | DDED templates/projects/partials/star_button.html |
| --- templates/includes/sidebar.html | |
| +++ templates/includes/sidebar.html | |
| @@ -22,10 +22,20 @@ | |
| 22 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 23 | <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> |
| 24 | </svg> |
| 25 | <span x-show="!collapsed" class="truncate">Dashboard</span> |
| 26 | </a> |
| 27 | |
| 28 | <!-- Explore --> |
| 29 | <a href="{% url 'explore' %}" |
| 30 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if request.path == '/explore/' %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| 31 | :title="collapsed ? 'Explore' : ''"> |
| 32 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 33 | <path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" /> |
| 34 | </svg> |
| 35 | <span x-show="!collapsed" class="truncate">Explore</span> |
| 36 | </a> |
| 37 | |
| 38 | <!-- Projects section --> |
| 39 | {% if perms.projects.view_project %} |
| 40 | <div> |
| 41 | <button @click="collapsed ? (collapsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)" |
| @@ -157,10 +167,22 @@ | |
| 167 | <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> |
| 168 | </svg> |
| 169 | <span x-show="!collapsed" class="truncate">Settings</span> |
| 170 | </a> |
| 171 | {% endif %} |
| 172 | |
| 173 | <!-- Audit Log --> |
| 174 | {% if user.is_superuser or perms.organization.change_organization %} |
| 175 | <a href="{% url 'organization:audit_log' %}" |
| 176 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/audit/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| 177 | :title="collapsed ? 'Audit Log' : ''"> |
| 178 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 179 | <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /> |
| 180 | </svg> |
| 181 | <span x-show="!collapsed" class="truncate">Audit Log</span> |
| 182 | </a> |
| 183 | {% endif %} |
| 184 | |
| 185 | <!-- Admin --> |
| 186 | {% if user.is_staff %} |
| 187 | <a href="{% url 'admin:index' %}" |
| 188 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white" |
| 189 | |
| 190 | DDED templates/organization/audit_log.html |
| 191 | DDED templates/projects/explore.html |
| 192 | DDED templates/projects/partials/star_button.html |
| --- a/templates/organization/audit_log.html | ||
| +++ b/templates/organization/audit_log.html | ||
| @@ -0,0 +1,36 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% load humanize %} | |
| 3 | +{% block title %}Audit Log — Fossilrepo{% endblock %} | |
| 4 | + | |
| 5 | +{% block content %} | |
| 6 | +<div class="md:flex md:items-center md:justify-between mb-6"> | |
| 7 | + <div> | |
| 8 | + <h1 class="text-2xl font-bold text-gray-100">Audit Log</h1> | |
| 9 | + <p class="mt-1 text-sm text-gray-400">History of changes across all tracked models.</p> | |
| 10 | + </div> | |
| 11 | +</div> | |
| 12 | + | |
| 13 | +<!-- Filter by model type --> | |
| 14 | +<div class="flex <div class="flex flex-wrap gap-2 mb-6"> | |
| 15 | + <a href="{% url 'organization:audit_log' %}" | |
| 16 | + class="rounded-md px-3 py-2 text-sm font-medium {% if not model_filter %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}"> | |
| 17 | + All | |
| 18 | + </a> | |
| 19 | + {% for model_name in available_models %} | |
| 20 | + <a href="{% url 'organization:audit_log' %}?model={{ model_name }}" | |
| 21 | + class="rounded-md px-3 py-2 text-sm font-medium {% if model_filter == model_name %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}"> | |
| 22 | + {{ model_name }} | |
| 23 | + </a> | |
| 24 | + {% endfor %} | |
| 25 | +</dihidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 26 | + <table class="min-w-full divide-y divide-gray-700"> | |
| 27 | + <thead class="bg-gray-900"> | |
| 28 | + <tr> | |
| 29 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Date</th> | |
| 30 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">User</th> | |
| 31 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Action</th> | |
| 32 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Model</th> | |
| 33 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Object</th> | |
| 34 | + </tr> | |
| 35 | + </thead> | |
| 36 | + <tbody clasendblock %} |
| --- a/templates/organization/audit_log.html | |
| +++ b/templates/organization/audit_log.html | |
| @@ -0,0 +1,36 @@ | |
| --- a/templates/organization/audit_log.html | |
| +++ b/templates/organization/audit_log.html | |
| @@ -0,0 +1,36 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load humanize %} |
| 3 | {% block title %}Audit Log — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <div class="md:flex md:items-center md:justify-between mb-6"> |
| 7 | <div> |
| 8 | <h1 class="text-2xl font-bold text-gray-100">Audit Log</h1> |
| 9 | <p class="mt-1 text-sm text-gray-400">History of changes across all tracked models.</p> |
| 10 | </div> |
| 11 | </div> |
| 12 | |
| 13 | <!-- Filter by model type --> |
| 14 | <div class="flex <div class="flex flex-wrap gap-2 mb-6"> |
| 15 | <a href="{% url 'organization:audit_log' %}" |
| 16 | class="rounded-md px-3 py-2 text-sm font-medium {% if not model_filter %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}"> |
| 17 | All |
| 18 | </a> |
| 19 | {% for model_name in available_models %} |
| 20 | <a href="{% url 'organization:audit_log' %}?model={{ model_name }}" |
| 21 | class="rounded-md px-3 py-2 text-sm font-medium {% if model_filter == model_name %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}"> |
| 22 | {{ model_name }} |
| 23 | </a> |
| 24 | {% endfor %} |
| 25 | </dihidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 26 | <table class="min-w-full divide-y divide-gray-700"> |
| 27 | <thead class="bg-gray-900"> |
| 28 | <tr> |
| 29 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Date</th> |
| 30 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">User</th> |
| 31 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Action</th> |
| 32 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Model</th> |
| 33 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Object</th> |
| 34 | </tr> |
| 35 | </thead> |
| 36 | <tbody clasendblock %} |
| --- a/templates/projects/explore.html | ||
| +++ b/templates/projects/explore.html | ||
| @@ -0,0 +1,51 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% load humanize %} | |
| 3 | +{% block title %}Explore Projects — Fossilrepo{% endblock %} | |
| 4 | + | |
| 5 | +{% block content %} | |
| 6 | +<div class="md:flex md:items-center md:justify-between mb-6"> | |
| 7 | + <div> | |
| 8 | + <h1 class="text-2xl font-bold text-gray-100">Explore Projects</h1> | |
| 9 | + <p class="mt-1 text-sm text-gray-400">Discover public and open-source Fossil repositories.</p> | |
| 10 | + </div> | |
| 11 | +</div> | |
| 12 | + | |
| 13 | +<!-- Search and Sort controls --> | |
| 14 | +<div class="flex flex-col sm:flex-row gap-4 mb-6"> | |
| 15 | + <form method="get" action="{% url 'explore' %}" class="flex-1 flex gap-2"> | |
| 16 | + <input type="search" | |
| 17 | + name="search" | |
| 18 | + value="{{ search }}" | |
| 19 | + placeholder="Searclass="flex-1 max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" /> | |
| 20 | + {% if sort != "stars" %} | |
| 21 | + <input type="hidden" name="sort" value="{{ sort }}" /> | |
| 22 | + {% endif %} | |
| 23 | + <button type="submit" | |
| 24 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 25 | + Search | |
| 26 | + </button> | |
| 27 | + <div class="flex flex-wrap gap-2"> | |
| 28 | + <a href="{% url 'explore' %}?sort=stars{% if search %}&search={{ search }}{% endif %}" | |
| 29 | + class="rounded-md px-3 py-2 text-sm font-medium {% if sort == 'stars' %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}"> | |
| 30 | + Stars | |
| 31 | + </a> | |
| 32 | + <a href="{% url 'explore' %}?sort=recent{% if search %}&search={{ search }}{% endif %}" | |
| 33 | + class="rounded-md px-3 py-2 text-sm font-medium {% if sort == 'recent' %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}"> | |
| 34 | + Recent | |
| 35 | + </a> | |
| 36 | + <a href="{% url 'explore' %}?sort=name{% if search %}&search={{ search }}{% endif %}" | |
| 37 | + class="rounded-md px-3 py-2 text-sm font-medium {% if sort == 'name' %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}"> | |
| 38 | + Name | |
| 39 | + </a> | |
| 40 | + </div> | |
| 41 | +</div> | |
| 42 | + | |
| 43 | +<!-- Project cards grid --> | |
| 44 | +<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> | |
| 45 | + {% for project in projects %} | |
| 46 | + <a href="{% url 'projects:detail' slug=project.slug %}" | |
| 47 | + class="group rounded-lg border border-gray-700 bg-gray-800 p-5 hover:border-gray-600 transition-colors"> | |
| 48 | + <div class="flex items-start justify-between mb-2"> | |
| 49 | + <h3 class="text-base font-semibold text-gray-100 group-hover:text-brand-light truncate">{{ project.name }}</h3> | |
| 50 | + {% if project.visibility == "public" %} | |
| 51 | + <span class="ml-2 flex-shrink-0 inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300 |
| --- a/templates/projects/explore.html | |
| +++ b/templates/projects/explore.html | |
| @@ -0,0 +1,51 @@ | |
| --- a/templates/projects/explore.html | |
| +++ b/templates/projects/explore.html | |
| @@ -0,0 +1,51 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load humanize %} |
| 3 | {% block title %}Explore Projects — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <div class="md:flex md:items-center md:justify-between mb-6"> |
| 7 | <div> |
| 8 | <h1 class="text-2xl font-bold text-gray-100">Explore Projects</h1> |
| 9 | <p class="mt-1 text-sm text-gray-400">Discover public and open-source Fossil repositories.</p> |
| 10 | </div> |
| 11 | </div> |
| 12 | |
| 13 | <!-- Search and Sort controls --> |
| 14 | <div class="flex flex-col sm:flex-row gap-4 mb-6"> |
| 15 | <form method="get" action="{% url 'explore' %}" class="flex-1 flex gap-2"> |
| 16 | <input type="search" |
| 17 | name="search" |
| 18 | value="{{ search }}" |
| 19 | placeholder="Searclass="flex-1 max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" /> |
| 20 | {% if sort != "stars" %} |
| 21 | <input type="hidden" name="sort" value="{{ sort }}" /> |
| 22 | {% endif %} |
| 23 | <button type="submit" |
| 24 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 25 | Search |
| 26 | </button> |
| 27 | <div class="flex flex-wrap gap-2"> |
| 28 | <a href="{% url 'explore' %}?sort=stars{% if search %}&search={{ search }}{% endif %}" |
| 29 | class="rounded-md px-3 py-2 text-sm font-medium {% if sort == 'stars' %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}"> |
| 30 | Stars |
| 31 | </a> |
| 32 | <a href="{% url 'explore' %}?sort=recent{% if search %}&search={{ search }}{% endif %}" |
| 33 | class="rounded-md px-3 py-2 text-sm font-medium {% if sort == 'recent' %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}"> |
| 34 | Recent |
| 35 | </a> |
| 36 | <a href="{% url 'explore' %}?sort=name{% if search %}&search={{ search }}{% endif %}" |
| 37 | class="rounded-md px-3 py-2 text-sm font-medium {% if sort == 'name' %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}"> |
| 38 | Name |
| 39 | </a> |
| 40 | </div> |
| 41 | </div> |
| 42 | |
| 43 | <!-- Project cards grid --> |
| 44 | <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> |
| 45 | {% for project in projects %} |
| 46 | <a href="{% url 'projects:detail' slug=project.slug %}" |
| 47 | class="group rounded-lg border border-gray-700 bg-gray-800 p-5 hover:border-gray-600 transition-colors"> |
| 48 | <div class="flex items-start justify-between mb-2"> |
| 49 | <h3 class="text-base font-semibold text-gray-100 group-hover:text-brand-light truncate">{{ project.name }}</h3> |
| 50 | {% if project.visibility == "public" %} |
| 51 | <span class="ml-2 flex-shrink-0 inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300 |
| --- a/templates/projects/partials/star_button.html | ||
| +++ b/templates/projects/partials/star_button.html | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +<div id="star-button"> | |
| 2 | + <button hx-post="{% url 'projects:toggle_star' slug=project.slug %}" | |
| 3 | + hx-target="#star-button" | |
| 4 | + hx-swap="outerHTML" | |
| 5 | + class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 6 | + {% if is_starred %} | |
| 7 | + <svg class="h-4 w-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24"> | |
| 8 | + <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/> | |
| 9 | + </svg> | |
| 10 | + <span>Starred</span> | |
| 11 | + {% else %} | |
| 12 | + <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 13 | + <path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"/> | |
| 14 | + </svg> | |
| 15 | + <span>Star</span> | |
| 16 | + {% endif %} | |
| 17 | + <span class="text-gray-400">{{ project.star_count }}</span> | |
| 18 | + </button> | |
| 19 | +</div> |
| --- a/templates/projects/partials/star_button.html | |
| +++ b/templates/projects/partials/star_button.html | |
| @@ -0,0 +1,19 @@ | |
| --- a/templates/projects/partials/star_button.html | |
| +++ b/templates/projects/partials/star_button.html | |
| @@ -0,0 +1,19 @@ | |
| 1 | <div id="star-button"> |
| 2 | <button hx-post="{% url 'projects:toggle_star' slug=project.slug %}" |
| 3 | hx-target="#star-button" |
| 4 | hx-swap="outerHTML" |
| 5 | class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 6 | {% if is_starred %} |
| 7 | <svg class="h-4 w-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24"> |
| 8 | <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/> |
| 9 | </svg> |
| 10 | <span>Starred</span> |
| 11 | {% else %} |
| 12 | <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 13 | <path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"/> |
| 14 | </svg> |
| 15 | <span>Star</span> |
| 16 | {% endif %} |
| 17 | <span class="text-gray-400">{{ project.star_count }}</span> |
| 18 | </button> |
| 19 | </div> |
| --- templates/projects/project_detail.html | ||
| +++ templates/projects/project_detail.html | ||
| @@ -11,10 +11,11 @@ | ||
| 11 | 11 | <h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1> |
| 12 | 12 | <p class="mt-1 text-sm text-gray-400">{{ project.description|default:"No description." }}</p> |
| 13 | 13 | </div> |
| 14 | 14 | <div class="flex gap-3"> |
| 15 | 15 | {% if user.is_authenticated %} |
| 16 | + {% include "projects/partials/star_button.html" %} | |
| 16 | 17 | <form method="post" action="{% url 'fossil:toggle_watch' slug=project.slug %}"> |
| 17 | 18 | {% csrf_token %} |
| 18 | 19 | {% if is_watching %} |
| 19 | 20 | <button type="submit" class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 20 | 21 | <svg class="h-4 w-4 text-brand" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg> |
| 21 | 22 | |
| 22 | 23 | ADDED tests/test_api_tokens.py |
| 23 | 24 | ADDED tests/test_audit_log.py |
| 24 | 25 | ADDED tests/test_branch_protection.py |
| 25 | 26 | ADDED tests/test_ci_status.py |
| 26 | 27 | ADDED tests/test_starring.py |
| 27 | 28 | ADDED tests/test_technotes.py |
| 28 | 29 | ADDED tests/test_unversioned.py |
| --- templates/projects/project_detail.html | |
| +++ templates/projects/project_detail.html | |
| @@ -11,10 +11,11 @@ | |
| 11 | <h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1> |
| 12 | <p class="mt-1 text-sm text-gray-400">{{ project.description|default:"No description." }}</p> |
| 13 | </div> |
| 14 | <div class="flex gap-3"> |
| 15 | {% if user.is_authenticated %} |
| 16 | <form method="post" action="{% url 'fossil:toggle_watch' slug=project.slug %}"> |
| 17 | {% csrf_token %} |
| 18 | {% if is_watching %} |
| 19 | <button type="submit" class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 20 | <svg class="h-4 w-4 text-brand" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg> |
| 21 | |
| 22 | DDED tests/test_api_tokens.py |
| 23 | DDED tests/test_audit_log.py |
| 24 | DDED tests/test_branch_protection.py |
| 25 | DDED tests/test_ci_status.py |
| 26 | DDED tests/test_starring.py |
| 27 | DDED tests/test_technotes.py |
| 28 | DDED tests/test_unversioned.py |
| --- templates/projects/project_detail.html | |
| +++ templates/projects/project_detail.html | |
| @@ -11,10 +11,11 @@ | |
| 11 | <h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1> |
| 12 | <p class="mt-1 text-sm text-gray-400">{{ project.description|default:"No description." }}</p> |
| 13 | </div> |
| 14 | <div class="flex gap-3"> |
| 15 | {% if user.is_authenticated %} |
| 16 | {% include "projects/partials/star_button.html" %} |
| 17 | <form method="post" action="{% url 'fossil:toggle_watch' slug=project.slug %}"> |
| 18 | {% csrf_token %} |
| 19 | {% if is_watching %} |
| 20 | <button type="submit" class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 21 | <svg class="h-4 w-4 text-brand" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg> |
| 22 | |
| 23 | DDED tests/test_api_tokens.py |
| 24 | DDED tests/test_audit_log.py |
| 25 | DDED tests/test_branch_protection.py |
| 26 | DDED tests/test_ci_status.py |
| 27 | DDED tests/test_starring.py |
| 28 | DDED tests/test_technotes.py |
| 29 | DDED tests/test_unversioned.py |
| --- a/tests/test_api_tokens.py | ||
| +++ b/tests/test_api_tokens.py | ||
| @@ -0,0 +1,291 @@ | ||
| 1 | +import pytest | |
| 2 | +from django.contrib.auth.models import User | |
| 3 | +from django.test import Client | |
| 4 | + | |
| 5 | +from fossil.api_tokens import APIToken, authenticate_api_token | |
| 6 | +from fossil.models import FossilRepository | |
| 7 | +from organization.models import Team | |
| 8 | +from projects.models import ProjectTeam | |
| 9 | + | |
| 10 | + | |
| 11 | +@pytest.fixture | |
| 12 | +def fossil_repo_obj(sample_project): | |
| 13 | + """Return the auto-created FossilRepository for sample_project.""" | |
| 14 | + return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True) | |
| 15 | + | |
| 16 | + | |
| 17 | +@pytest.fixture | |
| 18 | +def api_token(fossil_repo_obj, admin_user): | |
| 19 | + """Create an API token and return (APIToken instance, raw_token).""" | |
| 20 | + raw, token_hash, prefix = APIToken.generate() | |
| 21 | + token = APIToken.objects.create( | |
| 22 | + repository=fossil_repo_obj, | |
| 23 | + name="Test Token", | |
| 24 | + token_hash=token_hash, | |
| 25 | + token_prefix=prefix, | |
| 26 | + permissions="status:write", | |
| 27 | + created_by=admin_user, | |
| 28 | + ) | |
| 29 | + return token, raw | |
| 30 | + | |
| 31 | + | |
| 32 | +@pytest.fixture | |
| 33 | +def writer_user(db, admin_user, sample_project): | |
| 34 | + """User with write access but not admin.""" | |
| 35 | + writer = User.objects.create_user(username="writer_tok", password="testpass123") | |
| 36 | + team = Team.objects.create(name="Token Writers", organization=sample_project.organization, created_by=admin_user) | |
| 37 | + team.members.add(writer) | |
| 38 | + ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user) | |
| 39 | + return writer | |
| 40 | + | |
| 41 | + | |
| 42 | +@pytest.fixture | |
| 43 | +def writer_client(writer_user): | |
| 44 | + client = Client() | |
| 45 | + client.login(username="writer_tok", password="testpass123") | |
| 46 | + return client | |
| 47 | + | |
| 48 | + | |
| 49 | +# --- APIToken Model Tests --- | |
| 50 | + | |
| 51 | + | |
| 52 | +@pytest.mark.django_db | |
| 53 | +class TestAPITokenModel: | |
| 54 | + def test_generate_token(self): | |
| 55 | + raw, token_hash, prefix = APIToken.generate() | |
| 56 | + assert raw.startswith("frp_") | |
| 57 | + assert len(token_hash) == 64 # SHA-256 hex digest | |
| 58 | + assert prefix == raw[:12] | |
| 59 | + | |
| 60 | + def test_hash_token(self): | |
| 61 | + raw, token_hash, prefix = APIToken.generate() | |
| 62 | + assert APIToken.hash_token(raw) == token_hash | |
| 63 | + | |
| 64 | + def test_create_token(self, api_token): | |
| 65 | + token, raw = api_token | |
| 66 | + assert token.pk is not None | |
| 67 | + assert "Test Token" in str(token) | |
| 68 | + assert token.token_prefix in str(token) | |
| 69 | + | |
| 70 | + def test_soft_delete(self, api_token, admin_user): | |
| 71 | + token, _ = api_token | |
| 72 | + token.soft_delete(user=admin_user) | |
| 73 | + assert token.is_deleted | |
| 74 | + assert APIToken.objects.filter(pk=token.pk).count() == 0 | |
| 75 | + assert APIToken.all_objects.filter(pk=token.pk).count() == 1 | |
| 76 | + | |
| 77 | + def test_has_permission(self, api_token): | |
| 78 | + token, _ = api_token | |
| 79 | + assert token.has_permission("status:write") is True | |
| 80 | + assert token.has_permission("status:read") is False | |
| 81 | + | |
| 82 | + def test_has_permission_wildcard(self, fossil_repo_obj, admin_user): | |
| 83 | + raw, token_hash, prefix = APIToken.generate() | |
| 84 | + token = APIToken.objects.create( | |
| 85 | + repository=fossil_repo_obj, | |
| 86 | + name="Wildcard", | |
| 87 | + token_hash=token_hash, | |
| 88 | + token_prefix=prefix, | |
| 89 | + permissions="*", | |
| 90 | + created_by=admin_user, | |
| 91 | + ) | |
| 92 | + assert token.has_permission("status:write") is True | |
| 93 | + assert token.has_permission("anything") is True | |
| 94 | + | |
| 95 | + def test_unique_token_hash(self, fossil_repo_obj, admin_user, api_token): | |
| 96 | + """Token hashes must be unique across all tokens.""" | |
| 97 | + token, _ = api_token | |
| 98 | + from django.db import IntegrityError | |
| 99 | + | |
| 100 | + with pytest.raises(IntegrityError): | |
| 101 | + APIToken.objects.create( | |
| 102 | + repository=fossil_repo_obj, | |
| 103 | + name="Duplicate Hash", | |
| 104 | + token_hash=token.token_hash, | |
| 105 | + token_prefix="dup_", | |
| 106 | + created_by=admin_user, | |
| 107 | + ) | |
| 108 | + | |
| 109 | + | |
| 110 | +# --- authenticate_api_token Tests --- | |
| 111 | + | |
| 112 | + | |
| 113 | +@pytest.mark.django_db | |
| 114 | +class TestAuthenticateAPIToken: | |
| 115 | + def test_valid_token(self, api_token, fossil_repo_obj): | |
| 116 | + token, raw = api_token | |
| 117 | + | |
| 118 | + class FakeRequest: | |
| 119 | + META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"} | |
| 120 | + | |
| 121 | + result = authenticate_api_token(FakeRequest(), fossil_repo_obj) | |
| 122 | + assert result is not None | |
| 123 | + assert result.pk == token.pk | |
| 124 | + | |
| 125 | + def test_invalid_token(self, fossil_repo_obj): | |
| 126 | + class FakeRequest: | |
| 127 | + META = {"HTTP_AUTHORIZATION": "Bearer invalid_token_xyz"} | |
| 128 | + | |
| 129 | + result = authenticate_api_token(FakeRequest(), fossil_repo_obj) | |
| 130 | + assert result is None | |
| 131 | + | |
| 132 | + def test_no_auth_header(self, fossil_repo_obj): | |
| 133 | + class FakeRequest: | |
| 134 | + META = {} | |
| 135 | + | |
| 136 | + result = authenticate_api_token(FakeRequest(), fossil_repo_obj) | |
| 137 | + assert result is None | |
| 138 | + | |
| 139 | + def test_non_bearer_auth(self, fossil_repo_obj): | |
| 140 | + class FakeRequest: | |
| 141 | + META = {"HTTP_AUTHORIZATION": "Basic dXNlcjpwYXNz"} | |
| 142 | + | |
| 143 | + result = authenticate_api_token(FakeRequest(), fossil_repo_obj) | |
| 144 | + assert result is None | |
| 145 | + | |
| 146 | + def test_expired_token(self, fossil_repo_obj, admin_user): | |
| 147 | + from datetime import timedelta | |
| 148 | + | |
| 149 | + from django.utils import timezone | |
| 150 | + | |
| 151 | + raw, token_hash, prefix = APIToken.generate() | |
| 152 | + APIToken.objects.create( | |
| 153 | + repository=fossil_repo_obj, | |
| 154 | + name="Expired", | |
| 155 | + token_hash=token_hash, | |
| 156 | + token_prefix=prefix, | |
| 157 | + expires_at=timezone.now() - timedelta(days=1), | |
| 158 | + created_by=admin_user, | |
| 159 | + ) | |
| 160 | + | |
| 161 | + class FakeRequest: | |
| 162 | + META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"} | |
| 163 | + | |
| 164 | + result = authenticate_api_token(FakeRequest(), fossil_repo_obj) | |
| 165 | + assert result is None | |
| 166 | + | |
| 167 | + def test_updates_last_used_at(self, api_token, fossil_repo_obj): | |
| 168 | + token, raw = api_token | |
| 169 | + assert token.last_used_at is None | |
| 170 | + | |
| 171 | + class FakeRequest: | |
| 172 | + META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"} | |
| 173 | + | |
| 174 | + authenticate_api_token(FakeRequest(), fossil_repo_obj) | |
| 175 | + token.refresh_from_db() | |
| 176 | + assert token.last_used_at is not None | |
| 177 | + | |
| 178 | + def test_deleted_token_not_found(self, api_token, fossil_repo_obj, admin_user): | |
| 179 | + token, raw = api_token | |
| 180 | + token.soft_delete(user=admin_user) | |
| 181 | + | |
| 182 | + class FakeRequest: | |
| 183 | + META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"} | |
| 184 | + | |
| 185 | + result = authenticate_api_token(FakeRequest(), fossil_repo_obj) | |
| 186 | + assert result is None | |
| 187 | + | |
| 188 | + | |
| 189 | +# --- API Token List View Tests --- | |
| 190 | + | |
| 191 | + | |
| 192 | +@pytest.mark.django_db | |
| 193 | +class TestAPITokenListView: | |
| 194 | + def test_list_tokens(self, admin_client, sample_project, api_token): | |
| 195 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/") | |
| 196 | + assert response.status_code == 200 | |
| 197 | + content = response.content.decode() | |
| 198 | + assert "Test Token" in content | |
| 199 | + assert "status:write" in content | |
| 200 | + | |
| 201 | + def test_list_empty(self, admin_client, sample_project, fossil_repo_obj): | |
| 202 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/") | |
| 203 | + assert response.status_code == 200 | |
| 204 | + assert "No API tokens generated yet" in response.content.decode() | |
| 205 | + | |
| 206 | + def test_list_denied_for_writer(self, writer_client, sample_project, api_token): | |
| 207 | + """Token management requires admin, not just write.""" | |
| 208 | + response = writer_client.get(f"/projects/{sample_project.slug}/fossil/tokens/") | |
| 209 | + assert response.status_code == 403 | |
| 210 | + | |
| 211 | + def test_list_denied_for_no_perm(self, no_perm_client, sample_project): | |
| 212 | + response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/tokens/") | |
| 213 | + assert response.status_code == 403 | |
| 214 | + | |
| 215 | + def test_list_denied_for_anon(self, client, sample_project): | |
| 216 | + response = client.get(f"/projects/{sample_project.slug}/fossil/tokens/") | |
| 217 | + assert response.status_code == 302 # redirect to login | |
| 218 | + | |
| 219 | + | |
| 220 | +# --- API Token Create View Tests --- | |
| 221 | + | |
| 222 | + | |
| 223 | +@pytest.mark.django_db | |
| 224 | +class TestAPITokenCreateView: | |
| 225 | + def test_get_form(self, admin_client, sample_project, fossil_repo_obj): | |
| 226 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/create/") | |
| 227 | + assert response.status_code == 200 | |
| 228 | + assert "Generate API Token" in response.content.decode() | |
| 229 | + | |
| 230 | + def test_create_token(self, admin_client, sample_project, fossil_repo_obj): | |
| 231 | + response = admin_client.post( | |
| 232 | + f"/projects/{sample_project.slug}/fossil/tokens/create/", | |
| 233 | + {"name": "New CI Token", "permissions": "status:write"}, | |
| 234 | + ) | |
| 235 | + assert response.status_code == 200 # Shows the token on the same page | |
| 236 | + content = response.content.decode() | |
| 237 | + assert "frp_" in content # Raw token is displayed | |
| 238 | + assert "Token Generated" in content | |
| 239 | + | |
| 240 | + # Verify token was created in DB | |
| 241 | + token = APIToken.objects.get(name="New CI Token") | |
| 242 | + assert token.permissions == "status:write" | |
| 243 | + | |
| 244 | + def test_create_token_without_name_fails(self, admin_client, sample_project, fossil_repo_obj): | |
| 245 | + response = admin_client.post( | |
| 246 | + f"/projects/{sample_project.slug}/fossil/tokens/create/", | |
| 247 | + {"name": "", "permissions": "status:write"}, | |
| 248 | + ) | |
| 249 | + assert response.status_code == 200 | |
| 250 | + assert "Token name is required" in response.content.decode() | |
| 251 | + assert APIToken.objects.filter(repository__project=sample_project).count() == 0 | |
| 252 | + | |
| 253 | + def test_create_denied_for_writer(self, writer_client, sample_project): | |
| 254 | + response = writer_client.post( | |
| 255 | + f"/projects/{sample_project.slug}/fossil/tokens/create/", | |
| 256 | + {"name": "Evil Token"}, | |
| 257 | + ) | |
| 258 | + assert response.status_code == 403 | |
| 259 | + | |
| 260 | + | |
| 261 | +# --- API Token Delete View Tests --- | |
| 262 | + | |
| 263 | + | |
| 264 | +@pytest.mark.django_db | |
| 265 | +class TestAPITokenDeleteView: | |
| 266 | + def test_delete_token(self, admin_client, sample_project, api_token): | |
| 267 | + token, _ = api_token | |
| 268 | + response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/") | |
| 269 | + assert response.status_code == 302 | |
| 270 | + token.refresh_from_db() | |
| 271 | + assert token.is_deleted | |
| 272 | + | |
| 273 | + def test_delete_get_redirects(self, admin_client, sample_project, api_token): | |
| 274 | + token, _ = api_token | |
| 275 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/") | |
| 276 | + assert response.status_code == 302 # GET redirects to list | |
| 277 | + | |
| 278 | + def test_delete_denied_for_writer(self, writer_client, sample_project, api_token): | |
| 279 | + token, _ = api_token | |
| 280 | + response = writer_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/") | |
| 281 | + assert response.status_code == 403 | |
| 282 | + | |
| 283 | + def test_delete_nonexistent_token(self, admin_client, sample_project, fossil_repo_obj): | |
| 284 | + response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/99999/delete/") | |
| 285 | + assert response.status_code == 404 | |
| 286 | + | |
| 287 | + def test_deleted_token_cannot_be_deleted_again(self, admin_client, sample_project, api_token, admin_user): | |
| 288 | + token, _ = api_token | |
| 289 | + token.soft_delete(user=admin_user) | |
| 290 | + response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/") | |
| 291 | + assert response.status_code == 404 |
| --- a/tests/test_api_tokens.py | |
| +++ b/tests/test_api_tokens.py | |
| @@ -0,0 +1,291 @@ | |
| --- a/tests/test_api_tokens.py | |
| +++ b/tests/test_api_tokens.py | |
| @@ -0,0 +1,291 @@ | |
| 1 | import pytest |
| 2 | from django.contrib.auth.models import User |
| 3 | from django.test import Client |
| 4 | |
| 5 | from fossil.api_tokens import APIToken, authenticate_api_token |
| 6 | from fossil.models import FossilRepository |
| 7 | from organization.models import Team |
| 8 | from projects.models import ProjectTeam |
| 9 | |
| 10 | |
| 11 | @pytest.fixture |
| 12 | def fossil_repo_obj(sample_project): |
| 13 | """Return the auto-created FossilRepository for sample_project.""" |
| 14 | return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True) |
| 15 | |
| 16 | |
| 17 | @pytest.fixture |
| 18 | def api_token(fossil_repo_obj, admin_user): |
| 19 | """Create an API token and return (APIToken instance, raw_token).""" |
| 20 | raw, token_hash, prefix = APIToken.generate() |
| 21 | token = APIToken.objects.create( |
| 22 | repository=fossil_repo_obj, |
| 23 | name="Test Token", |
| 24 | token_hash=token_hash, |
| 25 | token_prefix=prefix, |
| 26 | permissions="status:write", |
| 27 | created_by=admin_user, |
| 28 | ) |
| 29 | return token, raw |
| 30 | |
| 31 | |
| 32 | @pytest.fixture |
| 33 | def writer_user(db, admin_user, sample_project): |
| 34 | """User with write access but not admin.""" |
| 35 | writer = User.objects.create_user(username="writer_tok", password="testpass123") |
| 36 | team = Team.objects.create(name="Token Writers", organization=sample_project.organization, created_by=admin_user) |
| 37 | team.members.add(writer) |
| 38 | ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user) |
| 39 | return writer |
| 40 | |
| 41 | |
| 42 | @pytest.fixture |
| 43 | def writer_client(writer_user): |
| 44 | client = Client() |
| 45 | client.login(username="writer_tok", password="testpass123") |
| 46 | return client |
| 47 | |
| 48 | |
| 49 | # --- APIToken Model Tests --- |
| 50 | |
| 51 | |
| 52 | @pytest.mark.django_db |
| 53 | class TestAPITokenModel: |
| 54 | def test_generate_token(self): |
| 55 | raw, token_hash, prefix = APIToken.generate() |
| 56 | assert raw.startswith("frp_") |
| 57 | assert len(token_hash) == 64 # SHA-256 hex digest |
| 58 | assert prefix == raw[:12] |
| 59 | |
| 60 | def test_hash_token(self): |
| 61 | raw, token_hash, prefix = APIToken.generate() |
| 62 | assert APIToken.hash_token(raw) == token_hash |
| 63 | |
| 64 | def test_create_token(self, api_token): |
| 65 | token, raw = api_token |
| 66 | assert token.pk is not None |
| 67 | assert "Test Token" in str(token) |
| 68 | assert token.token_prefix in str(token) |
| 69 | |
| 70 | def test_soft_delete(self, api_token, admin_user): |
| 71 | token, _ = api_token |
| 72 | token.soft_delete(user=admin_user) |
| 73 | assert token.is_deleted |
| 74 | assert APIToken.objects.filter(pk=token.pk).count() == 0 |
| 75 | assert APIToken.all_objects.filter(pk=token.pk).count() == 1 |
| 76 | |
| 77 | def test_has_permission(self, api_token): |
| 78 | token, _ = api_token |
| 79 | assert token.has_permission("status:write") is True |
| 80 | assert token.has_permission("status:read") is False |
| 81 | |
| 82 | def test_has_permission_wildcard(self, fossil_repo_obj, admin_user): |
| 83 | raw, token_hash, prefix = APIToken.generate() |
| 84 | token = APIToken.objects.create( |
| 85 | repository=fossil_repo_obj, |
| 86 | name="Wildcard", |
| 87 | token_hash=token_hash, |
| 88 | token_prefix=prefix, |
| 89 | permissions="*", |
| 90 | created_by=admin_user, |
| 91 | ) |
| 92 | assert token.has_permission("status:write") is True |
| 93 | assert token.has_permission("anything") is True |
| 94 | |
| 95 | def test_unique_token_hash(self, fossil_repo_obj, admin_user, api_token): |
| 96 | """Token hashes must be unique across all tokens.""" |
| 97 | token, _ = api_token |
| 98 | from django.db import IntegrityError |
| 99 | |
| 100 | with pytest.raises(IntegrityError): |
| 101 | APIToken.objects.create( |
| 102 | repository=fossil_repo_obj, |
| 103 | name="Duplicate Hash", |
| 104 | token_hash=token.token_hash, |
| 105 | token_prefix="dup_", |
| 106 | created_by=admin_user, |
| 107 | ) |
| 108 | |
| 109 | |
| 110 | # --- authenticate_api_token Tests --- |
| 111 | |
| 112 | |
| 113 | @pytest.mark.django_db |
| 114 | class TestAuthenticateAPIToken: |
| 115 | def test_valid_token(self, api_token, fossil_repo_obj): |
| 116 | token, raw = api_token |
| 117 | |
| 118 | class FakeRequest: |
| 119 | META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"} |
| 120 | |
| 121 | result = authenticate_api_token(FakeRequest(), fossil_repo_obj) |
| 122 | assert result is not None |
| 123 | assert result.pk == token.pk |
| 124 | |
| 125 | def test_invalid_token(self, fossil_repo_obj): |
| 126 | class FakeRequest: |
| 127 | META = {"HTTP_AUTHORIZATION": "Bearer invalid_token_xyz"} |
| 128 | |
| 129 | result = authenticate_api_token(FakeRequest(), fossil_repo_obj) |
| 130 | assert result is None |
| 131 | |
| 132 | def test_no_auth_header(self, fossil_repo_obj): |
| 133 | class FakeRequest: |
| 134 | META = {} |
| 135 | |
| 136 | result = authenticate_api_token(FakeRequest(), fossil_repo_obj) |
| 137 | assert result is None |
| 138 | |
| 139 | def test_non_bearer_auth(self, fossil_repo_obj): |
| 140 | class FakeRequest: |
| 141 | META = {"HTTP_AUTHORIZATION": "Basic dXNlcjpwYXNz"} |
| 142 | |
| 143 | result = authenticate_api_token(FakeRequest(), fossil_repo_obj) |
| 144 | assert result is None |
| 145 | |
| 146 | def test_expired_token(self, fossil_repo_obj, admin_user): |
| 147 | from datetime import timedelta |
| 148 | |
| 149 | from django.utils import timezone |
| 150 | |
| 151 | raw, token_hash, prefix = APIToken.generate() |
| 152 | APIToken.objects.create( |
| 153 | repository=fossil_repo_obj, |
| 154 | name="Expired", |
| 155 | token_hash=token_hash, |
| 156 | token_prefix=prefix, |
| 157 | expires_at=timezone.now() - timedelta(days=1), |
| 158 | created_by=admin_user, |
| 159 | ) |
| 160 | |
| 161 | class FakeRequest: |
| 162 | META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"} |
| 163 | |
| 164 | result = authenticate_api_token(FakeRequest(), fossil_repo_obj) |
| 165 | assert result is None |
| 166 | |
| 167 | def test_updates_last_used_at(self, api_token, fossil_repo_obj): |
| 168 | token, raw = api_token |
| 169 | assert token.last_used_at is None |
| 170 | |
| 171 | class FakeRequest: |
| 172 | META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"} |
| 173 | |
| 174 | authenticate_api_token(FakeRequest(), fossil_repo_obj) |
| 175 | token.refresh_from_db() |
| 176 | assert token.last_used_at is not None |
| 177 | |
| 178 | def test_deleted_token_not_found(self, api_token, fossil_repo_obj, admin_user): |
| 179 | token, raw = api_token |
| 180 | token.soft_delete(user=admin_user) |
| 181 | |
| 182 | class FakeRequest: |
| 183 | META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"} |
| 184 | |
| 185 | result = authenticate_api_token(FakeRequest(), fossil_repo_obj) |
| 186 | assert result is None |
| 187 | |
| 188 | |
| 189 | # --- API Token List View Tests --- |
| 190 | |
| 191 | |
| 192 | @pytest.mark.django_db |
| 193 | class TestAPITokenListView: |
| 194 | def test_list_tokens(self, admin_client, sample_project, api_token): |
| 195 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/") |
| 196 | assert response.status_code == 200 |
| 197 | content = response.content.decode() |
| 198 | assert "Test Token" in content |
| 199 | assert "status:write" in content |
| 200 | |
| 201 | def test_list_empty(self, admin_client, sample_project, fossil_repo_obj): |
| 202 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/") |
| 203 | assert response.status_code == 200 |
| 204 | assert "No API tokens generated yet" in response.content.decode() |
| 205 | |
| 206 | def test_list_denied_for_writer(self, writer_client, sample_project, api_token): |
| 207 | """Token management requires admin, not just write.""" |
| 208 | response = writer_client.get(f"/projects/{sample_project.slug}/fossil/tokens/") |
| 209 | assert response.status_code == 403 |
| 210 | |
| 211 | def test_list_denied_for_no_perm(self, no_perm_client, sample_project): |
| 212 | response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/tokens/") |
| 213 | assert response.status_code == 403 |
| 214 | |
| 215 | def test_list_denied_for_anon(self, client, sample_project): |
| 216 | response = client.get(f"/projects/{sample_project.slug}/fossil/tokens/") |
| 217 | assert response.status_code == 302 # redirect to login |
| 218 | |
| 219 | |
| 220 | # --- API Token Create View Tests --- |
| 221 | |
| 222 | |
| 223 | @pytest.mark.django_db |
| 224 | class TestAPITokenCreateView: |
| 225 | def test_get_form(self, admin_client, sample_project, fossil_repo_obj): |
| 226 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/create/") |
| 227 | assert response.status_code == 200 |
| 228 | assert "Generate API Token" in response.content.decode() |
| 229 | |
| 230 | def test_create_token(self, admin_client, sample_project, fossil_repo_obj): |
| 231 | response = admin_client.post( |
| 232 | f"/projects/{sample_project.slug}/fossil/tokens/create/", |
| 233 | {"name": "New CI Token", "permissions": "status:write"}, |
| 234 | ) |
| 235 | assert response.status_code == 200 # Shows the token on the same page |
| 236 | content = response.content.decode() |
| 237 | assert "frp_" in content # Raw token is displayed |
| 238 | assert "Token Generated" in content |
| 239 | |
| 240 | # Verify token was created in DB |
| 241 | token = APIToken.objects.get(name="New CI Token") |
| 242 | assert token.permissions == "status:write" |
| 243 | |
| 244 | def test_create_token_without_name_fails(self, admin_client, sample_project, fossil_repo_obj): |
| 245 | response = admin_client.post( |
| 246 | f"/projects/{sample_project.slug}/fossil/tokens/create/", |
| 247 | {"name": "", "permissions": "status:write"}, |
| 248 | ) |
| 249 | assert response.status_code == 200 |
| 250 | assert "Token name is required" in response.content.decode() |
| 251 | assert APIToken.objects.filter(repository__project=sample_project).count() == 0 |
| 252 | |
| 253 | def test_create_denied_for_writer(self, writer_client, sample_project): |
| 254 | response = writer_client.post( |
| 255 | f"/projects/{sample_project.slug}/fossil/tokens/create/", |
| 256 | {"name": "Evil Token"}, |
| 257 | ) |
| 258 | assert response.status_code == 403 |
| 259 | |
| 260 | |
| 261 | # --- API Token Delete View Tests --- |
| 262 | |
| 263 | |
| 264 | @pytest.mark.django_db |
| 265 | class TestAPITokenDeleteView: |
| 266 | def test_delete_token(self, admin_client, sample_project, api_token): |
| 267 | token, _ = api_token |
| 268 | response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/") |
| 269 | assert response.status_code == 302 |
| 270 | token.refresh_from_db() |
| 271 | assert token.is_deleted |
| 272 | |
| 273 | def test_delete_get_redirects(self, admin_client, sample_project, api_token): |
| 274 | token, _ = api_token |
| 275 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/") |
| 276 | assert response.status_code == 302 # GET redirects to list |
| 277 | |
| 278 | def test_delete_denied_for_writer(self, writer_client, sample_project, api_token): |
| 279 | token, _ = api_token |
| 280 | response = writer_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/") |
| 281 | assert response.status_code == 403 |
| 282 | |
| 283 | def test_delete_nonexistent_token(self, admin_client, sample_project, fossil_repo_obj): |
| 284 | response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/99999/delete/") |
| 285 | assert response.status_code == 404 |
| 286 | |
| 287 | def test_deleted_token_cannot_be_deleted_again(self, admin_client, sample_project, api_token, admin_user): |
| 288 | token, _ = api_token |
| 289 | token.soft_delete(user=admin_user) |
| 290 | response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/") |
| 291 | assert response.status_code == 404 |
| --- a/tests/test_audit_log.py | ||
| +++ b/tests/test_audit_log.py | ||
| @@ -0,0 +1,121 @@ | ||
| 1 | +"""Tests for Unified Audit Log: view, permissions, filtering.""" | |
| 2 | + | |
| 3 | +import pytest | |
| 4 | +from django.contrib.auth.models import Group, Permission | |
| 5 | + | |
| 6 | +from organization.models import Team | |
| 7 | +from projects.models import Project | |
| 8 | + | |
| 9 | + | |
| 10 | +@pytest.fixture | |
| 11 | +def org_admin_user(db): | |
| 12 | + """User with ORGANIZATION_CHANGE permission but not superuser.""" | |
| 13 | + user = __import__("django.contrib.auth.models", fromlist=["User"]).User.objects.create_user( | |
| 14 | + username="orgadmin", email="[email protected]", password="testpass123" | |
| 15 | + ) | |
| 16 | + group, _ = Group.objects.get_or_create(name="OrgAdmins") | |
| 17 | + perms = Permission.objects.filter( | |
| 18 | + content_type__app_label="organization", | |
| 19 | + ) | |
| 20 | + group.permissions.set(perms) | |
| 21 | + user.groups.add(group) | |
| 22 | + return user | |
| 23 | + | |
| 24 | + | |
| 25 | +@pytest.fixture | |
| 26 | +def org_admin_client(client, org_admin_user): | |
| 27 | + client.login(username="orgadmin", password="testpass123") | |
| 28 | + return client | |
| 29 | + | |
| 30 | + | |
| 31 | +# --- Access Control --- | |
| 32 | + | |
| 33 | + | |
| 34 | +@pytest.mark.django_db | |
| 35 | +class TestAuditLogAccess: | |
| 36 | + def test_audit_log_accessible_to_superuser(self, admin_client): | |
| 37 | + response = admin_client.get("/settings/audit/") | |
| 38 | + assert response.status_code == 200 | |
| 39 | + assert "Audit Log" in response.content.decode() | |
| 40 | + | |
| 41 | + def test_audit_log_accessible_to_org_admin(self, org_admin_client): | |
| 42 | + response = org_admin_client.get("/settings/audit/") | |
| 43 | + assert response.status_code == 200 | |
| 44 | + | |
| 45 | + def test_audit_log_denied_for_viewer(self, viewer_client): | |
| 46 | + response = viewer_client.get("/settings/audit/") | |
| 47 | + assert response.status_code == 403 | |
| 48 | + | |
| 49 | + def test_audit_log_denied_for_no_perm(self, no_perm_client): | |
| 50 | + response = no_perm_client.get("/settings/audit/") | |
| 51 | + assert response.status_code == 403 | |
| 52 | + | |
| 53 | + def test_audit_log_denied_for_anon(self, client): | |
| 54 | + response = client.get("/settings/audit/") | |
| 55 | + assert response.status_code == 302 # Redirect to login | |
| 56 | + | |
| 57 | + | |
| 58 | +# --- Content --- | |
| 59 | + | |
| 60 | + | |
| 61 | +@pytest.mark.django_db | |
| 62 | +class TestAuditLogContent: | |
| 63 | + def test_shows_project_history(self, admin_client, admin_user, org): | |
| 64 | + Project.objects.create(name="Audit Test Project", organization=org, created_by=admin_user) | |
| 65 | + response = admin_client.get("/settings/audit/") | |
| 66 | + content = response.content.decode() | |
| 67 | + assert "Audit Test Project" in content | |
| 68 | + assert "Created" in content | |
| 69 | + | |
| 70 | + def test_shows_organization_history(self, admin_client, org): | |
| 71 | + response = admin_client.get("/settings/audit/") | |
| 72 | + content = response.content.decode() | |
| 73 | + assert "Organization" in content | |
| 74 | + | |
| 75 | + def test_shows_team_history(self, admin_client, admin_user, org): | |
| 76 | + Team.objects.create(name="Audit Test Team", organization=org, created_by=admin_user) | |
| 77 | + response = admin_client.get("/settings/audit/") | |
| 78 | + content = response.content.decode() | |
| 79 | + assert "Audit Test Team" in content | |
| 80 | + | |
| 81 | + def test_filter_by_model_type(self, admin_client, admin_user, org): | |
| 82 | + Project.objects.create(name="Filter Test", organization=org, created_by=admin_user) | |
| 83 | + Team.objects.create(name="Should Not Show", organization=org, created_by=admin_user) | |
| 84 | + response = admin_client.get("/settings/audit/?model=Project") | |
| 85 | + content = response.content.decode() | |
| 86 | + assert "Filter Test" in content | |
| 87 | + assert "Should Not Show" not in content | |
| 88 | + | |
| 89 | + def test_filter_shows_all_when_no_filter(self, admin_client, admin_user, org): | |
| 90 | + Project.objects.create(name="Project Entry", organization=org, created_by=admin_user) | |
| 91 | + Team.objects.create(name="Team Entry", organization=org, created_by=admin_user) | |
| 92 | + response = admin_client.get("/settings/audit/") | |
| 93 | + content = response.content.decode() | |
| 94 | + assert "Project Entry" in content | |
| 95 | + assert "Team Entry" in content | |
| 96 | + | |
| 97 | + def test_audit_log_entries_sorted_by_date(self, admin_client, admin_user, org): | |
| 98 | + Project.objects.create(name="First Project", organization=org, created_by=admin_user) | |
| 99 | + Project.objects.create(name="Second Project", organization=org, created_by=admin_user) | |
| 100 | + response = admin_client.get("/settings/audit/?model=Project") | |
| 101 | + entries = response.context["entries"] | |
| 102 | + # Most recent first | |
| 103 | + project_entries = [e for e in entries if e["model"] == "Project"] | |
| 104 | + dates = [e["date"] for e in project_entries] | |
| 105 | + assert dates == sorted(dates, reverse=True) | |
| 106 | + | |
| 107 | + def test_available_models_in_context(self, admin_client): | |
| 108 | + response = admin_client.get("/settings/audit/") | |
| 109 | + assert "available_models" in response.context | |
| 110 | + assert "Project" in response.context["available_models"] | |
| 111 | + assert "Organization" in response.context["available_models"] | |
| 112 | + assert "Team" in response.context["available_models"] | |
| 113 | + assert "FossilRepository" in response.context["available_models"] | |
| 114 | + | |
| 115 | + def test_audit_log_sidebar_link_for_superuser(self, admin_client): | |
| 116 | + response = admin_client.get("/dashboard/") | |
| 117 | + assert "/settings/audit/" in response.content.decode() | |
| 118 | + | |
| 119 | + def test_audit_log_sidebar_link_hidden_for_viewer(self, viewer_client): | |
| 120 | + response = viewer_client.get("/dashboard/") | |
| 121 | + assert "/settings/audit/" not in response.content.decode() |
| --- a/tests/test_audit_log.py | |
| +++ b/tests/test_audit_log.py | |
| @@ -0,0 +1,121 @@ | |
| --- a/tests/test_audit_log.py | |
| +++ b/tests/test_audit_log.py | |
| @@ -0,0 +1,121 @@ | |
| 1 | """Tests for Unified Audit Log: view, permissions, filtering.""" |
| 2 | |
| 3 | import pytest |
| 4 | from django.contrib.auth.models import Group, Permission |
| 5 | |
| 6 | from organization.models import Team |
| 7 | from projects.models import Project |
| 8 | |
| 9 | |
| 10 | @pytest.fixture |
| 11 | def org_admin_user(db): |
| 12 | """User with ORGANIZATION_CHANGE permission but not superuser.""" |
| 13 | user = __import__("django.contrib.auth.models", fromlist=["User"]).User.objects.create_user( |
| 14 | username="orgadmin", email="[email protected]", password="testpass123" |
| 15 | ) |
| 16 | group, _ = Group.objects.get_or_create(name="OrgAdmins") |
| 17 | perms = Permission.objects.filter( |
| 18 | content_type__app_label="organization", |
| 19 | ) |
| 20 | group.permissions.set(perms) |
| 21 | user.groups.add(group) |
| 22 | return user |
| 23 | |
| 24 | |
| 25 | @pytest.fixture |
| 26 | def org_admin_client(client, org_admin_user): |
| 27 | client.login(username="orgadmin", password="testpass123") |
| 28 | return client |
| 29 | |
| 30 | |
| 31 | # --- Access Control --- |
| 32 | |
| 33 | |
| 34 | @pytest.mark.django_db |
| 35 | class TestAuditLogAccess: |
| 36 | def test_audit_log_accessible_to_superuser(self, admin_client): |
| 37 | response = admin_client.get("/settings/audit/") |
| 38 | assert response.status_code == 200 |
| 39 | assert "Audit Log" in response.content.decode() |
| 40 | |
| 41 | def test_audit_log_accessible_to_org_admin(self, org_admin_client): |
| 42 | response = org_admin_client.get("/settings/audit/") |
| 43 | assert response.status_code == 200 |
| 44 | |
| 45 | def test_audit_log_denied_for_viewer(self, viewer_client): |
| 46 | response = viewer_client.get("/settings/audit/") |
| 47 | assert response.status_code == 403 |
| 48 | |
| 49 | def test_audit_log_denied_for_no_perm(self, no_perm_client): |
| 50 | response = no_perm_client.get("/settings/audit/") |
| 51 | assert response.status_code == 403 |
| 52 | |
| 53 | def test_audit_log_denied_for_anon(self, client): |
| 54 | response = client.get("/settings/audit/") |
| 55 | assert response.status_code == 302 # Redirect to login |
| 56 | |
| 57 | |
| 58 | # --- Content --- |
| 59 | |
| 60 | |
| 61 | @pytest.mark.django_db |
| 62 | class TestAuditLogContent: |
| 63 | def test_shows_project_history(self, admin_client, admin_user, org): |
| 64 | Project.objects.create(name="Audit Test Project", organization=org, created_by=admin_user) |
| 65 | response = admin_client.get("/settings/audit/") |
| 66 | content = response.content.decode() |
| 67 | assert "Audit Test Project" in content |
| 68 | assert "Created" in content |
| 69 | |
| 70 | def test_shows_organization_history(self, admin_client, org): |
| 71 | response = admin_client.get("/settings/audit/") |
| 72 | content = response.content.decode() |
| 73 | assert "Organization" in content |
| 74 | |
| 75 | def test_shows_team_history(self, admin_client, admin_user, org): |
| 76 | Team.objects.create(name="Audit Test Team", organization=org, created_by=admin_user) |
| 77 | response = admin_client.get("/settings/audit/") |
| 78 | content = response.content.decode() |
| 79 | assert "Audit Test Team" in content |
| 80 | |
| 81 | def test_filter_by_model_type(self, admin_client, admin_user, org): |
| 82 | Project.objects.create(name="Filter Test", organization=org, created_by=admin_user) |
| 83 | Team.objects.create(name="Should Not Show", organization=org, created_by=admin_user) |
| 84 | response = admin_client.get("/settings/audit/?model=Project") |
| 85 | content = response.content.decode() |
| 86 | assert "Filter Test" in content |
| 87 | assert "Should Not Show" not in content |
| 88 | |
| 89 | def test_filter_shows_all_when_no_filter(self, admin_client, admin_user, org): |
| 90 | Project.objects.create(name="Project Entry", organization=org, created_by=admin_user) |
| 91 | Team.objects.create(name="Team Entry", organization=org, created_by=admin_user) |
| 92 | response = admin_client.get("/settings/audit/") |
| 93 | content = response.content.decode() |
| 94 | assert "Project Entry" in content |
| 95 | assert "Team Entry" in content |
| 96 | |
| 97 | def test_audit_log_entries_sorted_by_date(self, admin_client, admin_user, org): |
| 98 | Project.objects.create(name="First Project", organization=org, created_by=admin_user) |
| 99 | Project.objects.create(name="Second Project", organization=org, created_by=admin_user) |
| 100 | response = admin_client.get("/settings/audit/?model=Project") |
| 101 | entries = response.context["entries"] |
| 102 | # Most recent first |
| 103 | project_entries = [e for e in entries if e["model"] == "Project"] |
| 104 | dates = [e["date"] for e in project_entries] |
| 105 | assert dates == sorted(dates, reverse=True) |
| 106 | |
| 107 | def test_available_models_in_context(self, admin_client): |
| 108 | response = admin_client.get("/settings/audit/") |
| 109 | assert "available_models" in response.context |
| 110 | assert "Project" in response.context["available_models"] |
| 111 | assert "Organization" in response.context["available_models"] |
| 112 | assert "Team" in response.context["available_models"] |
| 113 | assert "FossilRepository" in response.context["available_models"] |
| 114 | |
| 115 | def test_audit_log_sidebar_link_for_superuser(self, admin_client): |
| 116 | response = admin_client.get("/dashboard/") |
| 117 | assert "/settings/audit/" in response.content.decode() |
| 118 | |
| 119 | def test_audit_log_sidebar_link_hidden_for_viewer(self, viewer_client): |
| 120 | response = viewer_client.get("/dashboard/") |
| 121 | assert "/settings/audit/" not in response.content.decode() |
| --- a/tests/test_branch_protection.py | ||
| +++ b/tests/test_branch_protection.py | ||
| @@ -0,0 +1,256 @@ | ||
| 1 | +import pytest | |
| 2 | +from django.contrib.auth.models import User | |
| 3 | +from django.test import Client | |
| 4 | + | |
| 5 | +from fossil.branch_protection import BranchProtection | |
| 6 | +from fossil.models import FossilRepository | |
| 7 | +from organization.models import Team | |
| 8 | +from projects.models import ProjectTeam | |
| 9 | + | |
| 10 | + | |
| 11 | +@pytest.fixture | |
| 12 | +def fossil_repo_obj(sample_project): | |
| 13 | + """Return the auto-created FossilRepository for sample_project.""" | |
| 14 | + return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True) | |
| 15 | + | |
| 16 | + | |
| 17 | +@pytest.fixture | |
| 18 | +def protection_rule(fossil_repo_obj, admin_user): | |
| 19 | + return BranchProtection.objects.create( | |
| 20 | + repository=fossil_repo_obj, | |
| 21 | + branch_pattern="trunk", | |
| 22 | + require_status_checks=True, | |
| 23 | + required_contexts="ci/tests\nci/lint", | |
| 24 | + restrict_push=True, | |
| 25 | + created_by=admin_user, | |
| 26 | + ) | |
| 27 | + | |
| 28 | + | |
| 29 | +@pytest.fixture | |
| 30 | +def writer_user(db, admin_user, sample_project): | |
| 31 | + """User with write access but not admin.""" | |
| 32 | + writer = User.objects.create_user(username="writer_bp", password="testpass123") | |
| 33 | + team = Team.objects.create(name="BP Writers", organization=sample_project.organization, created_by=admin_user) | |
| 34 | + team.members.add(writer) | |
| 35 | + ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user) | |
| 36 | + return writer | |
| 37 | + | |
| 38 | + | |
| 39 | +@pytest.fixture | |
| 40 | +def writer_client(writer_user): | |
| 41 | + client = Client() | |
| 42 | + client.login(username="writer_bp", password="testpass123") | |
| 43 | + return client | |
| 44 | + | |
| 45 | + | |
| 46 | +# --- BranchProtection Model Tests --- | |
| 47 | + | |
| 48 | + | |
| 49 | +@pytest.mark.django_db | |
| 50 | +class TestBranchProtectionModel: | |
| 51 | + def test_create_rule(self, protection_rule): | |
| 52 | + assert protection_rule.pk is not None | |
| 53 | + assert str(protection_rule) == f"trunk ({protection_rule.repository})" | |
| 54 | + | |
| 55 | + def test_soft_delete(self, protection_rule, admin_user): | |
| 56 | + protection_rule.soft_delete(user=admin_user) | |
| 57 | + assert protection_rule.is_deleted | |
| 58 | + assert BranchProtection.objects.filter(pk=protection_rule.pk).count() == 0 | |
| 59 | + assert BranchProtection.all_objects.filter(pk=protection_rule.pk).count() == 1 | |
| 60 | + | |
| 61 | + def test_unique_together(self, fossil_repo_obj, admin_user): | |
| 62 | + BranchProtection.objects.create( | |
| 63 | + repository=fossil_repo_obj, | |
| 64 | + branch_pattern="release-*", | |
| 65 | + created_by=admin_user, | |
| 66 | + ) | |
| 67 | + from django.db import IntegrityError | |
| 68 | + | |
| 69 | + with pytest.raises(IntegrityError): | |
| 70 | + BranchProtection.objects.create( | |
| 71 | + repository=fossil_repo_obj, | |
| 72 | + branch_pattern="release-*", | |
| 73 | + created_by=admin_user, | |
| 74 | + ) | |
| 75 | + | |
| 76 | + def test_ordering(self, fossil_repo_obj, admin_user): | |
| 77 | + r1 = BranchProtection.objects.create(repository=fossil_repo_obj, branch_pattern="trunk", created_by=admin_user) | |
| 78 | + r2 = BranchProtection.objects.create(repository=fossil_repo_obj, branch_pattern="develop", created_by=admin_user) | |
| 79 | + rules = list(BranchProtection.objects.filter(repository=fossil_repo_obj)) | |
| 80 | + # Ordered by branch_pattern alphabetically | |
| 81 | + assert rules[0] == r2 | |
| 82 | + assert rules[1] == r1 | |
| 83 | + | |
| 84 | + def test_get_required_contexts_list(self, protection_rule): | |
| 85 | + contexts = protection_rule.get_required_contexts_list() | |
| 86 | + assert contexts == ["ci/tests", "ci/lint"] | |
| 87 | + | |
| 88 | + def test_get_required_contexts_list_empty(self, fossil_repo_obj, admin_user): | |
| 89 | + rule = BranchProtection.objects.create( | |
| 90 | + repository=fossil_repo_obj, | |
| 91 | + branch_pattern="feature-*", | |
| 92 | + required_contexts="", | |
| 93 | + created_by=admin_user, | |
| 94 | + ) | |
| 95 | + assert rule.get_required_contexts_list() == [] | |
| 96 | + | |
| 97 | + def test_get_required_contexts_list_filters_blanks(self, fossil_repo_obj, admin_user): | |
| 98 | + rule = BranchProtection.objects.create( | |
| 99 | + repository=fossil_repo_obj, | |
| 100 | + branch_pattern="hotfix-*", | |
| 101 | + required_contexts="ci/tests\n\n \nci/lint\n", | |
| 102 | + created_by=admin_user, | |
| 103 | + ) | |
| 104 | + assert rule.get_required_contexts_list() == ["ci/tests", "ci/lint"] | |
| 105 | + | |
| 106 | + | |
| 107 | +# --- Branch Protection List View Tests --- | |
| 108 | + | |
| 109 | + | |
| 110 | +@pytest.mark.django_db | |
| 111 | +class TestBranchProtectionListView: | |
| 112 | + def test_list_rules(self, admin_client, sample_project, protection_rule): | |
| 113 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/") | |
| 114 | + assert response.status_code == 200 | |
| 115 | + content = response.content.decode() | |
| 116 | + assert "trunk" in content | |
| 117 | + assert "CI required" in content | |
| 118 | + assert "Push restricted" in content | |
| 119 | + | |
| 120 | + def test_list_empty(self, admin_client, sample_project, fossil_repo_obj): | |
| 121 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/") | |
| 122 | + assert response.status_code == 200 | |
| 123 | + assert "No branch protection rules configured" in response.content.decode() | |
| 124 | + | |
| 125 | + def test_list_denied_for_writer(self, writer_client, sample_project, protection_rule): | |
| 126 | + response = writer_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/") | |
| 127 | + assert response.status_code == 403 | |
| 128 | + | |
| 129 | + def test_list_denied_for_no_perm(self, no_perm_client, sample_project): | |
| 130 | + response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/") | |
| 131 | + assert response.status_code == 403 | |
| 132 | + | |
| 133 | + def test_list_denied_for_anon(self, client, sample_project): | |
| 134 | + response = client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/") | |
| 135 | + assert response.status_code == 302 # redirect to login | |
| 136 | + | |
| 137 | + | |
| 138 | +# --- Branch Protection Create View Tests --- | |
| 139 | + | |
| 140 | + | |
| 141 | +@pytest.mark.django_db | |
| 142 | +class TestBranchProtectionCreateView: | |
| 143 | + def test_get_form(self, admin_client, sample_project, fossil_repo_obj): | |
| 144 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/create/") | |
| 145 | + assert response.status_code == 200 | |
| 146 | + assert "Create Branch Protection Rule" in response.content.decode() | |
| 147 | + | |
| 148 | + def test_create_rule(self, admin_client, sample_project, fossil_repo_obj): | |
| 149 | + response = admin_client.post( | |
| 150 | + f"/projects/{sample_project.slug}/fossil/branches/protect/create/", | |
| 151 | + { | |
| 152 | + "branch_pattern": "develop", | |
| 153 | + "require_status_checks": "on", | |
| 154 | + "required_contexts": "ci/tests", | |
| 155 | + "restrict_push": "on", | |
| 156 | + }, | |
| 157 | + ) | |
| 158 | + assert response.status_code == 302 | |
| 159 | + rule = BranchProtection.objects.get(branch_pattern="develop") | |
| 160 | + assert rule.require_status_checks is True | |
| 161 | + assert rule.required_contexts == "ci/tests" | |
| 162 | + assert rule.restrict_push is True | |
| 163 | + | |
| 164 | + def test_create_without_pattern_fails(self, admin_client, sample_project, fossil_repo_obj): | |
| 165 | + response = admin_client.post( | |
| 166 | + f"/projects/{sample_project.slug}/fossil/branches/protect/create/", | |
| 167 | + {"branch_pattern": ""}, | |
| 168 | + ) | |
| 169 | + assert response.status_code == 200 # Re-renders form | |
| 170 | + assert BranchProtection.objects.count() == 0 | |
| 171 | + | |
| 172 | + def test_create_duplicate_pattern_fails(self, admin_client, sample_project, fossil_repo_obj, protection_rule): | |
| 173 | + response = admin_client.post( | |
| 174 | + f"/projects/{sample_project.slug}/fossil/branches/protect/create/", | |
| 175 | + {"branch_pattern": "trunk"}, | |
| 176 | + ) | |
| 177 | + assert response.status_code == 200 # Re-renders form with error | |
| 178 | + assert "already exists" in response.content.decode() | |
| 179 | + | |
| 180 | + def test_create_denied_for_writer(self, writer_client, sample_project): | |
| 181 | + response = writer_client.post( | |
| 182 | + f"/projects/{sample_project.slug}/fossil/branches/protect/create/", | |
| 183 | + {"branch_pattern": "evil-branch"}, | |
| 184 | + ) | |
| 185 | + assert response.status_code == 403 | |
| 186 | + | |
| 187 | + | |
| 188 | +# --- Branch Protection Edit View Tests --- | |
| 189 | + | |
| 190 | + | |
| 191 | +@pytest.mark.django_db | |
| 192 | +class TestBranchProtectionEditView: | |
| 193 | + def test_get_edit_form(self, admin_client, sample_project, protection_rule): | |
| 194 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/") | |
| 195 | + assert response.status_code == 200 | |
| 196 | + content = response.content.decode() | |
| 197 | + assert "trunk" in content | |
| 198 | + assert "Update Rule" in content | |
| 199 | + | |
| 200 | + def test_edit_rule(self, admin_client, sample_project, protection_rule): | |
| 201 | + response = admin_client.post( | |
| 202 | + f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/", | |
| 203 | + { | |
| 204 | + "branch_pattern": "trunk", | |
| 205 | + "require_status_checks": "on", | |
| 206 | + "required_contexts": "ci/tests\nci/lint\nci/build", | |
| 207 | + "restrict_push": "on", | |
| 208 | + }, | |
| 209 | + ) | |
| 210 | + assert response.status_code == 302 | |
| 211 | + protection_rule.refresh_from_db() | |
| 212 | + assert "ci/build" in protection_rule.required_contexts | |
| 213 | + | |
| 214 | + def test_edit_change_pattern(self, admin_client, sample_project, protection_rule): | |
| 215 | + response = admin_client.post( | |
| 216 | + f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/", | |
| 217 | + {"branch_pattern": "main", "restrict_push": "on"}, | |
| 218 | + ) | |
| 219 | + assert response.status_code == 302 | |
| 220 | + protection_rule.refresh_from_db() | |
| 221 | + assert protection_rule.branch_pattern == "main" | |
| 222 | + | |
| 223 | + def test_edit_denied_for_writer(self, writer_client, sample_project, protection_rule): | |
| 224 | + response = writer_client.post( | |
| 225 | + f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/", | |
| 226 | + {"branch_pattern": "evil-branch"}, | |
| 227 | + ) | |
| 228 | + assert response.status_code == 403 | |
| 229 | + | |
| 230 | + def test_edit_nonexistent_rule(self, admin_client, sample_project, fossil_repo_obj): | |
| 231 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/99999/edit/") | |
| 232 | + assert response.status_code == 404 | |
| 233 | + | |
| 234 | + | |
| 235 | +# --- Branch Protection Delete View Tests --- | |
| 236 | + | |
| 237 | + | |
| 238 | +@pytest.mark.django_db | |
| 239 | +class TestBranchProtectionDeleteView: | |
| 240 | + def test_delete_rule(self, admin_client, sample_project, protection_rule): | |
| 241 | + response = admin_client.post(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/delete/") | |
| 242 | + assert response.status_code == 302 | |
| 243 | + protection_rule.refresh_from_db() | |
| 244 | + assert protection_rule.is_deleted | |
| 245 | + | |
| 246 | + def test_delete_get_redirects(self, admin_client, sample_project, protection_rule): | |
| 247 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/delete/") | |
| 248 | + assert response.status_code == 302 # GET redirects to list | |
| 249 | + | |
| 250 | + def test_delete_denied_for_writer(self, writer_client, sample_project, protection_rule): | |
| 251 | + response = writer_client.post(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/delete/") | |
| 252 | + assert response.status_code == 403 | |
| 253 | + | |
| 254 | + def test_delete_nonexistent_rule(self, admin_client, sample_project, fossil_repo_obj): | |
| 255 | + response = admin_client.post(f"/projects/{sample_project.slug}/fossil/branches/protect/99999/delete/") | |
| 256 | + assert response.status_code == 404 |
| --- a/tests/test_branch_protection.py | |
| +++ b/tests/test_branch_protection.py | |
| @@ -0,0 +1,256 @@ | |
| --- a/tests/test_branch_protection.py | |
| +++ b/tests/test_branch_protection.py | |
| @@ -0,0 +1,256 @@ | |
| 1 | import pytest |
| 2 | from django.contrib.auth.models import User |
| 3 | from django.test import Client |
| 4 | |
| 5 | from fossil.branch_protection import BranchProtection |
| 6 | from fossil.models import FossilRepository |
| 7 | from organization.models import Team |
| 8 | from projects.models import ProjectTeam |
| 9 | |
| 10 | |
| 11 | @pytest.fixture |
| 12 | def fossil_repo_obj(sample_project): |
| 13 | """Return the auto-created FossilRepository for sample_project.""" |
| 14 | return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True) |
| 15 | |
| 16 | |
| 17 | @pytest.fixture |
| 18 | def protection_rule(fossil_repo_obj, admin_user): |
| 19 | return BranchProtection.objects.create( |
| 20 | repository=fossil_repo_obj, |
| 21 | branch_pattern="trunk", |
| 22 | require_status_checks=True, |
| 23 | required_contexts="ci/tests\nci/lint", |
| 24 | restrict_push=True, |
| 25 | created_by=admin_user, |
| 26 | ) |
| 27 | |
| 28 | |
| 29 | @pytest.fixture |
| 30 | def writer_user(db, admin_user, sample_project): |
| 31 | """User with write access but not admin.""" |
| 32 | writer = User.objects.create_user(username="writer_bp", password="testpass123") |
| 33 | team = Team.objects.create(name="BP Writers", organization=sample_project.organization, created_by=admin_user) |
| 34 | team.members.add(writer) |
| 35 | ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user) |
| 36 | return writer |
| 37 | |
| 38 | |
| 39 | @pytest.fixture |
| 40 | def writer_client(writer_user): |
| 41 | client = Client() |
| 42 | client.login(username="writer_bp", password="testpass123") |
| 43 | return client |
| 44 | |
| 45 | |
| 46 | # --- BranchProtection Model Tests --- |
| 47 | |
| 48 | |
| 49 | @pytest.mark.django_db |
| 50 | class TestBranchProtectionModel: |
| 51 | def test_create_rule(self, protection_rule): |
| 52 | assert protection_rule.pk is not None |
| 53 | assert str(protection_rule) == f"trunk ({protection_rule.repository})" |
| 54 | |
| 55 | def test_soft_delete(self, protection_rule, admin_user): |
| 56 | protection_rule.soft_delete(user=admin_user) |
| 57 | assert protection_rule.is_deleted |
| 58 | assert BranchProtection.objects.filter(pk=protection_rule.pk).count() == 0 |
| 59 | assert BranchProtection.all_objects.filter(pk=protection_rule.pk).count() == 1 |
| 60 | |
| 61 | def test_unique_together(self, fossil_repo_obj, admin_user): |
| 62 | BranchProtection.objects.create( |
| 63 | repository=fossil_repo_obj, |
| 64 | branch_pattern="release-*", |
| 65 | created_by=admin_user, |
| 66 | ) |
| 67 | from django.db import IntegrityError |
| 68 | |
| 69 | with pytest.raises(IntegrityError): |
| 70 | BranchProtection.objects.create( |
| 71 | repository=fossil_repo_obj, |
| 72 | branch_pattern="release-*", |
| 73 | created_by=admin_user, |
| 74 | ) |
| 75 | |
| 76 | def test_ordering(self, fossil_repo_obj, admin_user): |
| 77 | r1 = BranchProtection.objects.create(repository=fossil_repo_obj, branch_pattern="trunk", created_by=admin_user) |
| 78 | r2 = BranchProtection.objects.create(repository=fossil_repo_obj, branch_pattern="develop", created_by=admin_user) |
| 79 | rules = list(BranchProtection.objects.filter(repository=fossil_repo_obj)) |
| 80 | # Ordered by branch_pattern alphabetically |
| 81 | assert rules[0] == r2 |
| 82 | assert rules[1] == r1 |
| 83 | |
| 84 | def test_get_required_contexts_list(self, protection_rule): |
| 85 | contexts = protection_rule.get_required_contexts_list() |
| 86 | assert contexts == ["ci/tests", "ci/lint"] |
| 87 | |
| 88 | def test_get_required_contexts_list_empty(self, fossil_repo_obj, admin_user): |
| 89 | rule = BranchProtection.objects.create( |
| 90 | repository=fossil_repo_obj, |
| 91 | branch_pattern="feature-*", |
| 92 | required_contexts="", |
| 93 | created_by=admin_user, |
| 94 | ) |
| 95 | assert rule.get_required_contexts_list() == [] |
| 96 | |
| 97 | def test_get_required_contexts_list_filters_blanks(self, fossil_repo_obj, admin_user): |
| 98 | rule = BranchProtection.objects.create( |
| 99 | repository=fossil_repo_obj, |
| 100 | branch_pattern="hotfix-*", |
| 101 | required_contexts="ci/tests\n\n \nci/lint\n", |
| 102 | created_by=admin_user, |
| 103 | ) |
| 104 | assert rule.get_required_contexts_list() == ["ci/tests", "ci/lint"] |
| 105 | |
| 106 | |
| 107 | # --- Branch Protection List View Tests --- |
| 108 | |
| 109 | |
| 110 | @pytest.mark.django_db |
| 111 | class TestBranchProtectionListView: |
| 112 | def test_list_rules(self, admin_client, sample_project, protection_rule): |
| 113 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/") |
| 114 | assert response.status_code == 200 |
| 115 | content = response.content.decode() |
| 116 | assert "trunk" in content |
| 117 | assert "CI required" in content |
| 118 | assert "Push restricted" in content |
| 119 | |
| 120 | def test_list_empty(self, admin_client, sample_project, fossil_repo_obj): |
| 121 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/") |
| 122 | assert response.status_code == 200 |
| 123 | assert "No branch protection rules configured" in response.content.decode() |
| 124 | |
| 125 | def test_list_denied_for_writer(self, writer_client, sample_project, protection_rule): |
| 126 | response = writer_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/") |
| 127 | assert response.status_code == 403 |
| 128 | |
| 129 | def test_list_denied_for_no_perm(self, no_perm_client, sample_project): |
| 130 | response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/") |
| 131 | assert response.status_code == 403 |
| 132 | |
| 133 | def test_list_denied_for_anon(self, client, sample_project): |
| 134 | response = client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/") |
| 135 | assert response.status_code == 302 # redirect to login |
| 136 | |
| 137 | |
| 138 | # --- Branch Protection Create View Tests --- |
| 139 | |
| 140 | |
| 141 | @pytest.mark.django_db |
| 142 | class TestBranchProtectionCreateView: |
| 143 | def test_get_form(self, admin_client, sample_project, fossil_repo_obj): |
| 144 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/create/") |
| 145 | assert response.status_code == 200 |
| 146 | assert "Create Branch Protection Rule" in response.content.decode() |
| 147 | |
| 148 | def test_create_rule(self, admin_client, sample_project, fossil_repo_obj): |
| 149 | response = admin_client.post( |
| 150 | f"/projects/{sample_project.slug}/fossil/branches/protect/create/", |
| 151 | { |
| 152 | "branch_pattern": "develop", |
| 153 | "require_status_checks": "on", |
| 154 | "required_contexts": "ci/tests", |
| 155 | "restrict_push": "on", |
| 156 | }, |
| 157 | ) |
| 158 | assert response.status_code == 302 |
| 159 | rule = BranchProtection.objects.get(branch_pattern="develop") |
| 160 | assert rule.require_status_checks is True |
| 161 | assert rule.required_contexts == "ci/tests" |
| 162 | assert rule.restrict_push is True |
| 163 | |
| 164 | def test_create_without_pattern_fails(self, admin_client, sample_project, fossil_repo_obj): |
| 165 | response = admin_client.post( |
| 166 | f"/projects/{sample_project.slug}/fossil/branches/protect/create/", |
| 167 | {"branch_pattern": ""}, |
| 168 | ) |
| 169 | assert response.status_code == 200 # Re-renders form |
| 170 | assert BranchProtection.objects.count() == 0 |
| 171 | |
| 172 | def test_create_duplicate_pattern_fails(self, admin_client, sample_project, fossil_repo_obj, protection_rule): |
| 173 | response = admin_client.post( |
| 174 | f"/projects/{sample_project.slug}/fossil/branches/protect/create/", |
| 175 | {"branch_pattern": "trunk"}, |
| 176 | ) |
| 177 | assert response.status_code == 200 # Re-renders form with error |
| 178 | assert "already exists" in response.content.decode() |
| 179 | |
| 180 | def test_create_denied_for_writer(self, writer_client, sample_project): |
| 181 | response = writer_client.post( |
| 182 | f"/projects/{sample_project.slug}/fossil/branches/protect/create/", |
| 183 | {"branch_pattern": "evil-branch"}, |
| 184 | ) |
| 185 | assert response.status_code == 403 |
| 186 | |
| 187 | |
| 188 | # --- Branch Protection Edit View Tests --- |
| 189 | |
| 190 | |
| 191 | @pytest.mark.django_db |
| 192 | class TestBranchProtectionEditView: |
| 193 | def test_get_edit_form(self, admin_client, sample_project, protection_rule): |
| 194 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/") |
| 195 | assert response.status_code == 200 |
| 196 | content = response.content.decode() |
| 197 | assert "trunk" in content |
| 198 | assert "Update Rule" in content |
| 199 | |
| 200 | def test_edit_rule(self, admin_client, sample_project, protection_rule): |
| 201 | response = admin_client.post( |
| 202 | f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/", |
| 203 | { |
| 204 | "branch_pattern": "trunk", |
| 205 | "require_status_checks": "on", |
| 206 | "required_contexts": "ci/tests\nci/lint\nci/build", |
| 207 | "restrict_push": "on", |
| 208 | }, |
| 209 | ) |
| 210 | assert response.status_code == 302 |
| 211 | protection_rule.refresh_from_db() |
| 212 | assert "ci/build" in protection_rule.required_contexts |
| 213 | |
| 214 | def test_edit_change_pattern(self, admin_client, sample_project, protection_rule): |
| 215 | response = admin_client.post( |
| 216 | f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/", |
| 217 | {"branch_pattern": "main", "restrict_push": "on"}, |
| 218 | ) |
| 219 | assert response.status_code == 302 |
| 220 | protection_rule.refresh_from_db() |
| 221 | assert protection_rule.branch_pattern == "main" |
| 222 | |
| 223 | def test_edit_denied_for_writer(self, writer_client, sample_project, protection_rule): |
| 224 | response = writer_client.post( |
| 225 | f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/", |
| 226 | {"branch_pattern": "evil-branch"}, |
| 227 | ) |
| 228 | assert response.status_code == 403 |
| 229 | |
| 230 | def test_edit_nonexistent_rule(self, admin_client, sample_project, fossil_repo_obj): |
| 231 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/99999/edit/") |
| 232 | assert response.status_code == 404 |
| 233 | |
| 234 | |
| 235 | # --- Branch Protection Delete View Tests --- |
| 236 | |
| 237 | |
| 238 | @pytest.mark.django_db |
| 239 | class TestBranchProtectionDeleteView: |
| 240 | def test_delete_rule(self, admin_client, sample_project, protection_rule): |
| 241 | response = admin_client.post(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/delete/") |
| 242 | assert response.status_code == 302 |
| 243 | protection_rule.refresh_from_db() |
| 244 | assert protection_rule.is_deleted |
| 245 | |
| 246 | def test_delete_get_redirects(self, admin_client, sample_project, protection_rule): |
| 247 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/delete/") |
| 248 | assert response.status_code == 302 # GET redirects to list |
| 249 | |
| 250 | def test_delete_denied_for_writer(self, writer_client, sample_project, protection_rule): |
| 251 | response = writer_client.post(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/delete/") |
| 252 | assert response.status_code == 403 |
| 253 | |
| 254 | def test_delete_nonexistent_rule(self, admin_client, sample_project, fossil_repo_obj): |
| 255 | response = admin_client.post(f"/projects/{sample_project.slug}/fossil/branches/protect/99999/delete/") |
| 256 | assert response.status_code == 404 |
| --- a/tests/test_ci_status.py | ||
| +++ b/tests/test_ci_status.py | ||
| @@ -0,0 +1,349 @@ | ||
| 1 | +import json | |
| 2 | + | |
| 3 | +import pytest | |
| 4 | +from django.contrib.auth.models import User | |
| 5 | +from django.test import Client | |
| 6 | + | |
| 7 | +from fossil.api_tokens import APIToken | |
| 8 | +from fossil.ci import StatusCheck | |
| 9 | +from fossil.models import FossilRepository | |
| 10 | +from organization.models import Team | |
| 11 | +from projects.models import ProjectTeam | |
| 12 | + | |
| 13 | + | |
| 14 | +@pytest.fixture | |
| 15 | +def fossil_repo_obj(sample_project): | |
| 16 | + """Return the auto-created FossilRepository for sample_project.""" | |
| 17 | + return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True) | |
| 18 | + | |
| 19 | + | |
| 20 | +@pytest.fixture | |
| 21 | +def api_token(fossil_repo_obj, admin_user): | |
| 22 | + """Create an API token and return (APIToken instance, raw_token).""" | |
| 23 | + raw, token_hash, prefix = APIToken.generate() | |
| 24 | + token = APIToken.objects.create( | |
| 25 | + repository=fossil_repo_obj, | |
| 26 | + name="CI Token", | |
| 27 | + token_hash=token_hash, | |
| 28 | + token_prefix=prefix, | |
| 29 | + permissions="status:write", | |
| 30 | + created_by=admin_user, | |
| 31 | + ) | |
| 32 | + return token, raw | |
| 33 | + | |
| 34 | + | |
| 35 | +@pytest.fixture | |
| 36 | +def status_check(fossil_repo_obj): | |
| 37 | + return StatusCheck.objects.create( | |
| 38 | + repository=fossil_repo_obj, | |
| 39 | + checkin_uuid="abc123def456", | |
| 40 | + context="ci/tests", | |
| 41 | + state="success", | |
| 42 | + description="All 42 tests passed", | |
| 43 | + target_url="https://ci.example.com/build/1", | |
| 44 | + ) | |
| 45 | + | |
| 46 | + | |
| 47 | +@pytest.fixture | |
| 48 | +def writer_user(db, admin_user, sample_project): | |
| 49 | + """User with write access but not admin.""" | |
| 50 | + writer = User.objects.create_user(username="writer_ci", password="testpass123") | |
| 51 | + team = Team.objects.create(name="CI Writers", organization=sample_project.organization, created_by=admin_user) | |
| 52 | + team.members.add(writer) | |
| 53 | + ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user) | |
| 54 | + return writer | |
| 55 | + | |
| 56 | + | |
| 57 | +@pytest.fixture | |
| 58 | +def writer_client(writer_user): | |
| 59 | + client = Client() | |
| 60 | + client.login(username="writer_ci", password="testpass123") | |
| 61 | + return client | |
| 62 | + | |
| 63 | + | |
| 64 | +# --- StatusCheck Model Tests --- | |
| 65 | + | |
| 66 | + | |
| 67 | +@pytest.mark.django_db | |
| 68 | +class TestStatusCheckModel: | |
| 69 | + def test_create_status_check(self, status_check): | |
| 70 | + assert status_check.pk is not None | |
| 71 | + assert str(status_check) == "ci/tests: success @ abc123def4" | |
| 72 | + | |
| 73 | + def test_soft_delete(self, status_check, admin_user): | |
| 74 | + status_check.soft_delete(user=admin_user) | |
| 75 | + assert status_check.is_deleted | |
| 76 | + assert StatusCheck.objects.filter(pk=status_check.pk).count() == 0 | |
| 77 | + assert StatusCheck.all_objects.filter(pk=status_check.pk).count() == 1 | |
| 78 | + | |
| 79 | + def test_unique_together(self, fossil_repo_obj): | |
| 80 | + StatusCheck.objects.create( | |
| 81 | + repository=fossil_repo_obj, | |
| 82 | + checkin_uuid="unique123", | |
| 83 | + context="ci/lint", | |
| 84 | + state="pending", | |
| 85 | + ) | |
| 86 | + from django.db import IntegrityError | |
| 87 | + | |
| 88 | + with pytest.raises(IntegrityError): | |
| 89 | + StatusCheck.objects.create( | |
| 90 | + repository=fossil_repo_obj, | |
| 91 | + checkin_uuid="unique123", | |
| 92 | + context="ci/lint", | |
| 93 | + state="success", | |
| 94 | + ) | |
| 95 | + | |
| 96 | + def test_ordering(self, fossil_repo_obj): | |
| 97 | + c1 = StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="ord1", context="ci/first", state="pending") | |
| 98 | + c2 = StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="ord2", context="ci/second", state="success") | |
| 99 | + checks = list(StatusCheck.objects.filter(repository=fossil_repo_obj)) | |
| 100 | + assert checks[0] == c2 # newest first | |
| 101 | + assert checks[1] == c1 | |
| 102 | + | |
| 103 | + def test_state_choices(self): | |
| 104 | + assert "pending" in StatusCheck.State.values | |
| 105 | + assert "success" in StatusCheck.State.values | |
| 106 | + assert "failure" in StatusCheck.State.values | |
| 107 | + assert "error" in StatusCheck.State.values | |
| 108 | + | |
| 109 | + | |
| 110 | +# --- Status Check API POST Tests --- | |
| 111 | + | |
| 112 | + | |
| 113 | +@pytest.mark.django_db | |
| 114 | +class TestStatusCheckAPIPost: | |
| 115 | + def test_post_creates_status_check(self, client, sample_project, fossil_repo_obj, api_token): | |
| 116 | + token_obj, raw_token = api_token | |
| 117 | + response = client.post( | |
| 118 | + f"/projects/{sample_project.slug}/fossil/api/status", | |
| 119 | + data=json.dumps( | |
| 120 | + { | |
| 121 | + "checkin": "deadbeef123", | |
| 122 | + "context": "ci/tests", | |
| 123 | + "state": "success", | |
| 124 | + "description": "All tests passed", | |
| 125 | + "target_url": "https://ci.example.com/build/42", | |
| 126 | + } | |
| 127 | + ), | |
| 128 | + content_type="application/json", | |
| 129 | + HTTP_AUTHORIZATION=f"Bearer {raw_token}", | |
| 130 | + ) | |
| 131 | + assert response.status_code == 201 | |
| 132 | + data = response.json() | |
| 133 | + assert data["context"] == "ci/tests" | |
| 134 | + assert data["state"] == "success" | |
| 135 | + assert data["created"] is True | |
| 136 | + | |
| 137 | + check = StatusCheck.objects.get(repository=fossil_repo_obj, checkin_uuid="deadbeef123", context="ci/tests") | |
| 138 | + assert check.state == "success" | |
| 139 | + assert check.description == "All tests passed" | |
| 140 | + | |
| 141 | + def test_post_updates_existing_check(self, client, sample_project, fossil_repo_obj, api_token): | |
| 142 | + token_obj, raw_token = api_token | |
| 143 | + # Create initial check | |
| 144 | + StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="update123", context="ci/tests", state="pending") | |
| 145 | + # Update it | |
| 146 | + response = client.post( | |
| 147 | + f"/projects/{sample_project.slug}/fossil/api/status", | |
| 148 | + data=json.dumps( | |
| 149 | + { | |
| 150 | + "checkin": "update123", | |
| 151 | + "context": "ci/tests", | |
| 152 | + "state": "success", | |
| 153 | + "description": "Now passing", | |
| 154 | + } | |
| 155 | + ), | |
| 156 | + content_type="application/json", | |
| 157 | + HTTP_AUTHORIZATION=f"Bearer {raw_token}", | |
| 158 | + ) | |
| 159 | + assert response.status_code == 200 | |
| 160 | + data = response.json() | |
| 161 | + assert data["created"] is False | |
| 162 | + assert data["state"] == "success" | |
| 163 | + | |
| 164 | + def test_post_without_token_returns_401(self, client, sample_project, fossil_repo_obj): | |
| 165 | + response = client.post( | |
| 166 | + f"/projects/{sample_project.slug}/fossil/api/status", | |
| 167 | + data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}), | |
| 168 | + content_type="application/json", | |
| 169 | + ) | |
| 170 | + assert response.status_code == 401 | |
| 171 | + | |
| 172 | + def test_post_with_invalid_token_returns_401(self, client, sample_project, fossil_repo_obj): | |
| 173 | + response = client.post( | |
| 174 | + f"/projects/{sample_project.slug}/fossil/api/status", | |
| 175 | + data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}), | |
| 176 | + content_type="application/json", | |
| 177 | + HTTP_AUTHORIZATION="Bearer invalid_token_xyz", | |
| 178 | + ) | |
| 179 | + assert response.status_code == 401 | |
| 180 | + | |
| 181 | + def test_post_with_wrong_repo_token_returns_401(self, client, sample_project, fossil_repo_obj, admin_user, org): | |
| 182 | + """Token scoped to a different repo should fail.""" | |
| 183 | + from projects.models import Project | |
| 184 | + | |
| 185 | + other_project = Project.objects.create(name="Other Project", organization=org, visibility="private", created_by=admin_user) | |
| 186 | + other_repo = FossilRepository.objects.get(project=other_project, deleted_at__isnull=True) | |
| 187 | + raw, token_hash, prefix = APIToken.generate() | |
| 188 | + APIToken.objects.create( | |
| 189 | + repository=other_repo, name="Other Token", token_hash=token_hash, token_prefix=prefix, created_by=admin_user | |
| 190 | + ) | |
| 191 | + | |
| 192 | + response = client.post( | |
| 193 | + f"/projects/{sample_project.slug}/fossil/api/status", | |
| 194 | + data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}), | |
| 195 | + content_type="application/json", | |
| 196 | + HTTP_AUTHORIZATION=f"Bearer {raw}", | |
| 197 | + ) | |
| 198 | + assert response.status_code == 401 | |
| 199 | + | |
| 200 | + def test_post_missing_checkin_returns_400(self, client, sample_project, fossil_repo_obj, api_token): | |
| 201 | + _, raw_token = api_token | |
| 202 | + response = client.post( | |
| 203 | + f"/projects/{sample_project.slug}/fossil/api/status", | |
| 204 | + data=json.dumps({"context": "ci/tests", "state": "success"}), | |
| 205 | + content_type="application/json", | |
| 206 | + HTTP_AUTHORIZATION=f"Bearer {raw_token}", | |
| 207 | + ) | |
| 208 | + assert response.status_code == 400 | |
| 209 | + assert "checkin" in response.json()["error"] | |
| 210 | + | |
| 211 | + def test_post_missing_context_returns_400(self, client, sample_project, fossil_repo_obj, api_token): | |
| 212 | + _, raw_token = api_token | |
| 213 | + response = client.post( | |
| 214 | + f"/projects/{sample_project.slug}/fossil/api/status", | |
| 215 | + data=json.dumps({"checkin": "abc123", "state": "success"}), | |
| 216 | + content_type="application/json", | |
| 217 | + HTTP_AUTHORIZATION=f"Bearer {raw_token}", | |
| 218 | + ) | |
| 219 | + assert response.status_code == 400 | |
| 220 | + assert "context" in response.json()["error"] | |
| 221 | + | |
| 222 | + def test_post_invalid_state_returns_400(self, client, sample_project, fossil_repo_obj, api_token): | |
| 223 | + _, raw_token = api_token | |
| 224 | + response = client.post( | |
| 225 | + f"/projects/{sample_project.slug}/fossil/api/status", | |
| 226 | + data=json.dumps({"checkin": "abc123", "context": "ci/tests", "state": "bogus"}), | |
| 227 | + content_type="application/json", | |
| 228 | + HTTP_AUTHORIZATION=f"Bearer {raw_token}", | |
| 229 | + ) | |
| 230 | + assert response.status_code == 400 | |
| 231 | + assert "state" in response.json()["error"] | |
| 232 | + | |
| 233 | + def test_post_invalid_json_returns_400(self, client, sample_project, fossil_repo_obj, api_token): | |
| 234 | + _, raw_token = api_token | |
| 235 | + response = client.post( | |
| 236 | + f"/projects/{sample_project.slug}/fossil/api/status", | |
| 237 | + data="not json", | |
| 238 | + content_type="application/json", | |
| 239 | + HTTP_AUTHORIZATION=f"Bearer {raw_token}", | |
| 240 | + ) | |
| 241 | + assert response.status_code == 400 | |
| 242 | + | |
| 243 | + def test_post_expired_token_returns_401(self, client, sample_project, fossil_repo_obj, admin_user): | |
| 244 | + from datetime import timedelta | |
| 245 | + | |
| 246 | + from django.utils import timezone | |
| 247 | + | |
| 248 | + raw, token_hash, prefix = APIToken.generate() | |
| 249 | + APIToken.objects.create( | |
| 250 | + repository=fossil_repo_obj, | |
| 251 | + name="Expired Token", | |
| 252 | + token_hash=token_hash, | |
| 253 | + token_prefix=prefix, | |
| 254 | + expires_at=timezone.now() - timedelta(days=1), | |
| 255 | + created_by=admin_user, | |
| 256 | + ) | |
| 257 | + response = client.post( | |
| 258 | + f"/projects/{sample_project.slug}/fossil/api/status", | |
| 259 | + data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}), | |
| 260 | + content_type="application/json", | |
| 261 | + HTTP_AUTHORIZATION=f"Bearer {raw}", | |
| 262 | + ) | |
| 263 | + assert response.status_code == 401 | |
| 264 | + | |
| 265 | + def test_post_token_without_status_write_returns_403(self, client, sample_project, fossil_repo_obj, admin_user): | |
| 266 | + raw, token_hash, prefix = APIToken.generate() | |
| 267 | + APIToken.objects.create( | |
| 268 | + repository=fossil_repo_obj, | |
| 269 | + name="Read Only Token", | |
| 270 | + token_hash=token_hash, | |
| 271 | + token_prefix=prefix, | |
| 272 | + permissions="status:read", | |
| 273 | + created_by=admin_user, | |
| 274 | + ) | |
| 275 | + response = client.post( | |
| 276 | + f"/projects/{sample_project.slug}/fossil/api/status", | |
| 277 | + data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}), | |
| 278 | + content_type="application/json", | |
| 279 | + HTTP_AUTHORIZATION=f"Bearer {raw}", | |
| 280 | + ) | |
| 281 | + assert response.status_code == 403 | |
| 282 | + | |
| 283 | + def test_method_not_allowed(self, client, sample_project, fossil_repo_obj): | |
| 284 | + response = client.delete(f"/projects/{sample_project.slug}/fossil/api/status") | |
| 285 | + assert response.status_code == 405 | |
| 286 | + | |
| 287 | + | |
| 288 | +# --- Status Check API GET Tests --- | |
| 289 | + | |
| 290 | + | |
| 291 | +@pytest.mark.django_db | |
| 292 | +class TestStatusCheckAPIGet: | |
| 293 | + def test_get_checks_for_checkin(self, admin_client, sample_project, fossil_repo_obj, status_check): | |
| 294 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/api/status?checkin={status_check.checkin_uuid}") | |
| 295 | + assert response.status_code == 200 | |
| 296 | + data = response.json() | |
| 297 | + assert len(data["checks"]) == 1 | |
| 298 | + assert data["checks"][0]["context"] == "ci/tests" | |
| 299 | + assert data["checks"][0]["state"] == "success" | |
| 300 | + | |
| 301 | + def test_get_without_checkin_param_returns_400(self, admin_client, sample_project, fossil_repo_obj): | |
| 302 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/api/status") | |
| 303 | + assert response.status_code == 400 | |
| 304 | + | |
| 305 | + def test_get_empty_results(self, admin_client, sample_project, fossil_repo_obj): | |
| 306 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/api/status?checkin=nonexistent") | |
| 307 | + assert response.status_code == 200 | |
| 308 | + assert len(response.json()["checks"]) == 0 | |
| 309 | + | |
| 310 | + | |
| 311 | +# --- Status Badge Tests --- | |
| 312 | + | |
| 313 | + | |
| 314 | +@pytest.mark.django_db | |
| 315 | +class TestStatusBadge: | |
| 316 | + def test_badge_unknown_no_checks(self, client, sample_project, fossil_repo_obj): | |
| 317 | + response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/abc123/badge.svg") | |
| 318 | + assert response.status_code == 200 | |
| 319 | + assert response["Content-Type"] == "image/svg+xml" | |
| 320 | + assert "unknown" in response.content.decode() | |
| 321 | + | |
| 322 | + def test_badge_passing(self, client, sample_project, fossil_repo_obj): | |
| 323 | + StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="pass123", context="ci/tests", state="success") | |
| 324 | + response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/pass123/badge.svg") | |
| 325 | + assert response.status_code == 200 | |
| 326 | + assert "passing" in response.content.decode() | |
| 327 | + | |
| 328 | + def test_badge_failing(self, client, sample_project, fossil_repo_obj): | |
| 329 | + StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="fail123", context="ci/tests", state="failure") | |
| 330 | + response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/fail123/badge.svg") | |
| 331 | + assert response.status_code == 200 | |
| 332 | + assert "failing" in response.content.decode() | |
| 333 | + | |
| 334 | + def test_badge_pending(self, client, sample_project, fossil_repo_obj): | |
| 335 | + StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="pend123", context="ci/tests", state="pending") | |
| 336 | + response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/pend123/badge.svg") | |
| 337 | + assert response.status_code == 200 | |
| 338 | + assert "pending" in response.content.decode() | |
| 339 | + | |
| 340 | + def test_badge_mixed_failing_wins(self, client, sample_project, fossil_repo_obj): | |
| 341 | + """If any check is failing, the aggregate badge should say 'failing'.""" | |
| 342 | + StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="mixed123", context="ci/tests", state="success") | |
| 343 | + StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="mixed123", context="ci/lint", state="failure") | |
| 344 | + response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/mixed123/badge.svg") | |
| 345 | + assert "failing" in response.content.decode() | |
| 346 | + | |
| 347 | + def test_badge_nonexistent_project_returns_404(self, client): | |
| 348 | + response = client.get("/projects/nonexistent-project/fossil/api/status/abc/badge.svg") | |
| 349 | + assert response.status_code == 404 |
| --- a/tests/test_ci_status.py | |
| +++ b/tests/test_ci_status.py | |
| @@ -0,0 +1,349 @@ | |
| --- a/tests/test_ci_status.py | |
| +++ b/tests/test_ci_status.py | |
| @@ -0,0 +1,349 @@ | |
| 1 | import json |
| 2 | |
| 3 | import pytest |
| 4 | from django.contrib.auth.models import User |
| 5 | from django.test import Client |
| 6 | |
| 7 | from fossil.api_tokens import APIToken |
| 8 | from fossil.ci import StatusCheck |
| 9 | from fossil.models import FossilRepository |
| 10 | from organization.models import Team |
| 11 | from projects.models import ProjectTeam |
| 12 | |
| 13 | |
| 14 | @pytest.fixture |
| 15 | def fossil_repo_obj(sample_project): |
| 16 | """Return the auto-created FossilRepository for sample_project.""" |
| 17 | return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True) |
| 18 | |
| 19 | |
| 20 | @pytest.fixture |
| 21 | def api_token(fossil_repo_obj, admin_user): |
| 22 | """Create an API token and return (APIToken instance, raw_token).""" |
| 23 | raw, token_hash, prefix = APIToken.generate() |
| 24 | token = APIToken.objects.create( |
| 25 | repository=fossil_repo_obj, |
| 26 | name="CI Token", |
| 27 | token_hash=token_hash, |
| 28 | token_prefix=prefix, |
| 29 | permissions="status:write", |
| 30 | created_by=admin_user, |
| 31 | ) |
| 32 | return token, raw |
| 33 | |
| 34 | |
| 35 | @pytest.fixture |
| 36 | def status_check(fossil_repo_obj): |
| 37 | return StatusCheck.objects.create( |
| 38 | repository=fossil_repo_obj, |
| 39 | checkin_uuid="abc123def456", |
| 40 | context="ci/tests", |
| 41 | state="success", |
| 42 | description="All 42 tests passed", |
| 43 | target_url="https://ci.example.com/build/1", |
| 44 | ) |
| 45 | |
| 46 | |
| 47 | @pytest.fixture |
| 48 | def writer_user(db, admin_user, sample_project): |
| 49 | """User with write access but not admin.""" |
| 50 | writer = User.objects.create_user(username="writer_ci", password="testpass123") |
| 51 | team = Team.objects.create(name="CI Writers", organization=sample_project.organization, created_by=admin_user) |
| 52 | team.members.add(writer) |
| 53 | ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user) |
| 54 | return writer |
| 55 | |
| 56 | |
| 57 | @pytest.fixture |
| 58 | def writer_client(writer_user): |
| 59 | client = Client() |
| 60 | client.login(username="writer_ci", password="testpass123") |
| 61 | return client |
| 62 | |
| 63 | |
| 64 | # --- StatusCheck Model Tests --- |
| 65 | |
| 66 | |
| 67 | @pytest.mark.django_db |
| 68 | class TestStatusCheckModel: |
| 69 | def test_create_status_check(self, status_check): |
| 70 | assert status_check.pk is not None |
| 71 | assert str(status_check) == "ci/tests: success @ abc123def4" |
| 72 | |
| 73 | def test_soft_delete(self, status_check, admin_user): |
| 74 | status_check.soft_delete(user=admin_user) |
| 75 | assert status_check.is_deleted |
| 76 | assert StatusCheck.objects.filter(pk=status_check.pk).count() == 0 |
| 77 | assert StatusCheck.all_objects.filter(pk=status_check.pk).count() == 1 |
| 78 | |
| 79 | def test_unique_together(self, fossil_repo_obj): |
| 80 | StatusCheck.objects.create( |
| 81 | repository=fossil_repo_obj, |
| 82 | checkin_uuid="unique123", |
| 83 | context="ci/lint", |
| 84 | state="pending", |
| 85 | ) |
| 86 | from django.db import IntegrityError |
| 87 | |
| 88 | with pytest.raises(IntegrityError): |
| 89 | StatusCheck.objects.create( |
| 90 | repository=fossil_repo_obj, |
| 91 | checkin_uuid="unique123", |
| 92 | context="ci/lint", |
| 93 | state="success", |
| 94 | ) |
| 95 | |
| 96 | def test_ordering(self, fossil_repo_obj): |
| 97 | c1 = StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="ord1", context="ci/first", state="pending") |
| 98 | c2 = StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="ord2", context="ci/second", state="success") |
| 99 | checks = list(StatusCheck.objects.filter(repository=fossil_repo_obj)) |
| 100 | assert checks[0] == c2 # newest first |
| 101 | assert checks[1] == c1 |
| 102 | |
| 103 | def test_state_choices(self): |
| 104 | assert "pending" in StatusCheck.State.values |
| 105 | assert "success" in StatusCheck.State.values |
| 106 | assert "failure" in StatusCheck.State.values |
| 107 | assert "error" in StatusCheck.State.values |
| 108 | |
| 109 | |
| 110 | # --- Status Check API POST Tests --- |
| 111 | |
| 112 | |
| 113 | @pytest.mark.django_db |
| 114 | class TestStatusCheckAPIPost: |
| 115 | def test_post_creates_status_check(self, client, sample_project, fossil_repo_obj, api_token): |
| 116 | token_obj, raw_token = api_token |
| 117 | response = client.post( |
| 118 | f"/projects/{sample_project.slug}/fossil/api/status", |
| 119 | data=json.dumps( |
| 120 | { |
| 121 | "checkin": "deadbeef123", |
| 122 | "context": "ci/tests", |
| 123 | "state": "success", |
| 124 | "description": "All tests passed", |
| 125 | "target_url": "https://ci.example.com/build/42", |
| 126 | } |
| 127 | ), |
| 128 | content_type="application/json", |
| 129 | HTTP_AUTHORIZATION=f"Bearer {raw_token}", |
| 130 | ) |
| 131 | assert response.status_code == 201 |
| 132 | data = response.json() |
| 133 | assert data["context"] == "ci/tests" |
| 134 | assert data["state"] == "success" |
| 135 | assert data["created"] is True |
| 136 | |
| 137 | check = StatusCheck.objects.get(repository=fossil_repo_obj, checkin_uuid="deadbeef123", context="ci/tests") |
| 138 | assert check.state == "success" |
| 139 | assert check.description == "All tests passed" |
| 140 | |
| 141 | def test_post_updates_existing_check(self, client, sample_project, fossil_repo_obj, api_token): |
| 142 | token_obj, raw_token = api_token |
| 143 | # Create initial check |
| 144 | StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="update123", context="ci/tests", state="pending") |
| 145 | # Update it |
| 146 | response = client.post( |
| 147 | f"/projects/{sample_project.slug}/fossil/api/status", |
| 148 | data=json.dumps( |
| 149 | { |
| 150 | "checkin": "update123", |
| 151 | "context": "ci/tests", |
| 152 | "state": "success", |
| 153 | "description": "Now passing", |
| 154 | } |
| 155 | ), |
| 156 | content_type="application/json", |
| 157 | HTTP_AUTHORIZATION=f"Bearer {raw_token}", |
| 158 | ) |
| 159 | assert response.status_code == 200 |
| 160 | data = response.json() |
| 161 | assert data["created"] is False |
| 162 | assert data["state"] == "success" |
| 163 | |
| 164 | def test_post_without_token_returns_401(self, client, sample_project, fossil_repo_obj): |
| 165 | response = client.post( |
| 166 | f"/projects/{sample_project.slug}/fossil/api/status", |
| 167 | data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}), |
| 168 | content_type="application/json", |
| 169 | ) |
| 170 | assert response.status_code == 401 |
| 171 | |
| 172 | def test_post_with_invalid_token_returns_401(self, client, sample_project, fossil_repo_obj): |
| 173 | response = client.post( |
| 174 | f"/projects/{sample_project.slug}/fossil/api/status", |
| 175 | data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}), |
| 176 | content_type="application/json", |
| 177 | HTTP_AUTHORIZATION="Bearer invalid_token_xyz", |
| 178 | ) |
| 179 | assert response.status_code == 401 |
| 180 | |
| 181 | def test_post_with_wrong_repo_token_returns_401(self, client, sample_project, fossil_repo_obj, admin_user, org): |
| 182 | """Token scoped to a different repo should fail.""" |
| 183 | from projects.models import Project |
| 184 | |
| 185 | other_project = Project.objects.create(name="Other Project", organization=org, visibility="private", created_by=admin_user) |
| 186 | other_repo = FossilRepository.objects.get(project=other_project, deleted_at__isnull=True) |
| 187 | raw, token_hash, prefix = APIToken.generate() |
| 188 | APIToken.objects.create( |
| 189 | repository=other_repo, name="Other Token", token_hash=token_hash, token_prefix=prefix, created_by=admin_user |
| 190 | ) |
| 191 | |
| 192 | response = client.post( |
| 193 | f"/projects/{sample_project.slug}/fossil/api/status", |
| 194 | data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}), |
| 195 | content_type="application/json", |
| 196 | HTTP_AUTHORIZATION=f"Bearer {raw}", |
| 197 | ) |
| 198 | assert response.status_code == 401 |
| 199 | |
| 200 | def test_post_missing_checkin_returns_400(self, client, sample_project, fossil_repo_obj, api_token): |
| 201 | _, raw_token = api_token |
| 202 | response = client.post( |
| 203 | f"/projects/{sample_project.slug}/fossil/api/status", |
| 204 | data=json.dumps({"context": "ci/tests", "state": "success"}), |
| 205 | content_type="application/json", |
| 206 | HTTP_AUTHORIZATION=f"Bearer {raw_token}", |
| 207 | ) |
| 208 | assert response.status_code == 400 |
| 209 | assert "checkin" in response.json()["error"] |
| 210 | |
| 211 | def test_post_missing_context_returns_400(self, client, sample_project, fossil_repo_obj, api_token): |
| 212 | _, raw_token = api_token |
| 213 | response = client.post( |
| 214 | f"/projects/{sample_project.slug}/fossil/api/status", |
| 215 | data=json.dumps({"checkin": "abc123", "state": "success"}), |
| 216 | content_type="application/json", |
| 217 | HTTP_AUTHORIZATION=f"Bearer {raw_token}", |
| 218 | ) |
| 219 | assert response.status_code == 400 |
| 220 | assert "context" in response.json()["error"] |
| 221 | |
| 222 | def test_post_invalid_state_returns_400(self, client, sample_project, fossil_repo_obj, api_token): |
| 223 | _, raw_token = api_token |
| 224 | response = client.post( |
| 225 | f"/projects/{sample_project.slug}/fossil/api/status", |
| 226 | data=json.dumps({"checkin": "abc123", "context": "ci/tests", "state": "bogus"}), |
| 227 | content_type="application/json", |
| 228 | HTTP_AUTHORIZATION=f"Bearer {raw_token}", |
| 229 | ) |
| 230 | assert response.status_code == 400 |
| 231 | assert "state" in response.json()["error"] |
| 232 | |
| 233 | def test_post_invalid_json_returns_400(self, client, sample_project, fossil_repo_obj, api_token): |
| 234 | _, raw_token = api_token |
| 235 | response = client.post( |
| 236 | f"/projects/{sample_project.slug}/fossil/api/status", |
| 237 | data="not json", |
| 238 | content_type="application/json", |
| 239 | HTTP_AUTHORIZATION=f"Bearer {raw_token}", |
| 240 | ) |
| 241 | assert response.status_code == 400 |
| 242 | |
| 243 | def test_post_expired_token_returns_401(self, client, sample_project, fossil_repo_obj, admin_user): |
| 244 | from datetime import timedelta |
| 245 | |
| 246 | from django.utils import timezone |
| 247 | |
| 248 | raw, token_hash, prefix = APIToken.generate() |
| 249 | APIToken.objects.create( |
| 250 | repository=fossil_repo_obj, |
| 251 | name="Expired Token", |
| 252 | token_hash=token_hash, |
| 253 | token_prefix=prefix, |
| 254 | expires_at=timezone.now() - timedelta(days=1), |
| 255 | created_by=admin_user, |
| 256 | ) |
| 257 | response = client.post( |
| 258 | f"/projects/{sample_project.slug}/fossil/api/status", |
| 259 | data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}), |
| 260 | content_type="application/json", |
| 261 | HTTP_AUTHORIZATION=f"Bearer {raw}", |
| 262 | ) |
| 263 | assert response.status_code == 401 |
| 264 | |
| 265 | def test_post_token_without_status_write_returns_403(self, client, sample_project, fossil_repo_obj, admin_user): |
| 266 | raw, token_hash, prefix = APIToken.generate() |
| 267 | APIToken.objects.create( |
| 268 | repository=fossil_repo_obj, |
| 269 | name="Read Only Token", |
| 270 | token_hash=token_hash, |
| 271 | token_prefix=prefix, |
| 272 | permissions="status:read", |
| 273 | created_by=admin_user, |
| 274 | ) |
| 275 | response = client.post( |
| 276 | f"/projects/{sample_project.slug}/fossil/api/status", |
| 277 | data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}), |
| 278 | content_type="application/json", |
| 279 | HTTP_AUTHORIZATION=f"Bearer {raw}", |
| 280 | ) |
| 281 | assert response.status_code == 403 |
| 282 | |
| 283 | def test_method_not_allowed(self, client, sample_project, fossil_repo_obj): |
| 284 | response = client.delete(f"/projects/{sample_project.slug}/fossil/api/status") |
| 285 | assert response.status_code == 405 |
| 286 | |
| 287 | |
| 288 | # --- Status Check API GET Tests --- |
| 289 | |
| 290 | |
| 291 | @pytest.mark.django_db |
| 292 | class TestStatusCheckAPIGet: |
| 293 | def test_get_checks_for_checkin(self, admin_client, sample_project, fossil_repo_obj, status_check): |
| 294 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/api/status?checkin={status_check.checkin_uuid}") |
| 295 | assert response.status_code == 200 |
| 296 | data = response.json() |
| 297 | assert len(data["checks"]) == 1 |
| 298 | assert data["checks"][0]["context"] == "ci/tests" |
| 299 | assert data["checks"][0]["state"] == "success" |
| 300 | |
| 301 | def test_get_without_checkin_param_returns_400(self, admin_client, sample_project, fossil_repo_obj): |
| 302 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/api/status") |
| 303 | assert response.status_code == 400 |
| 304 | |
| 305 | def test_get_empty_results(self, admin_client, sample_project, fossil_repo_obj): |
| 306 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/api/status?checkin=nonexistent") |
| 307 | assert response.status_code == 200 |
| 308 | assert len(response.json()["checks"]) == 0 |
| 309 | |
| 310 | |
| 311 | # --- Status Badge Tests --- |
| 312 | |
| 313 | |
| 314 | @pytest.mark.django_db |
| 315 | class TestStatusBadge: |
| 316 | def test_badge_unknown_no_checks(self, client, sample_project, fossil_repo_obj): |
| 317 | response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/abc123/badge.svg") |
| 318 | assert response.status_code == 200 |
| 319 | assert response["Content-Type"] == "image/svg+xml" |
| 320 | assert "unknown" in response.content.decode() |
| 321 | |
| 322 | def test_badge_passing(self, client, sample_project, fossil_repo_obj): |
| 323 | StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="pass123", context="ci/tests", state="success") |
| 324 | response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/pass123/badge.svg") |
| 325 | assert response.status_code == 200 |
| 326 | assert "passing" in response.content.decode() |
| 327 | |
| 328 | def test_badge_failing(self, client, sample_project, fossil_repo_obj): |
| 329 | StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="fail123", context="ci/tests", state="failure") |
| 330 | response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/fail123/badge.svg") |
| 331 | assert response.status_code == 200 |
| 332 | assert "failing" in response.content.decode() |
| 333 | |
| 334 | def test_badge_pending(self, client, sample_project, fossil_repo_obj): |
| 335 | StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="pend123", context="ci/tests", state="pending") |
| 336 | response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/pend123/badge.svg") |
| 337 | assert response.status_code == 200 |
| 338 | assert "pending" in response.content.decode() |
| 339 | |
| 340 | def test_badge_mixed_failing_wins(self, client, sample_project, fossil_repo_obj): |
| 341 | """If any check is failing, the aggregate badge should say 'failing'.""" |
| 342 | StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="mixed123", context="ci/tests", state="success") |
| 343 | StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="mixed123", context="ci/lint", state="failure") |
| 344 | response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/mixed123/badge.svg") |
| 345 | assert "failing" in response.content.decode() |
| 346 | |
| 347 | def test_badge_nonexistent_project_returns_404(self, client): |
| 348 | response = client.get("/projects/nonexistent-project/fossil/api/status/abc/badge.svg") |
| 349 | assert response.status_code == 404 |
| --- a/tests/test_starring.py | ||
| +++ b/tests/test_starring.py | ||
| @@ -0,0 +1,181 @@ | ||
| 1 | +"""Tests for Project Starring: model, toggle view, explore page, and admin.""" | |
| 2 | + | |
| 3 | +import pytest | |
| 4 | +from django.contrib.auth.models import User | |
| 5 | +from django.db import IntegrityError | |
| 6 | + | |
| 7 | +from projects.models import Project, ProjectStar | |
| 8 | + | |
| 9 | +# --- Model Tests --- | |
| 10 | + | |
| 11 | + | |
| 12 | +@pytest.mark.django_db | |
| 13 | +class TestProjectStarModel: | |
| 14 | + def test_create_star(self, admin_user, sample_project): | |
| 15 | + star = ProjectStar.objects.create(user=admin_user, project=sample_project) | |
| 16 | + assert star.pk is not None | |
| 17 | + assert star.user == admin_user | |
| 18 | + assert star.project == sample_project | |
| 19 | + assert str(star) == f"{admin_user} starred {sample_project}" | |
| 20 | + | |
| 21 | + def test_unique_constraint(self, admin_user, sample_project): | |
| 22 | + ProjectStar.objects.create(user=admin_user, project=sample_project) | |
| 23 | + with pytest.raises(IntegrityError): | |
| 24 | + ProjectStar.objects.create(user=admin_user, project=sample_project) | |
| 25 | + | |
| 26 | + def test_star_count_property(self, admin_user, viewer_user, sample_project): | |
| 27 | + assert sample_project.star_count == 0 | |
| 28 | + ProjectStar.objects.create(user=admin_user, project=sample_project) | |
| 29 | + assert sample_project.star_count == 1 | |
| 30 | + ProjectStar.objects.create(user=viewer_user, project=sample_project) | |
| 31 | + assert sample_project.star_count == 2 | |
| 32 | + | |
| 33 | + def test_star_cascade_on_user_delete(self, org, admin_user): | |
| 34 | + """Stars cascade-delete when the user is deleted.""" | |
| 35 | + temp_user = User.objects.create_user(username="tempuser", password="testpass123") | |
| 36 | + project = Project.objects.create(name="Cascade Test", organization=org, created_by=admin_user) | |
| 37 | + ProjectStar.objects.create(user=temp_user, project=project) | |
| 38 | + temp_user.delete() | |
| 39 | + assert ProjectStar.objects.count() == 0 | |
| 40 | + | |
| 41 | + def test_multiple_users_can_star_same_project(self, admin_user, viewer_user, sample_project): | |
| 42 | + ProjectStar.objects.create(user=admin_user, project=sample_project) | |
| 43 | + ProjectStar.objects.create(user=viewer_user, project=sample_project) | |
| 44 | + assert ProjectStar.objects.filter(project=sample_project).count() == 2 | |
| 45 | + | |
| 46 | + | |
| 47 | +# --- Toggle Star View Tests --- | |
| 48 | + | |
| 49 | + | |
| 50 | +@pytest.mark.django_db | |
| 51 | +class TestToggleStarView: | |
| 52 | + def test_star_project(self, admin_client, admin_user, sample_project): | |
| 53 | + response = admin_client.post(f"/projects/{sample_project.slug}/star/") | |
| 54 | + assert response.status_code == 302 | |
| 55 | + assert ProjectStar.objects.filter(user=admin_user, project=sample_project).exists() | |
| 56 | + | |
| 57 | + def test_unstar_project(self, admin_client, admin_user, sample_project): | |
| 58 | + ProjectStar.objects.create(user=admin_user, project=sample_project) | |
| 59 | + response = admin_client.post(f"/projects/{sample_project.slug}/star/") | |
| 60 | + assert response.status_code == 302 | |
| 61 | + assert not ProjectStar.objects.filter(user=admin_user, project=sample_project).exists() | |
| 62 | + | |
| 63 | + def test_star_htmx_returns_partial(self, admin_client, admin_user, sample_project): | |
| 64 | + response = admin_client.post(f"/projects/{sample_project.slug}/star/", HTTP_HX_REQUEST="true") | |
| 65 | + assert response.status_code == 200 | |
| 66 | + content = response.content.decode() | |
| 67 | + assert "star-button" in content | |
| 68 | + assert "Starred" in content # Just starred it | |
| 69 | + assert "<!DOCTYPE html>" not in content | |
| 70 | + | |
| 71 | + def test_unstar_htmx_returns_partial(self, admin_client, admin_user, sample_project): | |
| 72 | + ProjectStar.objects.create(user=admin_user, project=sample_project) | |
| 73 | + response = admin_client.post(f"/projects/{sample_project.slug}/star/", HTTP_HX_REQUEST="true") | |
| 74 | + assert response.status_code == 200 | |
| 75 | + content = response.content.decode() | |
| 76 | + assert "Star" in content | |
| 77 | + | |
| 78 | + def test_star_denied_for_anon(self, client, sample_project): | |
| 79 | + response = client.post(f"/projects/{sample_project.slug}/star/") | |
| 80 | + assert response.status_code == 302 # Redirect to login | |
| 81 | + | |
| 82 | + def test_star_404_for_deleted_project(self, admin_client, admin_user, sample_project): | |
| 83 | + sample_project.soft_delete(user=admin_user) | |
| 84 | + response = admin_client.post(f"/projects/{sample_project.slug}/star/") | |
| 85 | + assert response.status_code == 404 | |
| 86 | + | |
| 87 | + def test_star_shows_on_project_detail(self, admin_client, admin_user, sample_project): | |
| 88 | + ProjectStar.objects.create(user=admin_user, project=sample_project) | |
| 89 | + response = admin_client.get(f"/projects/{sample_project.slug}/") | |
| 90 | + assert response.status_code == 200 | |
| 91 | + assert response.context["is_starred"] is True | |
| 92 | + | |
| 93 | + def test_unstarred_shows_on_project_detail(self, admin_client, admin_user, sample_project): | |
| 94 | + response = admin_client.get(f"/projects/{sample_project.slug}/") | |
| 95 | + assert response.status_code == 200 | |
| 96 | + assert response.context["is_starred"] is False | |
| 97 | + | |
| 98 | + | |
| 99 | +# --- Explore View Tests --- | |
| 100 | + | |
| 101 | + | |
| 102 | +@pytest.mark.django_db | |
| 103 | +class TestExploreView: | |
| 104 | + def test_explore_accessible_to_anon(self, client, org, admin_user): | |
| 105 | + Project.objects.create(name="Public Project", organization=org, visibility="public", created_by=admin_user) | |
| 106 | + response = client.get("/explore/") | |
| 107 | + assert response.status_code == 200 | |
| 108 | + assert "Public Project" in response.content.decode() | |
| 109 | + | |
| 110 | + def test_explore_anon_only_sees_public(self, client, org, admin_user): | |
| 111 | + Project.objects.create(name="Public One", organization=org, visibility="public", created_by=admin_user) | |
| 112 | + Project.objects.create(name="Internal One", organization=org, visibility="internal", created_by=admin_user) | |
| 113 | + Project.objects.create(name="Private One", organization=org, visibility="private", created_by=admin_user) | |
| 114 | + response = client.get("/explore/") | |
| 115 | + content = response.content.decode() | |
| 116 | + assert "Public One" in content | |
| 117 | + assert "Internal One" not in content | |
| 118 | + assert "Private One" not in content | |
| 119 | + | |
| 120 | + def test_explore_authenticated_sees_public_and_internal(self, admin_client, org, admin_user): | |
| 121 | + Project.objects.create(name="Public Two", organization=org, visibility="public", created_by=admin_user) | |
| 122 | + Project.objects.create(name="Internal Two", organization=org, visibility="internal", created_by=admin_user) | |
| 123 | + Project.objects.create(name="Private Two", organization=org, visibility="private", created_by=admin_user) | |
| 124 | + response = admin_client.get("/explore/") | |
| 125 | + # Check the explore queryset in context (not full page content, which includes sidebar) | |
| 126 | + explore_project_names = [p.name for p in response.context["projects"]] | |
| 127 | + assert "Public Two" in explore_project_names | |
| 128 | + assert "Internal Two" in explore_project_names | |
| 129 | + assert "Private Two" not in explore_project_names | |
| 130 | + | |
| 131 | + def test_explore_sort_by_name(self, client, org, admin_user): | |
| 132 | + Project.objects.create(name="Zebra", organization=org, visibility="public", created_by=admin_user) | |
| 133 | + Project.objects.create(name="Alpha", organization=org, visibility="public", created_by=admin_user) | |
| 134 | + response = client.get("/explore/?sort=name") | |
| 135 | + content = response.content.decode() | |
| 136 | + assert content.index("Alpha") < content.index("Zebra") | |
| 137 | + | |
| 138 | + def test_explore_sort_by_stars(self, client, org, admin_user): | |
| 139 | + p1 = Project.objects.create(name="Less Stars", organization=org, visibility="public", created_by=admin_user) | |
| 140 | + p2 = Project.objects.create(name="More Stars", organization=org, visibility="public", created_by=admin_user) | |
| 141 | + user1 = User.objects.create_user(username="u1", password="testpass123") | |
| 142 | + user2 = User.objects.create_user(username="u2", password="testpass123") | |
| 143 | + ProjectStar.objects.create(user=user1, project=p2) | |
| 144 | + ProjectStar.objects.create(user=user2, project=p2) | |
| 145 | + ProjectStar.objects.create(user=user1, project=p1) | |
| 146 | + response = client.get("/explore/?sort=stars") | |
| 147 | + content = response.content.decode() | |
| 148 | + assert content.index("More Stars") < content.index("Less Stars") | |
| 149 | + | |
| 150 | + def test_explore_sort_by_recent(self, client, org, admin_user): | |
| 151 | + Project.objects.create(name="Old Project", organization=org, visibility="public", created_by=admin_user) | |
| 152 | + Project.objects.create(name="New Project", organization=org, visibility="public", created_by=admin_user) | |
| 153 | + response = client.get("/explore/?sort=recent") | |
| 154 | + content = response.content.decode() | |
| 155 | + assert content.index("New Project") < content.index("Old Project") | |
| 156 | + | |
| 157 | + def test_explore_search(self, client, org, admin_user): | |
| 158 | + Project.objects.create(name="Fossil SCM", organization=org, visibility="public", created_by=admin_user) | |
| 159 | + Project.objects.create(name="Other Project", organization=org, visibility="public", created_by=admin_user) | |
| 160 | + response = client.get("/explore/?search=fossil") | |
| 161 | + content = response.content.decode() | |
| 162 | + assert "Fossil SCM" in content | |
| 163 | + assert "Other Project" not in content | |
| 164 | + | |
| 165 | + def test_explore_excludes_deleted_projects(self, client, org, admin_user): | |
| 166 | + project = Project.objects.create(name="Deleted Project", organization=org, visibility="public", created_by=admin_user) | |
| 167 | + project.soft_delete(user=admin_user) | |
| 168 | + response = client.get("/explore/") | |
| 169 | + assert "Deleted Project" not in response.content.decode() | |
| 170 | + | |
| 171 | + def test_explore_starred_ids_for_authenticated_user(self, admin_client, admin_user, org): | |
| 172 | + p1 = Project.objects.create(name="Starred P", organization=org, visibility="public", created_by=admin_user) | |
| 173 | + Project.objects.create(name="Unstarred P", organization=org, visibility="public", created_by=admin_user) | |
| 174 | + ProjectStar.objects.create(user=admin_user, project=p1) | |
| 175 | + response = admin_client.get("/explore/") | |
| 176 | + assert p1.id in response.context["starred_ids"] | |
| 177 | + | |
| 178 | + def test_explore_sidebar_link_exists(self, admin_client): | |
| 179 | + response = admin_client.get("/dashboard/") | |
| 180 | + assert response.status_code == 200 | |
| 181 | + assert "/explore/" in response.content.decode() |
| --- a/tests/test_starring.py | |
| +++ b/tests/test_starring.py | |
| @@ -0,0 +1,181 @@ | |
| --- a/tests/test_starring.py | |
| +++ b/tests/test_starring.py | |
| @@ -0,0 +1,181 @@ | |
| 1 | """Tests for Project Starring: model, toggle view, explore page, and admin.""" |
| 2 | |
| 3 | import pytest |
| 4 | from django.contrib.auth.models import User |
| 5 | from django.db import IntegrityError |
| 6 | |
| 7 | from projects.models import Project, ProjectStar |
| 8 | |
| 9 | # --- Model Tests --- |
| 10 | |
| 11 | |
| 12 | @pytest.mark.django_db |
| 13 | class TestProjectStarModel: |
| 14 | def test_create_star(self, admin_user, sample_project): |
| 15 | star = ProjectStar.objects.create(user=admin_user, project=sample_project) |
| 16 | assert star.pk is not None |
| 17 | assert star.user == admin_user |
| 18 | assert star.project == sample_project |
| 19 | assert str(star) == f"{admin_user} starred {sample_project}" |
| 20 | |
| 21 | def test_unique_constraint(self, admin_user, sample_project): |
| 22 | ProjectStar.objects.create(user=admin_user, project=sample_project) |
| 23 | with pytest.raises(IntegrityError): |
| 24 | ProjectStar.objects.create(user=admin_user, project=sample_project) |
| 25 | |
| 26 | def test_star_count_property(self, admin_user, viewer_user, sample_project): |
| 27 | assert sample_project.star_count == 0 |
| 28 | ProjectStar.objects.create(user=admin_user, project=sample_project) |
| 29 | assert sample_project.star_count == 1 |
| 30 | ProjectStar.objects.create(user=viewer_user, project=sample_project) |
| 31 | assert sample_project.star_count == 2 |
| 32 | |
| 33 | def test_star_cascade_on_user_delete(self, org, admin_user): |
| 34 | """Stars cascade-delete when the user is deleted.""" |
| 35 | temp_user = User.objects.create_user(username="tempuser", password="testpass123") |
| 36 | project = Project.objects.create(name="Cascade Test", organization=org, created_by=admin_user) |
| 37 | ProjectStar.objects.create(user=temp_user, project=project) |
| 38 | temp_user.delete() |
| 39 | assert ProjectStar.objects.count() == 0 |
| 40 | |
| 41 | def test_multiple_users_can_star_same_project(self, admin_user, viewer_user, sample_project): |
| 42 | ProjectStar.objects.create(user=admin_user, project=sample_project) |
| 43 | ProjectStar.objects.create(user=viewer_user, project=sample_project) |
| 44 | assert ProjectStar.objects.filter(project=sample_project).count() == 2 |
| 45 | |
| 46 | |
| 47 | # --- Toggle Star View Tests --- |
| 48 | |
| 49 | |
| 50 | @pytest.mark.django_db |
| 51 | class TestToggleStarView: |
| 52 | def test_star_project(self, admin_client, admin_user, sample_project): |
| 53 | response = admin_client.post(f"/projects/{sample_project.slug}/star/") |
| 54 | assert response.status_code == 302 |
| 55 | assert ProjectStar.objects.filter(user=admin_user, project=sample_project).exists() |
| 56 | |
| 57 | def test_unstar_project(self, admin_client, admin_user, sample_project): |
| 58 | ProjectStar.objects.create(user=admin_user, project=sample_project) |
| 59 | response = admin_client.post(f"/projects/{sample_project.slug}/star/") |
| 60 | assert response.status_code == 302 |
| 61 | assert not ProjectStar.objects.filter(user=admin_user, project=sample_project).exists() |
| 62 | |
| 63 | def test_star_htmx_returns_partial(self, admin_client, admin_user, sample_project): |
| 64 | response = admin_client.post(f"/projects/{sample_project.slug}/star/", HTTP_HX_REQUEST="true") |
| 65 | assert response.status_code == 200 |
| 66 | content = response.content.decode() |
| 67 | assert "star-button" in content |
| 68 | assert "Starred" in content # Just starred it |
| 69 | assert "<!DOCTYPE html>" not in content |
| 70 | |
| 71 | def test_unstar_htmx_returns_partial(self, admin_client, admin_user, sample_project): |
| 72 | ProjectStar.objects.create(user=admin_user, project=sample_project) |
| 73 | response = admin_client.post(f"/projects/{sample_project.slug}/star/", HTTP_HX_REQUEST="true") |
| 74 | assert response.status_code == 200 |
| 75 | content = response.content.decode() |
| 76 | assert "Star" in content |
| 77 | |
| 78 | def test_star_denied_for_anon(self, client, sample_project): |
| 79 | response = client.post(f"/projects/{sample_project.slug}/star/") |
| 80 | assert response.status_code == 302 # Redirect to login |
| 81 | |
| 82 | def test_star_404_for_deleted_project(self, admin_client, admin_user, sample_project): |
| 83 | sample_project.soft_delete(user=admin_user) |
| 84 | response = admin_client.post(f"/projects/{sample_project.slug}/star/") |
| 85 | assert response.status_code == 404 |
| 86 | |
| 87 | def test_star_shows_on_project_detail(self, admin_client, admin_user, sample_project): |
| 88 | ProjectStar.objects.create(user=admin_user, project=sample_project) |
| 89 | response = admin_client.get(f"/projects/{sample_project.slug}/") |
| 90 | assert response.status_code == 200 |
| 91 | assert response.context["is_starred"] is True |
| 92 | |
| 93 | def test_unstarred_shows_on_project_detail(self, admin_client, admin_user, sample_project): |
| 94 | response = admin_client.get(f"/projects/{sample_project.slug}/") |
| 95 | assert response.status_code == 200 |
| 96 | assert response.context["is_starred"] is False |
| 97 | |
| 98 | |
| 99 | # --- Explore View Tests --- |
| 100 | |
| 101 | |
| 102 | @pytest.mark.django_db |
| 103 | class TestExploreView: |
| 104 | def test_explore_accessible_to_anon(self, client, org, admin_user): |
| 105 | Project.objects.create(name="Public Project", organization=org, visibility="public", created_by=admin_user) |
| 106 | response = client.get("/explore/") |
| 107 | assert response.status_code == 200 |
| 108 | assert "Public Project" in response.content.decode() |
| 109 | |
| 110 | def test_explore_anon_only_sees_public(self, client, org, admin_user): |
| 111 | Project.objects.create(name="Public One", organization=org, visibility="public", created_by=admin_user) |
| 112 | Project.objects.create(name="Internal One", organization=org, visibility="internal", created_by=admin_user) |
| 113 | Project.objects.create(name="Private One", organization=org, visibility="private", created_by=admin_user) |
| 114 | response = client.get("/explore/") |
| 115 | content = response.content.decode() |
| 116 | assert "Public One" in content |
| 117 | assert "Internal One" not in content |
| 118 | assert "Private One" not in content |
| 119 | |
| 120 | def test_explore_authenticated_sees_public_and_internal(self, admin_client, org, admin_user): |
| 121 | Project.objects.create(name="Public Two", organization=org, visibility="public", created_by=admin_user) |
| 122 | Project.objects.create(name="Internal Two", organization=org, visibility="internal", created_by=admin_user) |
| 123 | Project.objects.create(name="Private Two", organization=org, visibility="private", created_by=admin_user) |
| 124 | response = admin_client.get("/explore/") |
| 125 | # Check the explore queryset in context (not full page content, which includes sidebar) |
| 126 | explore_project_names = [p.name for p in response.context["projects"]] |
| 127 | assert "Public Two" in explore_project_names |
| 128 | assert "Internal Two" in explore_project_names |
| 129 | assert "Private Two" not in explore_project_names |
| 130 | |
| 131 | def test_explore_sort_by_name(self, client, org, admin_user): |
| 132 | Project.objects.create(name="Zebra", organization=org, visibility="public", created_by=admin_user) |
| 133 | Project.objects.create(name="Alpha", organization=org, visibility="public", created_by=admin_user) |
| 134 | response = client.get("/explore/?sort=name") |
| 135 | content = response.content.decode() |
| 136 | assert content.index("Alpha") < content.index("Zebra") |
| 137 | |
| 138 | def test_explore_sort_by_stars(self, client, org, admin_user): |
| 139 | p1 = Project.objects.create(name="Less Stars", organization=org, visibility="public", created_by=admin_user) |
| 140 | p2 = Project.objects.create(name="More Stars", organization=org, visibility="public", created_by=admin_user) |
| 141 | user1 = User.objects.create_user(username="u1", password="testpass123") |
| 142 | user2 = User.objects.create_user(username="u2", password="testpass123") |
| 143 | ProjectStar.objects.create(user=user1, project=p2) |
| 144 | ProjectStar.objects.create(user=user2, project=p2) |
| 145 | ProjectStar.objects.create(user=user1, project=p1) |
| 146 | response = client.get("/explore/?sort=stars") |
| 147 | content = response.content.decode() |
| 148 | assert content.index("More Stars") < content.index("Less Stars") |
| 149 | |
| 150 | def test_explore_sort_by_recent(self, client, org, admin_user): |
| 151 | Project.objects.create(name="Old Project", organization=org, visibility="public", created_by=admin_user) |
| 152 | Project.objects.create(name="New Project", organization=org, visibility="public", created_by=admin_user) |
| 153 | response = client.get("/explore/?sort=recent") |
| 154 | content = response.content.decode() |
| 155 | assert content.index("New Project") < content.index("Old Project") |
| 156 | |
| 157 | def test_explore_search(self, client, org, admin_user): |
| 158 | Project.objects.create(name="Fossil SCM", organization=org, visibility="public", created_by=admin_user) |
| 159 | Project.objects.create(name="Other Project", organization=org, visibility="public", created_by=admin_user) |
| 160 | response = client.get("/explore/?search=fossil") |
| 161 | content = response.content.decode() |
| 162 | assert "Fossil SCM" in content |
| 163 | assert "Other Project" not in content |
| 164 | |
| 165 | def test_explore_excludes_deleted_projects(self, client, org, admin_user): |
| 166 | project = Project.objects.create(name="Deleted Project", organization=org, visibility="public", created_by=admin_user) |
| 167 | project.soft_delete(user=admin_user) |
| 168 | response = client.get("/explore/") |
| 169 | assert "Deleted Project" not in response.content.decode() |
| 170 | |
| 171 | def test_explore_starred_ids_for_authenticated_user(self, admin_client, admin_user, org): |
| 172 | p1 = Project.objects.create(name="Starred P", organization=org, visibility="public", created_by=admin_user) |
| 173 | Project.objects.create(name="Unstarred P", organization=org, visibility="public", created_by=admin_user) |
| 174 | ProjectStar.objects.create(user=admin_user, project=p1) |
| 175 | response = admin_client.get("/explore/") |
| 176 | assert p1.id in response.context["starred_ids"] |
| 177 | |
| 178 | def test_explore_sidebar_link_exists(self, admin_client): |
| 179 | response = admin_client.get("/dashboard/") |
| 180 | assert response.status_code == 200 |
| 181 | assert "/explore/" in response.content.decode() |
| --- a/tests/test_technotes.py | ||
| +++ b/tests/test_technotes.py | ||
| @@ -0,0 +1,383 @@ | ||
| 1 | +import sqlite3 | |
| 2 | +from pathlib import Path | |
| 3 | +from unittest.mock import MagicMock, patch | |
| 4 | + | |
| 5 | +import pytest | |
| 6 | + | |
| 7 | +from fossil.models import FossilRepository | |
| 8 | +from fossil.reader import FossilReader | |
| 9 | + | |
| 10 | +# Reusable patch that makes FossilRepository.exists_on_disk return True | |
| 11 | +_disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True)) | |
| 12 | + | |
| 13 | + | |
| 14 | +@pytest.fixture | |
| 15 | +def fossil_repo_obj(sample_project): | |
| 16 | + """Return the auto-created FossilRepository for sample_project.""" | |
| 17 | + return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True) | |
| 18 | + | |
| 19 | + | |
| 20 | +def _create_test_fossil_db(path: Path): | |
| 21 | + """Create a minimal .fossil SQLite database with the tables reader.py needs.""" | |
| 22 | + conn = sqlite3.connect(str(path)) | |
| 23 | + conn.execute( | |
| 24 | + """ | |
| 25 | + CREATE TABLE blob ( | |
| 26 | + rid INTEGER PRIMARY KEY, | |
| 27 | + uuid TEXT UNIQUE NOT NULL, | |
| 28 | + size INTEGER NOT NULL DEFAULT 0, | |
| 29 | + content BLOB | |
| 30 | + ) | |
| 31 | + """ | |
| 32 | + ) | |
| 33 | + conn.execute( | |
| 34 | + """ | |
| 35 | + CREATE TABLE event ( | |
| 36 | + type TEXT, | |
| 37 | + mtime REAL, | |
| 38 | + objid INTEGER, | |
| 39 | + user TEXT, | |
| 40 | + comment TEXT | |
| 41 | + ) | |
| 42 | + """ | |
| 43 | + ) | |
| 44 | + conn.commit() | |
| 45 | + return conn | |
| 46 | + | |
| 47 | + | |
| 48 | +def _insert_technote(conn, rid, uuid, mtime, user, comment, body_content=""): | |
| 49 | + """Insert a technote event and blob into the test database. | |
| 50 | + | |
| 51 | + Technotes use event.type = 'e'. The blob contains a Fossil wiki artifact | |
| 52 | + format: header cards followed by W <size>\\n<content>\\nZ <hash>. | |
| 53 | + """ | |
| 54 | + import struct | |
| 55 | + import zlib | |
| 56 | + | |
| 57 | + # Build a minimal Fossil wiki artifact containing the body | |
| 58 | + artifact = f"D 2024-01-01T00:00:00\nU {user}\nW {len(body_content.encode('utf-8'))}\n{body_content}\nZ 0000000000000000" | |
| 59 | + raw_bytes = artifact.encode("utf-8") | |
| 60 | + | |
| 61 | + # Fossil stores blobs with a 4-byte big-endian size prefix + zlib compressed content | |
| 62 | + compressed = struct.pack(">I", len(raw_bytes)) + zlib.compress(raw_bytes) | |
| 63 | + | |
| 64 | + conn.execute("INSERT INTO blob (rid, uuid, size, content) VALUES (?, ?, ?, ?)", (rid, uuid, len(raw_bytes), compressed)) | |
| 65 | + conn.execute("INSERT INTO event (type, mtime, objid, user, comment) VALUES ('e', ?, ?, ?, ?)", (mtime, rid, user, comment)) | |
| 66 | + conn.commit() | |
| 67 | + | |
| 68 | + | |
| 69 | +@pytest.fixture | |
| 70 | +def fossil_db(tmp_path): | |
| 71 | + """Create a temporary .fossil file with technote data for reader tests.""" | |
| 72 | + db_path = tmp_path / "test.fossil" | |
| 73 | + conn = _create_test_fossil_db(db_path) | |
| 74 | + _insert_technote(conn, 100, "abc123def456", 2460676.5, "admin", "First technote", "# Hello\n\nThis is the body.") | |
| 75 | + _insert_technote(conn, 101, "xyz789ghi012", 2460677.5, "dev", "Second technote", "Another note body.") | |
| 76 | + conn.close() | |
| 77 | + return db_path | |
| 78 | + | |
| 79 | + | |
| 80 | +def _make_reader_mock(**methods): | |
| 81 | + """Create a MagicMock that replaces FossilReader as a class. | |
| 82 | + | |
| 83 | + The returned mock supports: | |
| 84 | + reader = FossilReader(path) # returns a mock instance | |
| 85 | + with reader: # context manager | |
| 86 | + reader.some_method() # returns configured value | |
| 87 | + """ | |
| 88 | + mock_cls = MagicMock() | |
| 89 | + # The instance returned by calling the class | |
| 90 | + instance = MagicMock() | |
| 91 | + mock_cls.return_value = instance | |
| 92 | + # Context manager support: __enter__ returns the same instance | |
| 93 | + instance.__enter__ = MagicMock(return_value=instance) | |
| 94 | + instance.__exit__ = MagicMock(return_value=False) | |
| 95 | + for name, val in methods.items(): | |
| 96 | + getattr(instance, name).return_value = val | |
| 97 | + return mock_cls | |
| 98 | + | |
| 99 | + | |
| 100 | +# --- Reader unit tests (no Django DB needed) --- | |
| 101 | + | |
| 102 | + | |
| 103 | +class TestGetTechnotes: | |
| 104 | + def test_returns_technotes(self, fossil_db): | |
| 105 | + reader = FossilReader(fossil_db) | |
| 106 | + with reader: | |
| 107 | + notes = reader.get_technotes() | |
| 108 | + assert len(notes) == 2 | |
| 109 | + assert notes[0]["uuid"] == "xyz789ghi012" # Most recent first | |
| 110 | + assert notes[1]["uuid"] == "abc123def456" | |
| 111 | + | |
| 112 | + def test_technote_fields(self, fossil_db): | |
| 113 | + reader = FossilReader(fossil_db) | |
| 114 | + with reader: | |
| 115 | + notes = reader.get_technotes() | |
| 116 | + note = notes[1] # The first inserted one | |
| 117 | + assert note["user"] == "admin" | |
| 118 | + assert note["comment"] == "First technote" | |
| 119 | + assert note["timestamp"] is not None | |
| 120 | + | |
| 121 | + def test_empty_repo(self, tmp_path): | |
| 122 | + db_path = tmp_path / "empty.fossil" | |
| 123 | + conn = _create_test_fossil_db(db_path) | |
| 124 | + conn.close() | |
| 125 | + reader = FossilReader(db_path) | |
| 126 | + with reader: | |
| 127 | + notes = reader.get_technotes() | |
| 128 | + assert notes == [] | |
| 129 | + | |
| 130 | + | |
| 131 | +class TestGetTechnoteDetail: | |
| 132 | + def test_returns_detail_with_body(self, fossil_db): | |
| 133 | + reader = FossilReader(fossil_db) | |
| 134 | + with reader: | |
| 135 | + note = reader.get_technote_detail("abc123def456") | |
| 136 | + assert note is not None | |
| 137 | + assert note["uuid"] == "abc123def456" | |
| 138 | + assert note["comment"] == "First technote" | |
| 139 | + assert "# Hello" in note["body"] | |
| 140 | + assert "This is the body." in note["body"] | |
| 141 | + | |
| 142 | + def test_prefix_match(self, fossil_db): | |
| 143 | + reader = FossilReader(fossil_db) | |
| 144 | + with reader: | |
| 145 | + note = reader.get_technote_detail("abc123") | |
| 146 | + assert note is not None | |
| 147 | + assert note["uuid"] == "abc123def456" | |
| 148 | + | |
| 149 | + def test_not_found(self, fossil_db): | |
| 150 | + reader = FossilReader(fossil_db) | |
| 151 | + with reader: | |
| 152 | + note = reader.get_technote_detail("nonexistent") | |
| 153 | + assert note is None | |
| 154 | + | |
| 155 | + | |
| 156 | +class TestGetUnversionedFiles: | |
| 157 | + def test_returns_files(self, tmp_path): | |
| 158 | + db_path = tmp_path / "uv.fossil" | |
| 159 | + conn = _create_test_fossil_db(db_path) | |
| 160 | + conn.execute( | |
| 161 | + """ | |
| 162 | + CREATE TABLE unversioned ( | |
| 163 | + uvid INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 164 | + name TEXT UNIQUE, | |
| 165 | + rcvid INTEGER, | |
| 166 | + mtime DATETIME, | |
| 167 | + hash TEXT, | |
| 168 | + sz INTEGER, | |
| 169 | + encoding INT, | |
| 170 | + content BLOB | |
| 171 | + ) | |
| 172 | + """ | |
| 173 | + ) | |
| 174 | + conn.execute( | |
| 175 | + "INSERT INTO unversioned (name, mtime, hash, sz, encoding, content) VALUES (?, ?, ?, ?, ?, ?)", | |
| 176 | + ("readme.txt", 1700000000, "abc123hash", 42, 0, b"file content"), | |
| 177 | + ) | |
| 178 | + conn.execute( | |
| 179 | + "INSERT INTO unversioned (name, mtime, hash, sz, encoding, content) VALUES (?, ?, ?, ?, ?, ?)", | |
| 180 | + ("bin/app.tar.gz", 1700001000, "def456hash", 1024, 0, b"tarball"), | |
| 181 | + ) | |
| 182 | + conn.commit() | |
| 183 | + conn.close() | |
| 184 | + | |
| 185 | + reader = FossilReader(db_path) | |
| 186 | + with reader: | |
| 187 | + files = reader.get_unversioned_files() | |
| 188 | + assert len(files) == 2 | |
| 189 | + assert files[0]["name"] == "bin/app.tar.gz" # Alphabetical | |
| 190 | + assert files[1]["name"] == "readme.txt" | |
| 191 | + assert files[1]["size"] == 42 | |
| 192 | + assert files[1]["hash"] == "abc123hash" | |
| 193 | + assert files[1]["mtime"] is not None | |
| 194 | + | |
| 195 | + def test_no_unversioned_table(self, tmp_path): | |
| 196 | + """Repos without unversioned content don't have the table -- should return empty.""" | |
| 197 | + db_path = tmp_path / "no_uv.fossil" | |
| 198 | + conn = _create_test_fossil_db(db_path) | |
| 199 | + conn.close() | |
| 200 | + reader = FossilReader(db_path) | |
| 201 | + with reader: | |
| 202 | + files = reader.get_unversioned_files() | |
| 203 | + assert files == [] | |
| 204 | + | |
| 205 | + def test_deleted_files_excluded(self, tmp_path): | |
| 206 | + """Deleted UV files have empty hash -- should be excluded.""" | |
| 207 | + db_path = tmp_path / "del_uv.fossil" | |
| 208 | + conn = _create_test_fossil_db(db_path) | |
| 209 | + conn.execute( | |
| 210 | + """ | |
| 211 | + CREATE TABLE unversioned ( | |
| 212 | + uvid INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 213 | + name TEXT UNIQUE, rcvid INTEGER, mtime DATETIME, | |
| 214 | + hash TEXT, sz INTEGER, encoding INT, content BLOB | |
| 215 | + ) | |
| 216 | + """ | |
| 217 | + ) | |
| 218 | + conn.execute( | |
| 219 | + "INSERT INTO unversioned (name, mtime, hash, sz) VALUES (?, ?, ?, ?)", | |
| 220 | + ("alive.txt", 1700000000, "somehash", 10), | |
| 221 | + ) | |
| 222 | + conn.execute( | |
| 223 | + "INSERT INTO unversioned (name, mtime, hash, sz) VALUES (?, ?, ?, ?)", | |
| 224 | + ("deleted.txt", 1700000000, "", 0), | |
| 225 | + ) | |
| 226 | + conn.commit() | |
| 227 | + conn.close() | |
| 228 | + | |
| 229 | + reader = FossilReader(db_path) | |
| 230 | + with reader: | |
| 231 | + files = reader.get_unversioned_files() | |
| 232 | + assert len(files) == 1 | |
| 233 | + assert files[0]["name"] == "alive.txt" | |
| 234 | + | |
| 235 | + | |
| 236 | +# --- View tests (Django DB needed) --- | |
| 237 | + | |
| 238 | + | |
| 239 | +@pytest.mark.django_db | |
| 240 | +class TestTechnoteListView: | |
| 241 | + def test_list_page_loads(self, admin_client, sample_project, fossil_repo_obj): | |
| 242 | + mock = _make_reader_mock(get_technotes=[{"uuid": "abc123", "timestamp": "2024-01-01", "user": "admin", "comment": "Test note"}]) | |
| 243 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 244 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/") | |
| 245 | + assert response.status_code == 200 | |
| 246 | + content = response.content.decode() | |
| 247 | + assert "Technotes" in content | |
| 248 | + assert "Test note" in content | |
| 249 | + | |
| 250 | + def test_list_shows_create_button_for_writer(self, admin_client, sample_project, fossil_repo_obj): | |
| 251 | + mock = _make_reader_mock(get_technotes=[]) | |
| 252 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 253 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/") | |
| 254 | + assert response.status_code == 200 | |
| 255 | + assert "New Technote" in response.content.decode() | |
| 256 | + | |
| 257 | + def test_list_hides_create_button_for_reader(self, sample_project, fossil_repo_obj): | |
| 258 | + from django.contrib.auth.models import User | |
| 259 | + from django.test import Client | |
| 260 | + | |
| 261 | + User.objects.create_user(username="reader_only", password="testpass123") | |
| 262 | + c = Client() | |
| 263 | + c.login(username="reader_only", password="testpass123") | |
| 264 | + sample_project.visibility = "public" | |
| 265 | + sample_project.save() | |
| 266 | + mock = _make_reader_mock(get_technotes=[]) | |
| 267 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 268 | + response = c.get(f"/projects/{sample_project.slug}/fossil/technotes/") | |
| 269 | + assert response.status_code == 200 | |
| 270 | + assert "New Technote" not in response.content.decode() | |
| 271 | + | |
| 272 | + def test_list_denied_for_no_perm_on_private(self, no_perm_client, sample_project): | |
| 273 | + response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/technotes/") | |
| 274 | + assert response.status_code == 403 | |
| 275 | + | |
| 276 | + | |
| 277 | +@pytest.mark.django_db | |
| 278 | +class TestTechnoteCreateView: | |
| 279 | + def test_get_form(self, admin_client, sample_project, fossil_repo_obj): | |
| 280 | + with _disk_patch: | |
| 281 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/create/") | |
| 282 | + assert response.status_code == 200 | |
| 283 | + assert "New Technote" in response.content.decode() | |
| 284 | + | |
| 285 | + def test_create_technote(self, admin_client, sample_project, fossil_repo_obj): | |
| 286 | + mock_cli = MagicMock() | |
| 287 | + mock_cli.return_value.technote_create.return_value = True | |
| 288 | + with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli): | |
| 289 | + response = admin_client.post( | |
| 290 | + f"/projects/{sample_project.slug}/fossil/technotes/create/", | |
| 291 | + {"title": "My Note", "body": "Note body content", "timestamp": ""}, | |
| 292 | + ) | |
| 293 | + assert response.status_code == 302 # Redirect to list | |
| 294 | + | |
| 295 | + def test_create_denied_for_anon(self, client, sample_project): | |
| 296 | + response = client.get(f"/projects/{sample_project.slug}/fossil/technotes/create/") | |
| 297 | + assert response.status_code == 302 # Redirect to login | |
| 298 | + | |
| 299 | + def test_create_denied_for_no_perm(self, no_perm_client, sample_project): | |
| 300 | + response = no_perm_client.post( | |
| 301 | + f"/projects/{sample_project.slug}/fossil/technotes/create/", | |
| 302 | + {"title": "Nope", "body": "denied"}, | |
| 303 | + ) | |
| 304 | + assert response.status_code == 403 | |
| 305 | + | |
| 306 | + | |
| 307 | +@pytest.mark.django_db | |
| 308 | +class TestTechnoteDetailView: | |
| 309 | + def test_detail_page(self, admin_client, sample_project, fossil_repo_obj): | |
| 310 | + mock = _make_reader_mock( | |
| 311 | + get_technote_detail={ | |
| 312 | + "uuid": "abc123def456", | |
| 313 | + "timestamp": "2024-01-01", | |
| 314 | + "user": "admin", | |
| 315 | + "comment": "Test technote", | |
| 316 | + "body": "# Hello\n\nBody text.", | |
| 317 | + } | |
| 318 | + ) | |
| 319 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 320 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/") | |
| 321 | + assert response.status_code == 200 | |
| 322 | + content = response.content.decode() | |
| 323 | + assert "Test technote" in content | |
| 324 | + assert "Body text" in content | |
| 325 | + | |
| 326 | + def test_detail_not_found(self, admin_client, sample_project, fossil_repo_obj): | |
| 327 | + mock = _make_reader_mock(get_technote_detail=None) | |
| 328 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 329 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/nonexistent/") | |
| 330 | + assert response.status_code == 404 | |
| 331 | + | |
| 332 | + def test_detail_denied_for_no_perm_on_private(self, no_perm_client, sample_project): | |
| 333 | + response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123/") | |
| 334 | + assert response.status_code == 403 | |
| 335 | + | |
| 336 | + | |
| 337 | +@pytest.mark.django_db | |
| 338 | +class TestTechnoteEditView: | |
| 339 | + def test_get_edit_form(self, admin_client, sample_project, fossil_repo_obj): | |
| 340 | + mock = _make_reader_mock( | |
| 341 | + get_technote_detail={ | |
| 342 | + "uuid": "abc123def456", | |
| 343 | + "timestamp": "2024-01-01", | |
| 344 | + "user": "admin", | |
| 345 | + "comment": "Test technote", | |
| 346 | + "body": "Existing body content", | |
| 347 | + } | |
| 348 | + ) | |
| 349 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 350 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/edit/") | |
| 351 | + assert response.status_code == 200 | |
| 352 | + content = response.content.decode() | |
| 353 | + assert "Existing body content" in content | |
| 354 | + | |
| 355 | + def test_edit_technote(self, admin_client, sample_project, fossil_repo_obj): | |
| 356 | + mock = _make_reader_mock( | |
| 357 | + get_technote_detail={ | |
| 358 | + "uuid": "abc123def456", | |
| 359 | + "timestamp": "2024-01-01", | |
| 360 | + "user": "admin", | |
| 361 | + "comment": "Test technote", | |
| 362 | + "body": "Old body", | |
| 363 | + } | |
| 364 | + ) | |
| 365 | + mock_cli = MagicMock() | |
| 366 | + mock_cli.return_value.technote_edit.return_value = True | |
| 367 | + with _disk_patch, patch("fossil.views.FossilReader", mock), patch("fossil.cli.FossilCLI", mock_cli): | |
| 368 | + response = admin_client.post( | |
| 369 | + f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/edit/", | |
| 370 | + {"body": "Updated body content"}, | |
| 371 | + ) | |
| 372 | + assert response.status_code == 302 # Redirect to detail | |
| 373 | + | |
| 374 | + def test_edit_denied_for_anon(self, client, sample_project): | |
| 375 | + response = client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123/edit/") | |
| 376 | + assert response.status_code == 302 # Redirect to login | |
| 377 | + | |
| 378 | + def test_edit_denied_for_no_perm(self, no_perm_client, sample_project): | |
| 379 | + response = no_perm_client.post( | |
| 380 | + f"/projects/{sample_project.slug}/fossil/technotes/abc123/edit/", | |
| 381 | + {"body": "denied"}, | |
| 382 | + ) | |
| 383 | + assert response.status_code == 403 |
| --- a/tests/test_technotes.py | |
| +++ b/tests/test_technotes.py | |
| @@ -0,0 +1,383 @@ | |
| --- a/tests/test_technotes.py | |
| +++ b/tests/test_technotes.py | |
| @@ -0,0 +1,383 @@ | |
| 1 | import sqlite3 |
| 2 | from pathlib import Path |
| 3 | from unittest.mock import MagicMock, patch |
| 4 | |
| 5 | import pytest |
| 6 | |
| 7 | from fossil.models import FossilRepository |
| 8 | from fossil.reader import FossilReader |
| 9 | |
| 10 | # Reusable patch that makes FossilRepository.exists_on_disk return True |
| 11 | _disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True)) |
| 12 | |
| 13 | |
| 14 | @pytest.fixture |
| 15 | def fossil_repo_obj(sample_project): |
| 16 | """Return the auto-created FossilRepository for sample_project.""" |
| 17 | return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True) |
| 18 | |
| 19 | |
| 20 | def _create_test_fossil_db(path: Path): |
| 21 | """Create a minimal .fossil SQLite database with the tables reader.py needs.""" |
| 22 | conn = sqlite3.connect(str(path)) |
| 23 | conn.execute( |
| 24 | """ |
| 25 | CREATE TABLE blob ( |
| 26 | rid INTEGER PRIMARY KEY, |
| 27 | uuid TEXT UNIQUE NOT NULL, |
| 28 | size INTEGER NOT NULL DEFAULT 0, |
| 29 | content BLOB |
| 30 | ) |
| 31 | """ |
| 32 | ) |
| 33 | conn.execute( |
| 34 | """ |
| 35 | CREATE TABLE event ( |
| 36 | type TEXT, |
| 37 | mtime REAL, |
| 38 | objid INTEGER, |
| 39 | user TEXT, |
| 40 | comment TEXT |
| 41 | ) |
| 42 | """ |
| 43 | ) |
| 44 | conn.commit() |
| 45 | return conn |
| 46 | |
| 47 | |
| 48 | def _insert_technote(conn, rid, uuid, mtime, user, comment, body_content=""): |
| 49 | """Insert a technote event and blob into the test database. |
| 50 | |
| 51 | Technotes use event.type = 'e'. The blob contains a Fossil wiki artifact |
| 52 | format: header cards followed by W <size>\\n<content>\\nZ <hash>. |
| 53 | """ |
| 54 | import struct |
| 55 | import zlib |
| 56 | |
| 57 | # Build a minimal Fossil wiki artifact containing the body |
| 58 | artifact = f"D 2024-01-01T00:00:00\nU {user}\nW {len(body_content.encode('utf-8'))}\n{body_content}\nZ 0000000000000000" |
| 59 | raw_bytes = artifact.encode("utf-8") |
| 60 | |
| 61 | # Fossil stores blobs with a 4-byte big-endian size prefix + zlib compressed content |
| 62 | compressed = struct.pack(">I", len(raw_bytes)) + zlib.compress(raw_bytes) |
| 63 | |
| 64 | conn.execute("INSERT INTO blob (rid, uuid, size, content) VALUES (?, ?, ?, ?)", (rid, uuid, len(raw_bytes), compressed)) |
| 65 | conn.execute("INSERT INTO event (type, mtime, objid, user, comment) VALUES ('e', ?, ?, ?, ?)", (mtime, rid, user, comment)) |
| 66 | conn.commit() |
| 67 | |
| 68 | |
| 69 | @pytest.fixture |
| 70 | def fossil_db(tmp_path): |
| 71 | """Create a temporary .fossil file with technote data for reader tests.""" |
| 72 | db_path = tmp_path / "test.fossil" |
| 73 | conn = _create_test_fossil_db(db_path) |
| 74 | _insert_technote(conn, 100, "abc123def456", 2460676.5, "admin", "First technote", "# Hello\n\nThis is the body.") |
| 75 | _insert_technote(conn, 101, "xyz789ghi012", 2460677.5, "dev", "Second technote", "Another note body.") |
| 76 | conn.close() |
| 77 | return db_path |
| 78 | |
| 79 | |
| 80 | def _make_reader_mock(**methods): |
| 81 | """Create a MagicMock that replaces FossilReader as a class. |
| 82 | |
| 83 | The returned mock supports: |
| 84 | reader = FossilReader(path) # returns a mock instance |
| 85 | with reader: # context manager |
| 86 | reader.some_method() # returns configured value |
| 87 | """ |
| 88 | mock_cls = MagicMock() |
| 89 | # The instance returned by calling the class |
| 90 | instance = MagicMock() |
| 91 | mock_cls.return_value = instance |
| 92 | # Context manager support: __enter__ returns the same instance |
| 93 | instance.__enter__ = MagicMock(return_value=instance) |
| 94 | instance.__exit__ = MagicMock(return_value=False) |
| 95 | for name, val in methods.items(): |
| 96 | getattr(instance, name).return_value = val |
| 97 | return mock_cls |
| 98 | |
| 99 | |
| 100 | # --- Reader unit tests (no Django DB needed) --- |
| 101 | |
| 102 | |
| 103 | class TestGetTechnotes: |
| 104 | def test_returns_technotes(self, fossil_db): |
| 105 | reader = FossilReader(fossil_db) |
| 106 | with reader: |
| 107 | notes = reader.get_technotes() |
| 108 | assert len(notes) == 2 |
| 109 | assert notes[0]["uuid"] == "xyz789ghi012" # Most recent first |
| 110 | assert notes[1]["uuid"] == "abc123def456" |
| 111 | |
| 112 | def test_technote_fields(self, fossil_db): |
| 113 | reader = FossilReader(fossil_db) |
| 114 | with reader: |
| 115 | notes = reader.get_technotes() |
| 116 | note = notes[1] # The first inserted one |
| 117 | assert note["user"] == "admin" |
| 118 | assert note["comment"] == "First technote" |
| 119 | assert note["timestamp"] is not None |
| 120 | |
| 121 | def test_empty_repo(self, tmp_path): |
| 122 | db_path = tmp_path / "empty.fossil" |
| 123 | conn = _create_test_fossil_db(db_path) |
| 124 | conn.close() |
| 125 | reader = FossilReader(db_path) |
| 126 | with reader: |
| 127 | notes = reader.get_technotes() |
| 128 | assert notes == [] |
| 129 | |
| 130 | |
| 131 | class TestGetTechnoteDetail: |
| 132 | def test_returns_detail_with_body(self, fossil_db): |
| 133 | reader = FossilReader(fossil_db) |
| 134 | with reader: |
| 135 | note = reader.get_technote_detail("abc123def456") |
| 136 | assert note is not None |
| 137 | assert note["uuid"] == "abc123def456" |
| 138 | assert note["comment"] == "First technote" |
| 139 | assert "# Hello" in note["body"] |
| 140 | assert "This is the body." in note["body"] |
| 141 | |
| 142 | def test_prefix_match(self, fossil_db): |
| 143 | reader = FossilReader(fossil_db) |
| 144 | with reader: |
| 145 | note = reader.get_technote_detail("abc123") |
| 146 | assert note is not None |
| 147 | assert note["uuid"] == "abc123def456" |
| 148 | |
| 149 | def test_not_found(self, fossil_db): |
| 150 | reader = FossilReader(fossil_db) |
| 151 | with reader: |
| 152 | note = reader.get_technote_detail("nonexistent") |
| 153 | assert note is None |
| 154 | |
| 155 | |
| 156 | class TestGetUnversionedFiles: |
| 157 | def test_returns_files(self, tmp_path): |
| 158 | db_path = tmp_path / "uv.fossil" |
| 159 | conn = _create_test_fossil_db(db_path) |
| 160 | conn.execute( |
| 161 | """ |
| 162 | CREATE TABLE unversioned ( |
| 163 | uvid INTEGER PRIMARY KEY AUTOINCREMENT, |
| 164 | name TEXT UNIQUE, |
| 165 | rcvid INTEGER, |
| 166 | mtime DATETIME, |
| 167 | hash TEXT, |
| 168 | sz INTEGER, |
| 169 | encoding INT, |
| 170 | content BLOB |
| 171 | ) |
| 172 | """ |
| 173 | ) |
| 174 | conn.execute( |
| 175 | "INSERT INTO unversioned (name, mtime, hash, sz, encoding, content) VALUES (?, ?, ?, ?, ?, ?)", |
| 176 | ("readme.txt", 1700000000, "abc123hash", 42, 0, b"file content"), |
| 177 | ) |
| 178 | conn.execute( |
| 179 | "INSERT INTO unversioned (name, mtime, hash, sz, encoding, content) VALUES (?, ?, ?, ?, ?, ?)", |
| 180 | ("bin/app.tar.gz", 1700001000, "def456hash", 1024, 0, b"tarball"), |
| 181 | ) |
| 182 | conn.commit() |
| 183 | conn.close() |
| 184 | |
| 185 | reader = FossilReader(db_path) |
| 186 | with reader: |
| 187 | files = reader.get_unversioned_files() |
| 188 | assert len(files) == 2 |
| 189 | assert files[0]["name"] == "bin/app.tar.gz" # Alphabetical |
| 190 | assert files[1]["name"] == "readme.txt" |
| 191 | assert files[1]["size"] == 42 |
| 192 | assert files[1]["hash"] == "abc123hash" |
| 193 | assert files[1]["mtime"] is not None |
| 194 | |
| 195 | def test_no_unversioned_table(self, tmp_path): |
| 196 | """Repos without unversioned content don't have the table -- should return empty.""" |
| 197 | db_path = tmp_path / "no_uv.fossil" |
| 198 | conn = _create_test_fossil_db(db_path) |
| 199 | conn.close() |
| 200 | reader = FossilReader(db_path) |
| 201 | with reader: |
| 202 | files = reader.get_unversioned_files() |
| 203 | assert files == [] |
| 204 | |
| 205 | def test_deleted_files_excluded(self, tmp_path): |
| 206 | """Deleted UV files have empty hash -- should be excluded.""" |
| 207 | db_path = tmp_path / "del_uv.fossil" |
| 208 | conn = _create_test_fossil_db(db_path) |
| 209 | conn.execute( |
| 210 | """ |
| 211 | CREATE TABLE unversioned ( |
| 212 | uvid INTEGER PRIMARY KEY AUTOINCREMENT, |
| 213 | name TEXT UNIQUE, rcvid INTEGER, mtime DATETIME, |
| 214 | hash TEXT, sz INTEGER, encoding INT, content BLOB |
| 215 | ) |
| 216 | """ |
| 217 | ) |
| 218 | conn.execute( |
| 219 | "INSERT INTO unversioned (name, mtime, hash, sz) VALUES (?, ?, ?, ?)", |
| 220 | ("alive.txt", 1700000000, "somehash", 10), |
| 221 | ) |
| 222 | conn.execute( |
| 223 | "INSERT INTO unversioned (name, mtime, hash, sz) VALUES (?, ?, ?, ?)", |
| 224 | ("deleted.txt", 1700000000, "", 0), |
| 225 | ) |
| 226 | conn.commit() |
| 227 | conn.close() |
| 228 | |
| 229 | reader = FossilReader(db_path) |
| 230 | with reader: |
| 231 | files = reader.get_unversioned_files() |
| 232 | assert len(files) == 1 |
| 233 | assert files[0]["name"] == "alive.txt" |
| 234 | |
| 235 | |
| 236 | # --- View tests (Django DB needed) --- |
| 237 | |
| 238 | |
| 239 | @pytest.mark.django_db |
| 240 | class TestTechnoteListView: |
| 241 | def test_list_page_loads(self, admin_client, sample_project, fossil_repo_obj): |
| 242 | mock = _make_reader_mock(get_technotes=[{"uuid": "abc123", "timestamp": "2024-01-01", "user": "admin", "comment": "Test note"}]) |
| 243 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 244 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/") |
| 245 | assert response.status_code == 200 |
| 246 | content = response.content.decode() |
| 247 | assert "Technotes" in content |
| 248 | assert "Test note" in content |
| 249 | |
| 250 | def test_list_shows_create_button_for_writer(self, admin_client, sample_project, fossil_repo_obj): |
| 251 | mock = _make_reader_mock(get_technotes=[]) |
| 252 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 253 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/") |
| 254 | assert response.status_code == 200 |
| 255 | assert "New Technote" in response.content.decode() |
| 256 | |
| 257 | def test_list_hides_create_button_for_reader(self, sample_project, fossil_repo_obj): |
| 258 | from django.contrib.auth.models import User |
| 259 | from django.test import Client |
| 260 | |
| 261 | User.objects.create_user(username="reader_only", password="testpass123") |
| 262 | c = Client() |
| 263 | c.login(username="reader_only", password="testpass123") |
| 264 | sample_project.visibility = "public" |
| 265 | sample_project.save() |
| 266 | mock = _make_reader_mock(get_technotes=[]) |
| 267 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 268 | response = c.get(f"/projects/{sample_project.slug}/fossil/technotes/") |
| 269 | assert response.status_code == 200 |
| 270 | assert "New Technote" not in response.content.decode() |
| 271 | |
| 272 | def test_list_denied_for_no_perm_on_private(self, no_perm_client, sample_project): |
| 273 | response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/technotes/") |
| 274 | assert response.status_code == 403 |
| 275 | |
| 276 | |
| 277 | @pytest.mark.django_db |
| 278 | class TestTechnoteCreateView: |
| 279 | def test_get_form(self, admin_client, sample_project, fossil_repo_obj): |
| 280 | with _disk_patch: |
| 281 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/create/") |
| 282 | assert response.status_code == 200 |
| 283 | assert "New Technote" in response.content.decode() |
| 284 | |
| 285 | def test_create_technote(self, admin_client, sample_project, fossil_repo_obj): |
| 286 | mock_cli = MagicMock() |
| 287 | mock_cli.return_value.technote_create.return_value = True |
| 288 | with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli): |
| 289 | response = admin_client.post( |
| 290 | f"/projects/{sample_project.slug}/fossil/technotes/create/", |
| 291 | {"title": "My Note", "body": "Note body content", "timestamp": ""}, |
| 292 | ) |
| 293 | assert response.status_code == 302 # Redirect to list |
| 294 | |
| 295 | def test_create_denied_for_anon(self, client, sample_project): |
| 296 | response = client.get(f"/projects/{sample_project.slug}/fossil/technotes/create/") |
| 297 | assert response.status_code == 302 # Redirect to login |
| 298 | |
| 299 | def test_create_denied_for_no_perm(self, no_perm_client, sample_project): |
| 300 | response = no_perm_client.post( |
| 301 | f"/projects/{sample_project.slug}/fossil/technotes/create/", |
| 302 | {"title": "Nope", "body": "denied"}, |
| 303 | ) |
| 304 | assert response.status_code == 403 |
| 305 | |
| 306 | |
| 307 | @pytest.mark.django_db |
| 308 | class TestTechnoteDetailView: |
| 309 | def test_detail_page(self, admin_client, sample_project, fossil_repo_obj): |
| 310 | mock = _make_reader_mock( |
| 311 | get_technote_detail={ |
| 312 | "uuid": "abc123def456", |
| 313 | "timestamp": "2024-01-01", |
| 314 | "user": "admin", |
| 315 | "comment": "Test technote", |
| 316 | "body": "# Hello\n\nBody text.", |
| 317 | } |
| 318 | ) |
| 319 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 320 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/") |
| 321 | assert response.status_code == 200 |
| 322 | content = response.content.decode() |
| 323 | assert "Test technote" in content |
| 324 | assert "Body text" in content |
| 325 | |
| 326 | def test_detail_not_found(self, admin_client, sample_project, fossil_repo_obj): |
| 327 | mock = _make_reader_mock(get_technote_detail=None) |
| 328 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 329 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/nonexistent/") |
| 330 | assert response.status_code == 404 |
| 331 | |
| 332 | def test_detail_denied_for_no_perm_on_private(self, no_perm_client, sample_project): |
| 333 | response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123/") |
| 334 | assert response.status_code == 403 |
| 335 | |
| 336 | |
| 337 | @pytest.mark.django_db |
| 338 | class TestTechnoteEditView: |
| 339 | def test_get_edit_form(self, admin_client, sample_project, fossil_repo_obj): |
| 340 | mock = _make_reader_mock( |
| 341 | get_technote_detail={ |
| 342 | "uuid": "abc123def456", |
| 343 | "timestamp": "2024-01-01", |
| 344 | "user": "admin", |
| 345 | "comment": "Test technote", |
| 346 | "body": "Existing body content", |
| 347 | } |
| 348 | ) |
| 349 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 350 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/edit/") |
| 351 | assert response.status_code == 200 |
| 352 | content = response.content.decode() |
| 353 | assert "Existing body content" in content |
| 354 | |
| 355 | def test_edit_technote(self, admin_client, sample_project, fossil_repo_obj): |
| 356 | mock = _make_reader_mock( |
| 357 | get_technote_detail={ |
| 358 | "uuid": "abc123def456", |
| 359 | "timestamp": "2024-01-01", |
| 360 | "user": "admin", |
| 361 | "comment": "Test technote", |
| 362 | "body": "Old body", |
| 363 | } |
| 364 | ) |
| 365 | mock_cli = MagicMock() |
| 366 | mock_cli.return_value.technote_edit.return_value = True |
| 367 | with _disk_patch, patch("fossil.views.FossilReader", mock), patch("fossil.cli.FossilCLI", mock_cli): |
| 368 | response = admin_client.post( |
| 369 | f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/edit/", |
| 370 | {"body": "Updated body content"}, |
| 371 | ) |
| 372 | assert response.status_code == 302 # Redirect to detail |
| 373 | |
| 374 | def test_edit_denied_for_anon(self, client, sample_project): |
| 375 | response = client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123/edit/") |
| 376 | assert response.status_code == 302 # Redirect to login |
| 377 | |
| 378 | def test_edit_denied_for_no_perm(self, no_perm_client, sample_project): |
| 379 | response = no_perm_client.post( |
| 380 | f"/projects/{sample_project.slug}/fossil/technotes/abc123/edit/", |
| 381 | {"body": "denied"}, |
| 382 | ) |
| 383 | assert response.status_code == 403 |
| --- a/tests/test_unversioned.py | ||
| +++ b/tests/test_unversioned.py | ||
| @@ -0,0 +1,185 @@ | ||
| 1 | +from unittest.mock import MagicMock, patch | |
| 2 | + | |
| 3 | +import pytest | |
| 4 | + | |
| 5 | +from fossil.models import FossilRepository | |
| 6 | + | |
| 7 | +# Reusable patch that makes FossilRepository.exists_on_disk return True | |
| 8 | +_disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True)) | |
| 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 | +def _make_reader_mock(**methods): | |
| 18 | + """Create a MagicMock that replaces FossilReader as a class. | |
| 19 | + | |
| 20 | + The returned mock supports: | |
| 21 | + reader = FossilReader(path) # returns a mock instance | |
| 22 | + with reader: # context manager | |
| 23 | + reader.some_method() # returns configured value | |
| 24 | + """ | |
| 25 | + mock_cls = MagicMock() | |
| 26 | + instance = MagicMock() | |
| 27 | + mock_cls.return_value = instance | |
| 28 | + instance.__enter__ = MagicMock(return_value=instance) | |
| 29 | + instance.__exit__ = MagicMock(return_value=False) | |
| 30 | + for name, val in methods.items(): | |
| 31 | + getattr(instance, name).return_value = val | |
| 32 | + return mock_cls | |
| 33 | + | |
| 34 | + | |
| 35 | +@pytest.mark.django_db | |
| 36 | +class TestUnversionedListView: | |
| 37 | + def test_list_page_loads(self, admin_client, sample_project, fossil_repo_obj): | |
| 38 | + mock = _make_reader_mock( | |
| 39 | + get_unversioned_files=[ | |
| 40 | + {"name": "release.tar.gz", "size": 1024, "mtime": None, "hash": "abc"}, | |
| 41 | + {"name": "readme.txt", "size": 42, "mtime": None, "hash": "def"}, | |
| 42 | + ] | |
| 43 | + ) | |
| 44 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 45 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/") | |
| 46 | + assert response.status_code == 200 | |
| 47 | + content = response.content.decode() | |
| 48 | + assert "Unversioned Files" in content | |
| 49 | + assert "release.tar.gz" in content | |
| 50 | + assert "readme.txt" in content | |
| 51 | + | |
| 52 | + def test_list_empty(self, admin_client, sample_project, fossil_repo_obj): | |
| 53 | + mock = _make_reader_mock(get_unversioned_files=[]) | |
| 54 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 55 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/") | |
| 56 | + assert response.status_code == 200 | |
| 57 | + assert "No unversioned files" in response.content.decode() | |
| 58 | + | |
| 59 | + def test_list_shows_upload_for_admin(self, admin_client, sample_project, fossil_repo_obj): | |
| 60 | + mock = _make_reader_mock(get_unversioned_files=[]) | |
| 61 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 62 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/") | |
| 63 | + assert response.status_code == 200 | |
| 64 | + assert "Upload File" in response.content.decode() | |
| 65 | + | |
| 66 | + def test_list_hides_upload_for_non_admin(self, sample_project, fossil_repo_obj): | |
| 67 | + """A user with write but not admin access should not see the upload form.""" | |
| 68 | + from django.contrib.auth.models import User | |
| 69 | + from django.test import Client | |
| 70 | + | |
| 71 | + from organization.models import Team | |
| 72 | + from projects.models import ProjectTeam | |
| 73 | + | |
| 74 | + writer = User.objects.create_user(username="writer_only", password="testpass123") | |
| 75 | + team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=writer) | |
| 76 | + team.members.add(writer) | |
| 77 | + ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=writer) | |
| 78 | + | |
| 79 | + c = Client() | |
| 80 | + c.login(username="writer_only", password="testpass123") | |
| 81 | + mock = _make_reader_mock(get_unversioned_files=[]) | |
| 82 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 83 | + response = c.get(f"/projects/{sample_project.slug}/fossil/files/") | |
| 84 | + assert response.status_code == 200 | |
| 85 | + assert "Upload File" not in response.content.decode() | |
| 86 | + | |
| 87 | + def test_list_denied_for_no_perm_on_private(self, no_perm_client, sample_project): | |
| 88 | + response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/files/") | |
| 89 | + assert response.status_code == 403 | |
| 90 | + | |
| 91 | + | |
| 92 | +@pytest.mark.django_db | |
| 93 | +class TestUnversionedDownloadView: | |
| 94 | + def test_download_file(self, admin_client, sample_project, fossil_repo_obj): | |
| 95 | + mock_cli = MagicMock() | |
| 96 | + mock_cli.return_value.uv_cat.return_value = b"file content here" | |
| 97 | + with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli): | |
| 98 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/download/readme.txt") | |
| 99 | + assert response.status_code == 200 | |
| 100 | + assert response.content == b"file content here" | |
| 101 | + assert response["Content-Disposition"] == 'attachment; filename="readme.txt"' | |
| 102 | + | |
| 103 | + def test_download_nested_path(self, admin_client, sample_project, fossil_repo_obj): | |
| 104 | + mock_cli = MagicMock() | |
| 105 | + mock_cli.return_value.uv_cat.return_value = b"tarball bytes" | |
| 106 | + with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli): | |
| 107 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/download/dist/app-v1.0.tar.gz") | |
| 108 | + assert response.status_code == 200 | |
| 109 | + assert response["Content-Disposition"] == 'attachment; filename="app-v1.0.tar.gz"' | |
| 110 | + | |
| 111 | + def test_download_not_found(self, admin_client, sample_project, fossil_repo_obj): | |
| 112 | + mock_cli = MagicMock() | |
| 113 | + mock_cli.return_value.uv_cat.side_effect = FileNotFoundError("Not found") | |
| 114 | + with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli): | |
| 115 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/download/missing.txt") | |
| 116 | + assert response.status_code == 404 | |
| 117 | + | |
| 118 | + def test_download_denied_for_no_perm_on_private(self, no_perm_client, sample_project): | |
| 119 | + response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/files/download/secret.txt") | |
| 120 | + assert response.status_code == 403 | |
| 121 | + | |
| 122 | + | |
| 123 | +@pytest.mark.django_db | |
| 124 | +class TestUnversionedUploadView: | |
| 125 | + def test_upload_file(self, admin_client, sample_project, fossil_repo_obj): | |
| 126 | + from django.core.files.uploadedfile import SimpleUploadedFile | |
| 127 | + | |
| 128 | + uploaded = SimpleUploadedFile("artifact.bin", b"binary content", content_type="application/octet-stream") | |
| 129 | + mock_cli = MagicMock() | |
| 130 | + mock_cli.return_value.uv_add.return_value = True | |
| 131 | + with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli): | |
| 132 | + response = admin_client.post( | |
| 133 | + f"/projects/{sample_project.slug}/fossil/files/upload/", | |
| 134 | + {"file": uploaded}, | |
| 135 | + ) | |
| 136 | + assert response.status_code == 302 # Redirect to list | |
| 137 | + mock_cli.return_value.uv_add.assert_called_once() | |
| 138 | + | |
| 139 | + def test_upload_no_file(self, admin_client, sample_project, fossil_repo_obj): | |
| 140 | + with _disk_patch: | |
| 141 | + response = admin_client.post(f"/projects/{sample_project.slug}/fossil/files/upload/") | |
| 142 | + assert response.status_code == 302 # Redirect back with error | |
| 143 | + | |
| 144 | + def test_upload_get_redirects(self, admin_client, sample_project, fossil_repo_obj): | |
| 145 | + with _disk_patch: | |
| 146 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/upload/") | |
| 147 | + assert response.status_code == 302 | |
| 148 | + | |
| 149 | + def test_upload_denied_for_anon(self, client, sample_project): | |
| 150 | + response = client.post(f"/projects/{sample_project.slug}/fossil/files/upload/") | |
| 151 | + assert response.status_code == 302 # Redirect to login | |
| 152 | + | |
| 153 | + def test_upload_denied_for_writer(self, sample_project, fossil_repo_obj): | |
| 154 | + """Upload requires admin, not just write access.""" | |
| 155 | + from django.contrib.auth.models import User | |
| 156 | + from django.test import Client | |
| 157 | + | |
| 158 | + from organization.models import Team | |
| 159 | + from projects.models import ProjectTeam | |
| 160 | + | |
| 161 | + writer = User.objects.create_user(username="writer_upl", password="testpass123") | |
| 162 | + team = Team.objects.create(name="UplWriters", organization=sample_project.organization, created_by=writer) | |
| 163 | + team.members.add(writer) | |
| 164 | + ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=writer) | |
| 165 | + | |
| 166 | + c = Client() | |
| 167 | + c.login(username="writer_upl", password="testpass123") | |
| 168 | + from django.core.files.uploadedfile import SimpleUploadedFile | |
| 169 | + | |
| 170 | + uploaded = SimpleUploadedFile("hack.bin", b"nope", content_type="application/octet-stream") | |
| 171 | + response = c.post( | |
| 172 | + f"/projects/{sample_project.slug}/fossil/files/upload/", | |
| 173 | + {"file": uploaded}, | |
| 174 | + ) | |
| 175 | + assert response.status_code == 403 | |
| 176 | + | |
| 177 | + def test_upload_denied_for_no_perm(self, no_perm_client, sample_project): | |
| 178 | + from django.core.files.uploadedfile import SimpleUploadedFile | |
| 179 | + | |
| 180 | + uploaded = SimpleUploadedFile("hack.bin", b"nope", content_type="application/octet-stream") | |
| 181 | + response = no_perm_client.post( | |
| 182 | + f"/projects/{sample_project.slug}/fossil/files/upload/", | |
| 183 | + {"file": uploaded}, | |
| 184 | + ) | |
| 185 | + assert response.status_code == 403 |
| --- a/tests/test_unversioned.py | |
| +++ b/tests/test_unversioned.py | |
| @@ -0,0 +1,185 @@ | |
| --- a/tests/test_unversioned.py | |
| +++ b/tests/test_unversioned.py | |
| @@ -0,0 +1,185 @@ | |
| 1 | from unittest.mock import MagicMock, patch |
| 2 | |
| 3 | import pytest |
| 4 | |
| 5 | from fossil.models import FossilRepository |
| 6 | |
| 7 | # Reusable patch that makes FossilRepository.exists_on_disk return True |
| 8 | _disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True)) |
| 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 | def _make_reader_mock(**methods): |
| 18 | """Create a MagicMock that replaces FossilReader as a class. |
| 19 | |
| 20 | The returned mock supports: |
| 21 | reader = FossilReader(path) # returns a mock instance |
| 22 | with reader: # context manager |
| 23 | reader.some_method() # returns configured value |
| 24 | """ |
| 25 | mock_cls = MagicMock() |
| 26 | instance = MagicMock() |
| 27 | mock_cls.return_value = instance |
| 28 | instance.__enter__ = MagicMock(return_value=instance) |
| 29 | instance.__exit__ = MagicMock(return_value=False) |
| 30 | for name, val in methods.items(): |
| 31 | getattr(instance, name).return_value = val |
| 32 | return mock_cls |
| 33 | |
| 34 | |
| 35 | @pytest.mark.django_db |
| 36 | class TestUnversionedListView: |
| 37 | def test_list_page_loads(self, admin_client, sample_project, fossil_repo_obj): |
| 38 | mock = _make_reader_mock( |
| 39 | get_unversioned_files=[ |
| 40 | {"name": "release.tar.gz", "size": 1024, "mtime": None, "hash": "abc"}, |
| 41 | {"name": "readme.txt", "size": 42, "mtime": None, "hash": "def"}, |
| 42 | ] |
| 43 | ) |
| 44 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 45 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/") |
| 46 | assert response.status_code == 200 |
| 47 | content = response.content.decode() |
| 48 | assert "Unversioned Files" in content |
| 49 | assert "release.tar.gz" in content |
| 50 | assert "readme.txt" in content |
| 51 | |
| 52 | def test_list_empty(self, admin_client, sample_project, fossil_repo_obj): |
| 53 | mock = _make_reader_mock(get_unversioned_files=[]) |
| 54 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 55 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/") |
| 56 | assert response.status_code == 200 |
| 57 | assert "No unversioned files" in response.content.decode() |
| 58 | |
| 59 | def test_list_shows_upload_for_admin(self, admin_client, sample_project, fossil_repo_obj): |
| 60 | mock = _make_reader_mock(get_unversioned_files=[]) |
| 61 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 62 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/") |
| 63 | assert response.status_code == 200 |
| 64 | assert "Upload File" in response.content.decode() |
| 65 | |
| 66 | def test_list_hides_upload_for_non_admin(self, sample_project, fossil_repo_obj): |
| 67 | """A user with write but not admin access should not see the upload form.""" |
| 68 | from django.contrib.auth.models import User |
| 69 | from django.test import Client |
| 70 | |
| 71 | from organization.models import Team |
| 72 | from projects.models import ProjectTeam |
| 73 | |
| 74 | writer = User.objects.create_user(username="writer_only", password="testpass123") |
| 75 | team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=writer) |
| 76 | team.members.add(writer) |
| 77 | ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=writer) |
| 78 | |
| 79 | c = Client() |
| 80 | c.login(username="writer_only", password="testpass123") |
| 81 | mock = _make_reader_mock(get_unversioned_files=[]) |
| 82 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 83 | response = c.get(f"/projects/{sample_project.slug}/fossil/files/") |
| 84 | assert response.status_code == 200 |
| 85 | assert "Upload File" not in response.content.decode() |
| 86 | |
| 87 | def test_list_denied_for_no_perm_on_private(self, no_perm_client, sample_project): |
| 88 | response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/files/") |
| 89 | assert response.status_code == 403 |
| 90 | |
| 91 | |
| 92 | @pytest.mark.django_db |
| 93 | class TestUnversionedDownloadView: |
| 94 | def test_download_file(self, admin_client, sample_project, fossil_repo_obj): |
| 95 | mock_cli = MagicMock() |
| 96 | mock_cli.return_value.uv_cat.return_value = b"file content here" |
| 97 | with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli): |
| 98 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/download/readme.txt") |
| 99 | assert response.status_code == 200 |
| 100 | assert response.content == b"file content here" |
| 101 | assert response["Content-Disposition"] == 'attachment; filename="readme.txt"' |
| 102 | |
| 103 | def test_download_nested_path(self, admin_client, sample_project, fossil_repo_obj): |
| 104 | mock_cli = MagicMock() |
| 105 | mock_cli.return_value.uv_cat.return_value = b"tarball bytes" |
| 106 | with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli): |
| 107 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/download/dist/app-v1.0.tar.gz") |
| 108 | assert response.status_code == 200 |
| 109 | assert response["Content-Disposition"] == 'attachment; filename="app-v1.0.tar.gz"' |
| 110 | |
| 111 | def test_download_not_found(self, admin_client, sample_project, fossil_repo_obj): |
| 112 | mock_cli = MagicMock() |
| 113 | mock_cli.return_value.uv_cat.side_effect = FileNotFoundError("Not found") |
| 114 | with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli): |
| 115 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/download/missing.txt") |
| 116 | assert response.status_code == 404 |
| 117 | |
| 118 | def test_download_denied_for_no_perm_on_private(self, no_perm_client, sample_project): |
| 119 | response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/files/download/secret.txt") |
| 120 | assert response.status_code == 403 |
| 121 | |
| 122 | |
| 123 | @pytest.mark.django_db |
| 124 | class TestUnversionedUploadView: |
| 125 | def test_upload_file(self, admin_client, sample_project, fossil_repo_obj): |
| 126 | from django.core.files.uploadedfile import SimpleUploadedFile |
| 127 | |
| 128 | uploaded = SimpleUploadedFile("artifact.bin", b"binary content", content_type="application/octet-stream") |
| 129 | mock_cli = MagicMock() |
| 130 | mock_cli.return_value.uv_add.return_value = True |
| 131 | with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli): |
| 132 | response = admin_client.post( |
| 133 | f"/projects/{sample_project.slug}/fossil/files/upload/", |
| 134 | {"file": uploaded}, |
| 135 | ) |
| 136 | assert response.status_code == 302 # Redirect to list |
| 137 | mock_cli.return_value.uv_add.assert_called_once() |
| 138 | |
| 139 | def test_upload_no_file(self, admin_client, sample_project, fossil_repo_obj): |
| 140 | with _disk_patch: |
| 141 | response = admin_client.post(f"/projects/{sample_project.slug}/fossil/files/upload/") |
| 142 | assert response.status_code == 302 # Redirect back with error |
| 143 | |
| 144 | def test_upload_get_redirects(self, admin_client, sample_project, fossil_repo_obj): |
| 145 | with _disk_patch: |
| 146 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/upload/") |
| 147 | assert response.status_code == 302 |
| 148 | |
| 149 | def test_upload_denied_for_anon(self, client, sample_project): |
| 150 | response = client.post(f"/projects/{sample_project.slug}/fossil/files/upload/") |
| 151 | assert response.status_code == 302 # Redirect to login |
| 152 | |
| 153 | def test_upload_denied_for_writer(self, sample_project, fossil_repo_obj): |
| 154 | """Upload requires admin, not just write access.""" |
| 155 | from django.contrib.auth.models import User |
| 156 | from django.test import Client |
| 157 | |
| 158 | from organization.models import Team |
| 159 | from projects.models import ProjectTeam |
| 160 | |
| 161 | writer = User.objects.create_user(username="writer_upl", password="testpass123") |
| 162 | team = Team.objects.create(name="UplWriters", organization=sample_project.organization, created_by=writer) |
| 163 | team.members.add(writer) |
| 164 | ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=writer) |
| 165 | |
| 166 | c = Client() |
| 167 | c.login(username="writer_upl", password="testpass123") |
| 168 | from django.core.files.uploadedfile import SimpleUploadedFile |
| 169 | |
| 170 | uploaded = SimpleUploadedFile("hack.bin", b"nope", content_type="application/octet-stream") |
| 171 | response = c.post( |
| 172 | f"/projects/{sample_project.slug}/fossil/files/upload/", |
| 173 | {"file": uploaded}, |
| 174 | ) |
| 175 | assert response.status_code == 403 |
| 176 | |
| 177 | def test_upload_denied_for_no_perm(self, no_perm_client, sample_project): |
| 178 | from django.core.files.uploadedfile import SimpleUploadedFile |
| 179 | |
| 180 | uploaded = SimpleUploadedFile("hack.bin", b"nope", content_type="application/octet-stream") |
| 181 | response = no_perm_client.post( |
| 182 | f"/projects/{sample_project.slug}/fossil/files/upload/", |
| 183 | {"file": uploaded}, |
| 184 | ) |
| 185 | assert response.status_code == 403 |