FossilRepo
Add multi-remote sync UI, user profile page, personal access tokens Multi-remote sync: sync page redesigned as hub showing all Git mirrors per repo as cards with direction badges, schedule, status, and per-mirror Run/Edit/Delete actions. Add/edit form improved with sync direction, branch mapping, and content options. 30 tests. User profile: unified page at /auth/profile/ with display name, @handle, bio, location, website. Consolidates SSH keys, notification prefs, and personal access tokens into one view. Profile link added to top nav dropdown. 33 tests. Personal access tokens: user-scoped tokens (frp_ prefix) with hash-only storage, configurable scopes (read/write/admin), expiry, revocation tracking. Show-once page with copy button on creation.
2ea5fbeb0fd199151d9caa13f930c17fc9b56d4cb1b586b5803260ada81a02c2
| --- a/accounts/admin.py | ||
| +++ b/accounts/admin.py | ||
| @@ -0,0 +1,20 @@ | ||
| 1 | +from django.contrib import admin | |
| 2 | + | |
| 3 | +from .models import PersonalAccessToken, UserProfile | |
| 4 | + | |
| 5 | + | |
| 6 | +@admin.register(UserProfile) | |
| 7 | +class UserProfileAdmin(admin.ModelAdmin): | |
| 8 | + list_display = ("user", "handle", "location") | |
| 9 | + search_fields = ("user__username", "handle", "location") | |
| 10 | + raw_id_fields = ("user",) | |
| 11 | + readonly_fields = ("user",) | |
| 12 | + | |
| 13 | + | |
| 14 | +@admin.register(PersonalAccessToken) | |
| 15 | +class PersonalAccessTokenAdmin(admin.ModelAdmin): | |
| 16 | + list_display = ("name", "user", "token_prefix", "scopes", "created_at", "expires_at", "last_used_at", "revoked_at") | |
| 17 | + list_filter = ("scopes",) | |
| 18 | + search_fields = ("name", "user__username", "token_prefix") | |
| 19 | + raw_id_fields = ("user",) | |
| 20 | + readonly_fields = ("token_hash", "token_prefix", "created_at", "last_used_at") |
| --- a/accounts/admin.py | |
| +++ b/accounts/admin.py | |
| @@ -0,0 +1,20 @@ | |
| --- a/accounts/admin.py | |
| +++ b/accounts/admin.py | |
| @@ -0,0 +1,20 @@ | |
| 1 | from django.contrib import admin |
| 2 | |
| 3 | from .models import PersonalAccessToken, UserProfile |
| 4 | |
| 5 | |
| 6 | @admin.register(UserProfile) |
| 7 | class UserProfileAdmin(admin.ModelAdmin): |
| 8 | list_display = ("user", "handle", "location") |
| 9 | search_fields = ("user__username", "handle", "location") |
| 10 | raw_id_fields = ("user",) |
| 11 | readonly_fields = ("user",) |
| 12 | |
| 13 | |
| 14 | @admin.register(PersonalAccessToken) |
| 15 | class PersonalAccessTokenAdmin(admin.ModelAdmin): |
| 16 | list_display = ("name", "user", "token_prefix", "scopes", "created_at", "expires_at", "last_used_at", "revoked_at") |
| 17 | list_filter = ("scopes",) |
| 18 | search_fields = ("name", "user__username", "token_prefix") |
| 19 | raw_id_fields = ("user",) |
| 20 | readonly_fields = ("token_hash", "token_prefix", "created_at", "last_used_at") |
| --- a/accounts/migrations/0001_initial.py | ||
| +++ b/accounts/migrations/0001_initial.py | ||
| @@ -0,0 +1,92 @@ | ||
| 1 | +# Generated by Django 5.2.12 on 2026-04-07 15:40 | |
| 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 | + initial = True | |
| 10 | + | |
| 11 | + dependencies = [ | |
| 12 | + migrations.swappable_dependency(settings.AUTH_USER_MODEL), | |
| 13 | + ] | |
| 14 | + | |
| 15 | + operations = [ | |
| 16 | + migrations.CreateModel( | |
| 17 | + name="PersonalAccessToken", | |
| 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 | + ("name", models.CharField(max_length=200)), | |
| 29 | + ("token_hash", models.CharField(max_length=64, unique=True)), | |
| 30 | + ("token_prefix", models.CharField(max_length=12)), | |
| 31 | + ( | |
| 32 | + "scopes", | |
| 33 | + models.CharField( | |
| 34 | + default="read", | |
| 35 | + help_text="Comma-separated: read, write, admin", | |
| 36 | + max_length=500, | |
| 37 | + ), | |
| 38 | + ), | |
| 39 | + ("expires_at", models.DateTimeField(blank=True, null=True)), | |
| 40 | + ("last_used_at", models.DateTimeField(blank=True, null=True)), | |
| 41 | + ("created_at", models.DateTimeField(auto_now_add=True)), | |
| 42 | + ("revoked_at", models.DateTimeField(blank=True, null=True)), | |
| 43 | + ( | |
| 44 | + "user", | |
| 45 | + models.ForeignKey( | |
| 46 | + on_delete=django.db.models.deletion.CASCADE, | |
| 47 | + related_name="personal_tokens", | |
| 48 | + to=settings.AUTH_USER_MODEL, | |
| 49 | + ), | |
| 50 | + ), | |
| 51 | + ], | |
| 52 | + options={ | |
| 53 | + "ordering": ["-created_at"], | |
| 54 | + }, | |
| 55 | + ), | |
| 56 | + migrations.CreateModel( | |
| 57 | + name="UserProfile", | |
| 58 | + fields=[ | |
| 59 | + ( | |
| 60 | + "id", | |
| 61 | + models.BigAutoField( | |
| 62 | + auto_created=True, | |
| 63 | + primary_key=True, | |
| 64 | + serialize=False, | |
| 65 | + verbose_name="ID", | |
| 66 | + ), | |
| 67 | + ), | |
| 68 | + ( | |
| 69 | + "handle", | |
| 70 | + models.CharField( | |
| 71 | + blank=True, | |
| 72 | + default=None, | |
| 73 | + help_text="@handle for mentions (alphanumeric and hyphens only)", | |
| 74 | + max_length=50, | |
| 75 | + null=True, | |
| 76 | + unique=True, | |
| 77 | + ), | |
| 78 | + ), | |
| 79 | + ("bio", models.TextField(blank=True, default="", max_length=500)), | |
| 80 | + ("location", models.CharField(blank=True, default="", max_length=100)), | |
| 81 | + ("website", models.URLField(blank=True, default="")), | |
| 82 | + ( | |
| 83 | + "user", | |
| 84 | + models.OneToOneField( | |
| 85 | + on_delete=django.db.models.deletion.CASCADE, | |
| 86 | + related_name="profile", | |
| 87 | + to=settings.AUTH_USER_MODEL, | |
| 88 | + ), | |
| 89 | + ), | |
| 90 | + ], | |
| 91 | + ), | |
| 92 | + ] |
| --- a/accounts/migrations/0001_initial.py | |
| +++ b/accounts/migrations/0001_initial.py | |
| @@ -0,0 +1,92 @@ | |
| --- a/accounts/migrations/0001_initial.py | |
| +++ b/accounts/migrations/0001_initial.py | |
| @@ -0,0 +1,92 @@ | |
| 1 | # Generated by Django 5.2.12 on 2026-04-07 15:40 |
| 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 | initial = True |
| 10 | |
| 11 | dependencies = [ |
| 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
| 13 | ] |
| 14 | |
| 15 | operations = [ |
| 16 | migrations.CreateModel( |
| 17 | name="PersonalAccessToken", |
| 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 | ("name", models.CharField(max_length=200)), |
| 29 | ("token_hash", models.CharField(max_length=64, unique=True)), |
| 30 | ("token_prefix", models.CharField(max_length=12)), |
| 31 | ( |
| 32 | "scopes", |
| 33 | models.CharField( |
| 34 | default="read", |
| 35 | help_text="Comma-separated: read, write, admin", |
| 36 | max_length=500, |
| 37 | ), |
| 38 | ), |
| 39 | ("expires_at", models.DateTimeField(blank=True, null=True)), |
| 40 | ("last_used_at", models.DateTimeField(blank=True, null=True)), |
| 41 | ("created_at", models.DateTimeField(auto_now_add=True)), |
| 42 | ("revoked_at", models.DateTimeField(blank=True, null=True)), |
| 43 | ( |
| 44 | "user", |
| 45 | models.ForeignKey( |
| 46 | on_delete=django.db.models.deletion.CASCADE, |
| 47 | related_name="personal_tokens", |
| 48 | to=settings.AUTH_USER_MODEL, |
| 49 | ), |
| 50 | ), |
| 51 | ], |
| 52 | options={ |
| 53 | "ordering": ["-created_at"], |
| 54 | }, |
| 55 | ), |
| 56 | migrations.CreateModel( |
| 57 | name="UserProfile", |
| 58 | fields=[ |
| 59 | ( |
| 60 | "id", |
| 61 | models.BigAutoField( |
| 62 | auto_created=True, |
| 63 | primary_key=True, |
| 64 | serialize=False, |
| 65 | verbose_name="ID", |
| 66 | ), |
| 67 | ), |
| 68 | ( |
| 69 | "handle", |
| 70 | models.CharField( |
| 71 | blank=True, |
| 72 | default=None, |
| 73 | help_text="@handle for mentions (alphanumeric and hyphens only)", |
| 74 | max_length=50, |
| 75 | null=True, |
| 76 | unique=True, |
| 77 | ), |
| 78 | ), |
| 79 | ("bio", models.TextField(blank=True, default="", max_length=500)), |
| 80 | ("location", models.CharField(blank=True, default="", max_length=100)), |
| 81 | ("website", models.URLField(blank=True, default="")), |
| 82 | ( |
| 83 | "user", |
| 84 | models.OneToOneField( |
| 85 | on_delete=django.db.models.deletion.CASCADE, |
| 86 | related_name="profile", |
| 87 | to=settings.AUTH_USER_MODEL, |
| 88 | ), |
| 89 | ), |
| 90 | ], |
| 91 | ), |
| 92 | ] |
| --- a/accounts/models.py | ||
| +++ b/accounts/models.py | ||
| @@ -0,0 +1,88 @@ | ||
| 1 | +"""User profile and personal access token models. | |
| 2 | + | |
| 3 | +UserProfile extends Django's built-in User with optional profile fields. | |
| 4 | +PersonalAccessToken provides user-scoped tokens for API/CLI authentication, | |
| 5 | +separate from project-scoped APITokens. | |
| 6 | +""" | |
| 7 | + | |
| 8 | +import hashlib | |
| 9 | +import re | |
| 10 | +import secrets | |
| 11 | + | |
| 12 | +from django.contrib.auth.models import User | |
| 13 | +from django.db import models | |
| 14 | +from django.utils import timezone | |
| 15 | + | |
| 16 | + | |
| 17 | +class UserProfile(models.Model): | |
| 18 | + """Extended profile information for users.""" | |
| 19 | + | |
| 20 | + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") | |
| 21 | + handle = models.CharField( | |
| 22 | + max_length=50, | |
| 23 | + blank=True, | |
| 24 | + null=True, | |
| 25 | + default=None, | |
| 26 | + unique=True, | |
| 27 | + help_text="@handle for mentions (alphanumeric and hyphens only)", | |
| 28 | + ) | |
| 29 | + bio = models.TextField(blank=True, default="", max_length=500) | |
| 30 | + location = models.CharField(max_length=100, blank=True, default="") | |
| 31 | + website = models.URLField(blank=True, default="") | |
| 32 | + | |
| 33 | + def __str__(self): | |
| 34 | + return f"@{self.handle or self.user.username}" | |
| 35 | + | |
| 36 | + @staticmethod | |
| 37 | + def sanitize_handle(raw: str) -> str: | |
| 38 | + """Slugify a handle: lowercase, alphanumeric + hyphens, strip leading/trailing hyphens.""" | |
| 39 | + cleaned = re.sub(r"[^a-z0-9-]", "", raw.lower().strip()) | |
| 40 | + return cleaned.strip("-") | |
| 41 | + | |
| 42 | + | |
| 43 | +class PersonalAccessToken(models.Model): | |
| 44 | + """User-scoped personal access token for API/CLI authentication. | |
| 45 | + | |
| 46 | + Tokens are stored as SHA-256 hashes -- the raw value is shown once on | |
| 47 | + creation and never stored in plaintext. | |
| 48 | + """ | |
| 49 | + | |
| 50 | + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="personal_tokens") | |
| 51 | + name = models.CharField(max_length=200) | |
| 52 | + token_hash = models.CharField(max_length=64, unique=True) | |
| 53 | + token_prefix = models.CharField(max_length=12) | |
| 54 | + scopes = models.CharField(max_length=500, default="read", help_text="Comma-separated: read, write, admin") | |
| 55 | + expires_at = models.DateTimeField(null=True, blank=True) | |
| 56 | + last_used_at = models.DateTimeField(null=True, blank=True) | |
| 57 | + created_at = models.DateTimeField(auto_now_add=True) | |
| 58 | + revoked_at = models.DateTimeField(null=True, blank=True) | |
| 59 | + | |
| 60 | + class Meta: | |
| 61 | + ordering = ["-created_at"] | |
| 62 | + | |
| 63 | + @staticmethod | |
| 64 | + def generate(): | |
| 65 | + """Generate a new token. Returns (raw_token, token_hash, prefix).""" | |
| 66 | + raw = f"frp_{secrets.token_urlsafe(32)}" | |
| 67 | + hash_val = hashlib.sha256(raw.encode()).hexdigest() | |
| 68 | + prefix = raw[:12] | |
| 69 | + return raw, hash_val, prefix | |
| 70 | + | |
| 71 | + @staticmethod | |
| 72 | + def hash_token(raw_token): | |
| 73 | + return hashlib.sha256(raw_token.encode()).hexdigest() | |
| 74 | + | |
| 75 | + @property | |
| 76 | + def is_expired(self): | |
| 77 | + return bool(self.expires_at and self.expires_at < timezone.now()) | |
| 78 | + | |
| 79 | + @property | |
| 80 | + def is_revoked(self): | |
| 81 | + return self.revoked_at is not None | |
| 82 | + | |
| 83 | + @property | |
| 84 | + def is_active(self): | |
| 85 | + return not self.is_expired and not self.is_revoked | |
| 86 | + | |
| 87 | + def __str__(self): | |
| 88 | + return f"{self.name} ({self.token_prefix}...)" |
| --- a/accounts/models.py | |
| +++ b/accounts/models.py | |
| @@ -0,0 +1,88 @@ | |
| --- a/accounts/models.py | |
| +++ b/accounts/models.py | |
| @@ -0,0 +1,88 @@ | |
| 1 | """User profile and personal access token models. |
| 2 | |
| 3 | UserProfile extends Django's built-in User with optional profile fields. |
| 4 | PersonalAccessToken provides user-scoped tokens for API/CLI authentication, |
| 5 | separate from project-scoped APITokens. |
| 6 | """ |
| 7 | |
| 8 | import hashlib |
| 9 | import re |
| 10 | import secrets |
| 11 | |
| 12 | from django.contrib.auth.models import User |
| 13 | from django.db import models |
| 14 | from django.utils import timezone |
| 15 | |
| 16 | |
| 17 | class UserProfile(models.Model): |
| 18 | """Extended profile information for users.""" |
| 19 | |
| 20 | user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") |
| 21 | handle = models.CharField( |
| 22 | max_length=50, |
| 23 | blank=True, |
| 24 | null=True, |
| 25 | default=None, |
| 26 | unique=True, |
| 27 | help_text="@handle for mentions (alphanumeric and hyphens only)", |
| 28 | ) |
| 29 | bio = models.TextField(blank=True, default="", max_length=500) |
| 30 | location = models.CharField(max_length=100, blank=True, default="") |
| 31 | website = models.URLField(blank=True, default="") |
| 32 | |
| 33 | def __str__(self): |
| 34 | return f"@{self.handle or self.user.username}" |
| 35 | |
| 36 | @staticmethod |
| 37 | def sanitize_handle(raw: str) -> str: |
| 38 | """Slugify a handle: lowercase, alphanumeric + hyphens, strip leading/trailing hyphens.""" |
| 39 | cleaned = re.sub(r"[^a-z0-9-]", "", raw.lower().strip()) |
| 40 | return cleaned.strip("-") |
| 41 | |
| 42 | |
| 43 | class PersonalAccessToken(models.Model): |
| 44 | """User-scoped personal access token for API/CLI authentication. |
| 45 | |
| 46 | Tokens are stored as SHA-256 hashes -- the raw value is shown once on |
| 47 | creation and never stored in plaintext. |
| 48 | """ |
| 49 | |
| 50 | user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="personal_tokens") |
| 51 | name = models.CharField(max_length=200) |
| 52 | token_hash = models.CharField(max_length=64, unique=True) |
| 53 | token_prefix = models.CharField(max_length=12) |
| 54 | scopes = models.CharField(max_length=500, default="read", help_text="Comma-separated: read, write, admin") |
| 55 | expires_at = models.DateTimeField(null=True, blank=True) |
| 56 | last_used_at = models.DateTimeField(null=True, blank=True) |
| 57 | created_at = models.DateTimeField(auto_now_add=True) |
| 58 | revoked_at = models.DateTimeField(null=True, blank=True) |
| 59 | |
| 60 | class Meta: |
| 61 | ordering = ["-created_at"] |
| 62 | |
| 63 | @staticmethod |
| 64 | def generate(): |
| 65 | """Generate a new token. Returns (raw_token, token_hash, prefix).""" |
| 66 | raw = f"frp_{secrets.token_urlsafe(32)}" |
| 67 | hash_val = hashlib.sha256(raw.encode()).hexdigest() |
| 68 | prefix = raw[:12] |
| 69 | return raw, hash_val, prefix |
| 70 | |
| 71 | @staticmethod |
| 72 | def hash_token(raw_token): |
| 73 | return hashlib.sha256(raw_token.encode()).hexdigest() |
| 74 | |
| 75 | @property |
| 76 | def is_expired(self): |
| 77 | return bool(self.expires_at and self.expires_at < timezone.now()) |
| 78 | |
| 79 | @property |
| 80 | def is_revoked(self): |
| 81 | return self.revoked_at is not None |
| 82 | |
| 83 | @property |
| 84 | def is_active(self): |
| 85 | return not self.is_expired and not self.is_revoked |
| 86 | |
| 87 | def __str__(self): |
| 88 | return f"{self.name} ({self.token_prefix}...)" |
| --- accounts/tests.py | ||
| +++ accounts/tests.py | ||
| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | import pytest |
| 2 | 2 | from django.urls import reverse |
| 3 | 3 | |
| 4 | +from accounts.models import PersonalAccessToken, UserProfile | |
| 5 | + | |
| 4 | 6 | |
| 5 | 7 | @pytest.mark.django_db |
| 6 | 8 | class TestLogin: |
| 7 | 9 | def test_login_page_renders(self, client): |
| 8 | 10 | response = client.get(reverse("accounts:login")) |
| @@ -42,5 +44,265 @@ | ||
| 42 | 44 | assert response.status_code == 302 # redirected to login |
| 43 | 45 | |
| 44 | 46 | def test_logout_rejects_get(self, admin_client): |
| 45 | 47 | response = admin_client.get(reverse("accounts:logout")) |
| 46 | 48 | assert response.status_code == 405 |
| 49 | + | |
| 50 | + | |
| 51 | +# --------------------------------------------------------------------------- | |
| 52 | +# Profile views | |
| 53 | +# --------------------------------------------------------------------------- | |
| 54 | + | |
| 55 | + | |
| 56 | +@pytest.mark.django_db | |
| 57 | +class TestProfile: | |
| 58 | + def test_profile_page_renders(self, admin_client, admin_user): | |
| 59 | + response = admin_client.get(reverse("accounts:profile")) | |
| 60 | + assert response.status_code == 200 | |
| 61 | + assert b"Profile Info" in response.content | |
| 62 | + assert b"SSH Keys" in response.content | |
| 63 | + assert b"Personal Access Tokens" in response.content | |
| 64 | + | |
| 65 | + def test_profile_creates_user_profile_on_first_visit(self, admin_client, admin_user): | |
| 66 | + assert not UserProfile.objects.filter(user=admin_user).exists() | |
| 67 | + admin_client.get(reverse("accounts:profile")) | |
| 68 | + assert UserProfile.objects.filter(user=admin_user).exists() | |
| 69 | + | |
| 70 | + def test_profile_requires_login(self, client): | |
| 71 | + response = client.get(reverse("accounts:profile")) | |
| 72 | + assert response.status_code == 302 | |
| 73 | + assert "/auth/login/" in response.url | |
| 74 | + | |
| 75 | + def test_profile_top_level_redirect(self, admin_client): | |
| 76 | + response = admin_client.get("/profile/") | |
| 77 | + assert response.status_code == 302 | |
| 78 | + assert "/auth/profile/" in response.url | |
| 79 | + | |
| 80 | + | |
| 81 | +@pytest.mark.django_db | |
| 82 | +class TestProfileEdit: | |
| 83 | + def test_edit_page_renders(self, admin_client, admin_user): | |
| 84 | + response = admin_client.get(reverse("accounts:profile_edit")) | |
| 85 | + assert response.status_code == 200 | |
| 86 | + assert b"Edit Profile" in response.content | |
| 87 | + | |
| 88 | + def test_edit_updates_user_fields(self, admin_client, admin_user): | |
| 89 | + response = admin_client.post( | |
| 90 | + reverse("accounts:profile_edit"), | |
| 91 | + { | |
| 92 | + "first_name": "Alice", | |
| 93 | + "last_name": "Smith", | |
| 94 | + "email": "[email protected]", | |
| 95 | + "handle": "alice-s", | |
| 96 | + "bio": "Hello world", | |
| 97 | + "location": "NYC", | |
| 98 | + "website": "https://alice.dev", | |
| 99 | + }, | |
| 100 | + ) | |
| 101 | + assert response.status_code == 302 | |
| 102 | + admin_user.refresh_from_db() | |
| 103 | + assert admin_user.first_name == "Alice" | |
| 104 | + assert admin_user.last_name == "Smith" | |
| 105 | + assert admin_user.email == "[email protected]" | |
| 106 | + profile = UserProfile.objects.get(user=admin_user) | |
| 107 | + assert profile.handle == "alice-s" | |
| 108 | + assert profile.bio == "Hello world" | |
| 109 | + assert profile.location == "NYC" | |
| 110 | + assert profile.website == "https://alice.dev" | |
| 111 | + | |
| 112 | + def test_edit_sanitizes_handle(self, admin_client, admin_user): | |
| 113 | + admin_client.post( | |
| 114 | + reverse("accounts:profile_edit"), | |
| 115 | + {"handle": " UPPER Case! Stuff ", "first_name": "", "last_name": "", "email": ""}, | |
| 116 | + ) | |
| 117 | + profile = UserProfile.objects.get(user=admin_user) | |
| 118 | + assert profile.handle == "uppercasestuff" | |
| 119 | + | |
| 120 | + def test_edit_handle_uniqueness(self, admin_client, admin_user, viewer_user): | |
| 121 | + # Create a profile with handle for viewer_user | |
| 122 | + UserProfile.objects.create(user=viewer_user, handle="taken-handle") | |
| 123 | + response = admin_client.post( | |
| 124 | + reverse("accounts:profile_edit"), | |
| 125 | + {"handle": "taken-handle", "first_name": "", "last_name": "", "email": ""}, | |
| 126 | + ) | |
| 127 | + assert response.status_code == 200 # re-renders form with error | |
| 128 | + assert b"already taken" in response.content | |
| 129 | + | |
| 130 | + def test_edit_empty_handle_saves_as_none(self, admin_client, admin_user): | |
| 131 | + admin_client.post( | |
| 132 | + reverse("accounts:profile_edit"), | |
| 133 | + {"handle": "", "first_name": "", "last_name": "", "email": ""}, | |
| 134 | + ) | |
| 135 | + profile = UserProfile.objects.get(user=admin_user) | |
| 136 | + assert profile.handle is None | |
| 137 | + | |
| 138 | + def test_edit_requires_login(self, client): | |
| 139 | + response = client.get(reverse("accounts:profile_edit")) | |
| 140 | + assert response.status_code == 302 | |
| 141 | + assert "/auth/login/" in response.url | |
| 142 | + | |
| 143 | + | |
| 144 | +@pytest.mark.django_db | |
| 145 | +class TestPersonalAccessTokenCreate: | |
| 146 | + def test_create_form_renders(self, admin_client): | |
| 147 | + response = admin_client.get(reverse("accounts:profile_token_create")) | |
| 148 | + assert response.status_code == 200 | |
| 149 | + assert b"Generate Personal Access Token" in response.content | |
| 150 | + | |
| 151 | + def test_create_token_shows_raw_once(self, admin_client, admin_user): | |
| 152 | + response = admin_client.post( | |
| 153 | + reverse("accounts:profile_token_create"), | |
| 154 | + {"name": "CI Token", "scopes": "read,write"}, | |
| 155 | + ) | |
| 156 | + assert response.status_code == 200 | |
| 157 | + assert b"frp_" in response.content | |
| 158 | + assert b"will not be shown again" in response.content | |
| 159 | + token = PersonalAccessToken.objects.get(user=admin_user, name="CI Token") | |
| 160 | + assert token.scopes == "read,write" | |
| 161 | + assert token.token_prefix.startswith("frp_") | |
| 162 | + | |
| 163 | + def test_create_token_default_scope_is_read(self, admin_client, admin_user): | |
| 164 | + admin_client.post( | |
| 165 | + reverse("accounts:profile_token_create"), | |
| 166 | + {"name": "Default Token", "scopes": ""}, | |
| 167 | + ) | |
| 168 | + token = PersonalAccessToken.objects.get(user=admin_user, name="Default Token") | |
| 169 | + assert token.scopes == "read" | |
| 170 | + | |
| 171 | + def test_create_token_rejects_invalid_scopes(self, admin_client, admin_user): | |
| 172 | + admin_client.post( | |
| 173 | + reverse("accounts:profile_token_create"), | |
| 174 | + {"name": "Bad Token", "scopes": "delete,destroy"}, | |
| 175 | + ) | |
| 176 | + token = PersonalAccessToken.objects.get(user=admin_user, name="Bad Token") | |
| 177 | + assert token.scopes == "read" # falls back to read | |
| 178 | + | |
| 179 | + def test_create_token_requires_name(self, admin_client, admin_user): | |
| 180 | + response = admin_client.post( | |
| 181 | + reverse("accounts:profile_token_create"), | |
| 182 | + {"name": "", "scopes": "read"}, | |
| 183 | + ) | |
| 184 | + assert response.status_code == 200 | |
| 185 | + assert b"Token name is required" in response.content | |
| 186 | + assert PersonalAccessToken.objects.filter(user=admin_user).count() == 0 | |
| 187 | + | |
| 188 | + def test_create_token_requires_login(self, client): | |
| 189 | + response = client.get(reverse("accounts:profile_token_create")) | |
| 190 | + assert response.status_code == 302 | |
| 191 | + assert "/auth/login/" in response.url | |
| 192 | + | |
| 193 | + | |
| 194 | +@pytest.mark.django_db | |
| 195 | +class TestPersonalAccessTokenRevoke: | |
| 196 | + def test_revoke_token(self, admin_client, admin_user): | |
| 197 | + raw, token_hash, prefix = PersonalAccessToken.generate() | |
| 198 | + token = PersonalAccessToken.objects.create(user=admin_user, name="To Revoke", token_hash=token_hash, token_prefix=prefix) | |
| 199 | + response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix})) | |
| 200 | + assert response.status_code == 302 | |
| 201 | + token.refresh_from_db() | |
| 202 | + assert token.revoked_at is not None | |
| 203 | + | |
| 204 | + def test_revoke_token_htmx(self, admin_client, admin_user): | |
| 205 | + raw, token_hash, prefix = PersonalAccessToken.generate() | |
| 206 | + PersonalAccessToken.objects.create(user=admin_user, name="HX Revoke", token_hash=token_hash, token_prefix=prefix) | |
| 207 | + response = admin_client.post( | |
| 208 | + reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}), | |
| 209 | + HTTP_HX_REQUEST="true", | |
| 210 | + ) | |
| 211 | + assert response.status_code == 200 | |
| 212 | + assert response["HX-Redirect"] == "/auth/profile/" | |
| 213 | + | |
| 214 | + def test_revoke_token_wrong_user(self, admin_client, viewer_user): | |
| 215 | + """Cannot revoke another user's token.""" | |
| 216 | + raw, token_hash, prefix = PersonalAccessToken.generate() | |
| 217 | + PersonalAccessToken.objects.create(user=viewer_user, name="Other User", token_hash=token_hash, token_prefix=prefix) | |
| 218 | + response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix})) | |
| 219 | + assert response.status_code == 404 | |
| 220 | + | |
| 221 | + def test_revoke_already_revoked(self, admin_client, admin_user): | |
| 222 | + from django.utils import timezone | |
| 223 | + | |
| 224 | + raw, token_hash, prefix = PersonalAccessToken.generate() | |
| 225 | + PersonalAccessToken.objects.create( | |
| 226 | + user=admin_user, name="Already Revoked", token_hash=token_hash, token_prefix=prefix, revoked_at=timezone.now() | |
| 227 | + ) | |
| 228 | + response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix})) | |
| 229 | + assert response.status_code == 404 | |
| 230 | + | |
| 231 | + def test_revoke_requires_post(self, admin_client, admin_user): | |
| 232 | + raw, token_hash, prefix = PersonalAccessToken.generate() | |
| 233 | + PersonalAccessToken.objects.create(user=admin_user, name="GET test", token_hash=token_hash, token_prefix=prefix) | |
| 234 | + response = admin_client.get(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix})) | |
| 235 | + assert response.status_code == 405 | |
| 236 | + | |
| 237 | + def test_revoke_requires_login(self, client): | |
| 238 | + response = client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": "frp_xxxxxxx"})) | |
| 239 | + assert response.status_code == 302 | |
| 240 | + assert "/auth/login/" in response.url | |
| 241 | + | |
| 242 | + | |
| 243 | +# --------------------------------------------------------------------------- | |
| 244 | +# Model unit tests | |
| 245 | +# --------------------------------------------------------------------------- | |
| 246 | + | |
| 247 | + | |
| 248 | +@pytest.mark.django_db | |
| 249 | +class TestUserProfileModel: | |
| 250 | + def test_str_with_handle(self, admin_user): | |
| 251 | + profile = UserProfile.objects.create(user=admin_user, handle="testhandle") | |
| 252 | + assert str(profile) == "@testhandle" | |
| 253 | + | |
| 254 | + def test_str_without_handle(self, admin_user): | |
| 255 | + profile = UserProfile.objects.create(user=admin_user) | |
| 256 | + assert str(profile) == "@admin" | |
| 257 | + | |
| 258 | + def test_sanitize_handle(self): | |
| 259 | + assert UserProfile.sanitize_handle("Hello World!") == "helloworld" | |
| 260 | + assert UserProfile.sanitize_handle(" --test-handle-- ") == "test-handle" | |
| 261 | + assert UserProfile.sanitize_handle("UPPER_CASE") == "uppercase" | |
| 262 | + assert UserProfile.sanitize_handle("") == "" | |
| 263 | + | |
| 264 | + def test_multiple_null_handles_allowed(self, admin_user, viewer_user): | |
| 265 | + """Multiple profiles with handle=None should not violate unique constraint.""" | |
| 266 | + UserProfile.objects.create(user=admin_user, handle=None) | |
| 267 | + UserProfile.objects.create(user=viewer_user, handle=None) | |
| 268 | + assert UserProfile.objects.filter(handle__isnull=True).count() == 2 | |
| 269 | + | |
| 270 | + | |
| 271 | +@pytest.mark.django_db | |
| 272 | +class TestPersonalAccessTokenModel: | |
| 273 | + def test_generate_returns_triple(self): | |
| 274 | + raw, hash_val, prefix = PersonalAccessToken.generate() | |
| 275 | + assert raw.startswith("frp_") | |
| 276 | + assert len(hash_val) == 64 | |
| 277 | + assert prefix == raw[:12] | |
| 278 | + | |
| 279 | + def test_hash_token_matches_generate(self): | |
| 280 | + raw, expected_hash, _ = PersonalAccessToken.generate() | |
| 281 | + assert PersonalAccessToken.hash_token(raw) == expected_hash | |
| 282 | + | |
| 283 | + def test_is_expired(self, admin_user): | |
| 284 | + from django.utils import timezone | |
| 285 | + | |
| 286 | + token = PersonalAccessToken(user=admin_user, expires_at=timezone.now() - timezone.timedelta(days=1)) | |
| 287 | + assert token.is_expired is True | |
| 288 | + | |
| 289 | + def test_is_not_expired(self, admin_user): | |
| 290 | + from django.utils import timezone | |
| 291 | + | |
| 292 | + token = PersonalAccessToken(user=admin_user, expires_at=timezone.now() + timezone.timedelta(days=1)) | |
| 293 | + assert token.is_expired is False | |
| 294 | + | |
| 295 | + def test_is_active(self, admin_user): | |
| 296 | + token = PersonalAccessToken(user=admin_user) | |
| 297 | + assert token.is_active is True | |
| 298 | + | |
| 299 | + def test_is_revoked(self, admin_user): | |
| 300 | + from django.utils import timezone | |
| 301 | + | |
| 302 | + token = PersonalAccessToken(user=admin_user, revoked_at=timezone.now()) | |
| 303 | + assert token.is_active is False | |
| 304 | + assert token.is_revoked is True | |
| 305 | + | |
| 306 | + def test_str(self, admin_user): | |
| 307 | + token = PersonalAccessToken(user=admin_user, name="Test", token_prefix="frp_abc12345") | |
| 308 | + assert str(token) == "Test (frp_abc12345...)" | |
| 47 | 309 |
| --- accounts/tests.py | |
| +++ accounts/tests.py | |
| @@ -1,8 +1,10 @@ | |
| 1 | import pytest |
| 2 | from django.urls import reverse |
| 3 | |
| 4 | |
| 5 | @pytest.mark.django_db |
| 6 | class TestLogin: |
| 7 | def test_login_page_renders(self, client): |
| 8 | response = client.get(reverse("accounts:login")) |
| @@ -42,5 +44,265 @@ | |
| 42 | assert response.status_code == 302 # redirected to login |
| 43 | |
| 44 | def test_logout_rejects_get(self, admin_client): |
| 45 | response = admin_client.get(reverse("accounts:logout")) |
| 46 | assert response.status_code == 405 |
| 47 |
| --- accounts/tests.py | |
| +++ accounts/tests.py | |
| @@ -1,8 +1,10 @@ | |
| 1 | import pytest |
| 2 | from django.urls import reverse |
| 3 | |
| 4 | from accounts.models import PersonalAccessToken, UserProfile |
| 5 | |
| 6 | |
| 7 | @pytest.mark.django_db |
| 8 | class TestLogin: |
| 9 | def test_login_page_renders(self, client): |
| 10 | response = client.get(reverse("accounts:login")) |
| @@ -42,5 +44,265 @@ | |
| 44 | assert response.status_code == 302 # redirected to login |
| 45 | |
| 46 | def test_logout_rejects_get(self, admin_client): |
| 47 | response = admin_client.get(reverse("accounts:logout")) |
| 48 | assert response.status_code == 405 |
| 49 | |
| 50 | |
| 51 | # --------------------------------------------------------------------------- |
| 52 | # Profile views |
| 53 | # --------------------------------------------------------------------------- |
| 54 | |
| 55 | |
| 56 | @pytest.mark.django_db |
| 57 | class TestProfile: |
| 58 | def test_profile_page_renders(self, admin_client, admin_user): |
| 59 | response = admin_client.get(reverse("accounts:profile")) |
| 60 | assert response.status_code == 200 |
| 61 | assert b"Profile Info" in response.content |
| 62 | assert b"SSH Keys" in response.content |
| 63 | assert b"Personal Access Tokens" in response.content |
| 64 | |
| 65 | def test_profile_creates_user_profile_on_first_visit(self, admin_client, admin_user): |
| 66 | assert not UserProfile.objects.filter(user=admin_user).exists() |
| 67 | admin_client.get(reverse("accounts:profile")) |
| 68 | assert UserProfile.objects.filter(user=admin_user).exists() |
| 69 | |
| 70 | def test_profile_requires_login(self, client): |
| 71 | response = client.get(reverse("accounts:profile")) |
| 72 | assert response.status_code == 302 |
| 73 | assert "/auth/login/" in response.url |
| 74 | |
| 75 | def test_profile_top_level_redirect(self, admin_client): |
| 76 | response = admin_client.get("/profile/") |
| 77 | assert response.status_code == 302 |
| 78 | assert "/auth/profile/" in response.url |
| 79 | |
| 80 | |
| 81 | @pytest.mark.django_db |
| 82 | class TestProfileEdit: |
| 83 | def test_edit_page_renders(self, admin_client, admin_user): |
| 84 | response = admin_client.get(reverse("accounts:profile_edit")) |
| 85 | assert response.status_code == 200 |
| 86 | assert b"Edit Profile" in response.content |
| 87 | |
| 88 | def test_edit_updates_user_fields(self, admin_client, admin_user): |
| 89 | response = admin_client.post( |
| 90 | reverse("accounts:profile_edit"), |
| 91 | { |
| 92 | "first_name": "Alice", |
| 93 | "last_name": "Smith", |
| 94 | "email": "[email protected]", |
| 95 | "handle": "alice-s", |
| 96 | "bio": "Hello world", |
| 97 | "location": "NYC", |
| 98 | "website": "https://alice.dev", |
| 99 | }, |
| 100 | ) |
| 101 | assert response.status_code == 302 |
| 102 | admin_user.refresh_from_db() |
| 103 | assert admin_user.first_name == "Alice" |
| 104 | assert admin_user.last_name == "Smith" |
| 105 | assert admin_user.email == "[email protected]" |
| 106 | profile = UserProfile.objects.get(user=admin_user) |
| 107 | assert profile.handle == "alice-s" |
| 108 | assert profile.bio == "Hello world" |
| 109 | assert profile.location == "NYC" |
| 110 | assert profile.website == "https://alice.dev" |
| 111 | |
| 112 | def test_edit_sanitizes_handle(self, admin_client, admin_user): |
| 113 | admin_client.post( |
| 114 | reverse("accounts:profile_edit"), |
| 115 | {"handle": " UPPER Case! Stuff ", "first_name": "", "last_name": "", "email": ""}, |
| 116 | ) |
| 117 | profile = UserProfile.objects.get(user=admin_user) |
| 118 | assert profile.handle == "uppercasestuff" |
| 119 | |
| 120 | def test_edit_handle_uniqueness(self, admin_client, admin_user, viewer_user): |
| 121 | # Create a profile with handle for viewer_user |
| 122 | UserProfile.objects.create(user=viewer_user, handle="taken-handle") |
| 123 | response = admin_client.post( |
| 124 | reverse("accounts:profile_edit"), |
| 125 | {"handle": "taken-handle", "first_name": "", "last_name": "", "email": ""}, |
| 126 | ) |
| 127 | assert response.status_code == 200 # re-renders form with error |
| 128 | assert b"already taken" in response.content |
| 129 | |
| 130 | def test_edit_empty_handle_saves_as_none(self, admin_client, admin_user): |
| 131 | admin_client.post( |
| 132 | reverse("accounts:profile_edit"), |
| 133 | {"handle": "", "first_name": "", "last_name": "", "email": ""}, |
| 134 | ) |
| 135 | profile = UserProfile.objects.get(user=admin_user) |
| 136 | assert profile.handle is None |
| 137 | |
| 138 | def test_edit_requires_login(self, client): |
| 139 | response = client.get(reverse("accounts:profile_edit")) |
| 140 | assert response.status_code == 302 |
| 141 | assert "/auth/login/" in response.url |
| 142 | |
| 143 | |
| 144 | @pytest.mark.django_db |
| 145 | class TestPersonalAccessTokenCreate: |
| 146 | def test_create_form_renders(self, admin_client): |
| 147 | response = admin_client.get(reverse("accounts:profile_token_create")) |
| 148 | assert response.status_code == 200 |
| 149 | assert b"Generate Personal Access Token" in response.content |
| 150 | |
| 151 | def test_create_token_shows_raw_once(self, admin_client, admin_user): |
| 152 | response = admin_client.post( |
| 153 | reverse("accounts:profile_token_create"), |
| 154 | {"name": "CI Token", "scopes": "read,write"}, |
| 155 | ) |
| 156 | assert response.status_code == 200 |
| 157 | assert b"frp_" in response.content |
| 158 | assert b"will not be shown again" in response.content |
| 159 | token = PersonalAccessToken.objects.get(user=admin_user, name="CI Token") |
| 160 | assert token.scopes == "read,write" |
| 161 | assert token.token_prefix.startswith("frp_") |
| 162 | |
| 163 | def test_create_token_default_scope_is_read(self, admin_client, admin_user): |
| 164 | admin_client.post( |
| 165 | reverse("accounts:profile_token_create"), |
| 166 | {"name": "Default Token", "scopes": ""}, |
| 167 | ) |
| 168 | token = PersonalAccessToken.objects.get(user=admin_user, name="Default Token") |
| 169 | assert token.scopes == "read" |
| 170 | |
| 171 | def test_create_token_rejects_invalid_scopes(self, admin_client, admin_user): |
| 172 | admin_client.post( |
| 173 | reverse("accounts:profile_token_create"), |
| 174 | {"name": "Bad Token", "scopes": "delete,destroy"}, |
| 175 | ) |
| 176 | token = PersonalAccessToken.objects.get(user=admin_user, name="Bad Token") |
| 177 | assert token.scopes == "read" # falls back to read |
| 178 | |
| 179 | def test_create_token_requires_name(self, admin_client, admin_user): |
| 180 | response = admin_client.post( |
| 181 | reverse("accounts:profile_token_create"), |
| 182 | {"name": "", "scopes": "read"}, |
| 183 | ) |
| 184 | assert response.status_code == 200 |
| 185 | assert b"Token name is required" in response.content |
| 186 | assert PersonalAccessToken.objects.filter(user=admin_user).count() == 0 |
| 187 | |
| 188 | def test_create_token_requires_login(self, client): |
| 189 | response = client.get(reverse("accounts:profile_token_create")) |
| 190 | assert response.status_code == 302 |
| 191 | assert "/auth/login/" in response.url |
| 192 | |
| 193 | |
| 194 | @pytest.mark.django_db |
| 195 | class TestPersonalAccessTokenRevoke: |
| 196 | def test_revoke_token(self, admin_client, admin_user): |
| 197 | raw, token_hash, prefix = PersonalAccessToken.generate() |
| 198 | token = PersonalAccessToken.objects.create(user=admin_user, name="To Revoke", token_hash=token_hash, token_prefix=prefix) |
| 199 | response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix})) |
| 200 | assert response.status_code == 302 |
| 201 | token.refresh_from_db() |
| 202 | assert token.revoked_at is not None |
| 203 | |
| 204 | def test_revoke_token_htmx(self, admin_client, admin_user): |
| 205 | raw, token_hash, prefix = PersonalAccessToken.generate() |
| 206 | PersonalAccessToken.objects.create(user=admin_user, name="HX Revoke", token_hash=token_hash, token_prefix=prefix) |
| 207 | response = admin_client.post( |
| 208 | reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}), |
| 209 | HTTP_HX_REQUEST="true", |
| 210 | ) |
| 211 | assert response.status_code == 200 |
| 212 | assert response["HX-Redirect"] == "/auth/profile/" |
| 213 | |
| 214 | def test_revoke_token_wrong_user(self, admin_client, viewer_user): |
| 215 | """Cannot revoke another user's token.""" |
| 216 | raw, token_hash, prefix = PersonalAccessToken.generate() |
| 217 | PersonalAccessToken.objects.create(user=viewer_user, name="Other User", token_hash=token_hash, token_prefix=prefix) |
| 218 | response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix})) |
| 219 | assert response.status_code == 404 |
| 220 | |
| 221 | def test_revoke_already_revoked(self, admin_client, admin_user): |
| 222 | from django.utils import timezone |
| 223 | |
| 224 | raw, token_hash, prefix = PersonalAccessToken.generate() |
| 225 | PersonalAccessToken.objects.create( |
| 226 | user=admin_user, name="Already Revoked", token_hash=token_hash, token_prefix=prefix, revoked_at=timezone.now() |
| 227 | ) |
| 228 | response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix})) |
| 229 | assert response.status_code == 404 |
| 230 | |
| 231 | def test_revoke_requires_post(self, admin_client, admin_user): |
| 232 | raw, token_hash, prefix = PersonalAccessToken.generate() |
| 233 | PersonalAccessToken.objects.create(user=admin_user, name="GET test", token_hash=token_hash, token_prefix=prefix) |
| 234 | response = admin_client.get(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix})) |
| 235 | assert response.status_code == 405 |
| 236 | |
| 237 | def test_revoke_requires_login(self, client): |
| 238 | response = client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": "frp_xxxxxxx"})) |
| 239 | assert response.status_code == 302 |
| 240 | assert "/auth/login/" in response.url |
| 241 | |
| 242 | |
| 243 | # --------------------------------------------------------------------------- |
| 244 | # Model unit tests |
| 245 | # --------------------------------------------------------------------------- |
| 246 | |
| 247 | |
| 248 | @pytest.mark.django_db |
| 249 | class TestUserProfileModel: |
| 250 | def test_str_with_handle(self, admin_user): |
| 251 | profile = UserProfile.objects.create(user=admin_user, handle="testhandle") |
| 252 | assert str(profile) == "@testhandle" |
| 253 | |
| 254 | def test_str_without_handle(self, admin_user): |
| 255 | profile = UserProfile.objects.create(user=admin_user) |
| 256 | assert str(profile) == "@admin" |
| 257 | |
| 258 | def test_sanitize_handle(self): |
| 259 | assert UserProfile.sanitize_handle("Hello World!") == "helloworld" |
| 260 | assert UserProfile.sanitize_handle(" --test-handle-- ") == "test-handle" |
| 261 | assert UserProfile.sanitize_handle("UPPER_CASE") == "uppercase" |
| 262 | assert UserProfile.sanitize_handle("") == "" |
| 263 | |
| 264 | def test_multiple_null_handles_allowed(self, admin_user, viewer_user): |
| 265 | """Multiple profiles with handle=None should not violate unique constraint.""" |
| 266 | UserProfile.objects.create(user=admin_user, handle=None) |
| 267 | UserProfile.objects.create(user=viewer_user, handle=None) |
| 268 | assert UserProfile.objects.filter(handle__isnull=True).count() == 2 |
| 269 | |
| 270 | |
| 271 | @pytest.mark.django_db |
| 272 | class TestPersonalAccessTokenModel: |
| 273 | def test_generate_returns_triple(self): |
| 274 | raw, hash_val, prefix = PersonalAccessToken.generate() |
| 275 | assert raw.startswith("frp_") |
| 276 | assert len(hash_val) == 64 |
| 277 | assert prefix == raw[:12] |
| 278 | |
| 279 | def test_hash_token_matches_generate(self): |
| 280 | raw, expected_hash, _ = PersonalAccessToken.generate() |
| 281 | assert PersonalAccessToken.hash_token(raw) == expected_hash |
| 282 | |
| 283 | def test_is_expired(self, admin_user): |
| 284 | from django.utils import timezone |
| 285 | |
| 286 | token = PersonalAccessToken(user=admin_user, expires_at=timezone.now() - timezone.timedelta(days=1)) |
| 287 | assert token.is_expired is True |
| 288 | |
| 289 | def test_is_not_expired(self, admin_user): |
| 290 | from django.utils import timezone |
| 291 | |
| 292 | token = PersonalAccessToken(user=admin_user, expires_at=timezone.now() + timezone.timedelta(days=1)) |
| 293 | assert token.is_expired is False |
| 294 | |
| 295 | def test_is_active(self, admin_user): |
| 296 | token = PersonalAccessToken(user=admin_user) |
| 297 | assert token.is_active is True |
| 298 | |
| 299 | def test_is_revoked(self, admin_user): |
| 300 | from django.utils import timezone |
| 301 | |
| 302 | token = PersonalAccessToken(user=admin_user, revoked_at=timezone.now()) |
| 303 | assert token.is_active is False |
| 304 | assert token.is_revoked is True |
| 305 | |
| 306 | def test_str(self, admin_user): |
| 307 | token = PersonalAccessToken(user=admin_user, name="Test", token_prefix="frp_abc12345") |
| 308 | assert str(token) == "Test (frp_abc12345...)" |
| 309 |
| --- accounts/urls.py | ||
| +++ accounts/urls.py | ||
| @@ -8,6 +8,11 @@ | ||
| 8 | 8 | path("login/", views.login_view, name="login"), |
| 9 | 9 | path("logout/", views.logout_view, name="logout"), |
| 10 | 10 | path("ssh-keys/", views.ssh_keys, name="ssh_keys"), |
| 11 | 11 | path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"), |
| 12 | 12 | path("notifications/", views.notification_preferences, name="notification_prefs"), |
| 13 | + # Unified profile | |
| 14 | + path("profile/", views.profile, name="profile"), | |
| 15 | + path("profile/edit/", views.profile_edit, name="profile_edit"), | |
| 16 | + path("profile/tokens/create/", views.profile_token_create, name="profile_token_create"), | |
| 17 | + path("profile/tokens/<str:guid>/revoke/", views.profile_token_revoke, name="profile_token_revoke"), | |
| 13 | 18 | ] |
| 14 | 19 |
| --- accounts/urls.py | |
| +++ accounts/urls.py | |
| @@ -8,6 +8,11 @@ | |
| 8 | path("login/", views.login_view, name="login"), |
| 9 | path("logout/", views.logout_view, name="logout"), |
| 10 | path("ssh-keys/", views.ssh_keys, name="ssh_keys"), |
| 11 | path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"), |
| 12 | path("notifications/", views.notification_preferences, name="notification_prefs"), |
| 13 | ] |
| 14 |
| --- accounts/urls.py | |
| +++ accounts/urls.py | |
| @@ -8,6 +8,11 @@ | |
| 8 | path("login/", views.login_view, name="login"), |
| 9 | path("logout/", views.logout_view, name="logout"), |
| 10 | path("ssh-keys/", views.ssh_keys, name="ssh_keys"), |
| 11 | path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"), |
| 12 | path("notifications/", views.notification_preferences, name="notification_prefs"), |
| 13 | # Unified profile |
| 14 | path("profile/", views.profile, name="profile"), |
| 15 | path("profile/edit/", views.profile_edit, name="profile_edit"), |
| 16 | path("profile/tokens/create/", views.profile_token_create, name="profile_token_create"), |
| 17 | path("profile/tokens/<str:guid>/revoke/", views.profile_token_revoke, name="profile_token_revoke"), |
| 18 | ] |
| 19 |
| --- accounts/views.py | ||
| +++ accounts/views.py | ||
| @@ -8,10 +8,11 @@ | ||
| 8 | 8 | from django.utils.http import url_has_allowed_host_and_scheme |
| 9 | 9 | from django.views.decorators.http import require_POST |
| 10 | 10 | from django_ratelimit.decorators import ratelimit |
| 11 | 11 | |
| 12 | 12 | from .forms import LoginForm |
| 13 | +from .models import PersonalAccessToken, UserProfile | |
| 13 | 14 | |
| 14 | 15 | # Allowed SSH key type prefixes |
| 15 | 16 | _SSH_KEY_PREFIXES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "ssh-dss") |
| 16 | 17 | |
| 17 | 18 | |
| @@ -219,5 +220,120 @@ | ||
| 219 | 220 | return HttpResponse(status=200, headers={"HX-Redirect": "/auth/notifications/"}) |
| 220 | 221 | |
| 221 | 222 | return redirect("accounts:notification_prefs") |
| 222 | 223 | |
| 223 | 224 | return render(request, "accounts/notification_prefs.html", {"prefs": prefs}) |
| 225 | + | |
| 226 | + | |
| 227 | +# --------------------------------------------------------------------------- | |
| 228 | +# Unified profile | |
| 229 | +# --------------------------------------------------------------------------- | |
| 230 | + | |
| 231 | +VALID_SCOPES = {"read", "write", "admin"} | |
| 232 | + | |
| 233 | + | |
| 234 | +@login_required | |
| 235 | +def profile(request): | |
| 236 | + """Unified user profile page consolidating all personal settings.""" | |
| 237 | + from fossil.notifications import NotificationPreference | |
| 238 | + from fossil.user_keys import UserSSHKey | |
| 239 | + | |
| 240 | + user_profile, _ = UserProfile.objects.get_or_create(user=request.user) | |
| 241 | + notif_prefs, _ = NotificationPreference.objects.get_or_create(user=request.user) | |
| 242 | + ssh_keys = UserSSHKey.objects.filter(user=request.user) | |
| 243 | + tokens = PersonalAccessToken.objects.filter(user=request.user, revoked_at__isnull=True) | |
| 244 | + | |
| 245 | + return render( | |
| 246 | + request, | |
| 247 | + "accounts/profile.html", | |
| 248 | + { | |
| 249 | + "user_profile": user_profile, | |
| 250 | + "notif_prefs": notif_prefs, | |
| 251 | + "ssh_keys": ssh_keys, | |
| 252 | + "tokens": tokens, | |
| 253 | + }, | |
| 254 | + ) | |
| 255 | + | |
| 256 | + | |
| 257 | +@login_required | |
| 258 | +def profile_edit(request): | |
| 259 | + """Edit profile info: name, email, handle, bio, location, website.""" | |
| 260 | + user_profile, _ = UserProfile.objects.get_or_create(user=request.user) | |
| 261 | + | |
| 262 | + if request.method == "POST": | |
| 263 | + user = request.user | |
| 264 | + user.first_name = request.POST.get("first_name", "").strip()[:30] | |
| 265 | + user.last_name = request.POST.get("last_name", "").strip()[:150] | |
| 266 | + user.email = request.POST.get("email", "").strip()[:254] | |
| 267 | + user.save(update_fields=["first_name", "last_name", "email"]) | |
| 268 | + | |
| 269 | + raw_handle = request.POST.get("handle", "").strip() | |
| 270 | + handle = UserProfile.sanitize_handle(raw_handle) | |
| 271 | + if handle: | |
| 272 | + # Check uniqueness (excluding self) | |
| 273 | + conflict = UserProfile.objects.filter(handle=handle).exclude(pk=user_profile.pk).exists() | |
| 274 | + if conflict: | |
| 275 | + messages.error(request, f"Handle @{handle} is already taken.") | |
| 276 | + return render(request, "accounts/profile_edit.html", {"user_profile": user_profile}) | |
| 277 | + user_profile.handle = handle | |
| 278 | + else: | |
| 279 | + user_profile.handle = None | |
| 280 | + | |
| 281 | + user_profile.bio = request.POST.get("bio", "").strip()[:500] | |
| 282 | + user_profile.location = request.POST.get("location", "").strip()[:100] | |
| 283 | + user_profile.website = request.POST.get("website", "").strip()[:200] | |
| 284 | + user_profile.save() | |
| 285 | + | |
| 286 | + messages.success(request, "Profile updated.") | |
| 287 | + return redirect("accounts:profile") | |
| 288 | + | |
| 289 | + return render(request, "accounts/profile_edit.html", {"user_profile": user_profile}) | |
| 290 | + | |
| 291 | + | |
| 292 | +@login_required | |
| 293 | +def profile_token_create(request): | |
| 294 | + """Generate a personal access token. Shows the raw token once.""" | |
| 295 | + if request.method == "POST": | |
| 296 | + name = request.POST.get("name", "").strip() | |
| 297 | + if not name: | |
| 298 | + messages.error(request, "Token name is required.") | |
| 299 | + return render(request, "accounts/profile_token_create.html", {}) | |
| 300 | + | |
| 301 | + raw_scopes = request.POST.get("scopes", "read").strip() | |
| 302 | + scopes = ",".join(s.strip() for s in raw_scopes.split(",") if s.strip() in VALID_SCOPES) | |
| 303 | + if not scopes: | |
| 304 | + scopes = "read" | |
| 305 | + | |
| 306 | + raw_token, token_hash, prefix = PersonalAccessToken.generate() | |
| 307 | + PersonalAccessToken.objects.create( | |
| 308 | + user=request.user, | |
| 309 | + name=name, | |
| 310 | + token_hash=token_hash, | |
| 311 | + token_prefix=prefix, | |
| 312 | + scopes=scopes, | |
| 313 | + ) | |
| 314 | + | |
| 315 | + return render( | |
| 316 | + request, | |
| 317 | + "accounts/profile_token_created.html", | |
| 318 | + {"raw_token": raw_token, "token_name": name}, | |
| 319 | + ) | |
| 320 | + | |
| 321 | + return render(request, "accounts/profile_token_create.html", {}) | |
| 322 | + | |
| 323 | + | |
| 324 | +@login_required | |
| 325 | +@require_POST | |
| 326 | +def profile_token_revoke(request, guid): | |
| 327 | + """Revoke a personal access token by GUID (token_prefix used as public id).""" | |
| 328 | + from django.utils import timezone | |
| 329 | + | |
| 330 | + token = get_object_or_404(PersonalAccessToken, token_prefix=guid, user=request.user, revoked_at__isnull=True) | |
| 331 | + token.revoked_at = timezone.now() | |
| 332 | + token.save(update_fields=["revoked_at"]) | |
| 333 | + | |
| 334 | + messages.success(request, f'Token "{token.name}" revoked.') | |
| 335 | + | |
| 336 | + if request.headers.get("HX-Request"): | |
| 337 | + return HttpResponse(status=200, headers={"HX-Redirect": "/auth/profile/"}) | |
| 338 | + | |
| 339 | + return redirect("accounts:profile") | |
| 224 | 340 |
| --- accounts/views.py | |
| +++ accounts/views.py | |
| @@ -8,10 +8,11 @@ | |
| 8 | from django.utils.http import url_has_allowed_host_and_scheme |
| 9 | from django.views.decorators.http import require_POST |
| 10 | from django_ratelimit.decorators import ratelimit |
| 11 | |
| 12 | from .forms import LoginForm |
| 13 | |
| 14 | # Allowed SSH key type prefixes |
| 15 | _SSH_KEY_PREFIXES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "ssh-dss") |
| 16 | |
| 17 | |
| @@ -219,5 +220,120 @@ | |
| 219 | return HttpResponse(status=200, headers={"HX-Redirect": "/auth/notifications/"}) |
| 220 | |
| 221 | return redirect("accounts:notification_prefs") |
| 222 | |
| 223 | return render(request, "accounts/notification_prefs.html", {"prefs": prefs}) |
| 224 |
| --- accounts/views.py | |
| +++ accounts/views.py | |
| @@ -8,10 +8,11 @@ | |
| 8 | from django.utils.http import url_has_allowed_host_and_scheme |
| 9 | from django.views.decorators.http import require_POST |
| 10 | from django_ratelimit.decorators import ratelimit |
| 11 | |
| 12 | from .forms import LoginForm |
| 13 | from .models import PersonalAccessToken, UserProfile |
| 14 | |
| 15 | # Allowed SSH key type prefixes |
| 16 | _SSH_KEY_PREFIXES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "ssh-dss") |
| 17 | |
| 18 | |
| @@ -219,5 +220,120 @@ | |
| 220 | return HttpResponse(status=200, headers={"HX-Redirect": "/auth/notifications/"}) |
| 221 | |
| 222 | return redirect("accounts:notification_prefs") |
| 223 | |
| 224 | return render(request, "accounts/notification_prefs.html", {"prefs": prefs}) |
| 225 | |
| 226 | |
| 227 | # --------------------------------------------------------------------------- |
| 228 | # Unified profile |
| 229 | # --------------------------------------------------------------------------- |
| 230 | |
| 231 | VALID_SCOPES = {"read", "write", "admin"} |
| 232 | |
| 233 | |
| 234 | @login_required |
| 235 | def profile(request): |
| 236 | """Unified user profile page consolidating all personal settings.""" |
| 237 | from fossil.notifications import NotificationPreference |
| 238 | from fossil.user_keys import UserSSHKey |
| 239 | |
| 240 | user_profile, _ = UserProfile.objects.get_or_create(user=request.user) |
| 241 | notif_prefs, _ = NotificationPreference.objects.get_or_create(user=request.user) |
| 242 | ssh_keys = UserSSHKey.objects.filter(user=request.user) |
| 243 | tokens = PersonalAccessToken.objects.filter(user=request.user, revoked_at__isnull=True) |
| 244 | |
| 245 | return render( |
| 246 | request, |
| 247 | "accounts/profile.html", |
| 248 | { |
| 249 | "user_profile": user_profile, |
| 250 | "notif_prefs": notif_prefs, |
| 251 | "ssh_keys": ssh_keys, |
| 252 | "tokens": tokens, |
| 253 | }, |
| 254 | ) |
| 255 | |
| 256 | |
| 257 | @login_required |
| 258 | def profile_edit(request): |
| 259 | """Edit profile info: name, email, handle, bio, location, website.""" |
| 260 | user_profile, _ = UserProfile.objects.get_or_create(user=request.user) |
| 261 | |
| 262 | if request.method == "POST": |
| 263 | user = request.user |
| 264 | user.first_name = request.POST.get("first_name", "").strip()[:30] |
| 265 | user.last_name = request.POST.get("last_name", "").strip()[:150] |
| 266 | user.email = request.POST.get("email", "").strip()[:254] |
| 267 | user.save(update_fields=["first_name", "last_name", "email"]) |
| 268 | |
| 269 | raw_handle = request.POST.get("handle", "").strip() |
| 270 | handle = UserProfile.sanitize_handle(raw_handle) |
| 271 | if handle: |
| 272 | # Check uniqueness (excluding self) |
| 273 | conflict = UserProfile.objects.filter(handle=handle).exclude(pk=user_profile.pk).exists() |
| 274 | if conflict: |
| 275 | messages.error(request, f"Handle @{handle} is already taken.") |
| 276 | return render(request, "accounts/profile_edit.html", {"user_profile": user_profile}) |
| 277 | user_profile.handle = handle |
| 278 | else: |
| 279 | user_profile.handle = None |
| 280 | |
| 281 | user_profile.bio = request.POST.get("bio", "").strip()[:500] |
| 282 | user_profile.location = request.POST.get("location", "").strip()[:100] |
| 283 | user_profile.website = request.POST.get("website", "").strip()[:200] |
| 284 | user_profile.save() |
| 285 | |
| 286 | messages.success(request, "Profile updated.") |
| 287 | return redirect("accounts:profile") |
| 288 | |
| 289 | return render(request, "accounts/profile_edit.html", {"user_profile": user_profile}) |
| 290 | |
| 291 | |
| 292 | @login_required |
| 293 | def profile_token_create(request): |
| 294 | """Generate a personal access token. Shows the raw token once.""" |
| 295 | if request.method == "POST": |
| 296 | name = request.POST.get("name", "").strip() |
| 297 | if not name: |
| 298 | messages.error(request, "Token name is required.") |
| 299 | return render(request, "accounts/profile_token_create.html", {}) |
| 300 | |
| 301 | raw_scopes = request.POST.get("scopes", "read").strip() |
| 302 | scopes = ",".join(s.strip() for s in raw_scopes.split(",") if s.strip() in VALID_SCOPES) |
| 303 | if not scopes: |
| 304 | scopes = "read" |
| 305 | |
| 306 | raw_token, token_hash, prefix = PersonalAccessToken.generate() |
| 307 | PersonalAccessToken.objects.create( |
| 308 | user=request.user, |
| 309 | name=name, |
| 310 | token_hash=token_hash, |
| 311 | token_prefix=prefix, |
| 312 | scopes=scopes, |
| 313 | ) |
| 314 | |
| 315 | return render( |
| 316 | request, |
| 317 | "accounts/profile_token_created.html", |
| 318 | {"raw_token": raw_token, "token_name": name}, |
| 319 | ) |
| 320 | |
| 321 | return render(request, "accounts/profile_token_create.html", {}) |
| 322 | |
| 323 | |
| 324 | @login_required |
| 325 | @require_POST |
| 326 | def profile_token_revoke(request, guid): |
| 327 | """Revoke a personal access token by GUID (token_prefix used as public id).""" |
| 328 | from django.utils import timezone |
| 329 | |
| 330 | token = get_object_or_404(PersonalAccessToken, token_prefix=guid, user=request.user, revoked_at__isnull=True) |
| 331 | token.revoked_at = timezone.now() |
| 332 | token.save(update_fields=["revoked_at"]) |
| 333 | |
| 334 | messages.success(request, f'Token "{token.name}" revoked.') |
| 335 | |
| 336 | if request.headers.get("HX-Request"): |
| 337 | return HttpResponse(status=200, headers={"HX-Redirect": "/auth/profile/"}) |
| 338 | |
| 339 | return redirect("accounts:profile") |
| 340 |
| --- config/urls.py | ||
| +++ config/urls.py | ||
| @@ -244,10 +244,11 @@ | ||
| 244 | 244 | return explore(request) |
| 245 | 245 | |
| 246 | 246 | |
| 247 | 247 | urlpatterns = [ |
| 248 | 248 | path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)), |
| 249 | + path("profile/", RedirectView.as_view(pattern_name="accounts:profile", permanent=False)), | |
| 249 | 250 | path("status/", status_page, name="status"), |
| 250 | 251 | path("explore/", _explore_view, name="explore"), |
| 251 | 252 | path("dashboard/", include("core.urls")), |
| 252 | 253 | path("auth/", include("accounts.urls")), |
| 253 | 254 | path("settings/", include("organization.urls")), |
| 254 | 255 |
| --- config/urls.py | |
| +++ config/urls.py | |
| @@ -244,10 +244,11 @@ | |
| 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 |
| --- config/urls.py | |
| +++ config/urls.py | |
| @@ -244,10 +244,11 @@ | |
| 244 | return explore(request) |
| 245 | |
| 246 | |
| 247 | urlpatterns = [ |
| 248 | path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)), |
| 249 | path("profile/", RedirectView.as_view(pattern_name="accounts:profile", permanent=False)), |
| 250 | path("status/", status_page, name="status"), |
| 251 | path("explore/", _explore_view, name="explore"), |
| 252 | path("dashboard/", include("core.urls")), |
| 253 | path("auth/", include("accounts.urls")), |
| 254 | path("settings/", include("organization.urls")), |
| 255 |
| --- fossil/urls.py | ||
| +++ fossil/urls.py | ||
| @@ -55,10 +55,12 @@ | ||
| 55 | 55 | path("stats/", views.repo_stats, name="stats"), |
| 56 | 56 | path("compare/", views.compare_checkins, name="compare"), |
| 57 | 57 | path("settings/", views.repo_settings, name="repo_settings"), |
| 58 | 58 | path("sync/", views.sync_pull, name="sync"), |
| 59 | 59 | path("sync/git/", views.git_mirror_config, name="git_mirror"), |
| 60 | + path("sync/git/<int:mirror_id>/edit/", views.git_mirror_config, name="git_mirror_edit"), | |
| 61 | + path("sync/git/<int:mirror_id>/delete/", views.git_mirror_delete, name="git_mirror_delete"), | |
| 60 | 62 | path("sync/git/<int:mirror_id>/run/", views.git_mirror_run, name="git_mirror_run"), |
| 61 | 63 | path("sync/git/connect/github/", views.oauth_github_start, name="oauth_github"), |
| 62 | 64 | path("sync/git/connect/gitlab/", views.oauth_gitlab_start, name="oauth_gitlab"), |
| 63 | 65 | path("sync/git/callback/github/", views.oauth_github_callback, name="oauth_github_callback"), |
| 64 | 66 | path("sync/git/callback/gitlab/", views.oauth_gitlab_callback, name="oauth_gitlab_callback"), |
| 65 | 67 |
| --- fossil/urls.py | |
| +++ fossil/urls.py | |
| @@ -55,10 +55,12 @@ | |
| 55 | path("stats/", views.repo_stats, name="stats"), |
| 56 | path("compare/", views.compare_checkins, name="compare"), |
| 57 | path("settings/", views.repo_settings, name="repo_settings"), |
| 58 | path("sync/", views.sync_pull, name="sync"), |
| 59 | path("sync/git/", views.git_mirror_config, name="git_mirror"), |
| 60 | path("sync/git/<int:mirror_id>/run/", views.git_mirror_run, name="git_mirror_run"), |
| 61 | path("sync/git/connect/github/", views.oauth_github_start, name="oauth_github"), |
| 62 | path("sync/git/connect/gitlab/", views.oauth_gitlab_start, name="oauth_gitlab"), |
| 63 | path("sync/git/callback/github/", views.oauth_github_callback, name="oauth_github_callback"), |
| 64 | path("sync/git/callback/gitlab/", views.oauth_gitlab_callback, name="oauth_gitlab_callback"), |
| 65 |
| --- fossil/urls.py | |
| +++ fossil/urls.py | |
| @@ -55,10 +55,12 @@ | |
| 55 | path("stats/", views.repo_stats, name="stats"), |
| 56 | path("compare/", views.compare_checkins, name="compare"), |
| 57 | path("settings/", views.repo_settings, name="repo_settings"), |
| 58 | path("sync/", views.sync_pull, name="sync"), |
| 59 | path("sync/git/", views.git_mirror_config, name="git_mirror"), |
| 60 | path("sync/git/<int:mirror_id>/edit/", views.git_mirror_config, name="git_mirror_edit"), |
| 61 | path("sync/git/<int:mirror_id>/delete/", views.git_mirror_delete, name="git_mirror_delete"), |
| 62 | path("sync/git/<int:mirror_id>/run/", views.git_mirror_run, name="git_mirror_run"), |
| 63 | path("sync/git/connect/github/", views.oauth_github_start, name="oauth_github"), |
| 64 | path("sync/git/connect/gitlab/", views.oauth_gitlab_start, name="oauth_gitlab"), |
| 65 | path("sync/git/callback/github/", views.oauth_github_callback, name="oauth_github_callback"), |
| 66 | path("sync/git/callback/gitlab/", views.oauth_gitlab_callback, name="oauth_gitlab_callback"), |
| 67 |
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -1442,19 +1442,24 @@ | ||
| 1442 | 1442 | if result["artifacts_received"] > 0: |
| 1443 | 1443 | messages.success(request, f"Pulled {result['artifacts_received']} new artifacts.") |
| 1444 | 1444 | else: |
| 1445 | 1445 | messages.info(request, "Already up to date.") |
| 1446 | 1446 | |
| 1447 | + from fossil.sync_models import GitMirror | |
| 1448 | + | |
| 1449 | + mirrors = GitMirror.objects.filter(repository=fossil_repo, deleted_at__isnull=True) | |
| 1450 | + | |
| 1447 | 1451 | return render( |
| 1448 | 1452 | request, |
| 1449 | 1453 | "fossil/sync.html", |
| 1450 | 1454 | { |
| 1451 | 1455 | "project": project, |
| 1452 | 1456 | "fossil_repo": fossil_repo, |
| 1453 | 1457 | "detected_remote": detected_remote, |
| 1454 | 1458 | "sync_configured": bool(fossil_repo.remote_url), |
| 1455 | 1459 | "result": result, |
| 1460 | + "mirrors": mirrors, | |
| 1456 | 1461 | "active_tab": "sync", |
| 1457 | 1462 | }, |
| 1458 | 1463 | ) |
| 1459 | 1464 | |
| 1460 | 1465 | |
| @@ -1567,21 +1572,29 @@ | ||
| 1567 | 1572 | |
| 1568 | 1573 | # --- Git Mirror --- |
| 1569 | 1574 | |
| 1570 | 1575 | |
| 1571 | 1576 | @login_required |
| 1572 | -def git_mirror_config(request, slug): | |
| 1573 | - """Configure Git mirror sync for a project.""" | |
| 1577 | +def git_mirror_config(request, slug, mirror_id=None): | |
| 1578 | + """Configure Git mirror sync for a project. | |
| 1579 | + | |
| 1580 | + If mirror_id is provided, edit that mirror. Otherwise show the add form. | |
| 1581 | + """ | |
| 1574 | 1582 | project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin") |
| 1575 | 1583 | |
| 1576 | 1584 | from fossil.sync_models import GitMirror |
| 1577 | 1585 | |
| 1578 | 1586 | mirrors = GitMirror.objects.filter(repository=fossil_repo, deleted_at__isnull=True) |
| 1579 | 1587 | |
| 1588 | + editing_mirror = None | |
| 1589 | + if mirror_id: | |
| 1590 | + editing_mirror = get_object_or_404(GitMirror, pk=mirror_id, repository=fossil_repo, deleted_at__isnull=True) | |
| 1591 | + | |
| 1580 | 1592 | if request.method == "POST": |
| 1581 | 1593 | action = request.POST.get("action", "") |
| 1582 | - if action == "create": | |
| 1594 | + | |
| 1595 | + if action in ("create", "update"): | |
| 1583 | 1596 | git_url = request.POST.get("git_remote_url", "").strip() |
| 1584 | 1597 | auth_method = request.POST.get("auth_method", "token") |
| 1585 | 1598 | auth_credential = request.POST.get("auth_credential", "").strip() |
| 1586 | 1599 | # Use OAuth token from session if available and no manual credential provided |
| 1587 | 1600 | if not auth_credential: |
| @@ -1588,47 +1601,93 @@ | ||
| 1588 | 1601 | if auth_method == "oauth_github" and request.session.get("github_oauth_token"): |
| 1589 | 1602 | auth_credential = request.session.pop("github_oauth_token") |
| 1590 | 1603 | elif auth_method == "oauth_gitlab" and request.session.get("gitlab_oauth_token"): |
| 1591 | 1604 | auth_credential = request.session.pop("gitlab_oauth_token") |
| 1592 | 1605 | sync_mode = request.POST.get("sync_mode", "scheduled") |
| 1593 | - sync_schedule = request.POST.get("sync_schedule", "*/15 * * * *").strip() | |
| 1594 | - git_branch = request.POST.get("git_branch", "main").strip() | |
| 1595 | - | |
| 1596 | - if git_url: | |
| 1597 | - GitMirror.objects.create( | |
| 1598 | - repository=fossil_repo, | |
| 1599 | - git_remote_url=git_url, | |
| 1600 | - auth_method=auth_method, | |
| 1601 | - auth_credential=auth_credential, | |
| 1602 | - sync_mode=sync_mode, | |
| 1603 | - sync_schedule=sync_schedule, | |
| 1604 | - git_branch=git_branch, | |
| 1605 | - created_by=request.user, | |
| 1606 | - ) | |
| 1607 | - from django.contrib import messages | |
| 1608 | - | |
| 1609 | - messages.success(request, f"Git mirror configured: {git_url}") | |
| 1610 | - from django.shortcuts import redirect | |
| 1611 | - | |
| 1612 | - return redirect("fossil:git_mirror", slug=slug) | |
| 1613 | - | |
| 1614 | - elif action == "delete": | |
| 1615 | - mirror_id = request.POST.get("mirror_id") | |
| 1616 | - mirror = GitMirror.objects.filter(pk=mirror_id, repository=fossil_repo).first() | |
| 1617 | - if mirror: | |
| 1618 | - mirror.soft_delete(user=request.user) | |
| 1619 | - from django.contrib import messages | |
| 1620 | - | |
| 1621 | - messages.info(request, "Git mirror removed.") | |
| 1606 | + sync_direction = request.POST.get("sync_direction", "push") | |
| 1607 | + sync_schedule = request.POST.get("sync_schedule", "*/15 * * * *").strip() | |
| 1608 | + git_branch = request.POST.get("git_branch", "main").strip() | |
| 1609 | + fossil_branch = request.POST.get("fossil_branch", "trunk").strip() | |
| 1610 | + sync_tickets = request.POST.get("sync_tickets") == "on" | |
| 1611 | + sync_wiki = request.POST.get("sync_wiki") == "on" | |
| 1612 | + | |
| 1613 | + if git_url: | |
| 1614 | + from django.contrib import messages | |
| 1615 | + | |
| 1616 | + if action == "update" and editing_mirror: | |
| 1617 | + editing_mirror.git_remote_url = git_url | |
| 1618 | + editing_mirror.auth_method = auth_method | |
| 1619 | + if auth_credential: # Only update credential if a new one was provided | |
| 1620 | + editing_mirror.auth_credential = auth_credential | |
| 1621 | + editing_mirror.sync_mode = sync_mode | |
| 1622 | + editing_mirror.sync_direction = sync_direction | |
| 1623 | + editing_mirror.sync_schedule = sync_schedule | |
| 1624 | + editing_mirror.git_branch = git_branch | |
| 1625 | + editing_mirror.fossil_branch = fossil_branch | |
| 1626 | + editing_mirror.sync_tickets = sync_tickets | |
| 1627 | + editing_mirror.sync_wiki = sync_wiki | |
| 1628 | + editing_mirror.updated_by = request.user | |
| 1629 | + editing_mirror.save() | |
| 1630 | + messages.success(request, f"Mirror updated: {git_url}") | |
| 1631 | + else: | |
| 1632 | + GitMirror.objects.create( | |
| 1633 | + repository=fossil_repo, | |
| 1634 | + git_remote_url=git_url, | |
| 1635 | + auth_method=auth_method, | |
| 1636 | + auth_credential=auth_credential, | |
| 1637 | + sync_mode=sync_mode, | |
| 1638 | + sync_direction=sync_direction, | |
| 1639 | + sync_schedule=sync_schedule, | |
| 1640 | + git_branch=git_branch, | |
| 1641 | + fossil_branch=fossil_branch, | |
| 1642 | + sync_tickets=sync_tickets, | |
| 1643 | + sync_wiki=sync_wiki, | |
| 1644 | + created_by=request.user, | |
| 1645 | + ) | |
| 1646 | + messages.success(request, f"Git mirror configured: {git_url}") | |
| 1647 | + | |
| 1648 | + return redirect("fossil:git_mirror", slug=slug) | |
| 1622 | 1649 | |
| 1623 | 1650 | return render( |
| 1624 | 1651 | request, |
| 1625 | 1652 | "fossil/git_mirror.html", |
| 1626 | 1653 | { |
| 1627 | 1654 | "project": project, |
| 1628 | 1655 | "fossil_repo": fossil_repo, |
| 1629 | 1656 | "mirrors": mirrors, |
| 1657 | + "editing_mirror": editing_mirror, | |
| 1658 | + "auth_method_choices": GitMirror.AuthMethod.choices, | |
| 1659 | + "sync_mode_choices": GitMirror.SyncMode.choices, | |
| 1660 | + "sync_direction_choices": GitMirror.SyncDirection.choices, | |
| 1661 | + "active_tab": "sync", | |
| 1662 | + }, | |
| 1663 | + ) | |
| 1664 | + | |
| 1665 | + | |
| 1666 | +@login_required | |
| 1667 | +def git_mirror_delete(request, slug, mirror_id): | |
| 1668 | + """Delete (soft-delete) a git mirror after confirmation.""" | |
| 1669 | + project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin") | |
| 1670 | + | |
| 1671 | + from fossil.sync_models import GitMirror | |
| 1672 | + | |
| 1673 | + mirror = get_object_or_404(GitMirror, pk=mirror_id, repository=fossil_repo, deleted_at__isnull=True) | |
| 1674 | + | |
| 1675 | + if request.method == "POST": | |
| 1676 | + from django.contrib import messages | |
| 1677 | + | |
| 1678 | + mirror.soft_delete(user=request.user) | |
| 1679 | + messages.success(request, f"Mirror to {mirror.git_remote_url} removed.") | |
| 1680 | + return redirect("fossil:sync", slug=slug) | |
| 1681 | + | |
| 1682 | + return render( | |
| 1683 | + request, | |
| 1684 | + "fossil/git_mirror_delete.html", | |
| 1685 | + { | |
| 1686 | + "project": project, | |
| 1687 | + "fossil_repo": fossil_repo, | |
| 1688 | + "mirror": mirror, | |
| 1630 | 1689 | "active_tab": "sync", |
| 1631 | 1690 | }, |
| 1632 | 1691 | ) |
| 1633 | 1692 | |
| 1634 | 1693 | |
| 1635 | 1694 | |
| 1636 | 1695 | ADDED templates/accounts/profile.html |
| 1637 | 1696 | ADDED templates/accounts/profile_edit.html |
| 1638 | 1697 | ADDED templates/accounts/profile_token_create.html |
| 1639 | 1698 | ADDED templates/accounts/profile_token_created.html |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -1442,19 +1442,24 @@ | |
| 1442 | if result["artifacts_received"] > 0: |
| 1443 | messages.success(request, f"Pulled {result['artifacts_received']} new artifacts.") |
| 1444 | else: |
| 1445 | messages.info(request, "Already up to date.") |
| 1446 | |
| 1447 | return render( |
| 1448 | request, |
| 1449 | "fossil/sync.html", |
| 1450 | { |
| 1451 | "project": project, |
| 1452 | "fossil_repo": fossil_repo, |
| 1453 | "detected_remote": detected_remote, |
| 1454 | "sync_configured": bool(fossil_repo.remote_url), |
| 1455 | "result": result, |
| 1456 | "active_tab": "sync", |
| 1457 | }, |
| 1458 | ) |
| 1459 | |
| 1460 | |
| @@ -1567,21 +1572,29 @@ | |
| 1567 | |
| 1568 | # --- Git Mirror --- |
| 1569 | |
| 1570 | |
| 1571 | @login_required |
| 1572 | def git_mirror_config(request, slug): |
| 1573 | """Configure Git mirror sync for a project.""" |
| 1574 | project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin") |
| 1575 | |
| 1576 | from fossil.sync_models import GitMirror |
| 1577 | |
| 1578 | mirrors = GitMirror.objects.filter(repository=fossil_repo, deleted_at__isnull=True) |
| 1579 | |
| 1580 | if request.method == "POST": |
| 1581 | action = request.POST.get("action", "") |
| 1582 | if action == "create": |
| 1583 | git_url = request.POST.get("git_remote_url", "").strip() |
| 1584 | auth_method = request.POST.get("auth_method", "token") |
| 1585 | auth_credential = request.POST.get("auth_credential", "").strip() |
| 1586 | # Use OAuth token from session if available and no manual credential provided |
| 1587 | if not auth_credential: |
| @@ -1588,47 +1601,93 @@ | |
| 1588 | if auth_method == "oauth_github" and request.session.get("github_oauth_token"): |
| 1589 | auth_credential = request.session.pop("github_oauth_token") |
| 1590 | elif auth_method == "oauth_gitlab" and request.session.get("gitlab_oauth_token"): |
| 1591 | auth_credential = request.session.pop("gitlab_oauth_token") |
| 1592 | sync_mode = request.POST.get("sync_mode", "scheduled") |
| 1593 | sync_schedule = request.POST.get("sync_schedule", "*/15 * * * *").strip() |
| 1594 | git_branch = request.POST.get("git_branch", "main").strip() |
| 1595 | |
| 1596 | if git_url: |
| 1597 | GitMirror.objects.create( |
| 1598 | repository=fossil_repo, |
| 1599 | git_remote_url=git_url, |
| 1600 | auth_method=auth_method, |
| 1601 | auth_credential=auth_credential, |
| 1602 | sync_mode=sync_mode, |
| 1603 | sync_schedule=sync_schedule, |
| 1604 | git_branch=git_branch, |
| 1605 | created_by=request.user, |
| 1606 | ) |
| 1607 | from django.contrib import messages |
| 1608 | |
| 1609 | messages.success(request, f"Git mirror configured: {git_url}") |
| 1610 | from django.shortcuts import redirect |
| 1611 | |
| 1612 | return redirect("fossil:git_mirror", slug=slug) |
| 1613 | |
| 1614 | elif action == "delete": |
| 1615 | mirror_id = request.POST.get("mirror_id") |
| 1616 | mirror = GitMirror.objects.filter(pk=mirror_id, repository=fossil_repo).first() |
| 1617 | if mirror: |
| 1618 | mirror.soft_delete(user=request.user) |
| 1619 | from django.contrib import messages |
| 1620 | |
| 1621 | messages.info(request, "Git mirror removed.") |
| 1622 | |
| 1623 | return render( |
| 1624 | request, |
| 1625 | "fossil/git_mirror.html", |
| 1626 | { |
| 1627 | "project": project, |
| 1628 | "fossil_repo": fossil_repo, |
| 1629 | "mirrors": mirrors, |
| 1630 | "active_tab": "sync", |
| 1631 | }, |
| 1632 | ) |
| 1633 | |
| 1634 | |
| 1635 | |
| 1636 | DDED templates/accounts/profile.html |
| 1637 | DDED templates/accounts/profile_edit.html |
| 1638 | DDED templates/accounts/profile_token_create.html |
| 1639 | DDED templates/accounts/profile_token_created.html |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -1442,19 +1442,24 @@ | |
| 1442 | if result["artifacts_received"] > 0: |
| 1443 | messages.success(request, f"Pulled {result['artifacts_received']} new artifacts.") |
| 1444 | else: |
| 1445 | messages.info(request, "Already up to date.") |
| 1446 | |
| 1447 | from fossil.sync_models import GitMirror |
| 1448 | |
| 1449 | mirrors = GitMirror.objects.filter(repository=fossil_repo, deleted_at__isnull=True) |
| 1450 | |
| 1451 | return render( |
| 1452 | request, |
| 1453 | "fossil/sync.html", |
| 1454 | { |
| 1455 | "project": project, |
| 1456 | "fossil_repo": fossil_repo, |
| 1457 | "detected_remote": detected_remote, |
| 1458 | "sync_configured": bool(fossil_repo.remote_url), |
| 1459 | "result": result, |
| 1460 | "mirrors": mirrors, |
| 1461 | "active_tab": "sync", |
| 1462 | }, |
| 1463 | ) |
| 1464 | |
| 1465 | |
| @@ -1567,21 +1572,29 @@ | |
| 1572 | |
| 1573 | # --- Git Mirror --- |
| 1574 | |
| 1575 | |
| 1576 | @login_required |
| 1577 | def git_mirror_config(request, slug, mirror_id=None): |
| 1578 | """Configure Git mirror sync for a project. |
| 1579 | |
| 1580 | If mirror_id is provided, edit that mirror. Otherwise show the add form. |
| 1581 | """ |
| 1582 | project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin") |
| 1583 | |
| 1584 | from fossil.sync_models import GitMirror |
| 1585 | |
| 1586 | mirrors = GitMirror.objects.filter(repository=fossil_repo, deleted_at__isnull=True) |
| 1587 | |
| 1588 | editing_mirror = None |
| 1589 | if mirror_id: |
| 1590 | editing_mirror = get_object_or_404(GitMirror, pk=mirror_id, repository=fossil_repo, deleted_at__isnull=True) |
| 1591 | |
| 1592 | if request.method == "POST": |
| 1593 | action = request.POST.get("action", "") |
| 1594 | |
| 1595 | if action in ("create", "update"): |
| 1596 | git_url = request.POST.get("git_remote_url", "").strip() |
| 1597 | auth_method = request.POST.get("auth_method", "token") |
| 1598 | auth_credential = request.POST.get("auth_credential", "").strip() |
| 1599 | # Use OAuth token from session if available and no manual credential provided |
| 1600 | if not auth_credential: |
| @@ -1588,47 +1601,93 @@ | |
| 1601 | if auth_method == "oauth_github" and request.session.get("github_oauth_token"): |
| 1602 | auth_credential = request.session.pop("github_oauth_token") |
| 1603 | elif auth_method == "oauth_gitlab" and request.session.get("gitlab_oauth_token"): |
| 1604 | auth_credential = request.session.pop("gitlab_oauth_token") |
| 1605 | sync_mode = request.POST.get("sync_mode", "scheduled") |
| 1606 | sync_direction = request.POST.get("sync_direction", "push") |
| 1607 | sync_schedule = request.POST.get("sync_schedule", "*/15 * * * *").strip() |
| 1608 | git_branch = request.POST.get("git_branch", "main").strip() |
| 1609 | fossil_branch = request.POST.get("fossil_branch", "trunk").strip() |
| 1610 | sync_tickets = request.POST.get("sync_tickets") == "on" |
| 1611 | sync_wiki = request.POST.get("sync_wiki") == "on" |
| 1612 | |
| 1613 | if git_url: |
| 1614 | from django.contrib import messages |
| 1615 | |
| 1616 | if action == "update" and editing_mirror: |
| 1617 | editing_mirror.git_remote_url = git_url |
| 1618 | editing_mirror.auth_method = auth_method |
| 1619 | if auth_credential: # Only update credential if a new one was provided |
| 1620 | editing_mirror.auth_credential = auth_credential |
| 1621 | editing_mirror.sync_mode = sync_mode |
| 1622 | editing_mirror.sync_direction = sync_direction |
| 1623 | editing_mirror.sync_schedule = sync_schedule |
| 1624 | editing_mirror.git_branch = git_branch |
| 1625 | editing_mirror.fossil_branch = fossil_branch |
| 1626 | editing_mirror.sync_tickets = sync_tickets |
| 1627 | editing_mirror.sync_wiki = sync_wiki |
| 1628 | editing_mirror.updated_by = request.user |
| 1629 | editing_mirror.save() |
| 1630 | messages.success(request, f"Mirror updated: {git_url}") |
| 1631 | else: |
| 1632 | GitMirror.objects.create( |
| 1633 | repository=fossil_repo, |
| 1634 | git_remote_url=git_url, |
| 1635 | auth_method=auth_method, |
| 1636 | auth_credential=auth_credential, |
| 1637 | sync_mode=sync_mode, |
| 1638 | sync_direction=sync_direction, |
| 1639 | sync_schedule=sync_schedule, |
| 1640 | git_branch=git_branch, |
| 1641 | fossil_branch=fossil_branch, |
| 1642 | sync_tickets=sync_tickets, |
| 1643 | sync_wiki=sync_wiki, |
| 1644 | created_by=request.user, |
| 1645 | ) |
| 1646 | messages.success(request, f"Git mirror configured: {git_url}") |
| 1647 | |
| 1648 | return redirect("fossil:git_mirror", slug=slug) |
| 1649 | |
| 1650 | return render( |
| 1651 | request, |
| 1652 | "fossil/git_mirror.html", |
| 1653 | { |
| 1654 | "project": project, |
| 1655 | "fossil_repo": fossil_repo, |
| 1656 | "mirrors": mirrors, |
| 1657 | "editing_mirror": editing_mirror, |
| 1658 | "auth_method_choices": GitMirror.AuthMethod.choices, |
| 1659 | "sync_mode_choices": GitMirror.SyncMode.choices, |
| 1660 | "sync_direction_choices": GitMirror.SyncDirection.choices, |
| 1661 | "active_tab": "sync", |
| 1662 | }, |
| 1663 | ) |
| 1664 | |
| 1665 | |
| 1666 | @login_required |
| 1667 | def git_mirror_delete(request, slug, mirror_id): |
| 1668 | """Delete (soft-delete) a git mirror after confirmation.""" |
| 1669 | project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin") |
| 1670 | |
| 1671 | from fossil.sync_models import GitMirror |
| 1672 | |
| 1673 | mirror = get_object_or_404(GitMirror, pk=mirror_id, repository=fossil_repo, deleted_at__isnull=True) |
| 1674 | |
| 1675 | if request.method == "POST": |
| 1676 | from django.contrib import messages |
| 1677 | |
| 1678 | mirror.soft_delete(user=request.user) |
| 1679 | messages.success(request, f"Mirror to {mirror.git_remote_url} removed.") |
| 1680 | return redirect("fossil:sync", slug=slug) |
| 1681 | |
| 1682 | return render( |
| 1683 | request, |
| 1684 | "fossil/git_mirror_delete.html", |
| 1685 | { |
| 1686 | "project": project, |
| 1687 | "fossil_repo": fossil_repo, |
| 1688 | "mirror": mirror, |
| 1689 | "active_tab": "sync", |
| 1690 | }, |
| 1691 | ) |
| 1692 | |
| 1693 | |
| 1694 | |
| 1695 | DDED templates/accounts/profile.html |
| 1696 | DDED templates/accounts/profile_edit.html |
| 1697 | DDED templates/accounts/profile_token_create.html |
| 1698 | DDED templates/accounts/profile_token_created.html |
| --- a/templates/accounts/profile.html | ||
| +++ b/templates/accounts/profile.html | ||
| @@ -0,0 +1,66 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Profile — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<!-- Profile Header --> | |
| 6 | +<div class="text-sm font-mediugap-5 mb-8gray-800 px-4 py- min-w-0"> | |
| 7 | + <div class="flex-shrink-0 h-16 w-16 rounded-full bg-brand flex items-centtext-2xl font-boltems-center gap-2 mt-4 sm-1 min-w-0"> | |
| 8 | + <h1 class="text-2xl font-bold text-gray-100 truncate"> | |
| 9 | + {% if user.get_full_name %}{{ user.get_full_name }}{% else %}{{ user.username }}{% endif %} | |
| 10 | + </h1> | |
| 11 | +text-sm font-mediugap-3 mt-1 text-0.5">{{ user.email|default | |
| 12 | + <span>@{{ user_prof {% endif %} | |
| 13 | + <span>{</div> | |
| 14 | + items-center gap-2"rname=user.username %}"accounts:profile_edit' %}" | |
| 15 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text"> | |
| 16 | + Ed</a> | |
| 17 | + <a href="{% /div> | |
| 18 | +</div> | |
| 19 | + | |
| 20 | +<!-- Profile %}" | |
| 21 | + class="rounded-md bg-gray-800 px-4 py-2 text-sm font-medium teEdit Profile | |
| 22 | + </a gap-x-6 <prefs.notify_tickets %}{% if nt> | |
| 23 | + <dd class="text-gray-200 mt-0.5">{{ user.get_full_name|default:"Not set"andle</dt> | |
| 24 | + <dd class="text-gray-200 mt-0.5">{% if user_profile.handle %}@{{ user_profile.handle }}{% else %}Not set{% endif %}</dd> | |
| 25 | + </div> | |
| 26 | + <div> | |
| 27 | + <dt class="text-gray-500">Email</dt> | |
| 28 | + <dd class="text-gray-200 mt-0.5">{{ user.email|default:"Not set" }}</dd> | |
| 29 | + </div> | |
| 30 | + <div> | |
| 31 | + <dt class="text-gray-500">Location</dt> | |
| 32 | + <dd class="text-gray-200 mt-0.5">{{ user_profile.location|default:"Not set" }}</dd> | |
| 33 | + </div> | |
| 34 | + <div> | |
| 35 | + <dt class="text-gray-500">Website</dt> | |
| 36 | + <dd class="text-gray-200 mt-0.5"> | |
| 37 | + {% if user_profile.website %} | |
| 38 | + <a href="{{ user_profile.website }}" class="text-brand-light hover:text-brand" target="_blank" rel="noopener">{{ user_profile.website }}</a> | |
| 39 | + {% else %}Not set{% endif %} | |
| 40 | + </dd> | |
| 41 | + </div> | |
| 42 | + <div class="sm:col-span-2"> | |
| 43 | + <dt class="text-gray-500">Bio</dt> | |
| 44 | + <dd class="text-gray-200 mt-0.5">{{ user_profile.bio|default:"Not set"|linebreaksbr }}</dd> | |
| 45 | + </div> | |
| 46 | + </dl> | |
| 47 | +</div> | |
| 48 | + | |
| 49 | +<!-- SSH Keys Card --> | |
| 50 | +<div class="rounded-lg bg-gray-800 "> | |
| 51 | + <div class="p-4 border-b border-gray-700 flex items-center justify-bet <prefs.notify_tickets %}{% if nt> | |
| 52 | + <dd class="text-gray-200 | |
| 53 | + {% if notif_prefs.notify_checkins %}Checkins{% endif %} | |
| 54 | + tif_prefs.notify_tickets %}{% if notif_prefs.notify_checkins %}, {% endif %}Ticket endif %}Tickets{% endif %} | |
| 55 | + {% if notif_prefs.notify_wiki %}{% if notif_prefs.notify_checkins or notif_prefs.notify_tickets %}, {% endif %}Wiki{% endif %} | |
| 56 | + {% if notif_prefs.notify_releases %}{% if notif_prefs.notify_checkins or notif_prefs.notify_tickets or notif_prefs.notify_wiki %}, {% endif %}Releases{% endif %} | |
| 57 | + {% if notif_prefs.notify_forum %}{% if notif_prefs.notify_checkins or notif_prefs.notify_tickets or notif_prefs.notify_wiki or notif_prefs.notify_releases %}, {% endif %}Forum{% endif %} | |
| 58 | + {% if not notif_prefs.notify_checkins and not notif_prefs.notify_tickets and not notif_prefs.notify_wiki and not notif_prefs.notify_releases and not notif_prefs.notify_forum %}None{% endif %} | |
| 59 | + </dd> | |
| 60 | + </div> | |
| 61 | + </dl> | |
| 62 | + </div> | |
| 63 | +</div> | |
| 64 | + | |
| 65 | +<!-- Personal Access Tokens Card --> | |
| 66 | +<div class="rounded-lg bg-gray-800 |
| --- a/templates/accounts/profile.html | |
| +++ b/templates/accounts/profile.html | |
| @@ -0,0 +1,66 @@ | |
| --- a/templates/accounts/profile.html | |
| +++ b/templates/accounts/profile.html | |
| @@ -0,0 +1,66 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Profile — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <!-- Profile Header --> |
| 6 | <div class="text-sm font-mediugap-5 mb-8gray-800 px-4 py- min-w-0"> |
| 7 | <div class="flex-shrink-0 h-16 w-16 rounded-full bg-brand flex items-centtext-2xl font-boltems-center gap-2 mt-4 sm-1 min-w-0"> |
| 8 | <h1 class="text-2xl font-bold text-gray-100 truncate"> |
| 9 | {% if user.get_full_name %}{{ user.get_full_name }}{% else %}{{ user.username }}{% endif %} |
| 10 | </h1> |
| 11 | text-sm font-mediugap-3 mt-1 text-0.5">{{ user.email|default |
| 12 | <span>@{{ user_prof {% endif %} |
| 13 | <span>{</div> |
| 14 | items-center gap-2"rname=user.username %}"accounts:profile_edit' %}" |
| 15 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text"> |
| 16 | Ed</a> |
| 17 | <a href="{% /div> |
| 18 | </div> |
| 19 | |
| 20 | <!-- Profile %}" |
| 21 | class="rounded-md bg-gray-800 px-4 py-2 text-sm font-medium teEdit Profile |
| 22 | </a gap-x-6 <prefs.notify_tickets %}{% if nt> |
| 23 | <dd class="text-gray-200 mt-0.5">{{ user.get_full_name|default:"Not set"andle</dt> |
| 24 | <dd class="text-gray-200 mt-0.5">{% if user_profile.handle %}@{{ user_profile.handle }}{% else %}Not set{% endif %}</dd> |
| 25 | </div> |
| 26 | <div> |
| 27 | <dt class="text-gray-500">Email</dt> |
| 28 | <dd class="text-gray-200 mt-0.5">{{ user.email|default:"Not set" }}</dd> |
| 29 | </div> |
| 30 | <div> |
| 31 | <dt class="text-gray-500">Location</dt> |
| 32 | <dd class="text-gray-200 mt-0.5">{{ user_profile.location|default:"Not set" }}</dd> |
| 33 | </div> |
| 34 | <div> |
| 35 | <dt class="text-gray-500">Website</dt> |
| 36 | <dd class="text-gray-200 mt-0.5"> |
| 37 | {% if user_profile.website %} |
| 38 | <a href="{{ user_profile.website }}" class="text-brand-light hover:text-brand" target="_blank" rel="noopener">{{ user_profile.website }}</a> |
| 39 | {% else %}Not set{% endif %} |
| 40 | </dd> |
| 41 | </div> |
| 42 | <div class="sm:col-span-2"> |
| 43 | <dt class="text-gray-500">Bio</dt> |
| 44 | <dd class="text-gray-200 mt-0.5">{{ user_profile.bio|default:"Not set"|linebreaksbr }}</dd> |
| 45 | </div> |
| 46 | </dl> |
| 47 | </div> |
| 48 | |
| 49 | <!-- SSH Keys Card --> |
| 50 | <div class="rounded-lg bg-gray-800 "> |
| 51 | <div class="p-4 border-b border-gray-700 flex items-center justify-bet <prefs.notify_tickets %}{% if nt> |
| 52 | <dd class="text-gray-200 |
| 53 | {% if notif_prefs.notify_checkins %}Checkins{% endif %} |
| 54 | tif_prefs.notify_tickets %}{% if notif_prefs.notify_checkins %}, {% endif %}Ticket endif %}Tickets{% endif %} |
| 55 | {% if notif_prefs.notify_wiki %}{% if notif_prefs.notify_checkins or notif_prefs.notify_tickets %}, {% endif %}Wiki{% endif %} |
| 56 | {% if notif_prefs.notify_releases %}{% if notif_prefs.notify_checkins or notif_prefs.notify_tickets or notif_prefs.notify_wiki %}, {% endif %}Releases{% endif %} |
| 57 | {% if notif_prefs.notify_forum %}{% if notif_prefs.notify_checkins or notif_prefs.notify_tickets or notif_prefs.notify_wiki or notif_prefs.notify_releases %}, {% endif %}Forum{% endif %} |
| 58 | {% if not notif_prefs.notify_checkins and not notif_prefs.notify_tickets and not notif_prefs.notify_wiki and not notif_prefs.notify_releases and not notif_prefs.notify_forum %}None{% endif %} |
| 59 | </dd> |
| 60 | </div> |
| 61 | </dl> |
| 62 | </div> |
| 63 | </div> |
| 64 | |
| 65 | <!-- Personal Access Tokens Card --> |
| 66 | <div class="rounded-lg bg-gray-800 |
| --- a/templates/accounts/profile_edit.html | ||
| +++ b/templates/accounts/profile_edit.html | ||
| @@ -0,0 +1,48 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Edit Profile — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="max-w-2xl"> | |
| 6 | + <div class="flex items-center gap-3 mb-6"> | |
| 7 | + <a href="{% url 'accounts:profile' %}" class="text-gray-400 hover:text-white"> | |
| 8 | + <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 9 | + <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" /> | |
| 10 | + </svg> | |
| 11 | + </a> | |
| 12 | + <h1 class="text-2xl font-bold text-gray-100">Edit Profile</h1> | |
| 13 | + </div> | |
| 14 | + | |
| 15 | + <form method="post" class="space-y-6"> | |
| 16 | + {% csrf_token %} | |
| 17 | + | |
| 18 | + <div class="rounded-lg bg-gray-800 border bo">st_name" id="last_name" value="{{ user.last_name }}" maxlength="150" | |
| 19 | + class="w-div> | |
| 20 | + <label for="first_name" class="block text-sm font-medium text-gray-300 mb-1">First Name</label> | |
| 21 | + <input type="text" name="first_name" id="first_name" value="{{ user.first_name }}" maxlength="30" | |
| 22 | + class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand"> | |
| 23 | + </div> | |
| 24 | + <div> | |
| 25 | + <label for="last_name" class="block text-sm font-medium text-gray-300 mb-1">Last Name</label> | |
| 26 | + <input type="text" name="last_name" id="last_name" value="{{ user.last_name }}" maxlength="150" | |
| 27 | + class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand"> | |
| 28 | + </div> | |
| 29 | + </div> | |
| 30 | + | |
| 31 | + <div> | |
| 32 | + <label for="email" class="block text-sm font-medium text-gray-300 mb-1">Email</label> | |
| 33 | + <input type="email" name="email" id="email" value="{{ user.email }}" maxlength="254" | |
| 34 | + class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand"> | |
| 35 | + </div> | |
| 36 | + | |
| 37 | + <div> | |
| 38 | + <label for="handle" class="block text-sm font-medium text-gray-300 mb-1">Handle</label> | |
| 39 | + <div class="flex items-center"> | |
| 40 | + <span class="text-gray-500 text-sm mr-1">@</span> | |
| 41 | + <input type="text" name="handle" id="handle" value="{{ user_profile.handle }}" maxlength="50" | |
| 42 | + pattern="[a-z0-9\-]+" title="Lowercase letters, numbers, and hyphens only" | |
| 43 | + place"> | |
| 44 | + Save Changes | |
| 45 | + </button> | |
| 46 | + <a href="{% url 'accounts:profile' %}" class="text-sm text-gray-400 hover:text-white">Cancel</a> | |
| 47 | + </div> | |
| 48 | + </f |
| --- a/templates/accounts/profile_edit.html | |
| +++ b/templates/accounts/profile_edit.html | |
| @@ -0,0 +1,48 @@ | |
| --- a/templates/accounts/profile_edit.html | |
| +++ b/templates/accounts/profile_edit.html | |
| @@ -0,0 +1,48 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Edit Profile — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="max-w-2xl"> |
| 6 | <div class="flex items-center gap-3 mb-6"> |
| 7 | <a href="{% url 'accounts:profile' %}" class="text-gray-400 hover:text-white"> |
| 8 | <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 9 | <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" /> |
| 10 | </svg> |
| 11 | </a> |
| 12 | <h1 class="text-2xl font-bold text-gray-100">Edit Profile</h1> |
| 13 | </div> |
| 14 | |
| 15 | <form method="post" class="space-y-6"> |
| 16 | {% csrf_token %} |
| 17 | |
| 18 | <div class="rounded-lg bg-gray-800 border bo">st_name" id="last_name" value="{{ user.last_name }}" maxlength="150" |
| 19 | class="w-div> |
| 20 | <label for="first_name" class="block text-sm font-medium text-gray-300 mb-1">First Name</label> |
| 21 | <input type="text" name="first_name" id="first_name" value="{{ user.first_name }}" maxlength="30" |
| 22 | class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand"> |
| 23 | </div> |
| 24 | <div> |
| 25 | <label for="last_name" class="block text-sm font-medium text-gray-300 mb-1">Last Name</label> |
| 26 | <input type="text" name="last_name" id="last_name" value="{{ user.last_name }}" maxlength="150" |
| 27 | class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand"> |
| 28 | </div> |
| 29 | </div> |
| 30 | |
| 31 | <div> |
| 32 | <label for="email" class="block text-sm font-medium text-gray-300 mb-1">Email</label> |
| 33 | <input type="email" name="email" id="email" value="{{ user.email }}" maxlength="254" |
| 34 | class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand"> |
| 35 | </div> |
| 36 | |
| 37 | <div> |
| 38 | <label for="handle" class="block text-sm font-medium text-gray-300 mb-1">Handle</label> |
| 39 | <div class="flex items-center"> |
| 40 | <span class="text-gray-500 text-sm mr-1">@</span> |
| 41 | <input type="text" name="handle" id="handle" value="{{ user_profile.handle }}" maxlength="50" |
| 42 | pattern="[a-z0-9\-]+" title="Lowercase letters, numbers, and hyphens only" |
| 43 | place"> |
| 44 | Save Changes |
| 45 | </button> |
| 46 | <a href="{% url 'accounts:profile' %}" class="text-sm text-gray-400 hover:text-white">Cancel</a> |
| 47 | </div> |
| 48 | </f |
| --- a/templates/accounts/profile_token_create.html | ||
| +++ b/templates/accounts/profile_token_create.html | ||
| @@ -0,0 +1,76 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Generate Token — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="max-w-2xl"> | |
| 6 | + <div class="flex items-center gap-3 mb-6"> | |
| 7 | + <a href="{% url 'accounts:profile' %}" class="text-gray-400 hover:text-white"> | |
| 8 | + <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 9 | + <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" /> | |
| 10 | + </svg> | |
| 11 | + </a> | |
| 12 | + <h1 class="text-2xl font-bold text-gray-100">Generate Personal Access Token</h1> | |
| 13 | + </div> | |
| 14 | + | |
| 15 | + <form method="post" class="space-y-6"> | |
| 16 | + {% csrf_token %} | |
| 17 | + | |
| 18 | + <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 space-y-4"> | |
| 19 | + <div> | |
| 20 | + <label for="name" class="block text-sm font-medium text-gray-300 mb-1">Token Name</label> | |
| 21 | + <input type="text" name="name" id="name" required maxlength="200" | |
| 22 | + placeholder="e.g. CI/CD pipeline, CLI access" | |
| 23 | + class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand"> | |
| 24 | + <p class="mt-1 text-xs text-gray-500">A descriptive name to remember what this token is for.</p> | |
| 25 | + </div> | |
| 26 | + | |
| 27 | + <div> | |
| 28 | + <label class="block text-sm font-medium text-gray-300 mb-2">Scopes</label> | |
| 29 | + <div class="space-y-2"> | |
| 30 | + <label class="flex items-center gap-3 cursor-pointer"> | |
| 31 | + <input type="checkbox" name="scope_read" checked disabled | |
| 32 | + class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand"> | |
| 33 | + <div> | |
| 34 | + <span class="text-sm font-medium text-gray-200">read</span> | |
| 35 | + <span class="text-xs text-gray-500 ml-2">Read access to repositories and data (always included)</span> | |
| 36 | + </div> | |
| 37 | + </label> | |
| 38 | + <label class="flex items-center gap-3 cursor-pointer"> | |
| 39 | + <input type="checkbox" name="scope_write" value="write" | |
| 40 | + class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand"> | |
| 41 | + <div> | |
| 42 | + <span class="text-sm font-medium text-gray-200">write</span> | |
| 43 | + <span class="text-xs text-gray-500 ml-2">Push changes, create/update tickets and wiki</span> | |
| 44 | + </div> | |
| 45 | + </label> | |
| 46 | + <label class="flex items-center gap-3 cursor-pointer"> | |
| 47 | + <input type="checkbox" name="scope_admin" value="admin" | |
| 48 | + class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand"> | |
| 49 | + <div> | |
| 50 | + <span class="text-sm font-medium text-gray-200">admin</span> | |
| 51 | + <span class="text-xs text-gray-500 ml-2">Full administrative access</span> | |
| 52 | + </div> | |
| 53 | + </label> | |
| 54 | + </div> | |
| 55 | + <input type="hidden" name="scopes" id="scopes_hidden" value="read"> | |
| 56 | + </div> | |
| 57 | + </div> | |
| 58 | + | |
| 59 | + <div class="flex items-center gap-3"> | |
| 60 | + <button type="submit" onclick="buildScopes()" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover"> | |
| 61 | + Generate Token | |
| 62 | + </button> | |
| 63 | + <a href="{% url 'accounts:profile' %}" class="text-sm text-gray-400 hover:text-white">Cancel</a> | |
| 64 | + </div> | |
| 65 | + </form> | |
| 66 | +</div> | |
| 67 | + | |
| 68 | +<script> | |
| 69 | +function buildScopes() { | |
| 70 | + var scopes = ['read']; | |
| 71 | + if (document.querySelector('input[name="scope_write"]').checked) scopes.push('write'); | |
| 72 | + if (document.querySelector('input[name="scope_admin"]').checked) scopes.push('admin'); | |
| 73 | + document.getElementById('scopes_hidden').value = scopes.join(','); | |
| 74 | +} | |
| 75 | +</script> | |
| 76 | +{% endblock %} |
| --- a/templates/accounts/profile_token_create.html | |
| +++ b/templates/accounts/profile_token_create.html | |
| @@ -0,0 +1,76 @@ | |
| --- a/templates/accounts/profile_token_create.html | |
| +++ b/templates/accounts/profile_token_create.html | |
| @@ -0,0 +1,76 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Generate Token — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="max-w-2xl"> |
| 6 | <div class="flex items-center gap-3 mb-6"> |
| 7 | <a href="{% url 'accounts:profile' %}" class="text-gray-400 hover:text-white"> |
| 8 | <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 9 | <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" /> |
| 10 | </svg> |
| 11 | </a> |
| 12 | <h1 class="text-2xl font-bold text-gray-100">Generate Personal Access Token</h1> |
| 13 | </div> |
| 14 | |
| 15 | <form method="post" class="space-y-6"> |
| 16 | {% csrf_token %} |
| 17 | |
| 18 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 space-y-4"> |
| 19 | <div> |
| 20 | <label for="name" class="block text-sm font-medium text-gray-300 mb-1">Token Name</label> |
| 21 | <input type="text" name="name" id="name" required maxlength="200" |
| 22 | placeholder="e.g. CI/CD pipeline, CLI access" |
| 23 | class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand"> |
| 24 | <p class="mt-1 text-xs text-gray-500">A descriptive name to remember what this token is for.</p> |
| 25 | </div> |
| 26 | |
| 27 | <div> |
| 28 | <label class="block text-sm font-medium text-gray-300 mb-2">Scopes</label> |
| 29 | <div class="space-y-2"> |
| 30 | <label class="flex items-center gap-3 cursor-pointer"> |
| 31 | <input type="checkbox" name="scope_read" checked disabled |
| 32 | class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand"> |
| 33 | <div> |
| 34 | <span class="text-sm font-medium text-gray-200">read</span> |
| 35 | <span class="text-xs text-gray-500 ml-2">Read access to repositories and data (always included)</span> |
| 36 | </div> |
| 37 | </label> |
| 38 | <label class="flex items-center gap-3 cursor-pointer"> |
| 39 | <input type="checkbox" name="scope_write" value="write" |
| 40 | class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand"> |
| 41 | <div> |
| 42 | <span class="text-sm font-medium text-gray-200">write</span> |
| 43 | <span class="text-xs text-gray-500 ml-2">Push changes, create/update tickets and wiki</span> |
| 44 | </div> |
| 45 | </label> |
| 46 | <label class="flex items-center gap-3 cursor-pointer"> |
| 47 | <input type="checkbox" name="scope_admin" value="admin" |
| 48 | class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand"> |
| 49 | <div> |
| 50 | <span class="text-sm font-medium text-gray-200">admin</span> |
| 51 | <span class="text-xs text-gray-500 ml-2">Full administrative access</span> |
| 52 | </div> |
| 53 | </label> |
| 54 | </div> |
| 55 | <input type="hidden" name="scopes" id="scopes_hidden" value="read"> |
| 56 | </div> |
| 57 | </div> |
| 58 | |
| 59 | <div class="flex items-center gap-3"> |
| 60 | <button type="submit" onclick="buildScopes()" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover"> |
| 61 | Generate Token |
| 62 | </button> |
| 63 | <a href="{% url 'accounts:profile' %}" class="text-sm text-gray-400 hover:text-white">Cancel</a> |
| 64 | </div> |
| 65 | </form> |
| 66 | </div> |
| 67 | |
| 68 | <script> |
| 69 | function buildScopes() { |
| 70 | var scopes = ['read']; |
| 71 | if (document.querySelector('input[name="scope_write"]').checked) scopes.push('write'); |
| 72 | if (document.querySelector('input[name="scope_admin"]').checked) scopes.push('admin'); |
| 73 | document.getElementById('scopes_hidden').value = scopes.join(','); |
| 74 | } |
| 75 | </script> |
| 76 | {% endblock %} |
| --- a/templates/accounts/profile_token_created.html | ||
| +++ b/templates/accounts/profile_token_created.html | ||
| @@ -0,0 +1,44 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Token Generated — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="max-w-2xl"> | |
| 6 | + <div class="flex items-center gap-3 mb-6"> | |
| 7 | + <a href="{% url 'accounts:profile' %}" class="text-gray-400 hover:text-white"> | |
| 8 | + <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 9 | + <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" /> | |
| 10 | + </svg> | |
| 11 | + </a> | |
| 12 | + <h1 class="text-2xl font-bold text-gray-100">Token Generated</h1> | |
| 13 | + </div> | |
| 14 | + | |
| 15 | + <div class="rounded-lg bg-yellow-900/50 border border-yellow-700 p-4 mb-6"> | |
| 16 | + <div class="flex items-start gap-3"> | |
| 17 | + <svg class="h-5 w-5 text-yellow-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 18 | + <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /> | |
| 19 | + </svg> | |
| 20 | + <div> | |
| 21 | + <p class="text-sm font-medium text-yellow-300">Copy this token now. It will not be shown again.</p> | |
| 22 | + <p class="text-xs text-yellow-400 mt-1">Store it securely. If lost, revoke it and generate a new one.</p> | |
| 23 | + </div> | |
| 24 | + </div> | |
| 25 | + </div> | |
| 26 | + | |
| 27 | + <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6"> | |
| 28 | + <label class="block text-sm font-medium text-gray-300 mb-2">{{ token_name }}</label> | |
| 29 | + <div class="flex items-center gap-2" x-data="{ copied: false }"> | |
| 30 | + <code class="flex-1 block rounded-md bg-gray-900 border border-gray-600 px-3 py-2 text-sm font-mono text-gray-100 break-all select-all">{{ raw_token }}</code> | |
| 31 | + <button type="button" | |
| 32 | + @click="navigator.clipboard.writeText('{{ raw_token }}'); copied = true; setTimeout(() => copied = false, 2000)" | |
| 33 | + class="flex-shrink-0 rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-200 hover:bg-gray-600"> | |
| 34 | + <span x-show="!copied">Copy</span> | |
| 35 | + <span x-show="copied" style="display:none">Copied</span> | |
| 36 | + </button> | |
| 37 | + </div> | |
| 38 | + </div> | |
| 39 | + | |
| 40 | + <a href="{% url 'accounts:profile' %}" class="inline-block rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-600"> | |
| 41 | + Back to Profile | |
| 42 | + </a> | |
| 43 | +</div> | |
| 44 | +{% endblock %} |
| --- a/templates/accounts/profile_token_created.html | |
| +++ b/templates/accounts/profile_token_created.html | |
| @@ -0,0 +1,44 @@ | |
| --- a/templates/accounts/profile_token_created.html | |
| +++ b/templates/accounts/profile_token_created.html | |
| @@ -0,0 +1,44 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Token Generated — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="max-w-2xl"> |
| 6 | <div class="flex items-center gap-3 mb-6"> |
| 7 | <a href="{% url 'accounts:profile' %}" class="text-gray-400 hover:text-white"> |
| 8 | <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 9 | <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" /> |
| 10 | </svg> |
| 11 | </a> |
| 12 | <h1 class="text-2xl font-bold text-gray-100">Token Generated</h1> |
| 13 | </div> |
| 14 | |
| 15 | <div class="rounded-lg bg-yellow-900/50 border border-yellow-700 p-4 mb-6"> |
| 16 | <div class="flex items-start gap-3"> |
| 17 | <svg class="h-5 w-5 text-yellow-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 18 | <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /> |
| 19 | </svg> |
| 20 | <div> |
| 21 | <p class="text-sm font-medium text-yellow-300">Copy this token now. It will not be shown again.</p> |
| 22 | <p class="text-xs text-yellow-400 mt-1">Store it securely. If lost, revoke it and generate a new one.</p> |
| 23 | </div> |
| 24 | </div> |
| 25 | </div> |
| 26 | |
| 27 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6"> |
| 28 | <label class="block text-sm font-medium text-gray-300 mb-2">{{ token_name }}</label> |
| 29 | <div class="flex items-center gap-2" x-data="{ copied: false }"> |
| 30 | <code class="flex-1 block rounded-md bg-gray-900 border border-gray-600 px-3 py-2 text-sm font-mono text-gray-100 break-all select-all">{{ raw_token }}</code> |
| 31 | <button type="button" |
| 32 | @click="navigator.clipboard.writeText('{{ raw_token }}'); copied = true; setTimeout(() => copied = false, 2000)" |
| 33 | class="flex-shrink-0 rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-200 hover:bg-gray-600"> |
| 34 | <span x-show="!copied">Copy</span> |
| 35 | <span x-show="copied" style="display:none">Copied</span> |
| 36 | </button> |
| 37 | </div> |
| 38 | </div> |
| 39 | |
| 40 | <a href="{% url 'accounts:profile' %}" class="inline-block rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-600"> |
| 41 | Back to Profile |
| 42 | </a> |
| 43 | </div> |
| 44 | {% endblock %} |
| --- templates/fossil/git_mirror.html | ||
| +++ templates/fossil/git_mirror.html | ||
| @@ -1,66 +1,20 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | -{% block title %}Git Mirror — {{ project.name }} — Fossilrepo{% endblock %} | |
| 2 | +{% block title %}{% if editing_mirror %}Edit Mirror{% else %}Add Git Mirror{% endif %} — {{ project.name }} — Fossilrepo{% endblock %} | |
| 3 | 3 | |
| 4 | 4 | {% block content %} |
| 5 | 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 6 | {% include "fossil/_project_nav.html" %} |
| 7 | 7 | |
| 8 | 8 | <div class="max-w-3xl"> |
| 9 | 9 | <div class="flex items-center justify-between mb-6"> |
| 10 | - <h2 class="text-lg font-semibold text-gray-200">Git Mirrors</h2> | |
| 11 | - <a href="{% url 'fossil:sync' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to Fossil Sync</a> | |
| 12 | - </div> | |
| 13 | - | |
| 14 | - <!-- Existing mirrors --> | |
| 15 | - {% if mirrors %} | |
| 16 | - <div class="space-y-4 mb-8"> | |
| 17 | - {% for mirror in mirrors %} | |
| 18 | - <div class="rounded-lg bg-gray-800 border border-gray-700 p-5"> | |
| 19 | - <div class="flex items-start justify-between"> | |
| 20 | - <div> | |
| 21 | - <div class="font-mono text-sm text-gray-200">{{ mirror.git_remote_url }}</div> | |
| 22 | - <div class="mt-1 flex items-center gap-3 text-xs text-gray-500"> | |
| 23 | - <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_auth_method_display }}</span> | |
| 24 | - <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_sync_mode_display }}</span> | |
| 25 | - <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_sync_direction_display }}</span> | |
| 26 | - <span>→ {{ mirror.git_branch }}</span> | |
| 27 | - </div> | |
| 28 | - <div class="mt-2 text-xs text-gray-500"> | |
| 29 | - {% if mirror.last_sync_at %} | |
| 30 | - Last sync: {{ mirror.last_sync_at|timesince }} ago | |
| 31 | - <span class="{% if mirror.last_sync_status == 'success' %}text-green-400{% else %}text-red-400{% endif %}"> | |
| 32 | - ({{ mirror.last_sync_status }}) | |
| 33 | - </span> | |
| 34 | - — {{ mirror.total_syncs }} total syncs | |
| 35 | - {% else %} | |
| 36 | - Never synced | |
| 37 | - {% endif %} | |
| 38 | - </div> | |
| 39 | - {% if mirror.last_sync_message %} | |
| 40 | - <pre class="mt-1 text-xs text-gray-600 max-h-20 overflow-hidden">{{ mirror.last_sync_message|truncatechars:200 }}</pre> | |
| 41 | - {% endif %} | |
| 42 | - </div> | |
| 43 | - <div class="flex items-center gap-2"> | |
| 44 | - <form method="post" action="{% url 'fossil:git_mirror_run' slug=project.slug mirror_id=mirror.id %}"> | |
| 45 | - {% csrf_token %} | |
| 46 | - <button type="submit" class="rounded-md bg-brand px-3 py-1.5 text-xs font-semibold text-white hover:bg-brand-hover">Sync Now</button> | |
| 47 | - </form> | |
| 48 | - <form method="post"> | |
| 49 | - {% csrf_token %} | |
| 50 | - <input type="hidden" name="action" value="delete"> | |
| 51 | - <input type="hidden" name="mirror_id" value="{{ mirror.id }}"> | |
| 52 | - <button type="submit" class="rounded-md bg-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:text-red-400 ring-1 ring-inset ring-gray-600">Remove</button> | |
| 53 | - </form> | |
| 54 | - </div> | |
| 55 | - </div> | |
| 56 | - </div> | |
| 57 | - {% endfor %} | |
| 58 | - </div> | |
| 59 | - {% endif %} | |
| 60 | - | |
| 61 | - <!-- OAuth connect buttons --> | |
| 10 | + <h2 class="text-lg font-semibold text-gray-200">{% if editing_mirror %}Edit Git Mirror{% else %}Add Git Mirror{% endif %}</h2> | |
| 11 | + <a href="{% url 'fossil:sync' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to Sync</a> | |
| 12 | + </div> | |
| 13 | + | |
| 14 | + {# ── OAuth quick-connect (only shown when adding) ──────────────── #} | |
| 15 | + {% if not editing_mirror %} | |
| 62 | 16 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-5 mb-6"> |
| 63 | 17 | <h3 class="text-sm font-semibold text-gray-200 mb-3 text-center">Quick Connect</h3> |
| 64 | 18 | <div class="flex items-center justify-center gap-3"> |
| 65 | 19 | <a href="{% url 'fossil:oauth_github' slug=project.slug %}" |
| 66 | 20 | class="inline-flex items-center gap-2 rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-gray-200 ring-1 ring-inset ring-gray-600 hover:bg-gray-700"> |
| @@ -72,76 +26,119 @@ | ||
| 72 | 26 | <svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"/></svg> |
| 73 | 27 | Connect GitLab |
| 74 | 28 | </a> |
| 75 | 29 | </div> |
| 76 | 30 | {% if request.session.github_oauth_token %} |
| 77 | - <p class="mt-2 text-xs text-green-400">GitHub connected as {{ request.session.github_oauth_user }}. Token will be used for new mirrors.</p> | |
| 31 | + <p class="mt-2 text-xs text-green-400 text-center">GitHub connected as {{ request.session.github_oauth_user }}. Token will be used for new mirrors.</p> | |
| 78 | 32 | {% endif %} |
| 79 | 33 | {% if request.session.gitlab_oauth_token %} |
| 80 | - <p class="mt-2 text-xs text-green-400">GitLab connected. Token will be used for new mirrors.</p> | |
| 34 | + <p class="mt-2 text-xs text-green-400 text-center">GitLab connected. Token will be used for new mirrors.</p> | |
| 81 | 35 | {% endif %} |
| 82 | 36 | </div> |
| 37 | + {% endif %} | |
| 83 | 38 | |
| 84 | - <!-- Add new mirror --> | |
| 39 | + {# ── Mirror form (add or edit) ─────────────────────────────────── #} | |
| 85 | 40 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-6"> |
| 86 | - <h3 class="text-base font-semibold text-gray-200 mb-4">Add Git Mirror</h3> | |
| 87 | - | |
| 88 | 41 | <form method="post" class="space-y-4"> |
| 89 | 42 | {% csrf_token %} |
| 90 | - <input type="hidden" name="action" value="create"> | |
| 43 | + <input type="hidden" name="action" value="{% if editing_mirror %}update{% else %}create{% endif %}"> | |
| 91 | 44 | |
| 92 | 45 | <div> |
| 93 | 46 | <label class="block text-sm font-medium text-gray-300 mb-1">Git Remote URL <span class="text-red-400">*</span></label> |
| 94 | - <input type="text" name="git_remote_url" required placeholder="https://github.com/org/repo.git" | |
| 47 | + <input type="text" name="git_remote_url" required | |
| 48 | + value="{{ editing_mirror.git_remote_url|default:'' }}" | |
| 49 | + placeholder="https://github.com/org/repo.git" | |
| 95 | 50 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono"> |
| 96 | 51 | </div> |
| 97 | 52 | |
| 98 | 53 | <div class="grid grid-cols-2 gap-4"> |
| 99 | 54 | <div> |
| 100 | - <label class="block text-sm font-medium text-gray-300 mb-1">Auth Method</label> | |
| 101 | - <select name="auth_method" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 102 | - <option value="token">Personal Access Token</option> | |
| 103 | - <option value="ssh">SSH Key</option> | |
| 104 | - <option value="oauth_github">GitHub OAuth</option> | |
| 105 | - <option value="oauth_gitlab">GitLab OAuth</option> | |
| 55 | + <label class="block text-sm font-medium text-gray-300 mb-1">Sync Direction</label> | |
| 56 | + <select name="sync_direction" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 57 | + {% for value, label in sync_direction_choices %} | |
| 58 | + <option value="{{ value }}" {% if editing_mirror and editing_mirror.sync_direction == value %}selected{% endif %}>{{ label }}</option> | |
| 59 | + {% endfor %} | |
| 106 | 60 | </select> |
| 107 | 61 | </div> |
| 108 | 62 | <div> |
| 109 | - <label class="block text-sm font-medium text-gray-300 mb-1">Git Branch</label> | |
| 110 | - <input type="text" name="git_branch" value="main" | |
| 111 | - class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2"> | |
| 63 | + <label class="block text-sm font-medium text-gray-300 mb-1">Auth Method</label> | |
| 64 | + <select name="auth_method" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 65 | + {% for value, label in auth_method_choices %} | |
| 66 | + <option value="{{ value }}" {% if editing_mirror and editing_mirror.auth_method == value %}selected{% endif %}>{{ label }}</option> | |
| 67 | + {% endfor %} | |
| 68 | + </select> | |
| 112 | 69 | </div> |
| 113 | 70 | </div> |
| 114 | 71 | |
| 115 | 72 | <div> |
| 116 | 73 | <label class="block text-sm font-medium text-gray-300 mb-1">Token / Credential</label> |
| 117 | - <input type="password" name="auth_credential" placeholder="ghp_xxxxxxxxxxxx" | |
| 74 | + <input type="password" name="auth_credential" | |
| 75 | + placeholder="{% if editing_mirror %}Leave blank to keep current credential{% else %}ghp_xxxxxxxxxxxx{% endif %}" | |
| 118 | 76 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono"> |
| 119 | - <p class="mt-1 text-xs text-gray-500">For token auth: GitHub Personal Access Token or GitLab Access Token</p> | |
| 77 | + <p class="mt-1 text-xs text-gray-500"> | |
| 78 | + {% if editing_mirror %}Leave blank to keep the existing credential.{% else %}For token auth: GitHub Personal Access Token or GitLab Access Token.{% endif %} | |
| 79 | + </p> | |
| 80 | + </div> | |
| 81 | + | |
| 82 | + <div class="grid grid-cols-2 gap-4"> | |
| 83 | + <div> | |
| 84 | + <label class="block text-sm font-medium text-gray-300 mb-1">Fossil Branch</label> | |
| 85 | + <input type="text" name="fossil_branch" | |
| 86 | + value="{{ editing_mirror.fossil_branch|default:'trunk' }}" | |
| 87 | + class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2"> | |
| 88 | + </div> | |
| 89 | + <div> | |
| 90 | + <label class="block text-sm font-medium text-gray-300 mb-1">Git Branch</label> | |
| 91 | + <input type="text" name="git_branch" | |
| 92 | + value="{{ editing_mirror.git_branch|default:'main' }}" | |
| 93 | + class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2"> | |
| 94 | + </div> | |
| 120 | 95 | </div> |
| 121 | 96 | |
| 122 | 97 | <div class="grid grid-cols-2 gap-4"> |
| 123 | 98 | <div> |
| 124 | 99 | <label class="block text-sm font-medium text-gray-300 mb-1">Sync Mode</label> |
| 125 | 100 | <select name="sync_mode" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 126 | - <option value="scheduled">Scheduled</option> | |
| 127 | - <option value="on_change">On Change</option> | |
| 128 | - <option value="both">Both</option> | |
| 101 | + {% for value, label in sync_mode_choices %} | |
| 102 | + <option value="{{ value }}" {% if editing_mirror and editing_mirror.sync_mode == value %}selected{% endif %}>{{ label }}</option> | |
| 103 | + {% endfor %} | |
| 129 | 104 | </select> |
| 130 | 105 | </div> |
| 131 | 106 | <div> |
| 132 | 107 | <label class="block text-sm font-medium text-gray-300 mb-1">Schedule (cron)</label> |
| 133 | - <input type="text" name="sync_schedule" value="*/15 * * * *" | |
| 108 | + <input type="text" name="sync_schedule" | |
| 109 | + value="{{ editing_mirror.sync_schedule|default:'*/15 * * * *' }}" | |
| 134 | 110 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono"> |
| 135 | 111 | <p class="mt-1 text-xs text-gray-500">Default: every 15 minutes</p> |
| 136 | 112 | </div> |
| 137 | 113 | </div> |
| 138 | 114 | |
| 139 | - <div class="flex justify-end pt-2"> | |
| 115 | + {# Sync content options #} | |
| 116 | + <div> | |
| 117 | + <span class="block text-sm font-medium text-gray-300 mb-2">Sync Content</span> | |
| 118 | + <div class="flex items-center gap-6"> | |
| 119 | + <label class="flex items-center gap-2 text-sm text-gray-300"> | |
| 120 | + <input type="checkbox" name="sync_tickets" | |
| 121 | + {% if editing_mirror and editing_mirror.sync_tickets %}checked{% endif %} | |
| 122 | + class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand"> | |
| 123 | + Tickets as Issues | |
| 124 | + </label> | |
| 125 | + <label class="flex items-center gap-2 text-sm text-gray-300"> | |
| 126 | + <input type="checkbox" name="sync_wiki" | |
| 127 | + {% if editing_mirror and editing_mirror.sync_wiki %}checked{% endif %} | |
| 128 | + class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand"> | |
| 129 | + Wiki pages | |
| 130 | + </label> | |
| 131 | + </div> | |
| 132 | + <p class="mt-1 text-xs text-gray-500">Code is always synced. Optionally sync tickets and wiki.</p> | |
| 133 | + </div> | |
| 134 | + | |
| 135 | + <div class="flex items-center justify-between pt-2"> | |
| 136 | + <a href="{% url 'fossil:sync' slug=project.slug %}" class="text-sm text-gray-400 hover:text-gray-200">Cancel</a> | |
| 140 | 137 | <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 141 | - Add Mirror | |
| 138 | + {% if editing_mirror %}Update Mirror{% else %}Add Mirror{% endif %} | |
| 142 | 139 | </button> |
| 143 | 140 | </div> |
| 144 | 141 | </form> |
| 145 | 142 | </div> |
| 146 | 143 | </div> |
| 147 | 144 | {% endblock %} |
| 148 | 145 | |
| 149 | 146 | ADDED templates/fossil/git_mirror_delete.html |
| --- templates/fossil/git_mirror.html | |
| +++ templates/fossil/git_mirror.html | |
| @@ -1,66 +1,20 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Git Mirror — {{ 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="max-w-3xl"> |
| 9 | <div class="flex items-center justify-between mb-6"> |
| 10 | <h2 class="text-lg font-semibold text-gray-200">Git Mirrors</h2> |
| 11 | <a href="{% url 'fossil:sync' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to Fossil Sync</a> |
| 12 | </div> |
| 13 | |
| 14 | <!-- Existing mirrors --> |
| 15 | {% if mirrors %} |
| 16 | <div class="space-y-4 mb-8"> |
| 17 | {% for mirror in mirrors %} |
| 18 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-5"> |
| 19 | <div class="flex items-start justify-between"> |
| 20 | <div> |
| 21 | <div class="font-mono text-sm text-gray-200">{{ mirror.git_remote_url }}</div> |
| 22 | <div class="mt-1 flex items-center gap-3 text-xs text-gray-500"> |
| 23 | <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_auth_method_display }}</span> |
| 24 | <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_sync_mode_display }}</span> |
| 25 | <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_sync_direction_display }}</span> |
| 26 | <span>→ {{ mirror.git_branch }}</span> |
| 27 | </div> |
| 28 | <div class="mt-2 text-xs text-gray-500"> |
| 29 | {% if mirror.last_sync_at %} |
| 30 | Last sync: {{ mirror.last_sync_at|timesince }} ago |
| 31 | <span class="{% if mirror.last_sync_status == 'success' %}text-green-400{% else %}text-red-400{% endif %}"> |
| 32 | ({{ mirror.last_sync_status }}) |
| 33 | </span> |
| 34 | — {{ mirror.total_syncs }} total syncs |
| 35 | {% else %} |
| 36 | Never synced |
| 37 | {% endif %} |
| 38 | </div> |
| 39 | {% if mirror.last_sync_message %} |
| 40 | <pre class="mt-1 text-xs text-gray-600 max-h-20 overflow-hidden">{{ mirror.last_sync_message|truncatechars:200 }}</pre> |
| 41 | {% endif %} |
| 42 | </div> |
| 43 | <div class="flex items-center gap-2"> |
| 44 | <form method="post" action="{% url 'fossil:git_mirror_run' slug=project.slug mirror_id=mirror.id %}"> |
| 45 | {% csrf_token %} |
| 46 | <button type="submit" class="rounded-md bg-brand px-3 py-1.5 text-xs font-semibold text-white hover:bg-brand-hover">Sync Now</button> |
| 47 | </form> |
| 48 | <form method="post"> |
| 49 | {% csrf_token %} |
| 50 | <input type="hidden" name="action" value="delete"> |
| 51 | <input type="hidden" name="mirror_id" value="{{ mirror.id }}"> |
| 52 | <button type="submit" class="rounded-md bg-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:text-red-400 ring-1 ring-inset ring-gray-600">Remove</button> |
| 53 | </form> |
| 54 | </div> |
| 55 | </div> |
| 56 | </div> |
| 57 | {% endfor %} |
| 58 | </div> |
| 59 | {% endif %} |
| 60 | |
| 61 | <!-- OAuth connect buttons --> |
| 62 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-5 mb-6"> |
| 63 | <h3 class="text-sm font-semibold text-gray-200 mb-3 text-center">Quick Connect</h3> |
| 64 | <div class="flex items-center justify-center gap-3"> |
| 65 | <a href="{% url 'fossil:oauth_github' slug=project.slug %}" |
| 66 | class="inline-flex items-center gap-2 rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-gray-200 ring-1 ring-inset ring-gray-600 hover:bg-gray-700"> |
| @@ -72,76 +26,119 @@ | |
| 72 | <svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"/></svg> |
| 73 | Connect GitLab |
| 74 | </a> |
| 75 | </div> |
| 76 | {% if request.session.github_oauth_token %} |
| 77 | <p class="mt-2 text-xs text-green-400">GitHub connected as {{ request.session.github_oauth_user }}. Token will be used for new mirrors.</p> |
| 78 | {% endif %} |
| 79 | {% if request.session.gitlab_oauth_token %} |
| 80 | <p class="mt-2 text-xs text-green-400">GitLab connected. Token will be used for new mirrors.</p> |
| 81 | {% endif %} |
| 82 | </div> |
| 83 | |
| 84 | <!-- Add new mirror --> |
| 85 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-6"> |
| 86 | <h3 class="text-base font-semibold text-gray-200 mb-4">Add Git Mirror</h3> |
| 87 | |
| 88 | <form method="post" class="space-y-4"> |
| 89 | {% csrf_token %} |
| 90 | <input type="hidden" name="action" value="create"> |
| 91 | |
| 92 | <div> |
| 93 | <label class="block text-sm font-medium text-gray-300 mb-1">Git Remote URL <span class="text-red-400">*</span></label> |
| 94 | <input type="text" name="git_remote_url" required placeholder="https://github.com/org/repo.git" |
| 95 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono"> |
| 96 | </div> |
| 97 | |
| 98 | <div class="grid grid-cols-2 gap-4"> |
| 99 | <div> |
| 100 | <label class="block text-sm font-medium text-gray-300 mb-1">Auth Method</label> |
| 101 | <select name="auth_method" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 102 | <option value="token">Personal Access Token</option> |
| 103 | <option value="ssh">SSH Key</option> |
| 104 | <option value="oauth_github">GitHub OAuth</option> |
| 105 | <option value="oauth_gitlab">GitLab OAuth</option> |
| 106 | </select> |
| 107 | </div> |
| 108 | <div> |
| 109 | <label class="block text-sm font-medium text-gray-300 mb-1">Git Branch</label> |
| 110 | <input type="text" name="git_branch" value="main" |
| 111 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2"> |
| 112 | </div> |
| 113 | </div> |
| 114 | |
| 115 | <div> |
| 116 | <label class="block text-sm font-medium text-gray-300 mb-1">Token / Credential</label> |
| 117 | <input type="password" name="auth_credential" placeholder="ghp_xxxxxxxxxxxx" |
| 118 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono"> |
| 119 | <p class="mt-1 text-xs text-gray-500">For token auth: GitHub Personal Access Token or GitLab Access Token</p> |
| 120 | </div> |
| 121 | |
| 122 | <div class="grid grid-cols-2 gap-4"> |
| 123 | <div> |
| 124 | <label class="block text-sm font-medium text-gray-300 mb-1">Sync Mode</label> |
| 125 | <select name="sync_mode" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 126 | <option value="scheduled">Scheduled</option> |
| 127 | <option value="on_change">On Change</option> |
| 128 | <option value="both">Both</option> |
| 129 | </select> |
| 130 | </div> |
| 131 | <div> |
| 132 | <label class="block text-sm font-medium text-gray-300 mb-1">Schedule (cron)</label> |
| 133 | <input type="text" name="sync_schedule" value="*/15 * * * *" |
| 134 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono"> |
| 135 | <p class="mt-1 text-xs text-gray-500">Default: every 15 minutes</p> |
| 136 | </div> |
| 137 | </div> |
| 138 | |
| 139 | <div class="flex justify-end pt-2"> |
| 140 | <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 141 | Add Mirror |
| 142 | </button> |
| 143 | </div> |
| 144 | </form> |
| 145 | </div> |
| 146 | </div> |
| 147 | {% endblock %} |
| 148 | |
| 149 | DDED templates/fossil/git_mirror_delete.html |
| --- templates/fossil/git_mirror.html | |
| +++ templates/fossil/git_mirror.html | |
| @@ -1,66 +1,20 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{% if editing_mirror %}Edit Mirror{% else %}Add Git Mirror{% endif %} — {{ 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="max-w-3xl"> |
| 9 | <div class="flex items-center justify-between mb-6"> |
| 10 | <h2 class="text-lg font-semibold text-gray-200">{% if editing_mirror %}Edit Git Mirror{% else %}Add Git Mirror{% endif %}</h2> |
| 11 | <a href="{% url 'fossil:sync' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to Sync</a> |
| 12 | </div> |
| 13 | |
| 14 | {# ── OAuth quick-connect (only shown when adding) ──────────────── #} |
| 15 | {% if not editing_mirror %} |
| 16 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-5 mb-6"> |
| 17 | <h3 class="text-sm font-semibold text-gray-200 mb-3 text-center">Quick Connect</h3> |
| 18 | <div class="flex items-center justify-center gap-3"> |
| 19 | <a href="{% url 'fossil:oauth_github' slug=project.slug %}" |
| 20 | class="inline-flex items-center gap-2 rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-gray-200 ring-1 ring-inset ring-gray-600 hover:bg-gray-700"> |
| @@ -72,76 +26,119 @@ | |
| 26 | <svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"/></svg> |
| 27 | Connect GitLab |
| 28 | </a> |
| 29 | </div> |
| 30 | {% if request.session.github_oauth_token %} |
| 31 | <p class="mt-2 text-xs text-green-400 text-center">GitHub connected as {{ request.session.github_oauth_user }}. Token will be used for new mirrors.</p> |
| 32 | {% endif %} |
| 33 | {% if request.session.gitlab_oauth_token %} |
| 34 | <p class="mt-2 text-xs text-green-400 text-center">GitLab connected. Token will be used for new mirrors.</p> |
| 35 | {% endif %} |
| 36 | </div> |
| 37 | {% endif %} |
| 38 | |
| 39 | {# ── Mirror form (add or edit) ─────────────────────────────────── #} |
| 40 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-6"> |
| 41 | <form method="post" class="space-y-4"> |
| 42 | {% csrf_token %} |
| 43 | <input type="hidden" name="action" value="{% if editing_mirror %}update{% else %}create{% endif %}"> |
| 44 | |
| 45 | <div> |
| 46 | <label class="block text-sm font-medium text-gray-300 mb-1">Git Remote URL <span class="text-red-400">*</span></label> |
| 47 | <input type="text" name="git_remote_url" required |
| 48 | value="{{ editing_mirror.git_remote_url|default:'' }}" |
| 49 | placeholder="https://github.com/org/repo.git" |
| 50 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono"> |
| 51 | </div> |
| 52 | |
| 53 | <div class="grid grid-cols-2 gap-4"> |
| 54 | <div> |
| 55 | <label class="block text-sm font-medium text-gray-300 mb-1">Sync Direction</label> |
| 56 | <select name="sync_direction" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 57 | {% for value, label in sync_direction_choices %} |
| 58 | <option value="{{ value }}" {% if editing_mirror and editing_mirror.sync_direction == value %}selected{% endif %}>{{ label }}</option> |
| 59 | {% endfor %} |
| 60 | </select> |
| 61 | </div> |
| 62 | <div> |
| 63 | <label class="block text-sm font-medium text-gray-300 mb-1">Auth Method</label> |
| 64 | <select name="auth_method" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 65 | {% for value, label in auth_method_choices %} |
| 66 | <option value="{{ value }}" {% if editing_mirror and editing_mirror.auth_method == value %}selected{% endif %}>{{ label }}</option> |
| 67 | {% endfor %} |
| 68 | </select> |
| 69 | </div> |
| 70 | </div> |
| 71 | |
| 72 | <div> |
| 73 | <label class="block text-sm font-medium text-gray-300 mb-1">Token / Credential</label> |
| 74 | <input type="password" name="auth_credential" |
| 75 | placeholder="{% if editing_mirror %}Leave blank to keep current credential{% else %}ghp_xxxxxxxxxxxx{% endif %}" |
| 76 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono"> |
| 77 | <p class="mt-1 text-xs text-gray-500"> |
| 78 | {% if editing_mirror %}Leave blank to keep the existing credential.{% else %}For token auth: GitHub Personal Access Token or GitLab Access Token.{% endif %} |
| 79 | </p> |
| 80 | </div> |
| 81 | |
| 82 | <div class="grid grid-cols-2 gap-4"> |
| 83 | <div> |
| 84 | <label class="block text-sm font-medium text-gray-300 mb-1">Fossil Branch</label> |
| 85 | <input type="text" name="fossil_branch" |
| 86 | value="{{ editing_mirror.fossil_branch|default:'trunk' }}" |
| 87 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2"> |
| 88 | </div> |
| 89 | <div> |
| 90 | <label class="block text-sm font-medium text-gray-300 mb-1">Git Branch</label> |
| 91 | <input type="text" name="git_branch" |
| 92 | value="{{ editing_mirror.git_branch|default:'main' }}" |
| 93 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2"> |
| 94 | </div> |
| 95 | </div> |
| 96 | |
| 97 | <div class="grid grid-cols-2 gap-4"> |
| 98 | <div> |
| 99 | <label class="block text-sm font-medium text-gray-300 mb-1">Sync Mode</label> |
| 100 | <select name="sync_mode" class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 101 | {% for value, label in sync_mode_choices %} |
| 102 | <option value="{{ value }}" {% if editing_mirror and editing_mirror.sync_mode == value %}selected{% endif %}>{{ label }}</option> |
| 103 | {% endfor %} |
| 104 | </select> |
| 105 | </div> |
| 106 | <div> |
| 107 | <label class="block text-sm font-medium text-gray-300 mb-1">Schedule (cron)</label> |
| 108 | <input type="text" name="sync_schedule" |
| 109 | value="{{ editing_mirror.sync_schedule|default:'*/15 * * * *' }}" |
| 110 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-3 py-2 font-mono"> |
| 111 | <p class="mt-1 text-xs text-gray-500">Default: every 15 minutes</p> |
| 112 | </div> |
| 113 | </div> |
| 114 | |
| 115 | {# Sync content options #} |
| 116 | <div> |
| 117 | <span class="block text-sm font-medium text-gray-300 mb-2">Sync Content</span> |
| 118 | <div class="flex items-center gap-6"> |
| 119 | <label class="flex items-center gap-2 text-sm text-gray-300"> |
| 120 | <input type="checkbox" name="sync_tickets" |
| 121 | {% if editing_mirror and editing_mirror.sync_tickets %}checked{% endif %} |
| 122 | class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand"> |
| 123 | Tickets as Issues |
| 124 | </label> |
| 125 | <label class="flex items-center gap-2 text-sm text-gray-300"> |
| 126 | <input type="checkbox" name="sync_wiki" |
| 127 | {% if editing_mirror and editing_mirror.sync_wiki %}checked{% endif %} |
| 128 | class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand"> |
| 129 | Wiki pages |
| 130 | </label> |
| 131 | </div> |
| 132 | <p class="mt-1 text-xs text-gray-500">Code is always synced. Optionally sync tickets and wiki.</p> |
| 133 | </div> |
| 134 | |
| 135 | <div class="flex items-center justify-between pt-2"> |
| 136 | <a href="{% url 'fossil:sync' slug=project.slug %}" class="text-sm text-gray-400 hover:text-gray-200">Cancel</a> |
| 137 | <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 138 | {% if editing_mirror %}Update Mirror{% else %}Add Mirror{% endif %} |
| 139 | </button> |
| 140 | </div> |
| 141 | </form> |
| 142 | </div> |
| 143 | </div> |
| 144 | {% endblock %} |
| 145 | |
| 146 | DDED templates/fossil/git_mirror_delete.html |
| --- a/templates/fossil/git_mirror_delete.html | ||
| +++ b/templates/fossil/git_mirror_delete.html | ||
| @@ -0,0 +1,43 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Delete Mirror — {{ 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="mx-auto max-w-lg"> | |
| 9 | + <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 10 | + <h2 class="text-lg font-semibold text-gray-100">Delete Git Mirror</h2> | |
| 11 | + <p class="mt-2 text-sm text-gray-400"> | |
| 12 | + Are you sure you want to remove the mirror to | |
| 13 | + <strong class="text-gray-100 font-mono">{{ mirror.git_remote_url }}</strong>? | |
| 14 | + This action uses soft delete — the record will be marked as deleted but can be recovered. | |
| 15 | + </p> | |
| 16 | + <dl class="mt-4 space-y-2 text-sm"> | |
| 17 | + <div class="flex items-center justify-between"> | |
| 18 | + <dt class="text-gray-500">Direction</dt> | |
| 19 | + <dd class="text-gray-300">{{ mirror.get_sync_direction_display }}</dd> | |
| 20 | + </div> | |
| 21 | + <div class="flex items-center justify-between"> | |
| 22 | + <dt class="text-gray-500">Branch mapping</dt> | |
| 23 | + <dd class="text-gray-300">{{ mirror.fossil_branch }} → {{ mirror.git_branch }}</dd> | |
| 24 | + </div> | |
| 25 | + <div class="flex items-center justify-between"> | |
| 26 | + <dt class="text-gray-500">Total syncs</dt> | |
| 27 | + <dd class="text-gray-300">{{ mirror.total_syncs }}</dd> | |
| 28 | + </div> | |
| 29 | + </dl> | |
| 30 | + <form method="post" class="mt-6 flex justify-end gap-3"> | |
| 31 | + {% csrf_token %} | |
| 32 | + <a href="{% url 'fossil:sync' slug=project.slug %}" | |
| 33 | + 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"> | |
| 34 | + Cancel | |
| 35 | + </a> | |
| 36 | + <button type="submit" | |
| 37 | + class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> | |
| 38 | + Delete Mirror | |
| 39 | + </button> | |
| 40 | + </form> | |
| 41 | + </div> | |
| 42 | +</div> | |
| 43 | +{% endblock %} |
| --- a/templates/fossil/git_mirror_delete.html | |
| +++ b/templates/fossil/git_mirror_delete.html | |
| @@ -0,0 +1,43 @@ | |
| --- a/templates/fossil/git_mirror_delete.html | |
| +++ b/templates/fossil/git_mirror_delete.html | |
| @@ -0,0 +1,43 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Delete Mirror — {{ 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="mx-auto max-w-lg"> |
| 9 | <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 10 | <h2 class="text-lg font-semibold text-gray-100">Delete Git Mirror</h2> |
| 11 | <p class="mt-2 text-sm text-gray-400"> |
| 12 | Are you sure you want to remove the mirror to |
| 13 | <strong class="text-gray-100 font-mono">{{ mirror.git_remote_url }}</strong>? |
| 14 | This action uses soft delete — the record will be marked as deleted but can be recovered. |
| 15 | </p> |
| 16 | <dl class="mt-4 space-y-2 text-sm"> |
| 17 | <div class="flex items-center justify-between"> |
| 18 | <dt class="text-gray-500">Direction</dt> |
| 19 | <dd class="text-gray-300">{{ mirror.get_sync_direction_display }}</dd> |
| 20 | </div> |
| 21 | <div class="flex items-center justify-between"> |
| 22 | <dt class="text-gray-500">Branch mapping</dt> |
| 23 | <dd class="text-gray-300">{{ mirror.fossil_branch }} → {{ mirror.git_branch }}</dd> |
| 24 | </div> |
| 25 | <div class="flex items-center justify-between"> |
| 26 | <dt class="text-gray-500">Total syncs</dt> |
| 27 | <dd class="text-gray-300">{{ mirror.total_syncs }}</dd> |
| 28 | </div> |
| 29 | </dl> |
| 30 | <form method="post" class="mt-6 flex justify-end gap-3"> |
| 31 | {% csrf_token %} |
| 32 | <a href="{% url 'fossil:sync' slug=project.slug %}" |
| 33 | 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"> |
| 34 | Cancel |
| 35 | </a> |
| 36 | <button type="submit" |
| 37 | class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> |
| 38 | Delete Mirror |
| 39 | </button> |
| 40 | </form> |
| 41 | </div> |
| 42 | </div> |
| 43 | {% endblock %} |
| --- templates/fossil/sync.html | ||
| +++ templates/fossil/sync.html | ||
| @@ -3,13 +3,14 @@ | ||
| 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 | -<div class="max-w-2xl"> | |
| 8 | +<div class="max-w-3xl"> | |
| 9 | + | |
| 10 | + {# ── Upstream Fossil Sync ────────────────────────────────────────── #} | |
| 9 | 11 | {% if sync_configured %} |
| 10 | - <!-- Sync is configured — show status and pull button --> | |
| 11 | 12 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-5 mb-6"> |
| 12 | 13 | <div class="flex items-center justify-between mb-4"> |
| 13 | 14 | <h2 class="text-lg font-semibold text-gray-200">Upstream Sync</h2> |
| 14 | 15 | <span class="inline-flex rounded-full bg-green-900/50 px-2 py-0.5 text-xs font-semibold text-green-300">Configured</span> |
| 15 | 16 | </div> |
| @@ -48,18 +49,15 @@ | ||
| 48 | 49 | <input type="hidden" name="action" value="disable"> |
| 49 | 50 | <button type="submit" class="rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-400 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 50 | 51 | Disable Sync |
| 51 | 52 | </button> |
| 52 | 53 | </form> |
| 53 | - <a href="{% url 'fossil:git_mirror' slug=project.slug %}" class="rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-400 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 54 | - Git Mirrors | |
| 55 | - </a> | |
| 56 | 54 | </div> |
| 57 | 55 | </div> |
| 58 | 56 | |
| 59 | 57 | {% if result %} |
| 60 | - <div class="rounded-lg {% if result.success %}bg-green-900/20 border border-green-800{% else %}bg-red-900/20 border border-red-800{% endif %} p-4"> | |
| 58 | + <div class="rounded-lg {% if result.success %}bg-green-900/20 border border-green-800{% else %}bg-red-900/20 border border-red-800{% endif %} p-4 mb-6"> | |
| 61 | 59 | <div class="text-sm {% if result.success %}text-green-300{% else %}text-red-300{% endif %}"> |
| 62 | 60 | {% if result.success %} |
| 63 | 61 | {% if result.artifacts_received > 0 %}Pulled {{ result.artifacts_received }} new artifacts.{% else %}Already up to date.{% endif %} |
| 64 | 62 | {% else %} |
| 65 | 63 | Sync failed: {{ result.message }} |
| @@ -68,12 +66,12 @@ | ||
| 68 | 66 | {% if result.message %}<pre class="mt-2 text-xs text-gray-500 font-mono">{{ result.message }}</pre>{% endif %} |
| 69 | 67 | </div> |
| 70 | 68 | {% endif %} |
| 71 | 69 | |
| 72 | 70 | {% else %} |
| 73 | - <!-- Sync not configured — show setup wizard --> | |
| 74 | - <div class="rounded-lg bg-gray-800 border border-gray-700 p-6"> | |
| 71 | + {# ── Upstream not configured ─────────────────────────────────────── #} | |
| 72 | + <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6"> | |
| 75 | 73 | <div class="text-center mb-6"> |
| 76 | 74 | <svg class="mx-auto h-12 w-12 text-gray-600" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"> |
| 77 | 75 | <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" /> |
| 78 | 76 | </svg> |
| 79 | 77 | <h2 class="mt-3 text-lg font-semibold text-gray-200">Configure Upstream Sync</h2> |
| @@ -105,16 +103,108 @@ | ||
| 105 | 103 | <li>You can also pull manually at any time</li> |
| 106 | 104 | <li>Your local data is never overwritten — only new artifacts are added</li> |
| 107 | 105 | </ul> |
| 108 | 106 | </div> |
| 109 | 107 | |
| 110 | - <div class="flex items-center justify-between"> | |
| 111 | - <a href="{% url 'fossil:git_mirror' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">Or configure Git Mirror (GitHub/GitLab) →</a> | |
| 108 | + <div class="flex items-center justify-end"> | |
| 112 | 109 | <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 113 | 110 | Enable Sync |
| 114 | 111 | </button> |
| 115 | 112 | </div> |
| 116 | 113 | </form> |
| 117 | 114 | </div> |
| 118 | 115 | {% endif %} |
| 116 | + | |
| 117 | + {# ── Git Mirrors ─────────────────────────────────────────────────── #} | |
| 118 | + <div class="mb-4 flex items-center justify-between"> | |
| 119 | + <h2 class="text-lg font-semibold text-gray-200">Git Mirrors</h2> | |
| 120 | + <a href="{% url 'fossil:git_mirror' slug=project.slug %}" | |
| 121 | + class="inline-flex items-center gap-1 rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 122 | + <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> | |
| 123 | + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> | |
| 124 | + </svg> | |
| 125 | + Add Mirror | |
| 126 | + </a> | |
| 127 | + </div> | |
| 128 | + | |
| 129 | + {% if mirrors %} | |
| 130 | + <div class="space-y-4"> | |
| 131 | + {% for mirror in mirrors %} | |
| 132 | + <div class="rounded-lg bg-gray-800 border border-gray-700 p-5"> | |
| 133 | + <div class="flex items-start justify-between gap-4"> | |
| 134 | + <div class="min-w-0 flex-1"> | |
| 135 | + {# URL and direction badge #} | |
| 136 | + <div class="flex items-center gap-2 flex-wrap"> | |
| 137 | + <span class="font-mono text-sm text-gray-200 truncate">{{ mirror.git_remote_url }}</span> | |
| 138 | + <span class="inline-flex shrink-0 rounded-full px-2 py-0.5 text-xs font-medium | |
| 139 | + {% if mirror.sync_direction == 'push' %}bg-blue-900/50 text-blue-300 | |
| 140 | + {% elif mirror.sync_direction == 'pull' %}bg-purple-900/50 text-purple-300 | |
| 141 | + {% else %}bg-teal-900/50 text-teal-300{% endif %}"> | |
| 142 | + {{ mirror.get_sync_direction_display }} | |
| 143 | + </span> | |
| 144 | + </div> | |
| 145 | + | |
| 146 | + {# Detail line #} | |
| 147 | + <div class="mt-2 flex items-center gap-3 flex-wrap text-xs text-gray-500"> | |
| 148 | + <span class="inline-flex items-center gap-1"> | |
| 149 | + <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> | |
| 150 | + <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /> | |
| 151 | + </svg> | |
| 152 | + {% if mirror.sync_mode == 'disabled' %}Disabled{% else %}{{ mirror.get_sync_mode_display }}{% endif %} | |
| 153 | + </span> | |
| 154 | + {% if mirror.sync_mode != 'disabled' and mirror.sync_mode != 'on_change' %} | |
| 155 | + <span class="font-mono">{{ mirror.sync_schedule }}</span> | |
| 156 | + {% endif %} | |
| 157 | + <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_auth_method_display }}</span> | |
| 158 | + <span>{{ mirror.fossil_branch }} → {{ mirror.git_branch }}</span> | |
| 159 | + </div> | |
| 160 | + | |
| 161 | + {# Sync status #} | |
| 162 | + <div class="mt-2 text-xs text-gray-500"> | |
| 163 | + {% if mirror.last_sync_at %} | |
| 164 | + Last sync: {{ mirror.last_sync_at|timesince }} ago | |
| 165 | + {% if mirror.last_sync_status == 'success' %} | |
| 166 | + <span class="text-green-400">✓</span> | |
| 167 | + {% elif mirror.last_sync_status == 'failed' %} | |
| 168 | + <span class="text-red-400">✗</span> | |
| 169 | + {% elif mirror.last_sync_status %} | |
| 170 | + <span class="text-yellow-400">({{ mirror.last_sync_status }})</span> | |
| 171 | + {% endif %} | |
| 172 | + — {{ mirror.total_syncs }} total sync{{ mirror.total_syncs|pluralize:"es" }} | |
| 173 | + {% else %} | |
| 174 | + Never synced | |
| 175 | + {% endif %} | |
| 176 | + </div> | |
| 177 | + {% if mirror.last_sync_message and mirror.last_sync_status == 'failed' %} | |
| 178 | + <pre class="mt-1 text-xs text-red-400/70 max-h-16 overflow-hidden">{{ mirror.last_sync_message|truncatechars:200 }}</pre> | |
| 179 | + {% endif %} | |
| 180 | + </div> | |
| 181 | + | |
| 182 | + {# Action buttons #} | |
| 183 | + <div class="flex items-center gap-2 shrink-0"> | |
| 184 | + <form method="post" action="{% url 'fossil:git_mirror_run' slug=project.slug mirror_id=mirror.id %}"> | |
| 185 | + {% csrf_token %} | |
| 186 | + <button type="submit" class="rounded-md bg-brand px-3 py-1.5 text-xs font-semibold text-white hover:bg-brand-hover" | |
| 187 | + title="Run sync now">Run Now</button> | |
| 188 | + </form> | |
| 189 | + <a href="{% url 'fossil:git_mirror_edit' slug=project.slug mirror_id=mirror.id %}" | |
| 190 | + class="rounded-md bg-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600" | |
| 191 | + title="Edit mirror">Edit</a> | |
| 192 | + <a href="{% url 'fossil:git_mirror_delete' slug=project.slug mirror_id=mirror.id %}" | |
| 193 | + class="rounded-md bg-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:text-red-400 ring-1 ring-inset ring-gray-600 hover:bg-gray-600" | |
| 194 | + title="Delete mirror">Delete</a> | |
| 195 | + </div> | |
| 196 | + </div> | |
| 197 | + </div> | |
| 198 | + {% endfor %} | |
| 199 | + </div> | |
| 200 | + {% else %} | |
| 201 | + <div class="rounded-lg bg-gray-800 border border-gray-700 border-dashed p-8 text-center"> | |
| 202 | + <svg class="mx-auto h-10 w-10 text-gray-600" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"> | |
| 203 | + <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" /> | |
| 204 | + </svg> | |
| 205 | + <p class="mt-3 text-sm text-gray-400">No Git mirrors configured.</p> | |
| 206 | + <p class="mt-1 text-xs text-gray-500">Mirror this Fossil repository to GitHub, GitLab, or any Git remote.</p> | |
| 207 | + </div> | |
| 208 | + {% endif %} | |
| 119 | 209 | </div> |
| 120 | 210 | {% endblock %} |
| 121 | 211 |
| --- templates/fossil/sync.html | |
| +++ templates/fossil/sync.html | |
| @@ -3,13 +3,14 @@ | |
| 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="max-w-2xl"> |
| 9 | {% if sync_configured %} |
| 10 | <!-- Sync is configured — show status and pull button --> |
| 11 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-5 mb-6"> |
| 12 | <div class="flex items-center justify-between mb-4"> |
| 13 | <h2 class="text-lg font-semibold text-gray-200">Upstream Sync</h2> |
| 14 | <span class="inline-flex rounded-full bg-green-900/50 px-2 py-0.5 text-xs font-semibold text-green-300">Configured</span> |
| 15 | </div> |
| @@ -48,18 +49,15 @@ | |
| 48 | <input type="hidden" name="action" value="disable"> |
| 49 | <button type="submit" class="rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-400 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 50 | Disable Sync |
| 51 | </button> |
| 52 | </form> |
| 53 | <a href="{% url 'fossil:git_mirror' slug=project.slug %}" class="rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-400 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 54 | Git Mirrors |
| 55 | </a> |
| 56 | </div> |
| 57 | </div> |
| 58 | |
| 59 | {% if result %} |
| 60 | <div class="rounded-lg {% if result.success %}bg-green-900/20 border border-green-800{% else %}bg-red-900/20 border border-red-800{% endif %} p-4"> |
| 61 | <div class="text-sm {% if result.success %}text-green-300{% else %}text-red-300{% endif %}"> |
| 62 | {% if result.success %} |
| 63 | {% if result.artifacts_received > 0 %}Pulled {{ result.artifacts_received }} new artifacts.{% else %}Already up to date.{% endif %} |
| 64 | {% else %} |
| 65 | Sync failed: {{ result.message }} |
| @@ -68,12 +66,12 @@ | |
| 68 | {% if result.message %}<pre class="mt-2 text-xs text-gray-500 font-mono">{{ result.message }}</pre>{% endif %} |
| 69 | </div> |
| 70 | {% endif %} |
| 71 | |
| 72 | {% else %} |
| 73 | <!-- Sync not configured — show setup wizard --> |
| 74 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-6"> |
| 75 | <div class="text-center mb-6"> |
| 76 | <svg class="mx-auto h-12 w-12 text-gray-600" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"> |
| 77 | <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" /> |
| 78 | </svg> |
| 79 | <h2 class="mt-3 text-lg font-semibold text-gray-200">Configure Upstream Sync</h2> |
| @@ -105,16 +103,108 @@ | |
| 105 | <li>You can also pull manually at any time</li> |
| 106 | <li>Your local data is never overwritten — only new artifacts are added</li> |
| 107 | </ul> |
| 108 | </div> |
| 109 | |
| 110 | <div class="flex items-center justify-between"> |
| 111 | <a href="{% url 'fossil:git_mirror' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">Or configure Git Mirror (GitHub/GitLab) →</a> |
| 112 | <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 113 | Enable Sync |
| 114 | </button> |
| 115 | </div> |
| 116 | </form> |
| 117 | </div> |
| 118 | {% endif %} |
| 119 | </div> |
| 120 | {% endblock %} |
| 121 |
| --- templates/fossil/sync.html | |
| +++ templates/fossil/sync.html | |
| @@ -3,13 +3,14 @@ | |
| 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="max-w-3xl"> |
| 9 | |
| 10 | {# ── Upstream Fossil Sync ────────────────────────────────────────── #} |
| 11 | {% if sync_configured %} |
| 12 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-5 mb-6"> |
| 13 | <div class="flex items-center justify-between mb-4"> |
| 14 | <h2 class="text-lg font-semibold text-gray-200">Upstream Sync</h2> |
| 15 | <span class="inline-flex rounded-full bg-green-900/50 px-2 py-0.5 text-xs font-semibold text-green-300">Configured</span> |
| 16 | </div> |
| @@ -48,18 +49,15 @@ | |
| 49 | <input type="hidden" name="action" value="disable"> |
| 50 | <button type="submit" class="rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-400 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 51 | Disable Sync |
| 52 | </button> |
| 53 | </form> |
| 54 | </div> |
| 55 | </div> |
| 56 | |
| 57 | {% if result %} |
| 58 | <div class="rounded-lg {% if result.success %}bg-green-900/20 border border-green-800{% else %}bg-red-900/20 border border-red-800{% endif %} p-4 mb-6"> |
| 59 | <div class="text-sm {% if result.success %}text-green-300{% else %}text-red-300{% endif %}"> |
| 60 | {% if result.success %} |
| 61 | {% if result.artifacts_received > 0 %}Pulled {{ result.artifacts_received }} new artifacts.{% else %}Already up to date.{% endif %} |
| 62 | {% else %} |
| 63 | Sync failed: {{ result.message }} |
| @@ -68,12 +66,12 @@ | |
| 66 | {% if result.message %}<pre class="mt-2 text-xs text-gray-500 font-mono">{{ result.message }}</pre>{% endif %} |
| 67 | </div> |
| 68 | {% endif %} |
| 69 | |
| 70 | {% else %} |
| 71 | {# ── Upstream not configured ─────────────────────────────────────── #} |
| 72 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6"> |
| 73 | <div class="text-center mb-6"> |
| 74 | <svg class="mx-auto h-12 w-12 text-gray-600" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"> |
| 75 | <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" /> |
| 76 | </svg> |
| 77 | <h2 class="mt-3 text-lg font-semibold text-gray-200">Configure Upstream Sync</h2> |
| @@ -105,16 +103,108 @@ | |
| 103 | <li>You can also pull manually at any time</li> |
| 104 | <li>Your local data is never overwritten — only new artifacts are added</li> |
| 105 | </ul> |
| 106 | </div> |
| 107 | |
| 108 | <div class="flex items-center justify-end"> |
| 109 | <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 110 | Enable Sync |
| 111 | </button> |
| 112 | </div> |
| 113 | </form> |
| 114 | </div> |
| 115 | {% endif %} |
| 116 | |
| 117 | {# ── Git Mirrors ─────────────────────────────────────────────────── #} |
| 118 | <div class="mb-4 flex items-center justify-between"> |
| 119 | <h2 class="text-lg font-semibold text-gray-200">Git Mirrors</h2> |
| 120 | <a href="{% url 'fossil:git_mirror' slug=project.slug %}" |
| 121 | class="inline-flex items-center gap-1 rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 122 | <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> |
| 123 | <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> |
| 124 | </svg> |
| 125 | Add Mirror |
| 126 | </a> |
| 127 | </div> |
| 128 | |
| 129 | {% if mirrors %} |
| 130 | <div class="space-y-4"> |
| 131 | {% for mirror in mirrors %} |
| 132 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-5"> |
| 133 | <div class="flex items-start justify-between gap-4"> |
| 134 | <div class="min-w-0 flex-1"> |
| 135 | {# URL and direction badge #} |
| 136 | <div class="flex items-center gap-2 flex-wrap"> |
| 137 | <span class="font-mono text-sm text-gray-200 truncate">{{ mirror.git_remote_url }}</span> |
| 138 | <span class="inline-flex shrink-0 rounded-full px-2 py-0.5 text-xs font-medium |
| 139 | {% if mirror.sync_direction == 'push' %}bg-blue-900/50 text-blue-300 |
| 140 | {% elif mirror.sync_direction == 'pull' %}bg-purple-900/50 text-purple-300 |
| 141 | {% else %}bg-teal-900/50 text-teal-300{% endif %}"> |
| 142 | {{ mirror.get_sync_direction_display }} |
| 143 | </span> |
| 144 | </div> |
| 145 | |
| 146 | {# Detail line #} |
| 147 | <div class="mt-2 flex items-center gap-3 flex-wrap text-xs text-gray-500"> |
| 148 | <span class="inline-flex items-center gap-1"> |
| 149 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> |
| 150 | <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /> |
| 151 | </svg> |
| 152 | {% if mirror.sync_mode == 'disabled' %}Disabled{% else %}{{ mirror.get_sync_mode_display }}{% endif %} |
| 153 | </span> |
| 154 | {% if mirror.sync_mode != 'disabled' and mirror.sync_mode != 'on_change' %} |
| 155 | <span class="font-mono">{{ mirror.sync_schedule }}</span> |
| 156 | {% endif %} |
| 157 | <span class="inline-flex rounded-full bg-gray-700 px-2 py-0.5">{{ mirror.get_auth_method_display }}</span> |
| 158 | <span>{{ mirror.fossil_branch }} → {{ mirror.git_branch }}</span> |
| 159 | </div> |
| 160 | |
| 161 | {# Sync status #} |
| 162 | <div class="mt-2 text-xs text-gray-500"> |
| 163 | {% if mirror.last_sync_at %} |
| 164 | Last sync: {{ mirror.last_sync_at|timesince }} ago |
| 165 | {% if mirror.last_sync_status == 'success' %} |
| 166 | <span class="text-green-400">✓</span> |
| 167 | {% elif mirror.last_sync_status == 'failed' %} |
| 168 | <span class="text-red-400">✗</span> |
| 169 | {% elif mirror.last_sync_status %} |
| 170 | <span class="text-yellow-400">({{ mirror.last_sync_status }})</span> |
| 171 | {% endif %} |
| 172 | — {{ mirror.total_syncs }} total sync{{ mirror.total_syncs|pluralize:"es" }} |
| 173 | {% else %} |
| 174 | Never synced |
| 175 | {% endif %} |
| 176 | </div> |
| 177 | {% if mirror.last_sync_message and mirror.last_sync_status == 'failed' %} |
| 178 | <pre class="mt-1 text-xs text-red-400/70 max-h-16 overflow-hidden">{{ mirror.last_sync_message|truncatechars:200 }}</pre> |
| 179 | {% endif %} |
| 180 | </div> |
| 181 | |
| 182 | {# Action buttons #} |
| 183 | <div class="flex items-center gap-2 shrink-0"> |
| 184 | <form method="post" action="{% url 'fossil:git_mirror_run' slug=project.slug mirror_id=mirror.id %}"> |
| 185 | {% csrf_token %} |
| 186 | <button type="submit" class="rounded-md bg-brand px-3 py-1.5 text-xs font-semibold text-white hover:bg-brand-hover" |
| 187 | title="Run sync now">Run Now</button> |
| 188 | </form> |
| 189 | <a href="{% url 'fossil:git_mirror_edit' slug=project.slug mirror_id=mirror.id %}" |
| 190 | class="rounded-md bg-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600" |
| 191 | title="Edit mirror">Edit</a> |
| 192 | <a href="{% url 'fossil:git_mirror_delete' slug=project.slug mirror_id=mirror.id %}" |
| 193 | class="rounded-md bg-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:text-red-400 ring-1 ring-inset ring-gray-600 hover:bg-gray-600" |
| 194 | title="Delete mirror">Delete</a> |
| 195 | </div> |
| 196 | </div> |
| 197 | </div> |
| 198 | {% endfor %} |
| 199 | </div> |
| 200 | {% else %} |
| 201 | <div class="rounded-lg bg-gray-800 border border-gray-700 border-dashed p-8 text-center"> |
| 202 | <svg class="mx-auto h-10 w-10 text-gray-600" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"> |
| 203 | <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" /> |
| 204 | </svg> |
| 205 | <p class="mt-3 text-sm text-gray-400">No Git mirrors configured.</p> |
| 206 | <p class="mt-1 text-xs text-gray-500">Mirror this Fossil repository to GitHub, GitLab, or any Git remote.</p> |
| 207 | </div> |
| 208 | {% endif %} |
| 209 | </div> |
| 210 | {% endblock %} |
| 211 |
| --- templates/includes/nav.html | ||
| +++ templates/includes/nav.html | ||
| @@ -47,10 +47,11 @@ | ||
| 47 | 47 | {{ user.get_full_name|default:user.username }} |
| 48 | 48 | <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg> |
| 49 | 49 | </button> |
| 50 | 50 | <div x-show="open" @click.outside="open = false" x-transition |
| 51 | 51 | class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700"> |
| 52 | + <a href="{% url 'accounts:profile' %}" class="block px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Profile</a> | |
| 52 | 53 | <form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Sign out</button></form> |
| 53 | 54 | </div> |
| 54 | 55 | </div> |
| 55 | 56 | </div> |
| 56 | 57 | </div> |
| 57 | 58 | |
| 58 | 59 | ADDED tests/test_git_mirrors.py |
| --- templates/includes/nav.html | |
| +++ templates/includes/nav.html | |
| @@ -47,10 +47,11 @@ | |
| 47 | {{ user.get_full_name|default:user.username }} |
| 48 | <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg> |
| 49 | </button> |
| 50 | <div x-show="open" @click.outside="open = false" x-transition |
| 51 | class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700"> |
| 52 | <form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Sign out</button></form> |
| 53 | </div> |
| 54 | </div> |
| 55 | </div> |
| 56 | </div> |
| 57 | |
| 58 | DDED tests/test_git_mirrors.py |
| --- templates/includes/nav.html | |
| +++ templates/includes/nav.html | |
| @@ -47,10 +47,11 @@ | |
| 47 | {{ user.get_full_name|default:user.username }} |
| 48 | <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg> |
| 49 | </button> |
| 50 | <div x-show="open" @click.outside="open = false" x-transition |
| 51 | class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700"> |
| 52 | <a href="{% url 'accounts:profile' %}" class="block px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Profile</a> |
| 53 | <form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Sign out</button></form> |
| 54 | </div> |
| 55 | </div> |
| 56 | </div> |
| 57 | </div> |
| 58 | |
| 59 | DDED tests/test_git_mirrors.py |
| --- a/tests/test_git_mirrors.py | ||
| +++ b/tests/test_git_mirrors.py | ||
| @@ -0,0 +1,382 @@ | ||
| 1 | +"""Tests for Git mirror multi-remote sync UI views.""" | |
| 2 | + | |
| 3 | +from unittest.mock import MagicMock, patch | |
| 4 | + | |
| 5 | +import pytest | |
| 6 | +from django.contrib.auth.models import User | |
| 7 | +from django.test import Client | |
| 8 | + | |
| 9 | +from fossil.models import FossilRepository | |
| 10 | +from fossil.sync_models import GitMirror | |
| 11 | +from organization.models import Team | |
| 12 | +from projects.models import ProjectTeam | |
| 13 | + | |
| 14 | +# Reusable patch that makes FossilRepository.exists_on_disk return True | |
| 15 | +_disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True)) | |
| 16 | + | |
| 17 | + | |
| 18 | +def _make_reader_mock(**methods): | |
| 19 | + """Create a MagicMock that replaces FossilReader as a class.""" | |
| 20 | + mock_cls = MagicMock() | |
| 21 | + instance = MagicMock() | |
| 22 | + mock_cls.return_value = instance | |
| 23 | + instance.__enter__ = MagicMock(return_value=instance) | |
| 24 | + instance.__exit__ = MagicMock(return_value=False) | |
| 25 | + for name, val in methods.items(): | |
| 26 | + getattr(instance, name).return_value = val | |
| 27 | + return mock_cls | |
| 28 | + | |
| 29 | + | |
| 30 | +@pytest.fixture | |
| 31 | +def fossil_repo_obj(sample_project): | |
| 32 | + """Return the auto-created FossilRepository for sample_project.""" | |
| 33 | + return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True) | |
| 34 | + | |
| 35 | + | |
| 36 | +@pytest.fixture | |
| 37 | +def mirror(fossil_repo_obj, admin_user): | |
| 38 | + return GitMirror.objects.create( | |
| 39 | + repository=fossil_repo_obj, | |
| 40 | + git_remote_url="https://github.com/org/repo.git", | |
| 41 | + auth_method="token", | |
| 42 | + auth_credential="ghp_test123", | |
| 43 | + sync_direction="push", | |
| 44 | + sync_mode="scheduled", | |
| 45 | + sync_schedule="*/15 * * * *", | |
| 46 | + git_branch="main", | |
| 47 | + fossil_branch="trunk", | |
| 48 | + created_by=admin_user, | |
| 49 | + ) | |
| 50 | + | |
| 51 | + | |
| 52 | +@pytest.fixture | |
| 53 | +def second_mirror(fossil_repo_obj, admin_user): | |
| 54 | + return GitMirror.objects.create( | |
| 55 | + repository=fossil_repo_obj, | |
| 56 | + git_remote_url="https://gitlab.com/org/repo.git", | |
| 57 | + auth_method="oauth_gitlab", | |
| 58 | + sync_direction="both", | |
| 59 | + sync_mode="both", | |
| 60 | + sync_schedule="0 */6 * * *", | |
| 61 | + git_branch="main", | |
| 62 | + fossil_branch="trunk", | |
| 63 | + created_by=admin_user, | |
| 64 | + ) | |
| 65 | + | |
| 66 | + | |
| 67 | +@pytest.fixture | |
| 68 | +def writer_user(db, admin_user, sample_project): | |
| 69 | + """User with write access but not admin.""" | |
| 70 | + writer = User.objects.create_user(username="mirror_writer", password="testpass123") | |
| 71 | + team = Team.objects.create(name="Mirror Writers", organization=sample_project.organization, created_by=admin_user) | |
| 72 | + team.members.add(writer) | |
| 73 | + ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user) | |
| 74 | + return writer | |
| 75 | + | |
| 76 | + | |
| 77 | +@pytest.fixture | |
| 78 | +def writer_client(writer_user): | |
| 79 | + client = Client() | |
| 80 | + client.login(username="mirror_writer", password="testpass123") | |
| 81 | + return client | |
| 82 | + | |
| 83 | + | |
| 84 | +# --- GitMirror Model Tests --- | |
| 85 | + | |
| 86 | + | |
| 87 | +@pytest.mark.django_db | |
| 88 | +class TestGitMirrorModel: | |
| 89 | + def test_create_mirror(self, mirror): | |
| 90 | + assert mirror.pk is not None | |
| 91 | + assert mirror.git_remote_url == "https://github.com/org/repo.git" | |
| 92 | + assert mirror.sync_direction == "push" | |
| 93 | + | |
| 94 | + def test_str_representation(self, mirror): | |
| 95 | + assert "github.com/org/repo.git" in str(mirror) | |
| 96 | + | |
| 97 | + def test_soft_delete(self, mirror, admin_user): | |
| 98 | + mirror.soft_delete(user=admin_user) | |
| 99 | + assert mirror.is_deleted | |
| 100 | + assert GitMirror.objects.filter(pk=mirror.pk).count() == 0 | |
| 101 | + assert GitMirror.all_objects.filter(pk=mirror.pk).count() == 1 | |
| 102 | + | |
| 103 | + def test_multiple_mirrors_per_repo(self, mirror, second_mirror, fossil_repo_obj): | |
| 104 | + mirrors = GitMirror.objects.filter(repository=fossil_repo_obj) | |
| 105 | + assert mirrors.count() == 2 | |
| 106 | + | |
| 107 | + def test_ordering(self, mirror, second_mirror): | |
| 108 | + """Mirrors are ordered newest first.""" | |
| 109 | + mirrors = list(GitMirror.objects.all()) | |
| 110 | + assert mirrors[0] == second_mirror | |
| 111 | + assert mirrors[1] == mirror | |
| 112 | + | |
| 113 | + | |
| 114 | +# --- Sync Page (sync.html) showing mirrors --- | |
| 115 | + | |
| 116 | + | |
| 117 | +@pytest.mark.django_db | |
| 118 | +class TestSyncPageMirrorListing: | |
| 119 | + def test_sync_page_shows_mirrors(self, admin_client, sample_project, fossil_repo_obj, mirror, second_mirror): | |
| 120 | + mock = _make_reader_mock() | |
| 121 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 122 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/") | |
| 123 | + assert response.status_code == 200 | |
| 124 | + content = response.content.decode() | |
| 125 | + assert "github.com/org/repo.git" in content | |
| 126 | + assert "gitlab.com/org/repo.git" in content | |
| 127 | + assert "Git Mirrors" in content | |
| 128 | + | |
| 129 | + def test_sync_page_shows_empty_state(self, admin_client, sample_project, fossil_repo_obj): | |
| 130 | + mock = _make_reader_mock() | |
| 131 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 132 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/") | |
| 133 | + assert response.status_code == 200 | |
| 134 | + content = response.content.decode() | |
| 135 | + assert "No Git mirrors configured" in content | |
| 136 | + | |
| 137 | + def test_sync_page_shows_add_mirror_button(self, admin_client, sample_project, fossil_repo_obj): | |
| 138 | + mock = _make_reader_mock() | |
| 139 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 140 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/") | |
| 141 | + assert response.status_code == 200 | |
| 142 | + assert "Add Mirror" in response.content.decode() | |
| 143 | + | |
| 144 | + def test_sync_page_shows_mirror_direction_badges(self, admin_client, sample_project, fossil_repo_obj, mirror, second_mirror): | |
| 145 | + mock = _make_reader_mock() | |
| 146 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 147 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/") | |
| 148 | + content = response.content.decode() | |
| 149 | + # Check direction labels rendered | |
| 150 | + assert "Push" in content | |
| 151 | + assert "Bidirectional" in content | |
| 152 | + | |
| 153 | + def test_sync_page_shows_edit_delete_links(self, admin_client, sample_project, fossil_repo_obj, mirror): | |
| 154 | + mock = _make_reader_mock() | |
| 155 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 156 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/") | |
| 157 | + content = response.content.decode() | |
| 158 | + assert "Edit" in content | |
| 159 | + assert "Delete" in content | |
| 160 | + assert "Run Now" in content | |
| 161 | + | |
| 162 | + def test_sync_page_denied_for_anon(self, client, sample_project): | |
| 163 | + response = client.get(f"/projects/{sample_project.slug}/fossil/sync/") | |
| 164 | + assert response.status_code == 302 # redirect to login | |
| 165 | + | |
| 166 | + | |
| 167 | +# --- Git Mirror Config View (Add) --- | |
| 168 | + | |
| 169 | + | |
| 170 | +@pytest.mark.django_db | |
| 171 | +class TestGitMirrorAddView: | |
| 172 | + def test_get_add_form(self, admin_client, sample_project, fossil_repo_obj): | |
| 173 | + mock = _make_reader_mock() | |
| 174 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 175 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/") | |
| 176 | + assert response.status_code == 200 | |
| 177 | + content = response.content.decode() | |
| 178 | + assert "Add Git Mirror" in content | |
| 179 | + assert "Quick Connect" in content | |
| 180 | + | |
| 181 | + def test_create_mirror(self, admin_client, sample_project, fossil_repo_obj): | |
| 182 | + mock = _make_reader_mock() | |
| 183 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 184 | + response = admin_client.post( | |
| 185 | + f"/projects/{sample_project.slug}/fossil/sync/git/", | |
| 186 | + { | |
| 187 | + "action": "create", | |
| 188 | + "git_remote_url": "https://github.com/test/new-repo.git", | |
| 189 | + "auth_method": "token", | |
| 190 | + "auth_credential": "ghp_newtoken", | |
| 191 | + "sync_direction": "push", | |
| 192 | + "sync_mode": "scheduled", | |
| 193 | + "sync_schedule": "*/30 * * * *", | |
| 194 | + "git_branch": "main", | |
| 195 | + "fossil_branch": "trunk", | |
| 196 | + }, | |
| 197 | + ) | |
| 198 | + assert response.status_code == 302 | |
| 199 | + mirror = GitMirror.objects.get(git_remote_url="https://github.com/test/new-repo.git") | |
| 200 | + assert mirror.sync_direction == "push" | |
| 201 | + assert mirror.sync_schedule == "*/30 * * * *" | |
| 202 | + assert mirror.fossil_branch == "trunk" | |
| 203 | + | |
| 204 | + def test_create_mirror_with_sync_tickets(self, admin_client, sample_project, fossil_repo_obj): | |
| 205 | + mock = _make_reader_mock() | |
| 206 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 207 | + response = admin_client.post( | |
| 208 | + f"/projects/{sample_project.slug}/fossil/sync/git/", | |
| 209 | + { | |
| 210 | + "action": "create", | |
| 211 | + "git_remote_url": "https://github.com/test/tickets-repo.git", | |
| 212 | + "auth_method": "token", | |
| 213 | + "sync_direction": "push", | |
| 214 | + "sync_mode": "scheduled", | |
| 215 | + "sync_schedule": "*/15 * * * *", | |
| 216 | + "git_branch": "main", | |
| 217 | + "fossil_branch": "trunk", | |
| 218 | + "sync_tickets": "on", | |
| 219 | + "sync_wiki": "on", | |
| 220 | + }, | |
| 221 | + ) | |
| 222 | + assert response.status_code == 302 | |
| 223 | + mirror = GitMirror.objects.get(git_remote_url="https://github.com/test/tickets-repo.git") | |
| 224 | + assert mirror.sync_tickets is True | |
| 225 | + assert mirror.sync_wiki is True | |
| 226 | + | |
| 227 | + def test_create_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj): | |
| 228 | + response = writer_client.post( | |
| 229 | + f"/projects/{sample_project.slug}/fossil/sync/git/", | |
| 230 | + {"action": "create", "git_remote_url": "https://evil.com/repo.git"}, | |
| 231 | + ) | |
| 232 | + assert response.status_code == 403 | |
| 233 | + | |
| 234 | + def test_create_denied_for_anon(self, client, sample_project): | |
| 235 | + response = client.post( | |
| 236 | + f"/projects/{sample_project.slug}/fossil/sync/git/", | |
| 237 | + {"action": "create", "git_remote_url": "https://example.com/repo.git"}, | |
| 238 | + ) | |
| 239 | + assert response.status_code == 302 # redirect to login | |
| 240 | + | |
| 241 | + | |
| 242 | +# --- Git Mirror Config View (Edit) --- | |
| 243 | + | |
| 244 | + | |
| 245 | +@pytest.mark.django_db | |
| 246 | +class TestGitMirrorEditView: | |
| 247 | + def test_get_edit_form(self, admin_client, sample_project, fossil_repo_obj, mirror): | |
| 248 | + mock = _make_reader_mock() | |
| 249 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 250 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/") | |
| 251 | + assert response.status_code == 200 | |
| 252 | + content = response.content.decode() | |
| 253 | + assert "Edit Git Mirror" in content | |
| 254 | + assert "github.com/org/repo.git" in content | |
| 255 | + # Should NOT show quick connect section when editing | |
| 256 | + assert "Quick Connect" not in content | |
| 257 | + | |
| 258 | + def test_edit_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror): | |
| 259 | + mock = _make_reader_mock() | |
| 260 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 261 | + response = admin_client.post( | |
| 262 | + f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/", | |
| 263 | + { | |
| 264 | + "action": "update", | |
| 265 | + "git_remote_url": "https://github.com/org/updated-repo.git", | |
| 266 | + "auth_method": "ssh", | |
| 267 | + "sync_direction": "both", | |
| 268 | + "sync_mode": "both", | |
| 269 | + "sync_schedule": "0 */2 * * *", | |
| 270 | + "git_branch": "develop", | |
| 271 | + "fossil_branch": "trunk", | |
| 272 | + }, | |
| 273 | + ) | |
| 274 | + assert response.status_code == 302 | |
| 275 | + mirror.refresh_from_db() | |
| 276 | + assert mirror.git_remote_url == "https://github.com/org/updated-repo.git" | |
| 277 | + assert mirror.auth_method == "ssh" | |
| 278 | + assert mirror.sync_direction == "both" | |
| 279 | + assert mirror.sync_schedule == "0 */2 * * *" | |
| 280 | + assert mirror.git_branch == "develop" | |
| 281 | + | |
| 282 | + def test_edit_preserves_credential_when_blank(self, admin_client, sample_project, fossil_repo_obj, mirror): | |
| 283 | + """Editing without providing a new credential should keep the old one.""" | |
| 284 | + old_credential = mirror.auth_credential | |
| 285 | + mock = _make_reader_mock() | |
| 286 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 287 | + response = admin_client.post( | |
| 288 | + f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/", | |
| 289 | + { | |
| 290 | + "action": "update", | |
| 291 | + "git_remote_url": "https://github.com/org/repo.git", | |
| 292 | + "auth_method": "token", | |
| 293 | + "auth_credential": "", | |
| 294 | + "sync_direction": "push", | |
| 295 | + "sync_mode": "scheduled", | |
| 296 | + "sync_schedule": "*/15 * * * *", | |
| 297 | + "git_branch": "main", | |
| 298 | + "fossil_branch": "trunk", | |
| 299 | + }, | |
| 300 | + ) | |
| 301 | + assert response.status_code == 302 | |
| 302 | + mirror.refresh_from_db() | |
| 303 | + assert mirror.auth_credential == old_credential | |
| 304 | + | |
| 305 | + def test_edit_nonexistent_mirror(self, admin_client, sample_project, fossil_repo_obj): | |
| 306 | + mock = _make_reader_mock() | |
| 307 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 308 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/99999/edit/") | |
| 309 | + assert response.status_code == 404 | |
| 310 | + | |
| 311 | + def test_edit_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror): | |
| 312 | + response = writer_client.post( | |
| 313 | + f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/", | |
| 314 | + {"action": "update", "git_remote_url": "https://evil.com/repo.git"}, | |
| 315 | + ) | |
| 316 | + assert response.status_code == 403 | |
| 317 | + | |
| 318 | + | |
| 319 | +# --- Git Mirror Delete View --- | |
| 320 | + | |
| 321 | + | |
| 322 | +@pytest.mark.django_db | |
| 323 | +class TestGitMirrorDeleteView: | |
| 324 | + def test_get_delete_confirmation(self, admin_client, sample_project, fossil_repo_obj, mirror): | |
| 325 | + mock = _make_reader_mock() | |
| 326 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 327 | + response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/") | |
| 328 | + assert response.status_code == 200 | |
| 329 | + content = response.content.decode() | |
| 330 | + assert "Delete Git Mirror" in content | |
| 331 | + assert "github.com/org/repo.git" in content | |
| 332 | + | |
| 333 | + def test_delete_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror): | |
| 334 | + mock = _make_reader_mock() | |
| 335 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 336 | + response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/") | |
| 337 | + assert response.status_code == 302 | |
| 338 | + mirror.refresh_from_db() | |
| 339 | + assert mirror.is_deleted | |
| 340 | + | |
| 341 | + def test_delete_removes_from_active_queries(self, admin_client, sample_project, fossil_repo_obj, mirror): | |
| 342 | + mock = _make_reader_mock() | |
| 343 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 344 | + admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/") | |
| 345 | + assert GitMirror.objects.filter(pk=mirror.pk).count() == 0 | |
| 346 | + assert GitMirror.all_objects.filter(pk=mirror.pk).count() == 1 | |
| 347 | + | |
| 348 | + def test_delete_nonexistent_mirror(self, admin_client, sample_project, fossil_repo_obj): | |
| 349 | + mock = _make_reader_mock() | |
| 350 | + with _disk_patch, patch("fossil.views.FossilReader", mock): | |
| 351 | + response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/99999/delete/") | |
| 352 | + assert response.status_code == 404 | |
| 353 | + | |
| 354 | + def test_delete_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror): | |
| 355 | + response = writer_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/") | |
| 356 | + assert response.status_code == 403 | |
| 357 | + | |
| 358 | + def test_delete_denied_for_anon(self, client, sample_project, mirror): | |
| 359 | + response = client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/") | |
| 360 | + assert response.status_code == 302 # redirect to login | |
| 361 | + | |
| 362 | + | |
| 363 | +# --- Git Mirror Run View --- | |
| 364 | + | |
| 365 | + | |
| 366 | +@pytest.mark.django_db | |
| 367 | +class TestGitMirrorRunView: | |
| 368 | + def test_run_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror): | |
| 369 | + mock_reader = _make_reader_mock() | |
| 370 | + mock_task = MagicMock() | |
| 371 | + with _disk_patch, patch("fossil.views.FossilReader", mock_reader), patch("fossil.tasks.run_git_sync", mock_task): | |
| 372 | + response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/") | |
| 373 | + assert response.status_code == 302 | |
| 374 | + mock_task.delay.assert_called_once_with(mirror.pk) | |
| 375 | + | |
| 376 | + def test_run_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror): | |
| 377 | + response = writer_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/") | |
| 378 | + assert response.status_code == 403 | |
| 379 | + | |
| 380 | + def test_run_denied_for_anon(self, client, sample_project, mirror): | |
| 381 | + response = client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/") | |
| 382 | + assert response.status_code == 302 # redirect to login |
| --- a/tests/test_git_mirrors.py | |
| +++ b/tests/test_git_mirrors.py | |
| @@ -0,0 +1,382 @@ | |
| --- a/tests/test_git_mirrors.py | |
| +++ b/tests/test_git_mirrors.py | |
| @@ -0,0 +1,382 @@ | |
| 1 | """Tests for Git mirror multi-remote sync UI views.""" |
| 2 | |
| 3 | from unittest.mock import MagicMock, patch |
| 4 | |
| 5 | import pytest |
| 6 | from django.contrib.auth.models import User |
| 7 | from django.test import Client |
| 8 | |
| 9 | from fossil.models import FossilRepository |
| 10 | from fossil.sync_models import GitMirror |
| 11 | from organization.models import Team |
| 12 | from projects.models import ProjectTeam |
| 13 | |
| 14 | # Reusable patch that makes FossilRepository.exists_on_disk return True |
| 15 | _disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True)) |
| 16 | |
| 17 | |
| 18 | def _make_reader_mock(**methods): |
| 19 | """Create a MagicMock that replaces FossilReader as a class.""" |
| 20 | mock_cls = MagicMock() |
| 21 | instance = MagicMock() |
| 22 | mock_cls.return_value = instance |
| 23 | instance.__enter__ = MagicMock(return_value=instance) |
| 24 | instance.__exit__ = MagicMock(return_value=False) |
| 25 | for name, val in methods.items(): |
| 26 | getattr(instance, name).return_value = val |
| 27 | return mock_cls |
| 28 | |
| 29 | |
| 30 | @pytest.fixture |
| 31 | def fossil_repo_obj(sample_project): |
| 32 | """Return the auto-created FossilRepository for sample_project.""" |
| 33 | return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True) |
| 34 | |
| 35 | |
| 36 | @pytest.fixture |
| 37 | def mirror(fossil_repo_obj, admin_user): |
| 38 | return GitMirror.objects.create( |
| 39 | repository=fossil_repo_obj, |
| 40 | git_remote_url="https://github.com/org/repo.git", |
| 41 | auth_method="token", |
| 42 | auth_credential="ghp_test123", |
| 43 | sync_direction="push", |
| 44 | sync_mode="scheduled", |
| 45 | sync_schedule="*/15 * * * *", |
| 46 | git_branch="main", |
| 47 | fossil_branch="trunk", |
| 48 | created_by=admin_user, |
| 49 | ) |
| 50 | |
| 51 | |
| 52 | @pytest.fixture |
| 53 | def second_mirror(fossil_repo_obj, admin_user): |
| 54 | return GitMirror.objects.create( |
| 55 | repository=fossil_repo_obj, |
| 56 | git_remote_url="https://gitlab.com/org/repo.git", |
| 57 | auth_method="oauth_gitlab", |
| 58 | sync_direction="both", |
| 59 | sync_mode="both", |
| 60 | sync_schedule="0 */6 * * *", |
| 61 | git_branch="main", |
| 62 | fossil_branch="trunk", |
| 63 | created_by=admin_user, |
| 64 | ) |
| 65 | |
| 66 | |
| 67 | @pytest.fixture |
| 68 | def writer_user(db, admin_user, sample_project): |
| 69 | """User with write access but not admin.""" |
| 70 | writer = User.objects.create_user(username="mirror_writer", password="testpass123") |
| 71 | team = Team.objects.create(name="Mirror Writers", organization=sample_project.organization, created_by=admin_user) |
| 72 | team.members.add(writer) |
| 73 | ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user) |
| 74 | return writer |
| 75 | |
| 76 | |
| 77 | @pytest.fixture |
| 78 | def writer_client(writer_user): |
| 79 | client = Client() |
| 80 | client.login(username="mirror_writer", password="testpass123") |
| 81 | return client |
| 82 | |
| 83 | |
| 84 | # --- GitMirror Model Tests --- |
| 85 | |
| 86 | |
| 87 | @pytest.mark.django_db |
| 88 | class TestGitMirrorModel: |
| 89 | def test_create_mirror(self, mirror): |
| 90 | assert mirror.pk is not None |
| 91 | assert mirror.git_remote_url == "https://github.com/org/repo.git" |
| 92 | assert mirror.sync_direction == "push" |
| 93 | |
| 94 | def test_str_representation(self, mirror): |
| 95 | assert "github.com/org/repo.git" in str(mirror) |
| 96 | |
| 97 | def test_soft_delete(self, mirror, admin_user): |
| 98 | mirror.soft_delete(user=admin_user) |
| 99 | assert mirror.is_deleted |
| 100 | assert GitMirror.objects.filter(pk=mirror.pk).count() == 0 |
| 101 | assert GitMirror.all_objects.filter(pk=mirror.pk).count() == 1 |
| 102 | |
| 103 | def test_multiple_mirrors_per_repo(self, mirror, second_mirror, fossil_repo_obj): |
| 104 | mirrors = GitMirror.objects.filter(repository=fossil_repo_obj) |
| 105 | assert mirrors.count() == 2 |
| 106 | |
| 107 | def test_ordering(self, mirror, second_mirror): |
| 108 | """Mirrors are ordered newest first.""" |
| 109 | mirrors = list(GitMirror.objects.all()) |
| 110 | assert mirrors[0] == second_mirror |
| 111 | assert mirrors[1] == mirror |
| 112 | |
| 113 | |
| 114 | # --- Sync Page (sync.html) showing mirrors --- |
| 115 | |
| 116 | |
| 117 | @pytest.mark.django_db |
| 118 | class TestSyncPageMirrorListing: |
| 119 | def test_sync_page_shows_mirrors(self, admin_client, sample_project, fossil_repo_obj, mirror, second_mirror): |
| 120 | mock = _make_reader_mock() |
| 121 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 122 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/") |
| 123 | assert response.status_code == 200 |
| 124 | content = response.content.decode() |
| 125 | assert "github.com/org/repo.git" in content |
| 126 | assert "gitlab.com/org/repo.git" in content |
| 127 | assert "Git Mirrors" in content |
| 128 | |
| 129 | def test_sync_page_shows_empty_state(self, admin_client, sample_project, fossil_repo_obj): |
| 130 | mock = _make_reader_mock() |
| 131 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 132 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/") |
| 133 | assert response.status_code == 200 |
| 134 | content = response.content.decode() |
| 135 | assert "No Git mirrors configured" in content |
| 136 | |
| 137 | def test_sync_page_shows_add_mirror_button(self, admin_client, sample_project, fossil_repo_obj): |
| 138 | mock = _make_reader_mock() |
| 139 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 140 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/") |
| 141 | assert response.status_code == 200 |
| 142 | assert "Add Mirror" in response.content.decode() |
| 143 | |
| 144 | def test_sync_page_shows_mirror_direction_badges(self, admin_client, sample_project, fossil_repo_obj, mirror, second_mirror): |
| 145 | mock = _make_reader_mock() |
| 146 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 147 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/") |
| 148 | content = response.content.decode() |
| 149 | # Check direction labels rendered |
| 150 | assert "Push" in content |
| 151 | assert "Bidirectional" in content |
| 152 | |
| 153 | def test_sync_page_shows_edit_delete_links(self, admin_client, sample_project, fossil_repo_obj, mirror): |
| 154 | mock = _make_reader_mock() |
| 155 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 156 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/") |
| 157 | content = response.content.decode() |
| 158 | assert "Edit" in content |
| 159 | assert "Delete" in content |
| 160 | assert "Run Now" in content |
| 161 | |
| 162 | def test_sync_page_denied_for_anon(self, client, sample_project): |
| 163 | response = client.get(f"/projects/{sample_project.slug}/fossil/sync/") |
| 164 | assert response.status_code == 302 # redirect to login |
| 165 | |
| 166 | |
| 167 | # --- Git Mirror Config View (Add) --- |
| 168 | |
| 169 | |
| 170 | @pytest.mark.django_db |
| 171 | class TestGitMirrorAddView: |
| 172 | def test_get_add_form(self, admin_client, sample_project, fossil_repo_obj): |
| 173 | mock = _make_reader_mock() |
| 174 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 175 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/") |
| 176 | assert response.status_code == 200 |
| 177 | content = response.content.decode() |
| 178 | assert "Add Git Mirror" in content |
| 179 | assert "Quick Connect" in content |
| 180 | |
| 181 | def test_create_mirror(self, admin_client, sample_project, fossil_repo_obj): |
| 182 | mock = _make_reader_mock() |
| 183 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 184 | response = admin_client.post( |
| 185 | f"/projects/{sample_project.slug}/fossil/sync/git/", |
| 186 | { |
| 187 | "action": "create", |
| 188 | "git_remote_url": "https://github.com/test/new-repo.git", |
| 189 | "auth_method": "token", |
| 190 | "auth_credential": "ghp_newtoken", |
| 191 | "sync_direction": "push", |
| 192 | "sync_mode": "scheduled", |
| 193 | "sync_schedule": "*/30 * * * *", |
| 194 | "git_branch": "main", |
| 195 | "fossil_branch": "trunk", |
| 196 | }, |
| 197 | ) |
| 198 | assert response.status_code == 302 |
| 199 | mirror = GitMirror.objects.get(git_remote_url="https://github.com/test/new-repo.git") |
| 200 | assert mirror.sync_direction == "push" |
| 201 | assert mirror.sync_schedule == "*/30 * * * *" |
| 202 | assert mirror.fossil_branch == "trunk" |
| 203 | |
| 204 | def test_create_mirror_with_sync_tickets(self, admin_client, sample_project, fossil_repo_obj): |
| 205 | mock = _make_reader_mock() |
| 206 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 207 | response = admin_client.post( |
| 208 | f"/projects/{sample_project.slug}/fossil/sync/git/", |
| 209 | { |
| 210 | "action": "create", |
| 211 | "git_remote_url": "https://github.com/test/tickets-repo.git", |
| 212 | "auth_method": "token", |
| 213 | "sync_direction": "push", |
| 214 | "sync_mode": "scheduled", |
| 215 | "sync_schedule": "*/15 * * * *", |
| 216 | "git_branch": "main", |
| 217 | "fossil_branch": "trunk", |
| 218 | "sync_tickets": "on", |
| 219 | "sync_wiki": "on", |
| 220 | }, |
| 221 | ) |
| 222 | assert response.status_code == 302 |
| 223 | mirror = GitMirror.objects.get(git_remote_url="https://github.com/test/tickets-repo.git") |
| 224 | assert mirror.sync_tickets is True |
| 225 | assert mirror.sync_wiki is True |
| 226 | |
| 227 | def test_create_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj): |
| 228 | response = writer_client.post( |
| 229 | f"/projects/{sample_project.slug}/fossil/sync/git/", |
| 230 | {"action": "create", "git_remote_url": "https://evil.com/repo.git"}, |
| 231 | ) |
| 232 | assert response.status_code == 403 |
| 233 | |
| 234 | def test_create_denied_for_anon(self, client, sample_project): |
| 235 | response = client.post( |
| 236 | f"/projects/{sample_project.slug}/fossil/sync/git/", |
| 237 | {"action": "create", "git_remote_url": "https://example.com/repo.git"}, |
| 238 | ) |
| 239 | assert response.status_code == 302 # redirect to login |
| 240 | |
| 241 | |
| 242 | # --- Git Mirror Config View (Edit) --- |
| 243 | |
| 244 | |
| 245 | @pytest.mark.django_db |
| 246 | class TestGitMirrorEditView: |
| 247 | def test_get_edit_form(self, admin_client, sample_project, fossil_repo_obj, mirror): |
| 248 | mock = _make_reader_mock() |
| 249 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 250 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/") |
| 251 | assert response.status_code == 200 |
| 252 | content = response.content.decode() |
| 253 | assert "Edit Git Mirror" in content |
| 254 | assert "github.com/org/repo.git" in content |
| 255 | # Should NOT show quick connect section when editing |
| 256 | assert "Quick Connect" not in content |
| 257 | |
| 258 | def test_edit_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror): |
| 259 | mock = _make_reader_mock() |
| 260 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 261 | response = admin_client.post( |
| 262 | f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/", |
| 263 | { |
| 264 | "action": "update", |
| 265 | "git_remote_url": "https://github.com/org/updated-repo.git", |
| 266 | "auth_method": "ssh", |
| 267 | "sync_direction": "both", |
| 268 | "sync_mode": "both", |
| 269 | "sync_schedule": "0 */2 * * *", |
| 270 | "git_branch": "develop", |
| 271 | "fossil_branch": "trunk", |
| 272 | }, |
| 273 | ) |
| 274 | assert response.status_code == 302 |
| 275 | mirror.refresh_from_db() |
| 276 | assert mirror.git_remote_url == "https://github.com/org/updated-repo.git" |
| 277 | assert mirror.auth_method == "ssh" |
| 278 | assert mirror.sync_direction == "both" |
| 279 | assert mirror.sync_schedule == "0 */2 * * *" |
| 280 | assert mirror.git_branch == "develop" |
| 281 | |
| 282 | def test_edit_preserves_credential_when_blank(self, admin_client, sample_project, fossil_repo_obj, mirror): |
| 283 | """Editing without providing a new credential should keep the old one.""" |
| 284 | old_credential = mirror.auth_credential |
| 285 | mock = _make_reader_mock() |
| 286 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 287 | response = admin_client.post( |
| 288 | f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/", |
| 289 | { |
| 290 | "action": "update", |
| 291 | "git_remote_url": "https://github.com/org/repo.git", |
| 292 | "auth_method": "token", |
| 293 | "auth_credential": "", |
| 294 | "sync_direction": "push", |
| 295 | "sync_mode": "scheduled", |
| 296 | "sync_schedule": "*/15 * * * *", |
| 297 | "git_branch": "main", |
| 298 | "fossil_branch": "trunk", |
| 299 | }, |
| 300 | ) |
| 301 | assert response.status_code == 302 |
| 302 | mirror.refresh_from_db() |
| 303 | assert mirror.auth_credential == old_credential |
| 304 | |
| 305 | def test_edit_nonexistent_mirror(self, admin_client, sample_project, fossil_repo_obj): |
| 306 | mock = _make_reader_mock() |
| 307 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 308 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/99999/edit/") |
| 309 | assert response.status_code == 404 |
| 310 | |
| 311 | def test_edit_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror): |
| 312 | response = writer_client.post( |
| 313 | f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/", |
| 314 | {"action": "update", "git_remote_url": "https://evil.com/repo.git"}, |
| 315 | ) |
| 316 | assert response.status_code == 403 |
| 317 | |
| 318 | |
| 319 | # --- Git Mirror Delete View --- |
| 320 | |
| 321 | |
| 322 | @pytest.mark.django_db |
| 323 | class TestGitMirrorDeleteView: |
| 324 | def test_get_delete_confirmation(self, admin_client, sample_project, fossil_repo_obj, mirror): |
| 325 | mock = _make_reader_mock() |
| 326 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 327 | response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/") |
| 328 | assert response.status_code == 200 |
| 329 | content = response.content.decode() |
| 330 | assert "Delete Git Mirror" in content |
| 331 | assert "github.com/org/repo.git" in content |
| 332 | |
| 333 | def test_delete_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror): |
| 334 | mock = _make_reader_mock() |
| 335 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 336 | response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/") |
| 337 | assert response.status_code == 302 |
| 338 | mirror.refresh_from_db() |
| 339 | assert mirror.is_deleted |
| 340 | |
| 341 | def test_delete_removes_from_active_queries(self, admin_client, sample_project, fossil_repo_obj, mirror): |
| 342 | mock = _make_reader_mock() |
| 343 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 344 | admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/") |
| 345 | assert GitMirror.objects.filter(pk=mirror.pk).count() == 0 |
| 346 | assert GitMirror.all_objects.filter(pk=mirror.pk).count() == 1 |
| 347 | |
| 348 | def test_delete_nonexistent_mirror(self, admin_client, sample_project, fossil_repo_obj): |
| 349 | mock = _make_reader_mock() |
| 350 | with _disk_patch, patch("fossil.views.FossilReader", mock): |
| 351 | response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/99999/delete/") |
| 352 | assert response.status_code == 404 |
| 353 | |
| 354 | def test_delete_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror): |
| 355 | response = writer_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/") |
| 356 | assert response.status_code == 403 |
| 357 | |
| 358 | def test_delete_denied_for_anon(self, client, sample_project, mirror): |
| 359 | response = client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/") |
| 360 | assert response.status_code == 302 # redirect to login |
| 361 | |
| 362 | |
| 363 | # --- Git Mirror Run View --- |
| 364 | |
| 365 | |
| 366 | @pytest.mark.django_db |
| 367 | class TestGitMirrorRunView: |
| 368 | def test_run_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror): |
| 369 | mock_reader = _make_reader_mock() |
| 370 | mock_task = MagicMock() |
| 371 | with _disk_patch, patch("fossil.views.FossilReader", mock_reader), patch("fossil.tasks.run_git_sync", mock_task): |
| 372 | response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/") |
| 373 | assert response.status_code == 302 |
| 374 | mock_task.delay.assert_called_once_with(mirror.pk) |
| 375 | |
| 376 | def test_run_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror): |
| 377 | response = writer_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/") |
| 378 | assert response.status_code == 403 |
| 379 | |
| 380 | def test_run_denied_for_anon(self, client, sample_project, mirror): |
| 381 | response = client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/") |
| 382 | assert response.status_code == 302 # redirect to login |