FossilRepo
Rename auth1 to accounts, remove items app, register all models in admin - auth1 → accounts: same functionality, clearer name. URL prefix /auth/ unchanged. - items app removed entirely: boilerplate leftover, not used by fossilrepo. - All meaningful models now registered in Django admin: Organization, Team, OrganizationMember, Project, ProjectTeam, Page, Notification, ProjectWatch, SyncLog. BaseCoreAdmin uses all_objects so soft-deleted records visible. - Updated permissions, tests, seed data, bootstrap docs, pyproject.toml.
Commit
607da99a073ab957f971df4bbd60c501e32bfd2ce8c9d3d19d1f66d336973f13
Parent
d8ce3f74e2e122a…
50 files changed
+2
-3
+7
+22
+46
+12
+146
-7
-22
-46
-12
-146
+30
-32
+1
-2
+1
-2
+1
-1
+5
-6
+1
-1
+32
-28
+25
-12
-6
-18
-168
-22
-15
-133
-13
-86
+1
+1
-1
+5
-4
+3
-3
+36
+18
-36
-59
+1
-1
-28
-70
-42
-29
-44
+4
-20
~
CLAUDE.md
+
accounts/__init__.py
+
accounts/apps.py
+
accounts/forms.py
+
accounts/migrations/__init__.py
+
accounts/tests.py
+
accounts/urls.py
+
accounts/views.py
-
auth1/__init__.py
-
auth1/apps.py
-
auth1/forms.py
-
auth1/migrations/__init__.py
-
auth1/tests.py
-
auth1/urls.py
-
auth1/views.py
~
bootstrap.md
~
config/settings.py
~
config/urls.py
~
conftest.py
~
core/admin.py
~
core/permissions.py
~
core/templatetags/permissions_tags.py
~
core/tests.py
~
fossil/admin.py
-
items/__init__.py
-
items/admin.py
-
items/apps.py
-
items/forms.py
-
items/migrations/0001_initial.py
-
items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py
-
items/migrations/__init__.py
-
items/models.py
-
items/tests.py
-
items/urls.py
-
items/views.py
~
organization/admin.py
~
pages/admin.py
~
projects/admin.py
~
pyproject.toml
+
templates/accounts/login.html
+
templates/accounts/ssh_keys.html
-
templates/auth1/login.html
-
templates/auth1/ssh_keys.html
~
templates/includes/nav.html
-
templates/items/item_confirm_delete.html
-
templates/items/item_detail.html
-
templates/items/item_form.html
-
templates/items/item_list.html
-
templates/items/partials/item_table.html
~
testdata/management/commands/seed.py
+2
-3
| --- CLAUDE.md | ||
| +++ CLAUDE.md | ||
| @@ -26,13 +26,12 @@ | ||
| 26 | 26 | ## Repository Structure |
| 27 | 27 | |
| 28 | 28 | ``` |
| 29 | 29 | fossilrepo/ |
| 30 | 30 | ├── core/ # Base models, permissions, shared utilities |
| 31 | -├── auth1/ # Authentication | |
| 31 | +├── accounts/ # Authentication | |
| 32 | 32 | ├── organization/ # Org/team management |
| 33 | -├── items/ # Repo item models | |
| 34 | 33 | ├── config/ # Django settings |
| 35 | 34 | ├── templates/ # Django + HTMX templates |
| 36 | 35 | ├── static/ # Static assets |
| 37 | 36 | ├── docker/ # Caddy, Litestream container configs |
| 38 | 37 | ├── fossil-platform/ # Old exploration (Flask + React), kept for reference |
| @@ -45,10 +44,10 @@ | ||
| 45 | 44 | |
| 46 | 45 | - Prefer `Edit` over rewriting whole files. |
| 47 | 46 | - Run `ruff check .` and `ruff format --check .` before committing. |
| 48 | 47 | - Never expose integer PKs in URLs or templates -- use `slug` or `guid`. |
| 49 | 48 | - Auth check at the top of every view -- use `@login_required` + `P.PERMISSION.check(request.user)`. |
| 50 | -- Soft-delete only: call `item.soft_delete(user=request.user)`, never `.delete()`. | |
| 49 | +- Soft-delete only: call `obj.soft_delete(user=request.user)`, never `.delete()`. | |
| 51 | 50 | - HTMX partials: check `request.headers.get("HX-Request")` to return partial vs full page. |
| 52 | 51 | - CSRF: HTMX requests include CSRF token via `htmx:configRequest` event in `base.html`. |
| 53 | 52 | - Tests: pytest + real Postgres, assert against DB state. Both allowed and denied permission cases. |
| 54 | 53 | - Fossil is the source of truth; Git remotes are downstream mirrors. |
| 55 | 54 | |
| 56 | 55 | ADDED accounts/__init__.py |
| 57 | 56 | ADDED accounts/apps.py |
| 58 | 57 | ADDED accounts/forms.py |
| 59 | 58 | ADDED accounts/migrations/__init__.py |
| 60 | 59 | ADDED accounts/tests.py |
| 61 | 60 | ADDED accounts/urls.py |
| 62 | 61 | ADDED accounts/views.py |
| 63 | 62 | DELETED auth1/__init__.py |
| 64 | 63 | DELETED auth1/apps.py |
| 65 | 64 | DELETED auth1/forms.py |
| 66 | 65 | DELETED auth1/migrations/__init__.py |
| 67 | 66 | DELETED auth1/tests.py |
| 68 | 67 | DELETED auth1/urls.py |
| 69 | 68 | DELETED auth1/views.py |
| --- CLAUDE.md | |
| +++ CLAUDE.md | |
| @@ -26,13 +26,12 @@ | |
| 26 | ## Repository Structure |
| 27 | |
| 28 | ``` |
| 29 | fossilrepo/ |
| 30 | ├── core/ # Base models, permissions, shared utilities |
| 31 | ├── auth1/ # Authentication |
| 32 | ├── organization/ # Org/team management |
| 33 | ├── items/ # Repo item models |
| 34 | ├── config/ # Django settings |
| 35 | ├── templates/ # Django + HTMX templates |
| 36 | ├── static/ # Static assets |
| 37 | ├── docker/ # Caddy, Litestream container configs |
| 38 | ├── fossil-platform/ # Old exploration (Flask + React), kept for reference |
| @@ -45,10 +44,10 @@ | |
| 45 | |
| 46 | - Prefer `Edit` over rewriting whole files. |
| 47 | - Run `ruff check .` and `ruff format --check .` before committing. |
| 48 | - Never expose integer PKs in URLs or templates -- use `slug` or `guid`. |
| 49 | - Auth check at the top of every view -- use `@login_required` + `P.PERMISSION.check(request.user)`. |
| 50 | - Soft-delete only: call `item.soft_delete(user=request.user)`, never `.delete()`. |
| 51 | - HTMX partials: check `request.headers.get("HX-Request")` to return partial vs full page. |
| 52 | - CSRF: HTMX requests include CSRF token via `htmx:configRequest` event in `base.html`. |
| 53 | - Tests: pytest + real Postgres, assert against DB state. Both allowed and denied permission cases. |
| 54 | - Fossil is the source of truth; Git remotes are downstream mirrors. |
| 55 | |
| 56 | DDED accounts/__init__.py |
| 57 | DDED accounts/apps.py |
| 58 | DDED accounts/forms.py |
| 59 | DDED accounts/migrations/__init__.py |
| 60 | DDED accounts/tests.py |
| 61 | DDED accounts/urls.py |
| 62 | DDED accounts/views.py |
| 63 | ELETED auth1/__init__.py |
| 64 | ELETED auth1/apps.py |
| 65 | ELETED auth1/forms.py |
| 66 | ELETED auth1/migrations/__init__.py |
| 67 | ELETED auth1/tests.py |
| 68 | ELETED auth1/urls.py |
| 69 | ELETED auth1/views.py |
| --- CLAUDE.md | |
| +++ CLAUDE.md | |
| @@ -26,13 +26,12 @@ | |
| 26 | ## Repository Structure |
| 27 | |
| 28 | ``` |
| 29 | fossilrepo/ |
| 30 | ├── core/ # Base models, permissions, shared utilities |
| 31 | ├── accounts/ # Authentication |
| 32 | ├── organization/ # Org/team management |
| 33 | ├── config/ # Django settings |
| 34 | ├── templates/ # Django + HTMX templates |
| 35 | ├── static/ # Static assets |
| 36 | ├── docker/ # Caddy, Litestream container configs |
| 37 | ├── fossil-platform/ # Old exploration (Flask + React), kept for reference |
| @@ -45,10 +44,10 @@ | |
| 44 | |
| 45 | - Prefer `Edit` over rewriting whole files. |
| 46 | - Run `ruff check .` and `ruff format --check .` before committing. |
| 47 | - Never expose integer PKs in URLs or templates -- use `slug` or `guid`. |
| 48 | - Auth check at the top of every view -- use `@login_required` + `P.PERMISSION.check(request.user)`. |
| 49 | - Soft-delete only: call `obj.soft_delete(user=request.user)`, never `.delete()`. |
| 50 | - HTMX partials: check `request.headers.get("HX-Request")` to return partial vs full page. |
| 51 | - CSRF: HTMX requests include CSRF token via `htmx:configRequest` event in `base.html`. |
| 52 | - Tests: pytest + real Postgres, assert against DB state. Both allowed and denied permission cases. |
| 53 | - Fossil is the source of truth; Git remotes are downstream mirrors. |
| 54 | |
| 55 | DDED accounts/__init__.py |
| 56 | DDED accounts/apps.py |
| 57 | DDED accounts/forms.py |
| 58 | DDED accounts/migrations/__init__.py |
| 59 | DDED accounts/tests.py |
| 60 | DDED accounts/urls.py |
| 61 | DDED accounts/views.py |
| 62 | ELETED auth1/__init__.py |
| 63 | ELETED auth1/apps.py |
| 64 | ELETED auth1/forms.py |
| 65 | ELETED auth1/migrations/__init__.py |
| 66 | ELETED auth1/tests.py |
| 67 | ELETED auth1/urls.py |
| 68 | ELETED auth1/views.py |
No diff available
+7
| --- a/accounts/apps.py | ||
| +++ b/accounts/apps.py | ||
| @@ -0,0 +1,7 @@ | ||
| 1 | +from django.apps import AppConfig | |
| 2 | + | |
| 3 | + | |
| 4 | +class Auth1Config(AppConfig): | |
| 5 | + default_auto_field = "django.db.models.BigAutoField" | |
| 6 | + name = "accounts" | |
| 7 | + verbose_name = "Authentication" |
| --- a/accounts/apps.py | |
| +++ b/accounts/apps.py | |
| @@ -0,0 +1,7 @@ | |
| --- a/accounts/apps.py | |
| +++ b/accounts/apps.py | |
| @@ -0,0 +1,7 @@ | |
| 1 | from django.apps import AppConfig |
| 2 | |
| 3 | |
| 4 | class Auth1Config(AppConfig): |
| 5 | default_auto_field = "django.db.models.BigAutoField" |
| 6 | name = "accounts" |
| 7 | verbose_name = "Authentication" |
+22
| --- a/accounts/forms.py | ||
| +++ b/accounts/forms.py | ||
| @@ -0,0 +1,22 @@ | ||
| 1 | +from django import forms | |
| 2 | +from django.contrib.auth.forms import AuthenticationForm | |
| 3 | + | |
| 4 | + | |
| 5 | +class LoginForm(AuthenticationForm): | |
| 6 | + username = forms.CharField( | |
| 7 | + widget=forms.TextInput( | |
| 8 | + attrs={ | |
| 9 | + "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand", | |
| 10 | + "placeholder": "Username", | |
| 11 | + "autofocus": True, | |
| 12 | + } | |
| 13 | + ) | |
| 14 | + ) | |
| 15 | + password = forms.CharField( | |
| 16 | + widget=forms.PasswordInput( | |
| 17 | + attrs={ | |
| 18 | + "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand", | |
| 19 | + "placeholder": "Password", | |
| 20 | + } | |
| 21 | + ) | |
| 22 | + ) |
| --- a/accounts/forms.py | |
| +++ b/accounts/forms.py | |
| @@ -0,0 +1,22 @@ | |
| --- a/accounts/forms.py | |
| +++ b/accounts/forms.py | |
| @@ -0,0 +1,22 @@ | |
| 1 | from django import forms |
| 2 | from django.contrib.auth.forms import AuthenticationForm |
| 3 | |
| 4 | |
| 5 | class LoginForm(AuthenticationForm): |
| 6 | username = forms.CharField( |
| 7 | widget=forms.TextInput( |
| 8 | attrs={ |
| 9 | "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand", |
| 10 | "placeholder": "Username", |
| 11 | "autofocus": True, |
| 12 | } |
| 13 | ) |
| 14 | ) |
| 15 | password = forms.CharField( |
| 16 | widget=forms.PasswordInput( |
| 17 | attrs={ |
| 18 | "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand", |
| 19 | "placeholder": "Password", |
| 20 | } |
| 21 | ) |
| 22 | ) |
No diff available
+46
| --- a/accounts/tests.py | ||
| +++ b/accounts/tests.py | ||
| @@ -0,0 +1,46 @@ | ||
| 1 | +import pytest | |
| 2 | +from dlAccessToken, UserProfile | |
| 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")) | |
| 9 | + assert response.status_code == 200 | |
| 10 | + assert b"Sign in" in response.content | |
| 11 | + | |
| 12 | + def test_login_success_redirects_to_dashboard(self, client, admin_user): | |
| 13 | + response = client.post(reverse("accounts:login"), {"username": "admin", "password": "testpass123"}) | |
| 14 | + assert response.status_code == 302 | |
| 15 | + assert response.url == reverse("dashboard") | |
| 16 | + | |
| 17 | + def test_login_failure_shows_error(self, client, admin_user): | |
| 18 | + response = client.post(reverse("accounts:login"), {"username": "admin", "password": "wrong"}) | |
| 19 | + assert response.status_code == 200 | |
| 20 | + assert b"Invalid username or password" in response.content | |
| 21 | + | |
| 22 | + def test_login_redirect_when_already_authenticated(self, admin_client): | |
| 23 | + response = admin_client.get(reverse("accounts:login")) | |
| 24 | + assert response.status_code == 302 | |
| 25 | + | |
| 26 | + def test_login_with_next_param(self, client, admin_user): | |
| 27 | + response = client.post(reverse("accounts:login") + "?next=/projects/", {"username": "admin", "password": "testpass123"}) | |
| 28 | + assert response.status_code == 302 | |
| 29 | + assert response.url == "/projects/" | |
| 30 | + | |
| 31 | + | |
| 32 | +@pytest.mark.django_db | |
| 33 | +class TestLogout: | |
| 34 | + def test_logout_redirects_to_login(self, admin_client): | |
| 35 | + response = admin_client.post(reverse("accounts:logout")) | |
| 36 | + assert response.status_code == 302 | |
| 37 | + assert reverse("accounts:login") in response.url | |
| 38 | + | |
| 39 | + def test_logout_clears_session(self, admin_client): | |
| 40 | + admin_client.post(reverse("accounts:logout")) | |
| 41 | + response = admin_client.get(reverse("dashboard")) | |
| 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 r |
| --- a/accounts/tests.py | |
| +++ b/accounts/tests.py | |
| @@ -0,0 +1,46 @@ | |
| --- a/accounts/tests.py | |
| +++ b/accounts/tests.py | |
| @@ -0,0 +1,46 @@ | |
| 1 | import pytest |
| 2 | from dlAccessToken, UserProfile |
| 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")) |
| 9 | assert response.status_code == 200 |
| 10 | assert b"Sign in" in response.content |
| 11 | |
| 12 | def test_login_success_redirects_to_dashboard(self, client, admin_user): |
| 13 | response = client.post(reverse("accounts:login"), {"username": "admin", "password": "testpass123"}) |
| 14 | assert response.status_code == 302 |
| 15 | assert response.url == reverse("dashboard") |
| 16 | |
| 17 | def test_login_failure_shows_error(self, client, admin_user): |
| 18 | response = client.post(reverse("accounts:login"), {"username": "admin", "password": "wrong"}) |
| 19 | assert response.status_code == 200 |
| 20 | assert b"Invalid username or password" in response.content |
| 21 | |
| 22 | def test_login_redirect_when_already_authenticated(self, admin_client): |
| 23 | response = admin_client.get(reverse("accounts:login")) |
| 24 | assert response.status_code == 302 |
| 25 | |
| 26 | def test_login_with_next_param(self, client, admin_user): |
| 27 | response = client.post(reverse("accounts:login") + "?next=/projects/", {"username": "admin", "password": "testpass123"}) |
| 28 | assert response.status_code == 302 |
| 29 | assert response.url == "/projects/" |
| 30 | |
| 31 | |
| 32 | @pytest.mark.django_db |
| 33 | class TestLogout: |
| 34 | def test_logout_redirects_to_login(self, admin_client): |
| 35 | response = admin_client.post(reverse("accounts:logout")) |
| 36 | assert response.status_code == 302 |
| 37 | assert reverse("accounts:login") in response.url |
| 38 | |
| 39 | def test_logout_clears_session(self, admin_client): |
| 40 | admin_client.post(reverse("accounts:logout")) |
| 41 | response = admin_client.get(reverse("dashboard")) |
| 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 r |
+12
| --- a/accounts/urls.py | ||
| +++ b/accounts/urls.py | ||
| @@ -0,0 +1,12 @@ | ||
| 1 | +from django.urls import path | |
| 2 | + | |
| 3 | +from . import views | |
| 4 | + | |
| 5 | +app_name = "accounts" | |
| 6 | + | |
| 7 | +urlpatterns = [ | |
| 8 | + path("login/", views.login_view, name="login"), | |
| 9 | + path("logout/", views.logout_view, name="logout"), | |
| 10 | + path("ssh-keys/", views.ssh_keys, name="ssh_keys"), | |
| 11 | + path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"), | |
| 12 | +] |
| --- a/accounts/urls.py | |
| +++ b/accounts/urls.py | |
| @@ -0,0 +1,12 @@ | |
| --- a/accounts/urls.py | |
| +++ b/accounts/urls.py | |
| @@ -0,0 +1,12 @@ | |
| 1 | from django.urls import path |
| 2 | |
| 3 | from . import views |
| 4 | |
| 5 | app_name = "accounts" |
| 6 | |
| 7 | urlpatterns = [ |
| 8 | path("login/", views.login_view, name="login"), |
| 9 | path("logout/", views.logout_view, name="logout"), |
| 10 | path("ssh-keys/", views.ssh_keys, name="ssh_keys"), |
| 11 | path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"), |
| 12 | ] |
+146
| --- a/accounts/views.py | ||
| +++ b/accounts/views.py | ||
| @@ -0,0 +1,146 @@ | ||
| 1 | +from django.contrib import messages | |
| 2 | +from django.contrib.auth import login, logout | |
| 3 | +from django.contrib.auth.decorators import login_required | |
| 4 | +from django.http import HttpResponse | |
| 5 | +from django.shortcuts import get_object_or_404, redirect, render | |
| 6 | +from django.views.decorators.http import require_POST | |
| 7 | +from django_ratelimit.decorators import ratelimit | |
| 8 | + | |
| 9 | +from .forms import LoginForm | |
| 10 | + | |
| 11 | + | |
| 12 | +@ratelimit(key="ip", rate="10/m", block=True) | |
| 13 | +def login_view(request): | |
| 14 | + if request.user.is_authenticated: | |
| 15 | + return redirect("dashboard") | |
| 16 | + | |
| 17 | + if request.method == "POST": | |
| 18 | + form = LoginForm(request, data=request.POST) | |
| 19 | + if form.is_valid(): | |
| 20 | + login(request, form.get_user()) | |
| 21 | + next_url = request.GET.get("next", "dashboard") | |
| 22 | + return redirect(next_url) | |
| 23 | + else: | |
| 24 | + form = LoginForm() | |
| 25 | + | |
| 26 | + return render(request, "accounts/login.html", {"form": form}) | |
| 27 | + | |
| 28 | + | |
| 29 | +@require_POST | |
| 30 | +def logout_view(request): | |
| 31 | + logout(request) | |
| 32 | + return redirect("accounts:login") | |
| 33 | + | |
| 34 | + | |
| 35 | +# --------------------------------------------------------------------------- | |
| 36 | +# SSH key management | |
| 37 | +# --------------------------------------------------------------------------- | |
| 38 | + | |
| 39 | + | |
| 40 | +def _parse_key_type(public_key): | |
| 41 | + """Extract key type from public key string.""" | |
| 42 | + parts = public_key.strip().split() | |
| 43 | + if parts: | |
| 44 | + key_prefix = parts[0] | |
| 45 | + type_map = { | |
| 46 | + "ssh-ed25519": "ed25519", | |
| 47 | + "ssh-rsa": "rsa", | |
| 48 | + "ecdsa-sha2-nistp256": "ecdsa", | |
| 49 | + "ecdsa-sha2-nistp384": "ecdsa", | |
| 50 | + "ecdsa-sha2-nistp521": "ecdsa", | |
| 51 | + "ssh-dss": "dsa", | |
| 52 | + } | |
| 53 | + return type_map.get(key_prefix, key_prefix) | |
| 54 | + return "" | |
| 55 | + | |
| 56 | + | |
| 57 | +def _compute_fingerprint(public_key): | |
| 58 | + """Compute SSH key fingerprint (SHA256).""" | |
| 59 | + import base64 | |
| 60 | + import hashlib | |
| 61 | + | |
| 62 | + parts = public_key.strip().split() | |
| 63 | + if len(parts) >= 2: | |
| 64 | + try: | |
| 65 | + key_data = base64.b64decode(parts[1]) | |
| 66 | + digest = hashlib.sha256(key_data).digest() | |
| 67 | + return "SHA256:" + base64.b64encode(digest).rstrip(b"=").decode() | |
| 68 | + except Exception: | |
| 69 | + pass | |
| 70 | + return "" | |
| 71 | + | |
| 72 | + | |
| 73 | +def _regenerate_authorized_keys(): | |
| 74 | + """Regenerate the authorized_keys file from all active user SSH keys.""" | |
| 75 | + from pathlib import Path | |
| 76 | + | |
| 77 | + from constance import config | |
| 78 | + | |
| 79 | + from fossil.user_keys import UserSSHKey | |
| 80 | + | |
| 81 | + ssh_dir = Path(config.FOSSIL_DATA_DIR).parent / "ssh" | |
| 82 | + ssh_dir.mkdir(parents=True, exist_ok=True) | |
| 83 | + authorized_keys_path = ssh_dir / "authorized_keys" | |
| 84 | + | |
| 85 | + keys = UserSSHKey.objects.filter(deleted_at__isnull=True).select_related("user") | |
| 86 | + | |
| 87 | + lines = [] | |
| 88 | + for key in keys: | |
| 89 | + # Each key gets a forced command that identifies the user | |
| 90 | + forced_cmd = ( | |
| 91 | + f'command="/usr/local/bin/fossil-shell {key.user.username}",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty' | |
| 92 | + ) | |
| 93 | + lines.append(f"{forced_cmd} {key.public_key.strip()}") | |
| 94 | + | |
| 95 | + authorized_keys_path.write_text("\n".join(lines) + "\n" if lines else "") | |
| 96 | + authorized_keys_path.chmod(0o600) | |
| 97 | + | |
| 98 | + | |
| 99 | +@login_required | |
| 100 | +def ssh_keys(request): | |
| 101 | + """List and add SSH keys.""" | |
| 102 | + from fossil.user_keys import UserSSHKey | |
| 103 | + | |
| 104 | + keys = UserSSHKey.objects.filter(user=request.user) | |
| 105 | + | |
| 106 | + if request.method == "POST": | |
| 107 | + title = request.POST.get("title", "").strip() | |
| 108 | + public_key = request.POST.get("public_key", "").strip() | |
| 109 | + | |
| 110 | + if title and public_key: | |
| 111 | + key_type = _parse_key_type(public_key) | |
| 112 | + fingerprint = _compute_fingerprint(public_key) | |
| 113 | + | |
| 114 | + UserSSHKey.objects.create( | |
| 115 | + user=request.user, | |
| 116 | + title=title, | |
| 117 | + public_key=public_key, | |
| 118 | + key_type=key_type, | |
| 119 | + fingerprint=fingerprint, | |
| 120 | + created_by=request.user, | |
| 121 | + ) | |
| 122 | + | |
| 123 | + _regenerate_authorized_keys() | |
| 124 | + | |
| 125 | + messages.success(request, f'SSH key "{title}" added.') | |
| 126 | + return redirect("accounts:ssh_keys") | |
| 127 | + | |
| 128 | + return render(request, "accounts/ssh_keys.html", {"keys": keys}) | |
| 129 | + | |
| 130 | + | |
| 131 | +@login_required | |
| 132 | +@require_POST | |
| 133 | +def ssh_key_delete(request, pk): | |
| 134 | + """Delete an SSH key.""" | |
| 135 | + from fossil.user_keys import UserSSHKey | |
| 136 | + | |
| 137 | + key = get_object_or_404(UserSSHKey, pk=pk, user=request.user) | |
| 138 | + key.soft_delete(user=request.user) | |
| 139 | + _regenerate_authorized_keys() | |
| 140 | + | |
| 141 | + messages.success(request, f'SSH key "{key.title}" removed.') | |
| 142 | + | |
| 143 | + if request.headers.get("HX-Request"): | |
| 144 | + return HttpResponse(status=200, headers={"HX-Redirect": "/auth/ssh-keys/"}) | |
| 145 | + | |
| 146 | + return redirect("accounts:ssh_keys") |
| --- a/accounts/views.py | |
| +++ b/accounts/views.py | |
| @@ -0,0 +1,146 @@ | |
| --- a/accounts/views.py | |
| +++ b/accounts/views.py | |
| @@ -0,0 +1,146 @@ | |
| 1 | from django.contrib import messages |
| 2 | from django.contrib.auth import login, logout |
| 3 | from django.contrib.auth.decorators import login_required |
| 4 | from django.http import HttpResponse |
| 5 | from django.shortcuts import get_object_or_404, redirect, render |
| 6 | from django.views.decorators.http import require_POST |
| 7 | from django_ratelimit.decorators import ratelimit |
| 8 | |
| 9 | from .forms import LoginForm |
| 10 | |
| 11 | |
| 12 | @ratelimit(key="ip", rate="10/m", block=True) |
| 13 | def login_view(request): |
| 14 | if request.user.is_authenticated: |
| 15 | return redirect("dashboard") |
| 16 | |
| 17 | if request.method == "POST": |
| 18 | form = LoginForm(request, data=request.POST) |
| 19 | if form.is_valid(): |
| 20 | login(request, form.get_user()) |
| 21 | next_url = request.GET.get("next", "dashboard") |
| 22 | return redirect(next_url) |
| 23 | else: |
| 24 | form = LoginForm() |
| 25 | |
| 26 | return render(request, "accounts/login.html", {"form": form}) |
| 27 | |
| 28 | |
| 29 | @require_POST |
| 30 | def logout_view(request): |
| 31 | logout(request) |
| 32 | return redirect("accounts:login") |
| 33 | |
| 34 | |
| 35 | # --------------------------------------------------------------------------- |
| 36 | # SSH key management |
| 37 | # --------------------------------------------------------------------------- |
| 38 | |
| 39 | |
| 40 | def _parse_key_type(public_key): |
| 41 | """Extract key type from public key string.""" |
| 42 | parts = public_key.strip().split() |
| 43 | if parts: |
| 44 | key_prefix = parts[0] |
| 45 | type_map = { |
| 46 | "ssh-ed25519": "ed25519", |
| 47 | "ssh-rsa": "rsa", |
| 48 | "ecdsa-sha2-nistp256": "ecdsa", |
| 49 | "ecdsa-sha2-nistp384": "ecdsa", |
| 50 | "ecdsa-sha2-nistp521": "ecdsa", |
| 51 | "ssh-dss": "dsa", |
| 52 | } |
| 53 | return type_map.get(key_prefix, key_prefix) |
| 54 | return "" |
| 55 | |
| 56 | |
| 57 | def _compute_fingerprint(public_key): |
| 58 | """Compute SSH key fingerprint (SHA256).""" |
| 59 | import base64 |
| 60 | import hashlib |
| 61 | |
| 62 | parts = public_key.strip().split() |
| 63 | if len(parts) >= 2: |
| 64 | try: |
| 65 | key_data = base64.b64decode(parts[1]) |
| 66 | digest = hashlib.sha256(key_data).digest() |
| 67 | return "SHA256:" + base64.b64encode(digest).rstrip(b"=").decode() |
| 68 | except Exception: |
| 69 | pass |
| 70 | return "" |
| 71 | |
| 72 | |
| 73 | def _regenerate_authorized_keys(): |
| 74 | """Regenerate the authorized_keys file from all active user SSH keys.""" |
| 75 | from pathlib import Path |
| 76 | |
| 77 | from constance import config |
| 78 | |
| 79 | from fossil.user_keys import UserSSHKey |
| 80 | |
| 81 | ssh_dir = Path(config.FOSSIL_DATA_DIR).parent / "ssh" |
| 82 | ssh_dir.mkdir(parents=True, exist_ok=True) |
| 83 | authorized_keys_path = ssh_dir / "authorized_keys" |
| 84 | |
| 85 | keys = UserSSHKey.objects.filter(deleted_at__isnull=True).select_related("user") |
| 86 | |
| 87 | lines = [] |
| 88 | for key in keys: |
| 89 | # Each key gets a forced command that identifies the user |
| 90 | forced_cmd = ( |
| 91 | f'command="/usr/local/bin/fossil-shell {key.user.username}",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty' |
| 92 | ) |
| 93 | lines.append(f"{forced_cmd} {key.public_key.strip()}") |
| 94 | |
| 95 | authorized_keys_path.write_text("\n".join(lines) + "\n" if lines else "") |
| 96 | authorized_keys_path.chmod(0o600) |
| 97 | |
| 98 | |
| 99 | @login_required |
| 100 | def ssh_keys(request): |
| 101 | """List and add SSH keys.""" |
| 102 | from fossil.user_keys import UserSSHKey |
| 103 | |
| 104 | keys = UserSSHKey.objects.filter(user=request.user) |
| 105 | |
| 106 | if request.method == "POST": |
| 107 | title = request.POST.get("title", "").strip() |
| 108 | public_key = request.POST.get("public_key", "").strip() |
| 109 | |
| 110 | if title and public_key: |
| 111 | key_type = _parse_key_type(public_key) |
| 112 | fingerprint = _compute_fingerprint(public_key) |
| 113 | |
| 114 | UserSSHKey.objects.create( |
| 115 | user=request.user, |
| 116 | title=title, |
| 117 | public_key=public_key, |
| 118 | key_type=key_type, |
| 119 | fingerprint=fingerprint, |
| 120 | created_by=request.user, |
| 121 | ) |
| 122 | |
| 123 | _regenerate_authorized_keys() |
| 124 | |
| 125 | messages.success(request, f'SSH key "{title}" added.') |
| 126 | return redirect("accounts:ssh_keys") |
| 127 | |
| 128 | return render(request, "accounts/ssh_keys.html", {"keys": keys}) |
| 129 | |
| 130 | |
| 131 | @login_required |
| 132 | @require_POST |
| 133 | def ssh_key_delete(request, pk): |
| 134 | """Delete an SSH key.""" |
| 135 | from fossil.user_keys import UserSSHKey |
| 136 | |
| 137 | key = get_object_or_404(UserSSHKey, pk=pk, user=request.user) |
| 138 | key.soft_delete(user=request.user) |
| 139 | _regenerate_authorized_keys() |
| 140 | |
| 141 | messages.success(request, f'SSH key "{key.title}" removed.') |
| 142 | |
| 143 | if request.headers.get("HX-Request"): |
| 144 | return HttpResponse(status=200, headers={"HX-Redirect": "/auth/ssh-keys/"}) |
| 145 | |
| 146 | return redirect("accounts:ssh_keys") |
D
auth1/__init__.py
No diff available
D
auth1/apps.py
-7
| --- a/auth1/apps.py | ||
| +++ b/auth1/apps.py | ||
| @@ -1,7 +0,0 @@ | ||
| 1 | -from django.apps import AppConfig | |
| 2 | - | |
| 3 | - | |
| 4 | -class Auth1Config(AppConfig): | |
| 5 | - default_auto_field = "django.db.models.BigAutoField" | |
| 6 | - name = "auth1" | |
| 7 | - verbose_name = "Authentication" |
| --- a/auth1/apps.py | |
| +++ b/auth1/apps.py | |
| @@ -1,7 +0,0 @@ | |
| 1 | from django.apps import AppConfig |
| 2 | |
| 3 | |
| 4 | class Auth1Config(AppConfig): |
| 5 | default_auto_field = "django.db.models.BigAutoField" |
| 6 | name = "auth1" |
| 7 | verbose_name = "Authentication" |
| --- a/auth1/apps.py | |
| +++ b/auth1/apps.py | |
| @@ -1,7 +0,0 @@ | |
D
auth1/forms.py
-22
| --- a/auth1/forms.py | ||
| +++ b/auth1/forms.py | ||
| @@ -1,22 +0,0 @@ | ||
| 1 | -from django import forms | |
| 2 | -from django.contrib.auth.forms import AuthenticationForm | |
| 3 | - | |
| 4 | - | |
| 5 | -class LoginForm(AuthenticationForm): | |
| 6 | - username = forms.CharField( | |
| 7 | - widget=forms.TextInput( | |
| 8 | - attrs={ | |
| 9 | - "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand", | |
| 10 | - "placeholder": "Username", | |
| 11 | - "autofocus": True, | |
| 12 | - } | |
| 13 | - ) | |
| 14 | - ) | |
| 15 | - password = forms.CharField( | |
| 16 | - widget=forms.PasswordInput( | |
| 17 | - attrs={ | |
| 18 | - "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand", | |
| 19 | - "placeholder": "Password", | |
| 20 | - } | |
| 21 | - ) | |
| 22 | - ) |
| --- a/auth1/forms.py | |
| +++ b/auth1/forms.py | |
| @@ -1,22 +0,0 @@ | |
| 1 | from django import forms |
| 2 | from django.contrib.auth.forms import AuthenticationForm |
| 3 | |
| 4 | |
| 5 | class LoginForm(AuthenticationForm): |
| 6 | username = forms.CharField( |
| 7 | widget=forms.TextInput( |
| 8 | attrs={ |
| 9 | "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand", |
| 10 | "placeholder": "Username", |
| 11 | "autofocus": True, |
| 12 | } |
| 13 | ) |
| 14 | ) |
| 15 | password = forms.CharField( |
| 16 | widget=forms.PasswordInput( |
| 17 | attrs={ |
| 18 | "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand", |
| 19 | "placeholder": "Password", |
| 20 | } |
| 21 | ) |
| 22 | ) |
| --- a/auth1/forms.py | |
| +++ b/auth1/forms.py | |
| @@ -1,22 +0,0 @@ | |
D
auth1/migrations/__init__.py
No diff available
D
auth1/tests.py
-46
| --- a/auth1/tests.py | ||
| +++ b/auth1/tests.py | ||
| @@ -1,46 +0,0 @@ | ||
| 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("auth1:login")) | |
| 9 | - assert response.status_code == 200 | |
| 10 | - assert b"Sign in" in response.content | |
| 11 | - | |
| 12 | - def test_login_success_redirects_to_dashboard(self, client, admin_user): | |
| 13 | - response = client.post(reverse("auth1:login"), {"username": "admin", "password": "testpass123"}) | |
| 14 | - assert response.status_code == 302 | |
| 15 | - assert response.url == reverse("dashboard") | |
| 16 | - | |
| 17 | - def test_login_failure_shows_error(self, client, admin_user): | |
| 18 | - response = client.post(reverse("auth1:login"), {"username": "admin", "password": "wrong"}) | |
| 19 | - assert response.status_code == 200 | |
| 20 | - assert b"Invalid username or password" in response.content | |
| 21 | - | |
| 22 | - def test_login_redirect_when_already_authenticated(self, admin_client): | |
| 23 | - response = admin_client.get(reverse("auth1:login")) | |
| 24 | - assert response.status_code == 302 | |
| 25 | - | |
| 26 | - def test_login_with_next_param(self, client, admin_user): | |
| 27 | - response = client.post(reverse("auth1:login") + "?next=/items/", {"username": "admin", "password": "testpass123"}) | |
| 28 | - assert response.status_code == 302 | |
| 29 | - assert response.url == "/items/" | |
| 30 | - | |
| 31 | - | |
| 32 | -@pytest.mark.django_db | |
| 33 | -class TestLogout: | |
| 34 | - def test_logout_redirects_to_login(self, admin_client): | |
| 35 | - response = admin_client.post(reverse("auth1:logout")) | |
| 36 | - assert response.status_code == 302 | |
| 37 | - assert reverse("auth1:login") in response.url | |
| 38 | - | |
| 39 | - def test_logout_clears_session(self, admin_client): | |
| 40 | - admin_client.post(reverse("auth1:logout")) | |
| 41 | - response = admin_client.get(reverse("dashboard")) | |
| 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("auth1:logout")) | |
| 46 | - assert response.status_code == 405 |
| --- a/auth1/tests.py | |
| +++ b/auth1/tests.py | |
| @@ -1,46 +0,0 @@ | |
| 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("auth1:login")) |
| 9 | assert response.status_code == 200 |
| 10 | assert b"Sign in" in response.content |
| 11 | |
| 12 | def test_login_success_redirects_to_dashboard(self, client, admin_user): |
| 13 | response = client.post(reverse("auth1:login"), {"username": "admin", "password": "testpass123"}) |
| 14 | assert response.status_code == 302 |
| 15 | assert response.url == reverse("dashboard") |
| 16 | |
| 17 | def test_login_failure_shows_error(self, client, admin_user): |
| 18 | response = client.post(reverse("auth1:login"), {"username": "admin", "password": "wrong"}) |
| 19 | assert response.status_code == 200 |
| 20 | assert b"Invalid username or password" in response.content |
| 21 | |
| 22 | def test_login_redirect_when_already_authenticated(self, admin_client): |
| 23 | response = admin_client.get(reverse("auth1:login")) |
| 24 | assert response.status_code == 302 |
| 25 | |
| 26 | def test_login_with_next_param(self, client, admin_user): |
| 27 | response = client.post(reverse("auth1:login") + "?next=/items/", {"username": "admin", "password": "testpass123"}) |
| 28 | assert response.status_code == 302 |
| 29 | assert response.url == "/items/" |
| 30 | |
| 31 | |
| 32 | @pytest.mark.django_db |
| 33 | class TestLogout: |
| 34 | def test_logout_redirects_to_login(self, admin_client): |
| 35 | response = admin_client.post(reverse("auth1:logout")) |
| 36 | assert response.status_code == 302 |
| 37 | assert reverse("auth1:login") in response.url |
| 38 | |
| 39 | def test_logout_clears_session(self, admin_client): |
| 40 | admin_client.post(reverse("auth1:logout")) |
| 41 | response = admin_client.get(reverse("dashboard")) |
| 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("auth1:logout")) |
| 46 | assert response.status_code == 405 |
| --- a/auth1/tests.py | |
| +++ b/auth1/tests.py | |
| @@ -1,46 +0,0 @@ | |
D
auth1/urls.py
-12
| --- a/auth1/urls.py | ||
| +++ b/auth1/urls.py | ||
| @@ -1,12 +0,0 @@ | ||
| 1 | -from django.urls import path | |
| 2 | - | |
| 3 | -from . import views | |
| 4 | - | |
| 5 | -app_name = "auth1" | |
| 6 | - | |
| 7 | -urlpatterns = [ | |
| 8 | - path("login/", views.login_view, name="login"), | |
| 9 | - path("logout/", views.logout_view, name="logout"), | |
| 10 | - path("ssh-keys/", views.ssh_keys, name="ssh_keys"), | |
| 11 | - path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"), | |
| 12 | -] |
| --- a/auth1/urls.py | |
| +++ b/auth1/urls.py | |
| @@ -1,12 +0,0 @@ | |
| 1 | from django.urls import path |
| 2 | |
| 3 | from . import views |
| 4 | |
| 5 | app_name = "auth1" |
| 6 | |
| 7 | urlpatterns = [ |
| 8 | path("login/", views.login_view, name="login"), |
| 9 | path("logout/", views.logout_view, name="logout"), |
| 10 | path("ssh-keys/", views.ssh_keys, name="ssh_keys"), |
| 11 | path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"), |
| 12 | ] |
| --- a/auth1/urls.py | |
| +++ b/auth1/urls.py | |
| @@ -1,12 +0,0 @@ | |
D
auth1/views.py
-146
| --- a/auth1/views.py | ||
| +++ b/auth1/views.py | ||
| @@ -1,146 +0,0 @@ | ||
| 1 | -from django.contrib import messages | |
| 2 | -from django.contrib.auth import login, logout | |
| 3 | -from django.contrib.auth.decorators import login_required | |
| 4 | -from django.http import HttpResponse | |
| 5 | -from django.shortcuts import get_object_or_404, redirect, render | |
| 6 | -from django.views.decorators.http import require_POST | |
| 7 | -from django_ratelimit.decorators import ratelimit | |
| 8 | - | |
| 9 | -from .forms import LoginForm | |
| 10 | - | |
| 11 | - | |
| 12 | -@ratelimit(key="ip", rate="10/m", block=True) | |
| 13 | -def login_view(request): | |
| 14 | - if request.user.is_authenticated: | |
| 15 | - return redirect("dashboard") | |
| 16 | - | |
| 17 | - if request.method == "POST": | |
| 18 | - form = LoginForm(request, data=request.POST) | |
| 19 | - if form.is_valid(): | |
| 20 | - login(request, form.get_user()) | |
| 21 | - next_url = request.GET.get("next", "dashboard") | |
| 22 | - return redirect(next_url) | |
| 23 | - else: | |
| 24 | - form = LoginForm() | |
| 25 | - | |
| 26 | - return render(request, "auth1/login.html", {"form": form}) | |
| 27 | - | |
| 28 | - | |
| 29 | -@require_POST | |
| 30 | -def logout_view(request): | |
| 31 | - logout(request) | |
| 32 | - return redirect("auth1:login") | |
| 33 | - | |
| 34 | - | |
| 35 | -# --------------------------------------------------------------------------- | |
| 36 | -# SSH key management | |
| 37 | -# --------------------------------------------------------------------------- | |
| 38 | - | |
| 39 | - | |
| 40 | -def _parse_key_type(public_key): | |
| 41 | - """Extract key type from public key string.""" | |
| 42 | - parts = public_key.strip().split() | |
| 43 | - if parts: | |
| 44 | - key_prefix = parts[0] | |
| 45 | - type_map = { | |
| 46 | - "ssh-ed25519": "ed25519", | |
| 47 | - "ssh-rsa": "rsa", | |
| 48 | - "ecdsa-sha2-nistp256": "ecdsa", | |
| 49 | - "ecdsa-sha2-nistp384": "ecdsa", | |
| 50 | - "ecdsa-sha2-nistp521": "ecdsa", | |
| 51 | - "ssh-dss": "dsa", | |
| 52 | - } | |
| 53 | - return type_map.get(key_prefix, key_prefix) | |
| 54 | - return "" | |
| 55 | - | |
| 56 | - | |
| 57 | -def _compute_fingerprint(public_key): | |
| 58 | - """Compute SSH key fingerprint (SHA256).""" | |
| 59 | - import base64 | |
| 60 | - import hashlib | |
| 61 | - | |
| 62 | - parts = public_key.strip().split() | |
| 63 | - if len(parts) >= 2: | |
| 64 | - try: | |
| 65 | - key_data = base64.b64decode(parts[1]) | |
| 66 | - digest = hashlib.sha256(key_data).digest() | |
| 67 | - return "SHA256:" + base64.b64encode(digest).rstrip(b"=").decode() | |
| 68 | - except Exception: | |
| 69 | - pass | |
| 70 | - return "" | |
| 71 | - | |
| 72 | - | |
| 73 | -def _regenerate_authorized_keys(): | |
| 74 | - """Regenerate the authorized_keys file from all active user SSH keys.""" | |
| 75 | - from pathlib import Path | |
| 76 | - | |
| 77 | - from constance import config | |
| 78 | - | |
| 79 | - from fossil.user_keys import UserSSHKey | |
| 80 | - | |
| 81 | - ssh_dir = Path(config.FOSSIL_DATA_DIR).parent / "ssh" | |
| 82 | - ssh_dir.mkdir(parents=True, exist_ok=True) | |
| 83 | - authorized_keys_path = ssh_dir / "authorized_keys" | |
| 84 | - | |
| 85 | - keys = UserSSHKey.objects.filter(deleted_at__isnull=True).select_related("user") | |
| 86 | - | |
| 87 | - lines = [] | |
| 88 | - for key in keys: | |
| 89 | - # Each key gets a forced command that identifies the user | |
| 90 | - forced_cmd = ( | |
| 91 | - f'command="/usr/local/bin/fossil-shell {key.user.username}",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty' | |
| 92 | - ) | |
| 93 | - lines.append(f"{forced_cmd} {key.public_key.strip()}") | |
| 94 | - | |
| 95 | - authorized_keys_path.write_text("\n".join(lines) + "\n" if lines else "") | |
| 96 | - authorized_keys_path.chmod(0o600) | |
| 97 | - | |
| 98 | - | |
| 99 | -@login_required | |
| 100 | -def ssh_keys(request): | |
| 101 | - """List and add SSH keys.""" | |
| 102 | - from fossil.user_keys import UserSSHKey | |
| 103 | - | |
| 104 | - keys = UserSSHKey.objects.filter(user=request.user) | |
| 105 | - | |
| 106 | - if request.method == "POST": | |
| 107 | - title = request.POST.get("title", "").strip() | |
| 108 | - public_key = request.POST.get("public_key", "").strip() | |
| 109 | - | |
| 110 | - if title and public_key: | |
| 111 | - key_type = _parse_key_type(public_key) | |
| 112 | - fingerprint = _compute_fingerprint(public_key) | |
| 113 | - | |
| 114 | - UserSSHKey.objects.create( | |
| 115 | - user=request.user, | |
| 116 | - title=title, | |
| 117 | - public_key=public_key, | |
| 118 | - key_type=key_type, | |
| 119 | - fingerprint=fingerprint, | |
| 120 | - created_by=request.user, | |
| 121 | - ) | |
| 122 | - | |
| 123 | - _regenerate_authorized_keys() | |
| 124 | - | |
| 125 | - messages.success(request, f'SSH key "{title}" added.') | |
| 126 | - return redirect("auth1:ssh_keys") | |
| 127 | - | |
| 128 | - return render(request, "auth1/ssh_keys.html", {"keys": keys}) | |
| 129 | - | |
| 130 | - | |
| 131 | -@login_required | |
| 132 | -@require_POST | |
| 133 | -def ssh_key_delete(request, pk): | |
| 134 | - """Delete an SSH key.""" | |
| 135 | - from fossil.user_keys import UserSSHKey | |
| 136 | - | |
| 137 | - key = get_object_or_404(UserSSHKey, pk=pk, user=request.user) | |
| 138 | - key.soft_delete(user=request.user) | |
| 139 | - _regenerate_authorized_keys() | |
| 140 | - | |
| 141 | - messages.success(request, f'SSH key "{key.title}" removed.') | |
| 142 | - | |
| 143 | - if request.headers.get("HX-Request"): | |
| 144 | - return HttpResponse(status=200, headers={"HX-Redirect": "/auth/ssh-keys/"}) | |
| 145 | - | |
| 146 | - return redirect("auth1:ssh_keys") |
| --- a/auth1/views.py | |
| +++ b/auth1/views.py | |
| @@ -1,146 +0,0 @@ | |
| 1 | from django.contrib import messages |
| 2 | from django.contrib.auth import login, logout |
| 3 | from django.contrib.auth.decorators import login_required |
| 4 | from django.http import HttpResponse |
| 5 | from django.shortcuts import get_object_or_404, redirect, render |
| 6 | from django.views.decorators.http import require_POST |
| 7 | from django_ratelimit.decorators import ratelimit |
| 8 | |
| 9 | from .forms import LoginForm |
| 10 | |
| 11 | |
| 12 | @ratelimit(key="ip", rate="10/m", block=True) |
| 13 | def login_view(request): |
| 14 | if request.user.is_authenticated: |
| 15 | return redirect("dashboard") |
| 16 | |
| 17 | if request.method == "POST": |
| 18 | form = LoginForm(request, data=request.POST) |
| 19 | if form.is_valid(): |
| 20 | login(request, form.get_user()) |
| 21 | next_url = request.GET.get("next", "dashboard") |
| 22 | return redirect(next_url) |
| 23 | else: |
| 24 | form = LoginForm() |
| 25 | |
| 26 | return render(request, "auth1/login.html", {"form": form}) |
| 27 | |
| 28 | |
| 29 | @require_POST |
| 30 | def logout_view(request): |
| 31 | logout(request) |
| 32 | return redirect("auth1:login") |
| 33 | |
| 34 | |
| 35 | # --------------------------------------------------------------------------- |
| 36 | # SSH key management |
| 37 | # --------------------------------------------------------------------------- |
| 38 | |
| 39 | |
| 40 | def _parse_key_type(public_key): |
| 41 | """Extract key type from public key string.""" |
| 42 | parts = public_key.strip().split() |
| 43 | if parts: |
| 44 | key_prefix = parts[0] |
| 45 | type_map = { |
| 46 | "ssh-ed25519": "ed25519", |
| 47 | "ssh-rsa": "rsa", |
| 48 | "ecdsa-sha2-nistp256": "ecdsa", |
| 49 | "ecdsa-sha2-nistp384": "ecdsa", |
| 50 | "ecdsa-sha2-nistp521": "ecdsa", |
| 51 | "ssh-dss": "dsa", |
| 52 | } |
| 53 | return type_map.get(key_prefix, key_prefix) |
| 54 | return "" |
| 55 | |
| 56 | |
| 57 | def _compute_fingerprint(public_key): |
| 58 | """Compute SSH key fingerprint (SHA256).""" |
| 59 | import base64 |
| 60 | import hashlib |
| 61 | |
| 62 | parts = public_key.strip().split() |
| 63 | if len(parts) >= 2: |
| 64 | try: |
| 65 | key_data = base64.b64decode(parts[1]) |
| 66 | digest = hashlib.sha256(key_data).digest() |
| 67 | return "SHA256:" + base64.b64encode(digest).rstrip(b"=").decode() |
| 68 | except Exception: |
| 69 | pass |
| 70 | return "" |
| 71 | |
| 72 | |
| 73 | def _regenerate_authorized_keys(): |
| 74 | """Regenerate the authorized_keys file from all active user SSH keys.""" |
| 75 | from pathlib import Path |
| 76 | |
| 77 | from constance import config |
| 78 | |
| 79 | from fossil.user_keys import UserSSHKey |
| 80 | |
| 81 | ssh_dir = Path(config.FOSSIL_DATA_DIR).parent / "ssh" |
| 82 | ssh_dir.mkdir(parents=True, exist_ok=True) |
| 83 | authorized_keys_path = ssh_dir / "authorized_keys" |
| 84 | |
| 85 | keys = UserSSHKey.objects.filter(deleted_at__isnull=True).select_related("user") |
| 86 | |
| 87 | lines = [] |
| 88 | for key in keys: |
| 89 | # Each key gets a forced command that identifies the user |
| 90 | forced_cmd = ( |
| 91 | f'command="/usr/local/bin/fossil-shell {key.user.username}",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty' |
| 92 | ) |
| 93 | lines.append(f"{forced_cmd} {key.public_key.strip()}") |
| 94 | |
| 95 | authorized_keys_path.write_text("\n".join(lines) + "\n" if lines else "") |
| 96 | authorized_keys_path.chmod(0o600) |
| 97 | |
| 98 | |
| 99 | @login_required |
| 100 | def ssh_keys(request): |
| 101 | """List and add SSH keys.""" |
| 102 | from fossil.user_keys import UserSSHKey |
| 103 | |
| 104 | keys = UserSSHKey.objects.filter(user=request.user) |
| 105 | |
| 106 | if request.method == "POST": |
| 107 | title = request.POST.get("title", "").strip() |
| 108 | public_key = request.POST.get("public_key", "").strip() |
| 109 | |
| 110 | if title and public_key: |
| 111 | key_type = _parse_key_type(public_key) |
| 112 | fingerprint = _compute_fingerprint(public_key) |
| 113 | |
| 114 | UserSSHKey.objects.create( |
| 115 | user=request.user, |
| 116 | title=title, |
| 117 | public_key=public_key, |
| 118 | key_type=key_type, |
| 119 | fingerprint=fingerprint, |
| 120 | created_by=request.user, |
| 121 | ) |
| 122 | |
| 123 | _regenerate_authorized_keys() |
| 124 | |
| 125 | messages.success(request, f'SSH key "{title}" added.') |
| 126 | return redirect("auth1:ssh_keys") |
| 127 | |
| 128 | return render(request, "auth1/ssh_keys.html", {"keys": keys}) |
| 129 | |
| 130 | |
| 131 | @login_required |
| 132 | @require_POST |
| 133 | def ssh_key_delete(request, pk): |
| 134 | """Delete an SSH key.""" |
| 135 | from fossil.user_keys import UserSSHKey |
| 136 | |
| 137 | key = get_object_or_404(UserSSHKey, pk=pk, user=request.user) |
| 138 | key.soft_delete(user=request.user) |
| 139 | _regenerate_authorized_keys() |
| 140 | |
| 141 | messages.success(request, f'SSH key "{key.title}" removed.') |
| 142 | |
| 143 | if request.headers.get("HX-Request"): |
| 144 | return HttpResponse(status=200, headers={"HX-Redirect": "/auth/ssh-keys/"}) |
| 145 | |
| 146 | return redirect("auth1:ssh_keys") |
| --- a/auth1/views.py | |
| +++ b/auth1/views.py | |
| @@ -1,146 +0,0 @@ | |
+30
-32
| --- bootstrap.md | ||
| +++ bootstrap.md | ||
| @@ -74,13 +74,12 @@ | ||
| 74 | 74 | |
| 75 | 75 | ``` |
| 76 | 76 | fossilrepo/ |
| 77 | 77 | |-- config/ # Django settings, URLs, Celery |
| 78 | 78 | |-- core/ # Base models, permissions, middleware |
| 79 | -|-- auth1/ # Session-based auth | |
| 79 | +|-- accounts/ # Session-based auth | |
| 80 | 80 | |-- organization/ # Org + member management |
| 81 | -|-- items/ # Example CRUD app (reference only) | |
| 82 | 81 | |-- docker/ # Fossil-specific: Caddyfile, litestream.yml |
| 83 | 82 | |-- templates/ # HTMX templates |
| 84 | 83 | |-- _old_fossilrepo/ # Original server/sync/cli code (being ported) |
| 85 | 84 | +-- docs/ # Architecture guides |
| 86 | 85 | ``` |
| @@ -89,19 +88,19 @@ | ||
| 89 | 88 | |
| 90 | 89 | ## What's Already Built |
| 91 | 90 | |
| 92 | 91 | | Layer | What's there | |
| 93 | 92 | |---|---| |
| 94 | -| Auth | Session-based auth (auth1), login/logout views with templates, rate limiting | | |
| 93 | +| Auth | Session-based auth (accounts), login/logout views with templates, rate limiting | | |
| 95 | 94 | | Data | Postgres 16, `Tracking` base model (version, created/updated/deleted by+at, soft deletes, history) | |
| 96 | 95 | | API | Django views returning HTML (full pages + HTMX partials) | |
| 97 | 96 | | Permissions | Group-based via `P` enum, checked in every view | |
| 98 | 97 | | Async | Celery worker + beat, Redis broker | |
| 99 | 98 | | Admin | Django Admin with `BaseCoreAdmin` (import/export, tracking fields) | |
| 100 | 99 | | Infra | Docker Compose: postgres, redis, celery-worker, celery-beat, mailpit | |
| 101 | 100 | | CI | GitHub Actions: lint (Ruff) + tests (Postgres + Redis services) | |
| 102 | -| Seed | `python manage.py seed` creates admin/viewer users, sample items | | |
| 101 | +| Seed | `python manage.py seed` creates admin/viewer users, sample data | | |
| 103 | 102 | | Frontend | HTMX 2.0 + Alpine.js 3 + Tailwind CSS, server-rendered templates | |
| 104 | 103 | |
| 105 | 104 | --- |
| 106 | 105 | |
| 107 | 106 | ## App Structure |
| @@ -108,13 +107,12 @@ | ||
| 108 | 107 | |
| 109 | 108 | | App | Purpose | |
| 110 | 109 | |---|---| |
| 111 | 110 | | `config` | Django settings, URLs, Celery configuration | |
| 112 | 111 | | `core` | Base models (Tracking, BaseCoreModel), admin (BaseCoreAdmin), permissions (P enum), middleware | |
| 113 | -| `auth1` | Session-based authentication: login/logout views with rate limiting | | |
| 112 | +| `accounts` | Session-based authentication: login/logout views with rate limiting | | |
| 114 | 113 | | `organization` | Organization + OrganizationMember models | |
| 115 | -| `items` | Example CRUD domain demonstrating all patterns (reference only -- new Fossil-specific apps will replace this as the primary domain) | | |
| 116 | 114 | | `testdata` | `seed` management command for development data | |
| 117 | 115 | |
| 118 | 116 | --- |
| 119 | 117 | |
| 120 | 118 | ## Conventions |
| @@ -134,12 +132,12 @@ | ||
| 134 | 132 | |
| 135 | 133 | **`BaseCoreModel(Tracking)`** (abstract) -- named entities: |
| 136 | 134 | ```python |
| 137 | 135 | from core.models import BaseCoreModel |
| 138 | 136 | |
| 139 | -class Item(BaseCoreModel): | |
| 140 | - price = models.DecimalField(...) | |
| 137 | +class Project(BaseCoreModel): | |
| 138 | + visibility = models.CharField(...) | |
| 141 | 139 | ``` |
| 142 | 140 | Adds: `guid` (UUID), `name`, `slug` (auto-generated, unique), `description`. |
| 143 | 141 | |
| 144 | 142 | **Soft deletes:** call `obj.soft_delete(user=request.user)`, never `.delete()`. |
| 145 | 143 | |
| @@ -151,28 +149,28 @@ | ||
| 151 | 149 | |
| 152 | 150 | Views return full pages for normal requests, HTMX partials for `HX-Request`: |
| 153 | 151 | |
| 154 | 152 | ```python |
| 155 | 153 | @login_required |
| 156 | -def item_list(request): | |
| 157 | - P.ITEM_VIEW.check(request.user) | |
| 158 | - items = Item.objects.all() | |
| 154 | +def project_list(request): | |
| 155 | + P.PROJECT_VIEW.check(request.user) | |
| 156 | + projects = Project.objects.all() | |
| 159 | 157 | |
| 160 | 158 | if request.headers.get("HX-Request"): |
| 161 | - return render(request, "items/partials/item_table.html", {"items": items}) | |
| 159 | + return render(request, "projects/partials/project_table.html", {"projects": projects}) | |
| 162 | 160 | |
| 163 | - return render(request, "items/item_list.html", {"items": items}) | |
| 161 | + return render(request, "projects/project_list.html", {"projects": projects}) | |
| 164 | 162 | ``` |
| 165 | 163 | |
| 166 | 164 | **URL patterns** follow CRUD convention: |
| 167 | 165 | ```python |
| 168 | 166 | urlpatterns = [ |
| 169 | - path("", views.item_list, name="list"), | |
| 170 | - path("create/", views.item_create, name="create"), | |
| 171 | - path("<slug:slug>/", views.item_detail, name="detail"), | |
| 172 | - path("<slug:slug>/edit/", views.item_update, name="update"), | |
| 173 | - path("<slug:slug>/delete/", views.item_delete, name="delete"), | |
| 167 | + path("", views.project_list, name="list"), | |
| 168 | + path("create/", views.project_create, name="create"), | |
| 169 | + path("<slug:slug>/", views.project_detail, name="detail"), | |
| 170 | + path("<slug:slug>/edit/", views.project_update, name="update"), | |
| 171 | + path("<slug:slug>/delete/", views.project_delete, name="delete"), | |
| 174 | 172 | ] |
| 175 | 173 | ``` |
| 176 | 174 | |
| 177 | 175 | --- |
| 178 | 176 | |
| @@ -181,18 +179,18 @@ | ||
| 181 | 179 | Group-based. Never user-based. Checked in every view. |
| 182 | 180 | |
| 183 | 181 | ```python |
| 184 | 182 | from core.permissions import P |
| 185 | 183 | |
| 186 | -P.ITEM_VIEW.check(request.user) # raises PermissionDenied if denied | |
| 187 | -P.ITEM_ADD.check(request.user, raise_error=False) # returns False instead | |
| 184 | +P.PROJECT_VIEW.check(request.user) # raises PermissionDenied if denied | |
| 185 | +P.PROJECT_ADD.check(request.user, raise_error=False) # returns False instead | |
| 188 | 186 | ``` |
| 189 | 187 | |
| 190 | 188 | Template guards: |
| 191 | 189 | ```html |
| 192 | -{% if perms.items.view_item %} | |
| 193 | - <a href="{% url 'items:list' %}">Items</a> | |
| 190 | +{% if perms.projects.view_project %} | |
| 191 | + <a href="{% url 'projects:list' %}">Projects</a> | |
| 194 | 192 | {% endif %} |
| 195 | 193 | ``` |
| 196 | 194 | |
| 197 | 195 | --- |
| 198 | 196 | |
| @@ -200,13 +198,13 @@ | ||
| 200 | 198 | |
| 201 | 199 | All admin classes inherit `BaseCoreAdmin`: |
| 202 | 200 | ```python |
| 203 | 201 | from core.admin import BaseCoreAdmin |
| 204 | 202 | |
| 205 | -@admin.register(Item) | |
| 206 | -class ItemAdmin(BaseCoreAdmin): | |
| 207 | - list_display = ("name", "slug", "price", "created_at") | |
| 203 | +@admin.register(Project) | |
| 204 | +class ProjectAdmin(BaseCoreAdmin): | |
| 205 | + list_display = ("name", "slug", "visibility", "created_at") | |
| 208 | 206 | search_fields = ("name", "slug") |
| 209 | 207 | ``` |
| 210 | 208 | |
| 211 | 209 | `BaseCoreAdmin` provides: audit fields as readonly, `created_by`/`updated_by` auto-set, import/export. |
| 212 | 210 | |
| @@ -233,21 +231,21 @@ | ||
| 233 | 231 | |
| 234 | 232 | pytest + real Postgres. Assert against database state. |
| 235 | 233 | |
| 236 | 234 | ```python |
| 237 | 235 | @pytest.mark.django_db |
| 238 | -class TestItemCreate: | |
| 239 | - def test_create_saves_item(self, admin_client, admin_user): | |
| 240 | - response = admin_client.post(reverse("items:create"), { | |
| 241 | - "name": "Widget", "price": "9.99", ... | |
| 236 | +class TestProjectCreate: | |
| 237 | + def test_create_saves_project(self, admin_client, admin_user, org): | |
| 238 | + response = admin_client.post(reverse("projects:create"), { | |
| 239 | + "name": "New App", "visibility": "private", ... | |
| 242 | 240 | }) |
| 243 | 241 | assert response.status_code == 302 |
| 244 | - item = Item.objects.get(name="Widget") | |
| 245 | - assert item.created_by == admin_user | |
| 242 | + project = Project.objects.get(name="New App") | |
| 243 | + assert project.created_by == admin_user | |
| 246 | 244 | |
| 247 | 245 | def test_create_denied_for_viewer(self, viewer_client): |
| 248 | - response = viewer_client.get(reverse("items:create")) | |
| 246 | + response = viewer_client.get(reverse("projects:create")) | |
| 249 | 247 | assert response.status_code == 403 |
| 250 | 248 | ``` |
| 251 | 249 | |
| 252 | 250 | Both allowed AND denied permission cases for every endpoint. |
| 253 | 251 | |
| 254 | 252 |
| --- bootstrap.md | |
| +++ bootstrap.md | |
| @@ -74,13 +74,12 @@ | |
| 74 | |
| 75 | ``` |
| 76 | fossilrepo/ |
| 77 | |-- config/ # Django settings, URLs, Celery |
| 78 | |-- core/ # Base models, permissions, middleware |
| 79 | |-- auth1/ # Session-based auth |
| 80 | |-- organization/ # Org + member management |
| 81 | |-- items/ # Example CRUD app (reference only) |
| 82 | |-- docker/ # Fossil-specific: Caddyfile, litestream.yml |
| 83 | |-- templates/ # HTMX templates |
| 84 | |-- _old_fossilrepo/ # Original server/sync/cli code (being ported) |
| 85 | +-- docs/ # Architecture guides |
| 86 | ``` |
| @@ -89,19 +88,19 @@ | |
| 89 | |
| 90 | ## What's Already Built |
| 91 | |
| 92 | | Layer | What's there | |
| 93 | |---|---| |
| 94 | | Auth | Session-based auth (auth1), login/logout views with templates, rate limiting | |
| 95 | | Data | Postgres 16, `Tracking` base model (version, created/updated/deleted by+at, soft deletes, history) | |
| 96 | | API | Django views returning HTML (full pages + HTMX partials) | |
| 97 | | Permissions | Group-based via `P` enum, checked in every view | |
| 98 | | Async | Celery worker + beat, Redis broker | |
| 99 | | Admin | Django Admin with `BaseCoreAdmin` (import/export, tracking fields) | |
| 100 | | Infra | Docker Compose: postgres, redis, celery-worker, celery-beat, mailpit | |
| 101 | | CI | GitHub Actions: lint (Ruff) + tests (Postgres + Redis services) | |
| 102 | | Seed | `python manage.py seed` creates admin/viewer users, sample items | |
| 103 | | Frontend | HTMX 2.0 + Alpine.js 3 + Tailwind CSS, server-rendered templates | |
| 104 | |
| 105 | --- |
| 106 | |
| 107 | ## App Structure |
| @@ -108,13 +107,12 @@ | |
| 108 | |
| 109 | | App | Purpose | |
| 110 | |---|---| |
| 111 | | `config` | Django settings, URLs, Celery configuration | |
| 112 | | `core` | Base models (Tracking, BaseCoreModel), admin (BaseCoreAdmin), permissions (P enum), middleware | |
| 113 | | `auth1` | Session-based authentication: login/logout views with rate limiting | |
| 114 | | `organization` | Organization + OrganizationMember models | |
| 115 | | `items` | Example CRUD domain demonstrating all patterns (reference only -- new Fossil-specific apps will replace this as the primary domain) | |
| 116 | | `testdata` | `seed` management command for development data | |
| 117 | |
| 118 | --- |
| 119 | |
| 120 | ## Conventions |
| @@ -134,12 +132,12 @@ | |
| 134 | |
| 135 | **`BaseCoreModel(Tracking)`** (abstract) -- named entities: |
| 136 | ```python |
| 137 | from core.models import BaseCoreModel |
| 138 | |
| 139 | class Item(BaseCoreModel): |
| 140 | price = models.DecimalField(...) |
| 141 | ``` |
| 142 | Adds: `guid` (UUID), `name`, `slug` (auto-generated, unique), `description`. |
| 143 | |
| 144 | **Soft deletes:** call `obj.soft_delete(user=request.user)`, never `.delete()`. |
| 145 | |
| @@ -151,28 +149,28 @@ | |
| 151 | |
| 152 | Views return full pages for normal requests, HTMX partials for `HX-Request`: |
| 153 | |
| 154 | ```python |
| 155 | @login_required |
| 156 | def item_list(request): |
| 157 | P.ITEM_VIEW.check(request.user) |
| 158 | items = Item.objects.all() |
| 159 | |
| 160 | if request.headers.get("HX-Request"): |
| 161 | return render(request, "items/partials/item_table.html", {"items": items}) |
| 162 | |
| 163 | return render(request, "items/item_list.html", {"items": items}) |
| 164 | ``` |
| 165 | |
| 166 | **URL patterns** follow CRUD convention: |
| 167 | ```python |
| 168 | urlpatterns = [ |
| 169 | path("", views.item_list, name="list"), |
| 170 | path("create/", views.item_create, name="create"), |
| 171 | path("<slug:slug>/", views.item_detail, name="detail"), |
| 172 | path("<slug:slug>/edit/", views.item_update, name="update"), |
| 173 | path("<slug:slug>/delete/", views.item_delete, name="delete"), |
| 174 | ] |
| 175 | ``` |
| 176 | |
| 177 | --- |
| 178 | |
| @@ -181,18 +179,18 @@ | |
| 181 | Group-based. Never user-based. Checked in every view. |
| 182 | |
| 183 | ```python |
| 184 | from core.permissions import P |
| 185 | |
| 186 | P.ITEM_VIEW.check(request.user) # raises PermissionDenied if denied |
| 187 | P.ITEM_ADD.check(request.user, raise_error=False) # returns False instead |
| 188 | ``` |
| 189 | |
| 190 | Template guards: |
| 191 | ```html |
| 192 | {% if perms.items.view_item %} |
| 193 | <a href="{% url 'items:list' %}">Items</a> |
| 194 | {% endif %} |
| 195 | ``` |
| 196 | |
| 197 | --- |
| 198 | |
| @@ -200,13 +198,13 @@ | |
| 200 | |
| 201 | All admin classes inherit `BaseCoreAdmin`: |
| 202 | ```python |
| 203 | from core.admin import BaseCoreAdmin |
| 204 | |
| 205 | @admin.register(Item) |
| 206 | class ItemAdmin(BaseCoreAdmin): |
| 207 | list_display = ("name", "slug", "price", "created_at") |
| 208 | search_fields = ("name", "slug") |
| 209 | ``` |
| 210 | |
| 211 | `BaseCoreAdmin` provides: audit fields as readonly, `created_by`/`updated_by` auto-set, import/export. |
| 212 | |
| @@ -233,21 +231,21 @@ | |
| 233 | |
| 234 | pytest + real Postgres. Assert against database state. |
| 235 | |
| 236 | ```python |
| 237 | @pytest.mark.django_db |
| 238 | class TestItemCreate: |
| 239 | def test_create_saves_item(self, admin_client, admin_user): |
| 240 | response = admin_client.post(reverse("items:create"), { |
| 241 | "name": "Widget", "price": "9.99", ... |
| 242 | }) |
| 243 | assert response.status_code == 302 |
| 244 | item = Item.objects.get(name="Widget") |
| 245 | assert item.created_by == admin_user |
| 246 | |
| 247 | def test_create_denied_for_viewer(self, viewer_client): |
| 248 | response = viewer_client.get(reverse("items:create")) |
| 249 | assert response.status_code == 403 |
| 250 | ``` |
| 251 | |
| 252 | Both allowed AND denied permission cases for every endpoint. |
| 253 | |
| 254 |
| --- bootstrap.md | |
| +++ bootstrap.md | |
| @@ -74,13 +74,12 @@ | |
| 74 | |
| 75 | ``` |
| 76 | fossilrepo/ |
| 77 | |-- config/ # Django settings, URLs, Celery |
| 78 | |-- core/ # Base models, permissions, middleware |
| 79 | |-- accounts/ # Session-based auth |
| 80 | |-- organization/ # Org + member management |
| 81 | |-- docker/ # Fossil-specific: Caddyfile, litestream.yml |
| 82 | |-- templates/ # HTMX templates |
| 83 | |-- _old_fossilrepo/ # Original server/sync/cli code (being ported) |
| 84 | +-- docs/ # Architecture guides |
| 85 | ``` |
| @@ -89,19 +88,19 @@ | |
| 88 | |
| 89 | ## What's Already Built |
| 90 | |
| 91 | | Layer | What's there | |
| 92 | |---|---| |
| 93 | | Auth | Session-based auth (accounts), login/logout views with templates, rate limiting | |
| 94 | | Data | Postgres 16, `Tracking` base model (version, created/updated/deleted by+at, soft deletes, history) | |
| 95 | | API | Django views returning HTML (full pages + HTMX partials) | |
| 96 | | Permissions | Group-based via `P` enum, checked in every view | |
| 97 | | Async | Celery worker + beat, Redis broker | |
| 98 | | Admin | Django Admin with `BaseCoreAdmin` (import/export, tracking fields) | |
| 99 | | Infra | Docker Compose: postgres, redis, celery-worker, celery-beat, mailpit | |
| 100 | | CI | GitHub Actions: lint (Ruff) + tests (Postgres + Redis services) | |
| 101 | | Seed | `python manage.py seed` creates admin/viewer users, sample data | |
| 102 | | Frontend | HTMX 2.0 + Alpine.js 3 + Tailwind CSS, server-rendered templates | |
| 103 | |
| 104 | --- |
| 105 | |
| 106 | ## App Structure |
| @@ -108,13 +107,12 @@ | |
| 107 | |
| 108 | | App | Purpose | |
| 109 | |---|---| |
| 110 | | `config` | Django settings, URLs, Celery configuration | |
| 111 | | `core` | Base models (Tracking, BaseCoreModel), admin (BaseCoreAdmin), permissions (P enum), middleware | |
| 112 | | `accounts` | Session-based authentication: login/logout views with rate limiting | |
| 113 | | `organization` | Organization + OrganizationMember models | |
| 114 | | `testdata` | `seed` management command for development data | |
| 115 | |
| 116 | --- |
| 117 | |
| 118 | ## Conventions |
| @@ -134,12 +132,12 @@ | |
| 132 | |
| 133 | **`BaseCoreModel(Tracking)`** (abstract) -- named entities: |
| 134 | ```python |
| 135 | from core.models import BaseCoreModel |
| 136 | |
| 137 | class Project(BaseCoreModel): |
| 138 | visibility = models.CharField(...) |
| 139 | ``` |
| 140 | Adds: `guid` (UUID), `name`, `slug` (auto-generated, unique), `description`. |
| 141 | |
| 142 | **Soft deletes:** call `obj.soft_delete(user=request.user)`, never `.delete()`. |
| 143 | |
| @@ -151,28 +149,28 @@ | |
| 149 | |
| 150 | Views return full pages for normal requests, HTMX partials for `HX-Request`: |
| 151 | |
| 152 | ```python |
| 153 | @login_required |
| 154 | def project_list(request): |
| 155 | P.PROJECT_VIEW.check(request.user) |
| 156 | projects = Project.objects.all() |
| 157 | |
| 158 | if request.headers.get("HX-Request"): |
| 159 | return render(request, "projects/partials/project_table.html", {"projects": projects}) |
| 160 | |
| 161 | return render(request, "projects/project_list.html", {"projects": projects}) |
| 162 | ``` |
| 163 | |
| 164 | **URL patterns** follow CRUD convention: |
| 165 | ```python |
| 166 | urlpatterns = [ |
| 167 | path("", views.project_list, name="list"), |
| 168 | path("create/", views.project_create, name="create"), |
| 169 | path("<slug:slug>/", views.project_detail, name="detail"), |
| 170 | path("<slug:slug>/edit/", views.project_update, name="update"), |
| 171 | path("<slug:slug>/delete/", views.project_delete, name="delete"), |
| 172 | ] |
| 173 | ``` |
| 174 | |
| 175 | --- |
| 176 | |
| @@ -181,18 +179,18 @@ | |
| 179 | Group-based. Never user-based. Checked in every view. |
| 180 | |
| 181 | ```python |
| 182 | from core.permissions import P |
| 183 | |
| 184 | P.PROJECT_VIEW.check(request.user) # raises PermissionDenied if denied |
| 185 | P.PROJECT_ADD.check(request.user, raise_error=False) # returns False instead |
| 186 | ``` |
| 187 | |
| 188 | Template guards: |
| 189 | ```html |
| 190 | {% if perms.projects.view_project %} |
| 191 | <a href="{% url 'projects:list' %}">Projects</a> |
| 192 | {% endif %} |
| 193 | ``` |
| 194 | |
| 195 | --- |
| 196 | |
| @@ -200,13 +198,13 @@ | |
| 198 | |
| 199 | All admin classes inherit `BaseCoreAdmin`: |
| 200 | ```python |
| 201 | from core.admin import BaseCoreAdmin |
| 202 | |
| 203 | @admin.register(Project) |
| 204 | class ProjectAdmin(BaseCoreAdmin): |
| 205 | list_display = ("name", "slug", "visibility", "created_at") |
| 206 | search_fields = ("name", "slug") |
| 207 | ``` |
| 208 | |
| 209 | `BaseCoreAdmin` provides: audit fields as readonly, `created_by`/`updated_by` auto-set, import/export. |
| 210 | |
| @@ -233,21 +231,21 @@ | |
| 231 | |
| 232 | pytest + real Postgres. Assert against database state. |
| 233 | |
| 234 | ```python |
| 235 | @pytest.mark.django_db |
| 236 | class TestProjectCreate: |
| 237 | def test_create_saves_project(self, admin_client, admin_user, org): |
| 238 | response = admin_client.post(reverse("projects:create"), { |
| 239 | "name": "New App", "visibility": "private", ... |
| 240 | }) |
| 241 | assert response.status_code == 302 |
| 242 | project = Project.objects.get(name="New App") |
| 243 | assert project.created_by == admin_user |
| 244 | |
| 245 | def test_create_denied_for_viewer(self, viewer_client): |
| 246 | response = viewer_client.get(reverse("projects:create")) |
| 247 | assert response.status_code == 403 |
| 248 | ``` |
| 249 | |
| 250 | Both allowed AND denied permission cases for every endpoint. |
| 251 | |
| 252 |
+1
-2
| --- config/settings.py | ||
| +++ config/settings.py | ||
| @@ -57,13 +57,12 @@ | ||
| 57 | 57 | "corsheaders", |
| 58 | 58 | "constance", |
| 59 | 59 | "constance.backends.database", |
| 60 | 60 | # Project apps |
| 61 | 61 | "core", |
| 62 | - "auth1", | |
| 62 | + "accounts", | |
| 63 | 63 | "organization", |
| 64 | - "items", | |
| 65 | 64 | "projects", |
| 66 | 65 | "pages", |
| 67 | 66 | "fossil", |
| 68 | 67 | "testdata", |
| 69 | 68 | ] |
| 70 | 69 |
| --- config/settings.py | |
| +++ config/settings.py | |
| @@ -57,13 +57,12 @@ | |
| 57 | "corsheaders", |
| 58 | "constance", |
| 59 | "constance.backends.database", |
| 60 | # Project apps |
| 61 | "core", |
| 62 | "auth1", |
| 63 | "organization", |
| 64 | "items", |
| 65 | "projects", |
| 66 | "pages", |
| 67 | "fossil", |
| 68 | "testdata", |
| 69 | ] |
| 70 |
| --- config/settings.py | |
| +++ config/settings.py | |
| @@ -57,13 +57,12 @@ | |
| 57 | "corsheaders", |
| 58 | "constance", |
| 59 | "constance.backends.database", |
| 60 | # Project apps |
| 61 | "core", |
| 62 | "accounts", |
| 63 | "organization", |
| 64 | "projects", |
| 65 | "pages", |
| 66 | "fossil", |
| 67 | "testdata", |
| 68 | ] |
| 69 |
+1
-2
| --- config/urls.py | ||
| +++ config/urls.py | ||
| @@ -218,16 +218,15 @@ | ||
| 218 | 218 | |
| 219 | 219 | urlpatterns = [ |
| 220 | 220 | path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)), |
| 221 | 221 | path("status/", status_page, name="status"), |
| 222 | 222 | path("dashboard/", include("core.urls")), |
| 223 | - path("auth/", include("auth1.urls")), | |
| 223 | + path("auth/", include("accounts.urls")), | |
| 224 | 224 | path("settings/", include("organization.urls")), |
| 225 | 225 | path("projects/", include("projects.urls")), |
| 226 | 226 | path("projects/<slug:slug>/fossil/", include("fossil.urls")), |
| 227 | 227 | path("kb/", include("pages.urls")), |
| 228 | - path("items/", include("items.urls")), | |
| 229 | 228 | path("oauth/callback/github/", _oauth_github_callback, name="oauth_github_callback_global"), |
| 230 | 229 | path("oauth/callback/gitlab/", _oauth_gitlab_callback, name="oauth_gitlab_callback_global"), |
| 231 | 230 | path("admin/", admin.site.urls), |
| 232 | 231 | path("health/", health_check, name="health"), |
| 233 | 232 | ] |
| 234 | 233 |
| --- config/urls.py | |
| +++ config/urls.py | |
| @@ -218,16 +218,15 @@ | |
| 218 | |
| 219 | urlpatterns = [ |
| 220 | path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)), |
| 221 | path("status/", status_page, name="status"), |
| 222 | path("dashboard/", include("core.urls")), |
| 223 | path("auth/", include("auth1.urls")), |
| 224 | path("settings/", include("organization.urls")), |
| 225 | path("projects/", include("projects.urls")), |
| 226 | path("projects/<slug:slug>/fossil/", include("fossil.urls")), |
| 227 | path("kb/", include("pages.urls")), |
| 228 | path("items/", include("items.urls")), |
| 229 | path("oauth/callback/github/", _oauth_github_callback, name="oauth_github_callback_global"), |
| 230 | path("oauth/callback/gitlab/", _oauth_gitlab_callback, name="oauth_gitlab_callback_global"), |
| 231 | path("admin/", admin.site.urls), |
| 232 | path("health/", health_check, name="health"), |
| 233 | ] |
| 234 |
| --- config/urls.py | |
| +++ config/urls.py | |
| @@ -218,16 +218,15 @@ | |
| 218 | |
| 219 | urlpatterns = [ |
| 220 | path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)), |
| 221 | path("status/", status_page, name="status"), |
| 222 | path("dashboard/", include("core.urls")), |
| 223 | path("auth/", include("accounts.urls")), |
| 224 | path("settings/", include("organization.urls")), |
| 225 | path("projects/", include("projects.urls")), |
| 226 | path("projects/<slug:slug>/fossil/", include("fossil.urls")), |
| 227 | path("kb/", include("pages.urls")), |
| 228 | path("oauth/callback/github/", _oauth_github_callback, name="oauth_github_callback_global"), |
| 229 | path("oauth/callback/gitlab/", _oauth_gitlab_callback, name="oauth_gitlab_callback_global"), |
| 230 | path("admin/", admin.site.urls), |
| 231 | path("health/", health_check, name="health"), |
| 232 | ] |
| 233 |
+1
-1
| --- conftest.py | ||
| +++ conftest.py | ||
| @@ -15,11 +15,11 @@ | ||
| 15 | 15 | @pytest.fixture |
| 16 | 16 | def viewer_user(db): |
| 17 | 17 | user = User.objects.create_user(username="viewer", email="[email protected]", password="testpass123") |
| 18 | 18 | group, _ = Group.objects.get_or_create(name="Viewers") |
| 19 | 19 | view_perms = Permission.objects.filter( |
| 20 | - content_type__app_label__in=["items", "organization", "projects", "pages"], | |
| 20 | + content_type__app_label__in=["organization", "projects", "pages"], | |
| 21 | 21 | codename__startswith="view_", |
| 22 | 22 | ) |
| 23 | 23 | group.permissions.set(view_perms) |
| 24 | 24 | user.groups.add(group) |
| 25 | 25 | return user |
| 26 | 26 |
| --- conftest.py | |
| +++ conftest.py | |
| @@ -15,11 +15,11 @@ | |
| 15 | @pytest.fixture |
| 16 | def viewer_user(db): |
| 17 | user = User.objects.create_user(username="viewer", email="[email protected]", password="testpass123") |
| 18 | group, _ = Group.objects.get_or_create(name="Viewers") |
| 19 | view_perms = Permission.objects.filter( |
| 20 | content_type__app_label__in=["items", "organization", "projects", "pages"], |
| 21 | codename__startswith="view_", |
| 22 | ) |
| 23 | group.permissions.set(view_perms) |
| 24 | user.groups.add(group) |
| 25 | return user |
| 26 |
| --- conftest.py | |
| +++ conftest.py | |
| @@ -15,11 +15,11 @@ | |
| 15 | @pytest.fixture |
| 16 | def viewer_user(db): |
| 17 | user = User.objects.create_user(username="viewer", email="[email protected]", password="testpass123") |
| 18 | group, _ = Group.objects.get_or_create(name="Viewers") |
| 19 | view_perms = Permission.objects.filter( |
| 20 | content_type__app_label__in=["organization", "projects", "pages"], |
| 21 | codename__startswith="view_", |
| 22 | ) |
| 23 | group.permissions.set(view_perms) |
| 24 | user.groups.add(group) |
| 25 | return user |
| 26 |
+5
| --- core/admin.py | ||
| +++ core/admin.py | ||
| @@ -3,10 +3,15 @@ | ||
| 3 | 3 | |
| 4 | 4 | |
| 5 | 5 | class BaseCoreAdmin(ImportExportMixin, admin.ModelAdmin): |
| 6 | 6 | """Base admin class for all Fossilrepo models. Provides audit field handling and import/export.""" |
| 7 | 7 | |
| 8 | + def get_queryset(self, request): | |
| 9 | + if hasattr(self.model, "all_objects"): | |
| 10 | + return self.model.all_objects.all() | |
| 11 | + return super().get_queryset(request) | |
| 12 | + | |
| 8 | 13 | def get_readonly_fields(self, request, obj=None): |
| 9 | 14 | base = tuple(self.readonly_fields or ()) |
| 10 | 15 | return base + ("version", "created_at", "created_by", "updated_at", "updated_by", "deleted_at", "deleted_by") |
| 11 | 16 | |
| 12 | 17 | def get_raw_id_fields(self, request): |
| 13 | 18 |
| --- core/admin.py | |
| +++ core/admin.py | |
| @@ -3,10 +3,15 @@ | |
| 3 | |
| 4 | |
| 5 | class BaseCoreAdmin(ImportExportMixin, admin.ModelAdmin): |
| 6 | """Base admin class for all Fossilrepo models. Provides audit field handling and import/export.""" |
| 7 | |
| 8 | def get_readonly_fields(self, request, obj=None): |
| 9 | base = tuple(self.readonly_fields or ()) |
| 10 | return base + ("version", "created_at", "created_by", "updated_at", "updated_by", "deleted_at", "deleted_by") |
| 11 | |
| 12 | def get_raw_id_fields(self, request): |
| 13 |
| --- core/admin.py | |
| +++ core/admin.py | |
| @@ -3,10 +3,15 @@ | |
| 3 | |
| 4 | |
| 5 | class BaseCoreAdmin(ImportExportMixin, admin.ModelAdmin): |
| 6 | """Base admin class for all Fossilrepo models. Provides audit field handling and import/export.""" |
| 7 | |
| 8 | def get_queryset(self, request): |
| 9 | if hasattr(self.model, "all_objects"): |
| 10 | return self.model.all_objects.all() |
| 11 | return super().get_queryset(request) |
| 12 | |
| 13 | def get_readonly_fields(self, request, obj=None): |
| 14 | base = tuple(self.readonly_fields or ()) |
| 15 | return base + ("version", "created_at", "created_by", "updated_at", "updated_by", "deleted_at", "deleted_by") |
| 16 | |
| 17 | def get_raw_id_fields(self, request): |
| 18 |
-6
| --- core/permissions.py | ||
| +++ core/permissions.py | ||
| @@ -43,16 +43,10 @@ | ||
| 43 | 43 | PAGE_VIEW = "pages.view_page" |
| 44 | 44 | PAGE_ADD = "pages.add_page" |
| 45 | 45 | PAGE_CHANGE = "pages.change_page" |
| 46 | 46 | PAGE_DELETE = "pages.delete_page" |
| 47 | 47 | |
| 48 | - # Items (example domain) | |
| 49 | - ITEM_VIEW = "items.view_item" | |
| 50 | - ITEM_ADD = "items.add_item" | |
| 51 | - ITEM_CHANGE = "items.change_item" | |
| 52 | - ITEM_DELETE = "items.delete_item" | |
| 53 | - | |
| 54 | 48 | def check(self, user, raise_error=True): |
| 55 | 49 | """Check if user has this permission. Superusers always pass.""" |
| 56 | 50 | if not user or not user.is_authenticated: |
| 57 | 51 | if raise_error: |
| 58 | 52 | raise PermissionDenied("Authentication required.") |
| 59 | 53 |
| --- core/permissions.py | |
| +++ core/permissions.py | |
| @@ -43,16 +43,10 @@ | |
| 43 | PAGE_VIEW = "pages.view_page" |
| 44 | PAGE_ADD = "pages.add_page" |
| 45 | PAGE_CHANGE = "pages.change_page" |
| 46 | PAGE_DELETE = "pages.delete_page" |
| 47 | |
| 48 | # Items (example domain) |
| 49 | ITEM_VIEW = "items.view_item" |
| 50 | ITEM_ADD = "items.add_item" |
| 51 | ITEM_CHANGE = "items.change_item" |
| 52 | ITEM_DELETE = "items.delete_item" |
| 53 | |
| 54 | def check(self, user, raise_error=True): |
| 55 | """Check if user has this permission. Superusers always pass.""" |
| 56 | if not user or not user.is_authenticated: |
| 57 | if raise_error: |
| 58 | raise PermissionDenied("Authentication required.") |
| 59 |
| --- core/permissions.py | |
| +++ core/permissions.py | |
| @@ -43,16 +43,10 @@ | |
| 43 | PAGE_VIEW = "pages.view_page" |
| 44 | PAGE_ADD = "pages.add_page" |
| 45 | PAGE_CHANGE = "pages.change_page" |
| 46 | PAGE_DELETE = "pages.delete_page" |
| 47 | |
| 48 | def check(self, user, raise_error=True): |
| 49 | """Check if user has this permission. Superusers always pass.""" |
| 50 | if not user or not user.is_authenticated: |
| 51 | if raise_error: |
| 52 | raise PermissionDenied("Authentication required.") |
| 53 |
| --- core/templatetags/permissions_tags.py | ||
| +++ core/templatetags/permissions_tags.py | ||
| @@ -3,11 +3,11 @@ | ||
| 3 | 3 | register = template.Library() |
| 4 | 4 | |
| 5 | 5 | |
| 6 | 6 | @register.simple_tag(takes_context=True) |
| 7 | 7 | def has_perm(context, perm_string): |
| 8 | - """Check if the current user has a specific permission. Usage: {% has_perm 'items.view_item' as can_view %}""" | |
| 8 | + """Check if the current user has a specific permission. Usage: {% has_perm 'projects.view_project' as can_view %}""" | |
| 9 | 9 | user = context.get("user") or context["request"].user |
| 10 | 10 | if not user or not user.is_authenticated: |
| 11 | 11 | return False |
| 12 | 12 | if user.is_superuser: |
| 13 | 13 | return True |
| 14 | 14 |
| --- core/templatetags/permissions_tags.py | |
| +++ core/templatetags/permissions_tags.py | |
| @@ -3,11 +3,11 @@ | |
| 3 | register = template.Library() |
| 4 | |
| 5 | |
| 6 | @register.simple_tag(takes_context=True) |
| 7 | def has_perm(context, perm_string): |
| 8 | """Check if the current user has a specific permission. Usage: {% has_perm 'items.view_item' as can_view %}""" |
| 9 | user = context.get("user") or context["request"].user |
| 10 | if not user or not user.is_authenticated: |
| 11 | return False |
| 12 | if user.is_superuser: |
| 13 | return True |
| 14 |
| --- core/templatetags/permissions_tags.py | |
| +++ core/templatetags/permissions_tags.py | |
| @@ -3,11 +3,11 @@ | |
| 3 | register = template.Library() |
| 4 | |
| 5 | |
| 6 | @register.simple_tag(takes_context=True) |
| 7 | def has_perm(context, perm_string): |
| 8 | """Check if the current user has a specific permission. Usage: {% has_perm 'projects.view_project' as can_view %}""" |
| 9 | user = context.get("user") or context["request"].user |
| 10 | if not user or not user.is_authenticated: |
| 11 | return False |
| 12 | if user.is_superuser: |
| 13 | return True |
| 14 |
+32
-28
| --- core/tests.py | ||
| +++ core/tests.py | ||
| @@ -8,62 +8,66 @@ | ||
| 8 | 8 | |
| 9 | 9 | class TrackingModelTest(TestCase): |
| 10 | 10 | """Test the Tracking abstract model via a concrete model that uses it.""" |
| 11 | 11 | |
| 12 | 12 | def setUp(self): |
| 13 | - from items.models import Item | |
| 13 | + from organization.models import Organization | |
| 14 | + from projects.models import Project | |
| 14 | 15 | |
| 15 | 16 | self.user = User.objects.create_superuser(username="test", password="x") |
| 16 | - self.item = Item.objects.create(name="Test Widget", price="9.99", created_by=self.user) | |
| 17 | + self.org = Organization.objects.create(name="Test Org", created_by=self.user) | |
| 18 | + self.project = Project.objects.create(name="Test Project", organization=self.org, created_by=self.user) | |
| 17 | 19 | |
| 18 | 20 | def test_version_increments_on_save(self): |
| 19 | - initial_version = self.item.version | |
| 20 | - self.item.name = "Updated Widget" | |
| 21 | - self.item.save() | |
| 22 | - self.item.refresh_from_db() | |
| 23 | - self.assertEqual(self.item.version, initial_version + 1) | |
| 21 | + initial_version = self.project.version | |
| 22 | + self.project.name = "Updated Project" | |
| 23 | + self.project.save() | |
| 24 | + self.project.refresh_from_db() | |
| 25 | + self.assertEqual(self.project.version, initial_version + 1) | |
| 24 | 26 | |
| 25 | 27 | def test_soft_delete_sets_deleted_at(self): |
| 26 | - self.item.soft_delete(user=self.user) | |
| 27 | - self.item.refresh_from_db() | |
| 28 | - self.assertIsNotNone(self.item.deleted_at) | |
| 29 | - self.assertEqual(self.item.deleted_by, self.user) | |
| 30 | - self.assertTrue(self.item.is_deleted) | |
| 28 | + self.project.soft_delete(user=self.user) | |
| 29 | + self.project.refresh_from_db() | |
| 30 | + self.assertIsNotNone(self.project.deleted_at) | |
| 31 | + self.assertEqual(self.project.deleted_by, self.user) | |
| 32 | + self.assertTrue(self.project.is_deleted) | |
| 31 | 33 | |
| 32 | 34 | def test_created_at_auto_set(self): |
| 33 | - self.assertIsNotNone(self.item.created_at) | |
| 35 | + self.assertIsNotNone(self.project.created_at) | |
| 34 | 36 | |
| 35 | 37 | def test_updated_at_auto_set(self): |
| 36 | - self.assertIsNotNone(self.item.updated_at) | |
| 38 | + self.assertIsNotNone(self.project.updated_at) | |
| 37 | 39 | |
| 38 | 40 | |
| 39 | 41 | class BaseCoreModelTest(TestCase): |
| 40 | 42 | """Test BaseCoreModel slug generation and UUID.""" |
| 41 | 43 | |
| 42 | 44 | def setUp(self): |
| 43 | - from items.models import Item | |
| 45 | + from organization.models import Organization | |
| 46 | + from projects.models import Project | |
| 44 | 47 | |
| 45 | 48 | self.user = User.objects.create_superuser(username="test", password="x") |
| 46 | - self.item = Item.objects.create(name="My Item", price="19.99", created_by=self.user) | |
| 49 | + self.org = Organization.objects.create(name="Test Org", created_by=self.user) | |
| 50 | + self.project = Project.objects.create(name="My Project", organization=self.org, created_by=self.user) | |
| 47 | 51 | |
| 48 | 52 | def test_slug_auto_generated(self): |
| 49 | - self.assertEqual(self.item.slug, "my-item") | |
| 53 | + self.assertEqual(self.project.slug, "my-project") | |
| 50 | 54 | |
| 51 | 55 | def test_guid_is_uuid(self): |
| 52 | 56 | import uuid |
| 53 | 57 | |
| 54 | - self.assertIsInstance(self.item.guid, uuid.UUID) | |
| 58 | + self.assertIsInstance(self.project.guid, uuid.UUID) | |
| 55 | 59 | |
| 56 | 60 | def test_slug_uniqueness(self): |
| 57 | - from items.models import Item | |
| 61 | + from projects.models import Project | |
| 58 | 62 | |
| 59 | - p2 = Item.objects.create(name="My Item", price="29.99", created_by=self.user) | |
| 60 | - self.assertNotEqual(self.item.slug, p2.slug) | |
| 61 | - self.assertTrue(p2.slug.startswith("my-item")) | |
| 63 | + p2 = Project.objects.create(name="My Project", organization=self.org, created_by=self.user) | |
| 64 | + self.assertNotEqual(self.project.slug, p2.slug) | |
| 65 | + self.assertTrue(p2.slug.startswith("my-project")) | |
| 62 | 66 | |
| 63 | 67 | def test_str_returns_name(self): |
| 64 | - self.assertEqual(str(self.item), "My Item") | |
| 68 | + self.assertEqual(str(self.project), "My Project") | |
| 65 | 69 | |
| 66 | 70 | |
| 67 | 71 | class PermissionsTest(TestCase): |
| 68 | 72 | """Test the P permission enum.""" |
| 69 | 73 | |
| @@ -70,28 +74,28 @@ | ||
| 70 | 74 | def setUp(self): |
| 71 | 75 | self.superuser = User.objects.create_superuser(username="super", password="x") |
| 72 | 76 | self.regular = User.objects.create_user(username="regular", password="x") |
| 73 | 77 | |
| 74 | 78 | def test_superuser_passes_all_checks(self): |
| 75 | - self.assertTrue(P.ITEM_VIEW.check(self.superuser)) | |
| 76 | - self.assertTrue(P.ITEM_ADD.check(self.superuser)) | |
| 79 | + self.assertTrue(P.PROJECT_VIEW.check(self.superuser)) | |
| 80 | + self.assertTrue(P.PROJECT_ADD.check(self.superuser)) | |
| 77 | 81 | |
| 78 | 82 | def test_regular_user_without_perm_denied(self): |
| 79 | 83 | from django.core.exceptions import PermissionDenied |
| 80 | 84 | |
| 81 | 85 | with self.assertRaises(PermissionDenied): |
| 82 | - P.ITEM_ADD.check(self.regular) | |
| 86 | + P.PROJECT_ADD.check(self.regular) | |
| 83 | 87 | |
| 84 | 88 | def test_regular_user_without_perm_returns_false(self): |
| 85 | - self.assertFalse(P.ITEM_ADD.check(self.regular, raise_error=False)) | |
| 89 | + self.assertFalse(P.PROJECT_ADD.check(self.regular, raise_error=False)) | |
| 86 | 90 | |
| 87 | 91 | def test_unauthenticated_user_denied(self): |
| 88 | 92 | from django.contrib.auth.models import AnonymousUser |
| 89 | 93 | from django.core.exceptions import PermissionDenied |
| 90 | 94 | |
| 91 | 95 | with self.assertRaises(PermissionDenied): |
| 92 | - P.ITEM_VIEW.check(AnonymousUser()) | |
| 96 | + P.PROJECT_VIEW.check(AnonymousUser()) | |
| 93 | 97 | |
| 94 | 98 | |
| 95 | 99 | @pytest.mark.django_db |
| 96 | 100 | class TestDashboard: |
| 97 | 101 | def test_dashboard_requires_login(self, client): |
| 98 | 102 |
| --- core/tests.py | |
| +++ core/tests.py | |
| @@ -8,62 +8,66 @@ | |
| 8 | |
| 9 | class TrackingModelTest(TestCase): |
| 10 | """Test the Tracking abstract model via a concrete model that uses it.""" |
| 11 | |
| 12 | def setUp(self): |
| 13 | from items.models import Item |
| 14 | |
| 15 | self.user = User.objects.create_superuser(username="test", password="x") |
| 16 | self.item = Item.objects.create(name="Test Widget", price="9.99", created_by=self.user) |
| 17 | |
| 18 | def test_version_increments_on_save(self): |
| 19 | initial_version = self.item.version |
| 20 | self.item.name = "Updated Widget" |
| 21 | self.item.save() |
| 22 | self.item.refresh_from_db() |
| 23 | self.assertEqual(self.item.version, initial_version + 1) |
| 24 | |
| 25 | def test_soft_delete_sets_deleted_at(self): |
| 26 | self.item.soft_delete(user=self.user) |
| 27 | self.item.refresh_from_db() |
| 28 | self.assertIsNotNone(self.item.deleted_at) |
| 29 | self.assertEqual(self.item.deleted_by, self.user) |
| 30 | self.assertTrue(self.item.is_deleted) |
| 31 | |
| 32 | def test_created_at_auto_set(self): |
| 33 | self.assertIsNotNone(self.item.created_at) |
| 34 | |
| 35 | def test_updated_at_auto_set(self): |
| 36 | self.assertIsNotNone(self.item.updated_at) |
| 37 | |
| 38 | |
| 39 | class BaseCoreModelTest(TestCase): |
| 40 | """Test BaseCoreModel slug generation and UUID.""" |
| 41 | |
| 42 | def setUp(self): |
| 43 | from items.models import Item |
| 44 | |
| 45 | self.user = User.objects.create_superuser(username="test", password="x") |
| 46 | self.item = Item.objects.create(name="My Item", price="19.99", created_by=self.user) |
| 47 | |
| 48 | def test_slug_auto_generated(self): |
| 49 | self.assertEqual(self.item.slug, "my-item") |
| 50 | |
| 51 | def test_guid_is_uuid(self): |
| 52 | import uuid |
| 53 | |
| 54 | self.assertIsInstance(self.item.guid, uuid.UUID) |
| 55 | |
| 56 | def test_slug_uniqueness(self): |
| 57 | from items.models import Item |
| 58 | |
| 59 | p2 = Item.objects.create(name="My Item", price="29.99", created_by=self.user) |
| 60 | self.assertNotEqual(self.item.slug, p2.slug) |
| 61 | self.assertTrue(p2.slug.startswith("my-item")) |
| 62 | |
| 63 | def test_str_returns_name(self): |
| 64 | self.assertEqual(str(self.item), "My Item") |
| 65 | |
| 66 | |
| 67 | class PermissionsTest(TestCase): |
| 68 | """Test the P permission enum.""" |
| 69 | |
| @@ -70,28 +74,28 @@ | |
| 70 | def setUp(self): |
| 71 | self.superuser = User.objects.create_superuser(username="super", password="x") |
| 72 | self.regular = User.objects.create_user(username="regular", password="x") |
| 73 | |
| 74 | def test_superuser_passes_all_checks(self): |
| 75 | self.assertTrue(P.ITEM_VIEW.check(self.superuser)) |
| 76 | self.assertTrue(P.ITEM_ADD.check(self.superuser)) |
| 77 | |
| 78 | def test_regular_user_without_perm_denied(self): |
| 79 | from django.core.exceptions import PermissionDenied |
| 80 | |
| 81 | with self.assertRaises(PermissionDenied): |
| 82 | P.ITEM_ADD.check(self.regular) |
| 83 | |
| 84 | def test_regular_user_without_perm_returns_false(self): |
| 85 | self.assertFalse(P.ITEM_ADD.check(self.regular, raise_error=False)) |
| 86 | |
| 87 | def test_unauthenticated_user_denied(self): |
| 88 | from django.contrib.auth.models import AnonymousUser |
| 89 | from django.core.exceptions import PermissionDenied |
| 90 | |
| 91 | with self.assertRaises(PermissionDenied): |
| 92 | P.ITEM_VIEW.check(AnonymousUser()) |
| 93 | |
| 94 | |
| 95 | @pytest.mark.django_db |
| 96 | class TestDashboard: |
| 97 | def test_dashboard_requires_login(self, client): |
| 98 |
| --- core/tests.py | |
| +++ core/tests.py | |
| @@ -8,62 +8,66 @@ | |
| 8 | |
| 9 | class TrackingModelTest(TestCase): |
| 10 | """Test the Tracking abstract model via a concrete model that uses it.""" |
| 11 | |
| 12 | def setUp(self): |
| 13 | from organization.models import Organization |
| 14 | from projects.models import Project |
| 15 | |
| 16 | self.user = User.objects.create_superuser(username="test", password="x") |
| 17 | self.org = Organization.objects.create(name="Test Org", created_by=self.user) |
| 18 | self.project = Project.objects.create(name="Test Project", organization=self.org, created_by=self.user) |
| 19 | |
| 20 | def test_version_increments_on_save(self): |
| 21 | initial_version = self.project.version |
| 22 | self.project.name = "Updated Project" |
| 23 | self.project.save() |
| 24 | self.project.refresh_from_db() |
| 25 | self.assertEqual(self.project.version, initial_version + 1) |
| 26 | |
| 27 | def test_soft_delete_sets_deleted_at(self): |
| 28 | self.project.soft_delete(user=self.user) |
| 29 | self.project.refresh_from_db() |
| 30 | self.assertIsNotNone(self.project.deleted_at) |
| 31 | self.assertEqual(self.project.deleted_by, self.user) |
| 32 | self.assertTrue(self.project.is_deleted) |
| 33 | |
| 34 | def test_created_at_auto_set(self): |
| 35 | self.assertIsNotNone(self.project.created_at) |
| 36 | |
| 37 | def test_updated_at_auto_set(self): |
| 38 | self.assertIsNotNone(self.project.updated_at) |
| 39 | |
| 40 | |
| 41 | class BaseCoreModelTest(TestCase): |
| 42 | """Test BaseCoreModel slug generation and UUID.""" |
| 43 | |
| 44 | def setUp(self): |
| 45 | from organization.models import Organization |
| 46 | from projects.models import Project |
| 47 | |
| 48 | self.user = User.objects.create_superuser(username="test", password="x") |
| 49 | self.org = Organization.objects.create(name="Test Org", created_by=self.user) |
| 50 | self.project = Project.objects.create(name="My Project", organization=self.org, created_by=self.user) |
| 51 | |
| 52 | def test_slug_auto_generated(self): |
| 53 | self.assertEqual(self.project.slug, "my-project") |
| 54 | |
| 55 | def test_guid_is_uuid(self): |
| 56 | import uuid |
| 57 | |
| 58 | self.assertIsInstance(self.project.guid, uuid.UUID) |
| 59 | |
| 60 | def test_slug_uniqueness(self): |
| 61 | from projects.models import Project |
| 62 | |
| 63 | p2 = Project.objects.create(name="My Project", organization=self.org, created_by=self.user) |
| 64 | self.assertNotEqual(self.project.slug, p2.slug) |
| 65 | self.assertTrue(p2.slug.startswith("my-project")) |
| 66 | |
| 67 | def test_str_returns_name(self): |
| 68 | self.assertEqual(str(self.project), "My Project") |
| 69 | |
| 70 | |
| 71 | class PermissionsTest(TestCase): |
| 72 | """Test the P permission enum.""" |
| 73 | |
| @@ -70,28 +74,28 @@ | |
| 74 | def setUp(self): |
| 75 | self.superuser = User.objects.create_superuser(username="super", password="x") |
| 76 | self.regular = User.objects.create_user(username="regular", password="x") |
| 77 | |
| 78 | def test_superuser_passes_all_checks(self): |
| 79 | self.assertTrue(P.PROJECT_VIEW.check(self.superuser)) |
| 80 | self.assertTrue(P.PROJECT_ADD.check(self.superuser)) |
| 81 | |
| 82 | def test_regular_user_without_perm_denied(self): |
| 83 | from django.core.exceptions import PermissionDenied |
| 84 | |
| 85 | with self.assertRaises(PermissionDenied): |
| 86 | P.PROJECT_ADD.check(self.regular) |
| 87 | |
| 88 | def test_regular_user_without_perm_returns_false(self): |
| 89 | self.assertFalse(P.PROJECT_ADD.check(self.regular, raise_error=False)) |
| 90 | |
| 91 | def test_unauthenticated_user_denied(self): |
| 92 | from django.contrib.auth.models import AnonymousUser |
| 93 | from django.core.exceptions import PermissionDenied |
| 94 | |
| 95 | with self.assertRaises(PermissionDenied): |
| 96 | P.PROJECT_VIEW.check(AnonymousUser()) |
| 97 | |
| 98 | |
| 99 | @pytest.mark.django_db |
| 100 | class TestDashboard: |
| 101 | def test_dashboard_requires_login(self, client): |
| 102 |
+25
| --- fossil/admin.py | ||
| +++ fossil/admin.py | ||
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | from django.contrib import admin |
| 2 | 2 | |
| 3 | 3 | from core.admin import BaseCoreAdmin |
| 4 | 4 | |
| 5 | 5 | from .models import FossilRepository, FossilSnapshot |
| 6 | +from .notifications import Notification, ProjectWatch | |
| 6 | 7 | from .sync_models import GitMirror, SSHKey, SyncLog |
| 7 | 8 | from .user_keys import UserSSHKey |
| 8 | 9 | |
| 9 | 10 | |
| 10 | 11 | class FossilSnapshotInline(admin.TabularInline): |
| @@ -51,5 +52,29 @@ | ||
| 51 | 52 | class UserSSHKeyAdmin(BaseCoreAdmin): |
| 52 | 53 | list_display = ("title", "user", "key_type", "fingerprint", "last_used_at", "created_at") |
| 53 | 54 | list_filter = ("key_type",) |
| 54 | 55 | search_fields = ("title", "user__username", "fingerprint") |
| 55 | 56 | readonly_fields = ("fingerprint", "key_type") |
| 57 | + | |
| 58 | + | |
| 59 | +@admin.register(Notification) | |
| 60 | +class NotificationAdmin(admin.ModelAdmin): | |
| 61 | + list_display = ("title", "user", "project", "event_type", "read", "emailed", "created_at") | |
| 62 | + list_filter = ("event_type", "read", "emailed") | |
| 63 | + search_fields = ("title", "user__username", "project__name") | |
| 64 | + raw_id_fields = ("user", "project") | |
| 65 | + | |
| 66 | + | |
| 67 | +@admin.register(ProjectWatch) | |
| 68 | +class ProjectWatchAdmin(BaseCoreAdmin): | |
| 69 | + list_display = ("user", "project", "event_filter", "email_enabled", "created_at") | |
| 70 | + list_filter = ("event_filter", "email_enabled") | |
| 71 | + search_fields = ("user__username", "project__name") | |
| 72 | + raw_id_fields = ("user", "project") | |
| 73 | + | |
| 74 | + | |
| 75 | +@admin.register(SyncLog) | |
| 76 | +class SyncLogAdmin(admin.ModelAdmin): | |
| 77 | + list_display = ("mirror", "status", "started_at", "completed_at", "artifacts_synced", "triggered_by") | |
| 78 | + list_filter = ("status", "triggered_by") | |
| 79 | + search_fields = ("mirror__repository__filename", "message") | |
| 80 | + raw_id_fields = ("mirror",) | |
| 56 | 81 | |
| 57 | 82 | DELETED items/__init__.py |
| 58 | 83 | DELETED items/admin.py |
| 59 | 84 | DELETED items/apps.py |
| 60 | 85 | DELETED items/forms.py |
| 61 | 86 | DELETED items/migrations/0001_initial.py |
| 62 | 87 | DELETED items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py |
| 63 | 88 | DELETED items/migrations/__init__.py |
| 64 | 89 | DELETED items/models.py |
| 65 | 90 | DELETED items/tests.py |
| 66 | 91 | DELETED items/urls.py |
| 67 | 92 | DELETED items/views.py |
| --- fossil/admin.py | |
| +++ fossil/admin.py | |
| @@ -1,10 +1,11 @@ | |
| 1 | from django.contrib import admin |
| 2 | |
| 3 | from core.admin import BaseCoreAdmin |
| 4 | |
| 5 | from .models import FossilRepository, FossilSnapshot |
| 6 | from .sync_models import GitMirror, SSHKey, SyncLog |
| 7 | from .user_keys import UserSSHKey |
| 8 | |
| 9 | |
| 10 | class FossilSnapshotInline(admin.TabularInline): |
| @@ -51,5 +52,29 @@ | |
| 51 | class UserSSHKeyAdmin(BaseCoreAdmin): |
| 52 | list_display = ("title", "user", "key_type", "fingerprint", "last_used_at", "created_at") |
| 53 | list_filter = ("key_type",) |
| 54 | search_fields = ("title", "user__username", "fingerprint") |
| 55 | readonly_fields = ("fingerprint", "key_type") |
| 56 | |
| 57 | ELETED items/__init__.py |
| 58 | ELETED items/admin.py |
| 59 | ELETED items/apps.py |
| 60 | ELETED items/forms.py |
| 61 | ELETED items/migrations/0001_initial.py |
| 62 | ELETED items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py |
| 63 | ELETED items/migrations/__init__.py |
| 64 | ELETED items/models.py |
| 65 | ELETED items/tests.py |
| 66 | ELETED items/urls.py |
| 67 | ELETED items/views.py |
| --- fossil/admin.py | |
| +++ fossil/admin.py | |
| @@ -1,10 +1,11 @@ | |
| 1 | from django.contrib import admin |
| 2 | |
| 3 | from core.admin import BaseCoreAdmin |
| 4 | |
| 5 | from .models import FossilRepository, FossilSnapshot |
| 6 | from .notifications import Notification, ProjectWatch |
| 7 | from .sync_models import GitMirror, SSHKey, SyncLog |
| 8 | from .user_keys import UserSSHKey |
| 9 | |
| 10 | |
| 11 | class FossilSnapshotInline(admin.TabularInline): |
| @@ -51,5 +52,29 @@ | |
| 52 | class UserSSHKeyAdmin(BaseCoreAdmin): |
| 53 | list_display = ("title", "user", "key_type", "fingerprint", "last_used_at", "created_at") |
| 54 | list_filter = ("key_type",) |
| 55 | search_fields = ("title", "user__username", "fingerprint") |
| 56 | readonly_fields = ("fingerprint", "key_type") |
| 57 | |
| 58 | |
| 59 | @admin.register(Notification) |
| 60 | class NotificationAdmin(admin.ModelAdmin): |
| 61 | list_display = ("title", "user", "project", "event_type", "read", "emailed", "created_at") |
| 62 | list_filter = ("event_type", "read", "emailed") |
| 63 | search_fields = ("title", "user__username", "project__name") |
| 64 | raw_id_fields = ("user", "project") |
| 65 | |
| 66 | |
| 67 | @admin.register(ProjectWatch) |
| 68 | class ProjectWatchAdmin(BaseCoreAdmin): |
| 69 | list_display = ("user", "project", "event_filter", "email_enabled", "created_at") |
| 70 | list_filter = ("event_filter", "email_enabled") |
| 71 | search_fields = ("user__username", "project__name") |
| 72 | raw_id_fields = ("user", "project") |
| 73 | |
| 74 | |
| 75 | @admin.register(SyncLog) |
| 76 | class SyncLogAdmin(admin.ModelAdmin): |
| 77 | list_display = ("mirror", "status", "started_at", "completed_at", "artifacts_synced", "triggered_by") |
| 78 | list_filter = ("status", "triggered_by") |
| 79 | search_fields = ("mirror__repository__filename", "message") |
| 80 | raw_id_fields = ("mirror",) |
| 81 | |
| 82 | ELETED items/__init__.py |
| 83 | ELETED items/admin.py |
| 84 | ELETED items/apps.py |
| 85 | ELETED items/forms.py |
| 86 | ELETED items/migrations/0001_initial.py |
| 87 | ELETED items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py |
| 88 | ELETED items/migrations/__init__.py |
| 89 | ELETED items/models.py |
| 90 | ELETED items/tests.py |
| 91 | ELETED items/urls.py |
| 92 | ELETED items/views.py |
D
items/__init__.py
No diff available
D
items/admin.py
-12
| --- a/items/admin.py | ||
| +++ b/items/admin.py | ||
| @@ -1,12 +0,0 @@ | ||
| 1 | -from django.contrib import admin | |
| 2 | - | |
| 3 | -from core.admin import BaseCoreAdmin | |
| 4 | - | |
| 5 | -from .models import Item | |
| 6 | - | |
| 7 | - | |
| 8 | -@admin.register(Item) | |
| 9 | -class ItemAdmin(BaseCoreAdmin): | |
| 10 | - list_display = ("name", "slug", "price", "sku", "is_active", "created_at") | |
| 11 | - list_filter = ("is_active",) | |
| 12 | - search_fields = ("name", "slug", "sku") |
| --- a/items/admin.py | |
| +++ b/items/admin.py | |
| @@ -1,12 +0,0 @@ | |
| 1 | from django.contrib import admin |
| 2 | |
| 3 | from core.admin import BaseCoreAdmin |
| 4 | |
| 5 | from .models import Item |
| 6 | |
| 7 | |
| 8 | @admin.register(Item) |
| 9 | class ItemAdmin(BaseCoreAdmin): |
| 10 | list_display = ("name", "slug", "price", "sku", "is_active", "created_at") |
| 11 | list_filter = ("is_active",) |
| 12 | search_fields = ("name", "slug", "sku") |
| --- a/items/admin.py | |
| +++ b/items/admin.py | |
| @@ -1,12 +0,0 @@ | |
D
items/apps.py
-6
| --- a/items/apps.py | ||
| +++ b/items/apps.py | ||
| @@ -1,6 +0,0 @@ | ||
| 1 | -from django.apps import AppConfig | |
| 2 | - | |
| 3 | - | |
| 4 | -class ItemsConfig(AppConfig): | |
| 5 | - default_auto_field = "django.db.models.BigAutoField" | |
| 6 | - name = "items" |
| --- a/items/apps.py | |
| +++ b/items/apps.py | |
| @@ -1,6 +0,0 @@ | |
| 1 | from django.apps import AppConfig |
| 2 | |
| 3 | |
| 4 | class ItemsConfig(AppConfig): |
| 5 | default_auto_field = "django.db.models.BigAutoField" |
| 6 | name = "items" |
| --- a/items/apps.py | |
| +++ b/items/apps.py | |
| @@ -1,6 +0,0 @@ | |
D
items/forms.py
-18
| --- a/items/forms.py | ||
| +++ b/items/forms.py | ||
| @@ -1,18 +0,0 @@ | ||
| 1 | -from django import forms | |
| 2 | - | |
| 3 | -from .models import Item | |
| 4 | - | |
| 5 | -tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" | |
| 6 | - | |
| 7 | - | |
| 8 | -class ItemForm(forms.ModelForm): | |
| 9 | - class Meta: | |
| 10 | - model = Item | |
| 11 | - fields = ["name", "description", "price", "sku", "is_active"] | |
| 12 | - widgets = { | |
| 13 | - "name": forms.TextInput(attrs={"class": tw, "placeholder": "Item name"}), | |
| 14 | - "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}), | |
| 15 | - "price": forms.NumberInput(attrs={"class": tw, "step": "0.01", "placeholder": "0.00"}), | |
| 16 | - "sku": forms.TextInput(attrs={"class": tw, "placeholder": "SKU-001"}), | |
| 17 | - "is_active": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand"}), | |
| 18 | - } |
| --- a/items/forms.py | |
| +++ b/items/forms.py | |
| @@ -1,18 +0,0 @@ | |
| 1 | from django import forms |
| 2 | |
| 3 | from .models import Item |
| 4 | |
| 5 | tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 6 | |
| 7 | |
| 8 | class ItemForm(forms.ModelForm): |
| 9 | class Meta: |
| 10 | model = Item |
| 11 | fields = ["name", "description", "price", "sku", "is_active"] |
| 12 | widgets = { |
| 13 | "name": forms.TextInput(attrs={"class": tw, "placeholder": "Item name"}), |
| 14 | "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}), |
| 15 | "price": forms.NumberInput(attrs={"class": tw, "step": "0.01", "placeholder": "0.00"}), |
| 16 | "sku": forms.TextInput(attrs={"class": tw, "placeholder": "SKU-001"}), |
| 17 | "is_active": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand"}), |
| 18 | } |
| --- a/items/forms.py | |
| +++ b/items/forms.py | |
| @@ -1,18 +0,0 @@ | |
D
items/migrations/0001_initial.py
-168
| --- a/items/migrations/0001_initial.py | ||
| +++ b/items/migrations/0001_initial.py | ||
| @@ -1,168 +0,0 @@ | ||
| 1 | -# Generated by Django 5.2.12 on 2026-03-26 05:59 | |
| 2 | - | |
| 3 | -import uuid | |
| 4 | - | |
| 5 | -import django.db.models.deletion | |
| 6 | -import simple_history.models | |
| 7 | -from django.conf import settings | |
| 8 | -from django.db import migrations, models | |
| 9 | - | |
| 10 | - | |
| 11 | -class Migration(migrations.Migration): | |
| 12 | - initial = True | |
| 13 | - | |
| 14 | - dependencies = [ | |
| 15 | - migrations.swappable_dependency(settings.AUTH_USER_MODEL), | |
| 16 | - ] | |
| 17 | - | |
| 18 | - operations = [ | |
| 19 | - migrations.CreateModel( | |
| 20 | - name="HistoricalItem", | |
| 21 | - fields=[ | |
| 22 | - ( | |
| 23 | - "id", | |
| 24 | - models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"), | |
| 25 | - ), | |
| 26 | - ("version", models.PositiveIntegerField(default=1, editable=False)), | |
| 27 | - ("created_at", models.DateTimeField(blank=True, editable=False)), | |
| 28 | - ("updated_at", models.DateTimeField(blank=True, editable=False)), | |
| 29 | - ("deleted_at", models.DateTimeField(blank=True, null=True)), | |
| 30 | - ( | |
| 31 | - "guid", | |
| 32 | - models.UUIDField(db_index=True, default=uuid.uuid4, editable=False), | |
| 33 | - ), | |
| 34 | - ("name", models.CharField(max_length=200)), | |
| 35 | - ("slug", models.SlugField(max_length=200)), | |
| 36 | - ("description", models.TextField(blank=True, default="")), | |
| 37 | - ("price", models.DecimalField(decimal_places=2, max_digits=10)), | |
| 38 | - ( | |
| 39 | - "sku", | |
| 40 | - models.CharField(blank=True, db_index=True, default="", max_length=50), | |
| 41 | - ), | |
| 42 | - ("is_active", models.BooleanField(default=True)), | |
| 43 | - ("history_id", models.AutoField(primary_key=True, serialize=False)), | |
| 44 | - ("history_date", models.DateTimeField(db_index=True)), | |
| 45 | - ("history_change_reason", models.CharField(max_length=100, null=True)), | |
| 46 | - ( | |
| 47 | - "history_type", | |
| 48 | - models.CharField( | |
| 49 | - choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], | |
| 50 | - max_length=1, | |
| 51 | - ), | |
| 52 | - ), | |
| 53 | - ( | |
| 54 | - "created_by", | |
| 55 | - models.ForeignKey( | |
| 56 | - blank=True, | |
| 57 | - db_constraint=False, | |
| 58 | - null=True, | |
| 59 | - on_delete=django.db.models.deletion.DO_NOTHING, | |
| 60 | - related_name="+", | |
| 61 | - to=settings.AUTH_USER_MODEL, | |
| 62 | - ), | |
| 63 | - ), | |
| 64 | - ( | |
| 65 | - "deleted_by", | |
| 66 | - models.ForeignKey( | |
| 67 | - blank=True, | |
| 68 | - db_constraint=False, | |
| 69 | - null=True, | |
| 70 | - on_delete=django.db.models.deletion.DO_NOTHING, | |
| 71 | - related_name="+", | |
| 72 | - to=settings.AUTH_USER_MODEL, | |
| 73 | - ), | |
| 74 | - ), | |
| 75 | - ( | |
| 76 | - "history_user", | |
| 77 | - models.ForeignKey( | |
| 78 | - null=True, | |
| 79 | - on_delete=django.db.models.deletion.SET_NULL, | |
| 80 | - related_name="+", | |
| 81 | - to=settings.AUTH_USER_MODEL, | |
| 82 | - ), | |
| 83 | - ), | |
| 84 | - ( | |
| 85 | - "updated_by", | |
| 86 | - models.ForeignKey( | |
| 87 | - blank=True, | |
| 88 | - db_constraint=False, | |
| 89 | - null=True, | |
| 90 | - on_delete=django.db.models.deletion.DO_NOTHING, | |
| 91 | - related_name="+", | |
| 92 | - to=settings.AUTH_USER_MODEL, | |
| 93 | - ), | |
| 94 | - ), | |
| 95 | - ], | |
| 96 | - options={ | |
| 97 | - "verbose_name": "historical item", | |
| 98 | - "verbose_name_plural": "historical items", | |
| 99 | - "ordering": ("-history_date", "-history_id"), | |
| 100 | - "get_latest_by": ("history_date", "history_id"), | |
| 101 | - }, | |
| 102 | - bases=(simple_history.models.HistoricalChanges, models.Model), | |
| 103 | - ), | |
| 104 | - migrations.CreateModel( | |
| 105 | - name="Item", | |
| 106 | - fields=[ | |
| 107 | - ( | |
| 108 | - "id", | |
| 109 | - models.BigAutoField( | |
| 110 | - auto_created=True, | |
| 111 | - primary_key=True, | |
| 112 | - serialize=False, | |
| 113 | - verbose_name="ID", | |
| 114 | - ), | |
| 115 | - ), | |
| 116 | - ("version", models.PositiveIntegerField(default=1, editable=False)), | |
| 117 | - ("created_at", models.DateTimeField(auto_now_add=True)), | |
| 118 | - ("updated_at", models.DateTimeField(auto_now=True)), | |
| 119 | - ("deleted_at", models.DateTimeField(blank=True, null=True)), | |
| 120 | - ( | |
| 121 | - "guid", | |
| 122 | - models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True), | |
| 123 | - ), | |
| 124 | - ("name", models.CharField(max_length=200)), | |
| 125 | - ("slug", models.SlugField(max_length=200, unique=True)), | |
| 126 | - ("description", models.TextField(blank=True, default="")), | |
| 127 | - ("price", models.DecimalField(decimal_places=2, max_digits=10)), | |
| 128 | - ( | |
| 129 | - "sku", | |
| 130 | - models.CharField(blank=True, default="", max_length=50, unique=True), | |
| 131 | - ), | |
| 132 | - ("is_active", models.BooleanField(default=True)), | |
| 133 | - ( | |
| 134 | - "created_by", | |
| 135 | - models.ForeignKey( | |
| 136 | - blank=True, | |
| 137 | - null=True, | |
| 138 | - on_delete=django.db.models.deletion.SET_NULL, | |
| 139 | - related_name="+", | |
| 140 | - to=settings.AUTH_USER_MODEL, | |
| 141 | - ), | |
| 142 | - ), | |
| 143 | - ( | |
| 144 | - "deleted_by", | |
| 145 | - models.ForeignKey( | |
| 146 | - blank=True, | |
| 147 | - null=True, | |
| 148 | - on_delete=django.db.models.deletion.SET_NULL, | |
| 149 | - related_name="+", | |
| 150 | - to=settings.AUTH_USER_MODEL, | |
| 151 | - ), | |
| 152 | - ), | |
| 153 | - ( | |
| 154 | - "updated_by", | |
| 155 | - models.ForeignKey( | |
| 156 | - blank=True, | |
| 157 | - null=True, | |
| 158 | - on_delete=django.db.models.deletion.SET_NULL, | |
| 159 | - related_name="+", | |
| 160 | - to=settings.AUTH_USER_MODEL, | |
| 161 | - ), | |
| 162 | - ), | |
| 163 | - ], | |
| 164 | - options={ | |
| 165 | - "ordering": ["-created_at"], | |
| 166 | - }, | |
| 167 | - ), | |
| 168 | - ] |
| --- a/items/migrations/0001_initial.py | |
| +++ b/items/migrations/0001_initial.py | |
| @@ -1,168 +0,0 @@ | |
| 1 | # Generated by Django 5.2.12 on 2026-03-26 05:59 |
| 2 | |
| 3 | import uuid |
| 4 | |
| 5 | import django.db.models.deletion |
| 6 | import simple_history.models |
| 7 | from django.conf import settings |
| 8 | from django.db import migrations, models |
| 9 | |
| 10 | |
| 11 | class Migration(migrations.Migration): |
| 12 | initial = True |
| 13 | |
| 14 | dependencies = [ |
| 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
| 16 | ] |
| 17 | |
| 18 | operations = [ |
| 19 | migrations.CreateModel( |
| 20 | name="HistoricalItem", |
| 21 | fields=[ |
| 22 | ( |
| 23 | "id", |
| 24 | models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"), |
| 25 | ), |
| 26 | ("version", models.PositiveIntegerField(default=1, editable=False)), |
| 27 | ("created_at", models.DateTimeField(blank=True, editable=False)), |
| 28 | ("updated_at", models.DateTimeField(blank=True, editable=False)), |
| 29 | ("deleted_at", models.DateTimeField(blank=True, null=True)), |
| 30 | ( |
| 31 | "guid", |
| 32 | models.UUIDField(db_index=True, default=uuid.uuid4, editable=False), |
| 33 | ), |
| 34 | ("name", models.CharField(max_length=200)), |
| 35 | ("slug", models.SlugField(max_length=200)), |
| 36 | ("description", models.TextField(blank=True, default="")), |
| 37 | ("price", models.DecimalField(decimal_places=2, max_digits=10)), |
| 38 | ( |
| 39 | "sku", |
| 40 | models.CharField(blank=True, db_index=True, default="", max_length=50), |
| 41 | ), |
| 42 | ("is_active", models.BooleanField(default=True)), |
| 43 | ("history_id", models.AutoField(primary_key=True, serialize=False)), |
| 44 | ("history_date", models.DateTimeField(db_index=True)), |
| 45 | ("history_change_reason", models.CharField(max_length=100, null=True)), |
| 46 | ( |
| 47 | "history_type", |
| 48 | models.CharField( |
| 49 | choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], |
| 50 | max_length=1, |
| 51 | ), |
| 52 | ), |
| 53 | ( |
| 54 | "created_by", |
| 55 | models.ForeignKey( |
| 56 | blank=True, |
| 57 | db_constraint=False, |
| 58 | null=True, |
| 59 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 60 | related_name="+", |
| 61 | to=settings.AUTH_USER_MODEL, |
| 62 | ), |
| 63 | ), |
| 64 | ( |
| 65 | "deleted_by", |
| 66 | models.ForeignKey( |
| 67 | blank=True, |
| 68 | db_constraint=False, |
| 69 | null=True, |
| 70 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 71 | related_name="+", |
| 72 | to=settings.AUTH_USER_MODEL, |
| 73 | ), |
| 74 | ), |
| 75 | ( |
| 76 | "history_user", |
| 77 | models.ForeignKey( |
| 78 | null=True, |
| 79 | on_delete=django.db.models.deletion.SET_NULL, |
| 80 | related_name="+", |
| 81 | to=settings.AUTH_USER_MODEL, |
| 82 | ), |
| 83 | ), |
| 84 | ( |
| 85 | "updated_by", |
| 86 | models.ForeignKey( |
| 87 | blank=True, |
| 88 | db_constraint=False, |
| 89 | null=True, |
| 90 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 91 | related_name="+", |
| 92 | to=settings.AUTH_USER_MODEL, |
| 93 | ), |
| 94 | ), |
| 95 | ], |
| 96 | options={ |
| 97 | "verbose_name": "historical item", |
| 98 | "verbose_name_plural": "historical items", |
| 99 | "ordering": ("-history_date", "-history_id"), |
| 100 | "get_latest_by": ("history_date", "history_id"), |
| 101 | }, |
| 102 | bases=(simple_history.models.HistoricalChanges, models.Model), |
| 103 | ), |
| 104 | migrations.CreateModel( |
| 105 | name="Item", |
| 106 | fields=[ |
| 107 | ( |
| 108 | "id", |
| 109 | models.BigAutoField( |
| 110 | auto_created=True, |
| 111 | primary_key=True, |
| 112 | serialize=False, |
| 113 | verbose_name="ID", |
| 114 | ), |
| 115 | ), |
| 116 | ("version", models.PositiveIntegerField(default=1, editable=False)), |
| 117 | ("created_at", models.DateTimeField(auto_now_add=True)), |
| 118 | ("updated_at", models.DateTimeField(auto_now=True)), |
| 119 | ("deleted_at", models.DateTimeField(blank=True, null=True)), |
| 120 | ( |
| 121 | "guid", |
| 122 | models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True), |
| 123 | ), |
| 124 | ("name", models.CharField(max_length=200)), |
| 125 | ("slug", models.SlugField(max_length=200, unique=True)), |
| 126 | ("description", models.TextField(blank=True, default="")), |
| 127 | ("price", models.DecimalField(decimal_places=2, max_digits=10)), |
| 128 | ( |
| 129 | "sku", |
| 130 | models.CharField(blank=True, default="", max_length=50, unique=True), |
| 131 | ), |
| 132 | ("is_active", models.BooleanField(default=True)), |
| 133 | ( |
| 134 | "created_by", |
| 135 | models.ForeignKey( |
| 136 | blank=True, |
| 137 | null=True, |
| 138 | on_delete=django.db.models.deletion.SET_NULL, |
| 139 | related_name="+", |
| 140 | to=settings.AUTH_USER_MODEL, |
| 141 | ), |
| 142 | ), |
| 143 | ( |
| 144 | "deleted_by", |
| 145 | models.ForeignKey( |
| 146 | blank=True, |
| 147 | null=True, |
| 148 | on_delete=django.db.models.deletion.SET_NULL, |
| 149 | related_name="+", |
| 150 | to=settings.AUTH_USER_MODEL, |
| 151 | ), |
| 152 | ), |
| 153 | ( |
| 154 | "updated_by", |
| 155 | models.ForeignKey( |
| 156 | blank=True, |
| 157 | null=True, |
| 158 | on_delete=django.db.models.deletion.SET_NULL, |
| 159 | related_name="+", |
| 160 | to=settings.AUTH_USER_MODEL, |
| 161 | ), |
| 162 | ), |
| 163 | ], |
| 164 | options={ |
| 165 | "ordering": ["-created_at"], |
| 166 | }, |
| 167 | ), |
| 168 | ] |
| --- a/items/migrations/0001_initial.py | |
| +++ b/items/migrations/0001_initial.py | |
| @@ -1,168 +0,0 @@ | |
D
items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py
-22
| --- a/items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py | ||
| +++ b/items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py | ||
| @@ -1,22 +0,0 @@ | ||
| 1 | -# Generated by Django 5.2.12 on 2026-03-26 06:01 | |
| 2 | - | |
| 3 | -from django.db import migrations, models | |
| 4 | - | |
| 5 | - | |
| 6 | -class Migration(migrations.Migration): | |
| 7 | - dependencies = [ | |
| 8 | - ("items", "0001_initial"), | |
| 9 | - ] | |
| 10 | - | |
| 11 | - operations = [ | |
| 12 | - migrations.AlterField( | |
| 13 | - model_name="historicalitem", | |
| 14 | - name="sku", | |
| 15 | - field=models.CharField(blank=True, db_index=True, default=None, max_length=50, null=True), | |
| 16 | - ), | |
| 17 | - migrations.AlterField( | |
| 18 | - model_name="item", | |
| 19 | - name="sku", | |
| 20 | - field=models.CharField(blank=True, default=None, max_length=50, null=True, unique=True), | |
| 21 | - ), | |
| 22 | - ] |
| --- a/items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py | |
| +++ b/items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py | |
| @@ -1,22 +0,0 @@ | |
| 1 | # Generated by Django 5.2.12 on 2026-03-26 06:01 |
| 2 | |
| 3 | from django.db import migrations, models |
| 4 | |
| 5 | |
| 6 | class Migration(migrations.Migration): |
| 7 | dependencies = [ |
| 8 | ("items", "0001_initial"), |
| 9 | ] |
| 10 | |
| 11 | operations = [ |
| 12 | migrations.AlterField( |
| 13 | model_name="historicalitem", |
| 14 | name="sku", |
| 15 | field=models.CharField(blank=True, db_index=True, default=None, max_length=50, null=True), |
| 16 | ), |
| 17 | migrations.AlterField( |
| 18 | model_name="item", |
| 19 | name="sku", |
| 20 | field=models.CharField(blank=True, default=None, max_length=50, null=True, unique=True), |
| 21 | ), |
| 22 | ] |
| --- a/items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py | |
| +++ b/items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py | |
| @@ -1,22 +0,0 @@ | |
D
items/migrations/__init__.py
No diff available
D
items/models.py
-15
| --- a/items/models.py | ||
| +++ b/items/models.py | ||
| @@ -1,15 +0,0 @@ | ||
| 1 | -from django.db import models | |
| 2 | - | |
| 3 | -from core.models import ActiveManager, BaseCoreModel | |
| 4 | - | |
| 5 | - | |
| 6 | -class Item(BaseCoreModel): | |
| 7 | - price = models.DecimalField(max_digits=10, decimal_places=2) | |
| 8 | - sku = models.CharField(max_length=50, unique=True, blank=True, null=True, default=None) | |
| 9 | - is_active = models.BooleanField(default=True) | |
| 10 | - | |
| 11 | - objects = ActiveManager() | |
| 12 | - all_objects = models.Manager() | |
| 13 | - | |
| 14 | - class Meta: | |
| 15 | - ordering = ["-created_at"] |
| --- a/items/models.py | |
| +++ b/items/models.py | |
| @@ -1,15 +0,0 @@ | |
| 1 | from django.db import models |
| 2 | |
| 3 | from core.models import ActiveManager, BaseCoreModel |
| 4 | |
| 5 | |
| 6 | class Item(BaseCoreModel): |
| 7 | price = models.DecimalField(max_digits=10, decimal_places=2) |
| 8 | sku = models.CharField(max_length=50, unique=True, blank=True, null=True, default=None) |
| 9 | is_active = models.BooleanField(default=True) |
| 10 | |
| 11 | objects = ActiveManager() |
| 12 | all_objects = models.Manager() |
| 13 | |
| 14 | class Meta: |
| 15 | ordering = ["-created_at"] |
| --- a/items/models.py | |
| +++ b/items/models.py | |
| @@ -1,15 +0,0 @@ | |
D
items/tests.py
-133
| --- a/items/tests.py | ||
| +++ b/items/tests.py | ||
| @@ -1,133 +0,0 @@ | ||
| 1 | -import pytest | |
| 2 | -from django.urls import reverse | |
| 3 | - | |
| 4 | -from .models import Item | |
| 5 | - | |
| 6 | - | |
| 7 | -@pytest.fixture | |
| 8 | -def sample_item(db, admin_user): | |
| 9 | - return Item.objects.create(name="Test Widget", price="29.99", sku="TST-001", created_by=admin_user) | |
| 10 | - | |
| 11 | - | |
| 12 | -@pytest.mark.django_db | |
| 13 | -class TestItemList: | |
| 14 | - def test_list_requires_login(self, client): | |
| 15 | - response = client.get(reverse("items:list")) | |
| 16 | - assert response.status_code == 302 | |
| 17 | - | |
| 18 | - def test_list_renders_for_superuser(self, admin_client, sample_item): | |
| 19 | - response = admin_client.get(reverse("items:list")) | |
| 20 | - assert response.status_code == 200 | |
| 21 | - assert b"Test Widget" in response.content | |
| 22 | - | |
| 23 | - def test_list_renders_for_viewer(self, viewer_client, sample_item): | |
| 24 | - response = viewer_client.get(reverse("items:list")) | |
| 25 | - assert response.status_code == 200 | |
| 26 | - assert b"Test Widget" in response.content | |
| 27 | - | |
| 28 | - def test_list_denied_for_user_without_perm(self, no_perm_client, sample_item): | |
| 29 | - response = no_perm_client.get(reverse("items:list")) | |
| 30 | - assert response.status_code == 403 | |
| 31 | - | |
| 32 | - def test_list_htmx_returns_partial(self, admin_client, sample_item): | |
| 33 | - response = admin_client.get(reverse("items:list"), HTTP_HX_REQUEST="true") | |
| 34 | - assert response.status_code == 200 | |
| 35 | - assert b"item-table" in response.content | |
| 36 | - assert b"<!DOCTYPE" not in response.content # partial, not full page | |
| 37 | - | |
| 38 | - def test_list_search_filters(self, admin_client, admin_user): | |
| 39 | - Item.objects.create(name="Alpha", price="10.00", created_by=admin_user) | |
| 40 | - Item.objects.create(name="Beta", price="20.00", created_by=admin_user) | |
| 41 | - response = admin_client.get(reverse("items:list") + "?search=Alpha") | |
| 42 | - assert b"Alpha" in response.content | |
| 43 | - assert b"Beta" not in response.content | |
| 44 | - | |
| 45 | - | |
| 46 | -@pytest.mark.django_db | |
| 47 | -class TestItemCreate: | |
| 48 | - def test_create_form_renders(self, admin_client): | |
| 49 | - response = admin_client.get(reverse("items:create")) | |
| 50 | - assert response.status_code == 200 | |
| 51 | - assert b"New Item" in response.content | |
| 52 | - | |
| 53 | - def test_create_saves_item(self, admin_client, admin_user): | |
| 54 | - response = admin_client.post( | |
| 55 | - reverse("items:create"), | |
| 56 | - {"name": "New Gadget", "description": "A new gadget", "price": "49.99", "sku": "NGT-001", "is_active": True}, | |
| 57 | - ) | |
| 58 | - assert response.status_code == 302 | |
| 59 | - item = Item.objects.get(sku="NGT-001") | |
| 60 | - assert item.name == "New Gadget" | |
| 61 | - assert item.created_by == admin_user | |
| 62 | - | |
| 63 | - def test_create_denied_for_viewer(self, viewer_client): | |
| 64 | - response = viewer_client.get(reverse("items:create")) | |
| 65 | - assert response.status_code == 403 | |
| 66 | - | |
| 67 | - def test_create_invalid_data_shows_errors(self, admin_client): | |
| 68 | - response = admin_client.post(reverse("items:create"), {"name": "", "price": ""}) | |
| 69 | - assert response.status_code == 200 # re-renders form with errors | |
| 70 | - | |
| 71 | - | |
| 72 | -@pytest.mark.django_db | |
| 73 | -class TestItemDetail: | |
| 74 | - def test_detail_renders(self, admin_client, sample_item): | |
| 75 | - response = admin_client.get(reverse("items:detail", kwargs={"slug": sample_item.slug})) | |
| 76 | - assert response.status_code == 200 | |
| 77 | - assert b"Test Widget" in response.content | |
| 78 | - assert str(sample_item.guid).encode() in response.content | |
| 79 | - | |
| 80 | - def test_detail_404_for_deleted(self, admin_client, sample_item, admin_user): | |
| 81 | - sample_item.soft_delete(user=admin_user) | |
| 82 | - response = admin_client.get(reverse("items:detail", kwargs={"slug": sample_item.slug})) | |
| 83 | - assert response.status_code == 404 | |
| 84 | - | |
| 85 | - | |
| 86 | -@pytest.mark.django_db | |
| 87 | -class TestItemUpdate: | |
| 88 | - def test_update_form_renders(self, admin_client, sample_item): | |
| 89 | - response = admin_client.get(reverse("items:update", kwargs={"slug": sample_item.slug})) | |
| 90 | - assert response.status_code == 200 | |
| 91 | - assert b"Edit Item" in response.content | |
| 92 | - | |
| 93 | - def test_update_saves_changes(self, admin_client, sample_item): | |
| 94 | - response = admin_client.post( | |
| 95 | - reverse("items:update", kwargs={"slug": sample_item.slug}), | |
| 96 | - {"name": "Updated Widget", "description": "Updated", "price": "39.99", "sku": "TST-001", "is_active": True}, | |
| 97 | - ) | |
| 98 | - assert response.status_code == 302 | |
| 99 | - sample_item.refresh_from_db() | |
| 100 | - assert sample_item.name == "Updated Widget" | |
| 101 | - from decimal import Decimal | |
| 102 | - | |
| 103 | - assert sample_item.price == Decimal("39.99") | |
| 104 | - | |
| 105 | - def test_update_denied_for_viewer(self, viewer_client, sample_item): | |
| 106 | - response = viewer_client.get(reverse("items:update", kwargs={"slug": sample_item.slug})) | |
| 107 | - assert response.status_code == 403 | |
| 108 | - | |
| 109 | - | |
| 110 | -@pytest.mark.django_db | |
| 111 | -class TestItemDelete: | |
| 112 | - def test_delete_confirm_renders(self, admin_client, sample_item): | |
| 113 | - response = admin_client.get(reverse("items:delete", kwargs={"slug": sample_item.slug})) | |
| 114 | - assert response.status_code == 200 | |
| 115 | - assert b"Delete Item" in response.content | |
| 116 | - | |
| 117 | - def test_delete_soft_deletes(self, admin_client, sample_item): | |
| 118 | - response = admin_client.post(reverse("items:delete", kwargs={"slug": sample_item.slug})) | |
| 119 | - assert response.status_code == 302 | |
| 120 | - sample_item.refresh_from_db() | |
| 121 | - assert sample_item.is_deleted | |
| 122 | - | |
| 123 | - def test_delete_htmx_returns_redirect_header(self, admin_client, sample_item): | |
| 124 | - response = admin_client.post( | |
| 125 | - reverse("items:delete", kwargs={"slug": sample_item.slug}), | |
| 126 | - HTTP_HX_REQUEST="true", | |
| 127 | - ) | |
| 128 | - assert response.status_code == 200 | |
| 129 | - assert response.headers.get("HX-Redirect") == "/items/" | |
| 130 | - | |
| 131 | - def test_delete_denied_for_viewer(self, viewer_client, sample_item): | |
| 132 | - response = viewer_client.post(reverse("items:delete", kwargs={"slug": sample_item.slug})) | |
| 133 | - assert response.status_code == 403 |
| --- a/items/tests.py | |
| +++ b/items/tests.py | |
| @@ -1,133 +0,0 @@ | |
| 1 | import pytest |
| 2 | from django.urls import reverse |
| 3 | |
| 4 | from .models import Item |
| 5 | |
| 6 | |
| 7 | @pytest.fixture |
| 8 | def sample_item(db, admin_user): |
| 9 | return Item.objects.create(name="Test Widget", price="29.99", sku="TST-001", created_by=admin_user) |
| 10 | |
| 11 | |
| 12 | @pytest.mark.django_db |
| 13 | class TestItemList: |
| 14 | def test_list_requires_login(self, client): |
| 15 | response = client.get(reverse("items:list")) |
| 16 | assert response.status_code == 302 |
| 17 | |
| 18 | def test_list_renders_for_superuser(self, admin_client, sample_item): |
| 19 | response = admin_client.get(reverse("items:list")) |
| 20 | assert response.status_code == 200 |
| 21 | assert b"Test Widget" in response.content |
| 22 | |
| 23 | def test_list_renders_for_viewer(self, viewer_client, sample_item): |
| 24 | response = viewer_client.get(reverse("items:list")) |
| 25 | assert response.status_code == 200 |
| 26 | assert b"Test Widget" in response.content |
| 27 | |
| 28 | def test_list_denied_for_user_without_perm(self, no_perm_client, sample_item): |
| 29 | response = no_perm_client.get(reverse("items:list")) |
| 30 | assert response.status_code == 403 |
| 31 | |
| 32 | def test_list_htmx_returns_partial(self, admin_client, sample_item): |
| 33 | response = admin_client.get(reverse("items:list"), HTTP_HX_REQUEST="true") |
| 34 | assert response.status_code == 200 |
| 35 | assert b"item-table" in response.content |
| 36 | assert b"<!DOCTYPE" not in response.content # partial, not full page |
| 37 | |
| 38 | def test_list_search_filters(self, admin_client, admin_user): |
| 39 | Item.objects.create(name="Alpha", price="10.00", created_by=admin_user) |
| 40 | Item.objects.create(name="Beta", price="20.00", created_by=admin_user) |
| 41 | response = admin_client.get(reverse("items:list") + "?search=Alpha") |
| 42 | assert b"Alpha" in response.content |
| 43 | assert b"Beta" not in response.content |
| 44 | |
| 45 | |
| 46 | @pytest.mark.django_db |
| 47 | class TestItemCreate: |
| 48 | def test_create_form_renders(self, admin_client): |
| 49 | response = admin_client.get(reverse("items:create")) |
| 50 | assert response.status_code == 200 |
| 51 | assert b"New Item" in response.content |
| 52 | |
| 53 | def test_create_saves_item(self, admin_client, admin_user): |
| 54 | response = admin_client.post( |
| 55 | reverse("items:create"), |
| 56 | {"name": "New Gadget", "description": "A new gadget", "price": "49.99", "sku": "NGT-001", "is_active": True}, |
| 57 | ) |
| 58 | assert response.status_code == 302 |
| 59 | item = Item.objects.get(sku="NGT-001") |
| 60 | assert item.name == "New Gadget" |
| 61 | assert item.created_by == admin_user |
| 62 | |
| 63 | def test_create_denied_for_viewer(self, viewer_client): |
| 64 | response = viewer_client.get(reverse("items:create")) |
| 65 | assert response.status_code == 403 |
| 66 | |
| 67 | def test_create_invalid_data_shows_errors(self, admin_client): |
| 68 | response = admin_client.post(reverse("items:create"), {"name": "", "price": ""}) |
| 69 | assert response.status_code == 200 # re-renders form with errors |
| 70 | |
| 71 | |
| 72 | @pytest.mark.django_db |
| 73 | class TestItemDetail: |
| 74 | def test_detail_renders(self, admin_client, sample_item): |
| 75 | response = admin_client.get(reverse("items:detail", kwargs={"slug": sample_item.slug})) |
| 76 | assert response.status_code == 200 |
| 77 | assert b"Test Widget" in response.content |
| 78 | assert str(sample_item.guid).encode() in response.content |
| 79 | |
| 80 | def test_detail_404_for_deleted(self, admin_client, sample_item, admin_user): |
| 81 | sample_item.soft_delete(user=admin_user) |
| 82 | response = admin_client.get(reverse("items:detail", kwargs={"slug": sample_item.slug})) |
| 83 | assert response.status_code == 404 |
| 84 | |
| 85 | |
| 86 | @pytest.mark.django_db |
| 87 | class TestItemUpdate: |
| 88 | def test_update_form_renders(self, admin_client, sample_item): |
| 89 | response = admin_client.get(reverse("items:update", kwargs={"slug": sample_item.slug})) |
| 90 | assert response.status_code == 200 |
| 91 | assert b"Edit Item" in response.content |
| 92 | |
| 93 | def test_update_saves_changes(self, admin_client, sample_item): |
| 94 | response = admin_client.post( |
| 95 | reverse("items:update", kwargs={"slug": sample_item.slug}), |
| 96 | {"name": "Updated Widget", "description": "Updated", "price": "39.99", "sku": "TST-001", "is_active": True}, |
| 97 | ) |
| 98 | assert response.status_code == 302 |
| 99 | sample_item.refresh_from_db() |
| 100 | assert sample_item.name == "Updated Widget" |
| 101 | from decimal import Decimal |
| 102 | |
| 103 | assert sample_item.price == Decimal("39.99") |
| 104 | |
| 105 | def test_update_denied_for_viewer(self, viewer_client, sample_item): |
| 106 | response = viewer_client.get(reverse("items:update", kwargs={"slug": sample_item.slug})) |
| 107 | assert response.status_code == 403 |
| 108 | |
| 109 | |
| 110 | @pytest.mark.django_db |
| 111 | class TestItemDelete: |
| 112 | def test_delete_confirm_renders(self, admin_client, sample_item): |
| 113 | response = admin_client.get(reverse("items:delete", kwargs={"slug": sample_item.slug})) |
| 114 | assert response.status_code == 200 |
| 115 | assert b"Delete Item" in response.content |
| 116 | |
| 117 | def test_delete_soft_deletes(self, admin_client, sample_item): |
| 118 | response = admin_client.post(reverse("items:delete", kwargs={"slug": sample_item.slug})) |
| 119 | assert response.status_code == 302 |
| 120 | sample_item.refresh_from_db() |
| 121 | assert sample_item.is_deleted |
| 122 | |
| 123 | def test_delete_htmx_returns_redirect_header(self, admin_client, sample_item): |
| 124 | response = admin_client.post( |
| 125 | reverse("items:delete", kwargs={"slug": sample_item.slug}), |
| 126 | HTTP_HX_REQUEST="true", |
| 127 | ) |
| 128 | assert response.status_code == 200 |
| 129 | assert response.headers.get("HX-Redirect") == "/items/" |
| 130 | |
| 131 | def test_delete_denied_for_viewer(self, viewer_client, sample_item): |
| 132 | response = viewer_client.post(reverse("items:delete", kwargs={"slug": sample_item.slug})) |
| 133 | assert response.status_code == 403 |
| --- a/items/tests.py | |
| +++ b/items/tests.py | |
| @@ -1,133 +0,0 @@ | |
D
items/urls.py
-13
| --- a/items/urls.py | ||
| +++ b/items/urls.py | ||
| @@ -1,13 +0,0 @@ | ||
| 1 | -from django.urls import path | |
| 2 | - | |
| 3 | -from . import views | |
| 4 | - | |
| 5 | -app_name = "items" | |
| 6 | - | |
| 7 | -urlpatterns = [ | |
| 8 | - path("", views.item_list, name="list"), | |
| 9 | - path("create/", views.item_create, name="create"), | |
| 10 | - path("<slug:slug>/", views.item_detail, name="detail"), | |
| 11 | - path("<slug:slug>/edit/", views.item_update, name="update"), | |
| 12 | - path("<slug:slug>/delete/", views.item_delete, name="delete"), | |
| 13 | -] |
| --- a/items/urls.py | |
| +++ b/items/urls.py | |
| @@ -1,13 +0,0 @@ | |
| 1 | from django.urls import path |
| 2 | |
| 3 | from . import views |
| 4 | |
| 5 | app_name = "items" |
| 6 | |
| 7 | urlpatterns = [ |
| 8 | path("", views.item_list, name="list"), |
| 9 | path("create/", views.item_create, name="create"), |
| 10 | path("<slug:slug>/", views.item_detail, name="detail"), |
| 11 | path("<slug:slug>/edit/", views.item_update, name="update"), |
| 12 | path("<slug:slug>/delete/", views.item_delete, name="delete"), |
| 13 | ] |
| --- a/items/urls.py | |
| +++ b/items/urls.py | |
| @@ -1,13 +0,0 @@ | |
D
items/views.py
-86
| --- a/items/views.py | ||
| +++ b/items/views.py | ||
| @@ -1,86 +0,0 @@ | ||
| 1 | -from django.contrib import messages | |
| 2 | -from django.contrib.auth.decorators import login_required | |
| 3 | -from django.shortcuts import get_object_or_404, redirect, render | |
| 4 | - | |
| 5 | -from core.permissions import P | |
| 6 | - | |
| 7 | -from .forms import ItemForm | |
| 8 | -from .models import Item | |
| 9 | - | |
| 10 | - | |
| 11 | -@login_required | |
| 12 | -def item_list(request): | |
| 13 | - P.ITEM_VIEW.check(request.user) | |
| 14 | - items = Item.objects.all() | |
| 15 | - | |
| 16 | - search = request.GET.get("search", "").strip() | |
| 17 | - if search: | |
| 18 | - items = items.filter(name__icontains=search) | |
| 19 | - | |
| 20 | - if request.headers.get("HX-Request"): | |
| 21 | - return render(request, "items/partials/item_table.html", {"items": items}) | |
| 22 | - | |
| 23 | - return render(request, "items/item_list.html", {"items": items, "search": search}) | |
| 24 | - | |
| 25 | - | |
| 26 | -@login_required | |
| 27 | -def item_create(request): | |
| 28 | - P.ITEM_ADD.check(request.user) | |
| 29 | - | |
| 30 | - if request.method == "POST": | |
| 31 | - form = ItemForm(request.POST) | |
| 32 | - if form.is_valid(): | |
| 33 | - item = form.save(commit=False) | |
| 34 | - item.created_by = request.user | |
| 35 | - item.save() | |
| 36 | - messages.success(request, f'Item "{item.name}" created.') | |
| 37 | - return redirect("items:detail", slug=item.slug) | |
| 38 | - else: | |
| 39 | - form = ItemForm() | |
| 40 | - | |
| 41 | - return render(request, "items/item_form.html", {"form": form, "title": "New Item"}) | |
| 42 | - | |
| 43 | - | |
| 44 | -@login_required | |
| 45 | -def item_detail(request, slug): | |
| 46 | - P.ITEM_VIEW.check(request.user) | |
| 47 | - item = get_object_or_404(Item, slug=slug, deleted_at__isnull=True) | |
| 48 | - return render(request, "items/item_detail.html", {"item": item}) | |
| 49 | - | |
| 50 | - | |
| 51 | -@login_required | |
| 52 | -def item_update(request, slug): | |
| 53 | - P.ITEM_CHANGE.check(request.user) | |
| 54 | - item = get_object_or_404(Item, slug=slug, deleted_at__isnull=True) | |
| 55 | - | |
| 56 | - if request.method == "POST": | |
| 57 | - form = ItemForm(request.POST, instance=item) | |
| 58 | - if form.is_valid(): | |
| 59 | - item = form.save(commit=False) | |
| 60 | - item.updated_by = request.user | |
| 61 | - item.save() | |
| 62 | - messages.success(request, f'Item "{item.name}" updated.') | |
| 63 | - return redirect("items:detail", slug=item.slug) | |
| 64 | - else: | |
| 65 | - form = ItemForm(instance=item) | |
| 66 | - | |
| 67 | - return render(request, "items/item_form.html", {"form": form, "item": item, "title": "Edit Item"}) | |
| 68 | - | |
| 69 | - | |
| 70 | -@login_required | |
| 71 | -def item_delete(request, slug): | |
| 72 | - P.ITEM_DELETE.check(request.user) | |
| 73 | - item = get_object_or_404(Item, slug=slug, deleted_at__isnull=True) | |
| 74 | - | |
| 75 | - if request.method == "POST": | |
| 76 | - item.soft_delete(user=request.user) | |
| 77 | - messages.success(request, f'Item "{item.name}" deleted.') | |
| 78 | - | |
| 79 | - if request.headers.get("HX-Request"): | |
| 80 | - from django.http import HttpResponse | |
| 81 | - | |
| 82 | - return HttpResponse(status=200, headers={"HX-Redirect": "/items/"}) | |
| 83 | - | |
| 84 | - return redirect("items:list") | |
| 85 | - | |
| 86 | - return render(request, "items/item_confirm_delete.html", {"item": item}) |
| --- a/items/views.py | |
| +++ b/items/views.py | |
| @@ -1,86 +0,0 @@ | |
| 1 | from django.contrib import messages |
| 2 | from django.contrib.auth.decorators import login_required |
| 3 | from django.shortcuts import get_object_or_404, redirect, render |
| 4 | |
| 5 | from core.permissions import P |
| 6 | |
| 7 | from .forms import ItemForm |
| 8 | from .models import Item |
| 9 | |
| 10 | |
| 11 | @login_required |
| 12 | def item_list(request): |
| 13 | P.ITEM_VIEW.check(request.user) |
| 14 | items = Item.objects.all() |
| 15 | |
| 16 | search = request.GET.get("search", "").strip() |
| 17 | if search: |
| 18 | items = items.filter(name__icontains=search) |
| 19 | |
| 20 | if request.headers.get("HX-Request"): |
| 21 | return render(request, "items/partials/item_table.html", {"items": items}) |
| 22 | |
| 23 | return render(request, "items/item_list.html", {"items": items, "search": search}) |
| 24 | |
| 25 | |
| 26 | @login_required |
| 27 | def item_create(request): |
| 28 | P.ITEM_ADD.check(request.user) |
| 29 | |
| 30 | if request.method == "POST": |
| 31 | form = ItemForm(request.POST) |
| 32 | if form.is_valid(): |
| 33 | item = form.save(commit=False) |
| 34 | item.created_by = request.user |
| 35 | item.save() |
| 36 | messages.success(request, f'Item "{item.name}" created.') |
| 37 | return redirect("items:detail", slug=item.slug) |
| 38 | else: |
| 39 | form = ItemForm() |
| 40 | |
| 41 | return render(request, "items/item_form.html", {"form": form, "title": "New Item"}) |
| 42 | |
| 43 | |
| 44 | @login_required |
| 45 | def item_detail(request, slug): |
| 46 | P.ITEM_VIEW.check(request.user) |
| 47 | item = get_object_or_404(Item, slug=slug, deleted_at__isnull=True) |
| 48 | return render(request, "items/item_detail.html", {"item": item}) |
| 49 | |
| 50 | |
| 51 | @login_required |
| 52 | def item_update(request, slug): |
| 53 | P.ITEM_CHANGE.check(request.user) |
| 54 | item = get_object_or_404(Item, slug=slug, deleted_at__isnull=True) |
| 55 | |
| 56 | if request.method == "POST": |
| 57 | form = ItemForm(request.POST, instance=item) |
| 58 | if form.is_valid(): |
| 59 | item = form.save(commit=False) |
| 60 | item.updated_by = request.user |
| 61 | item.save() |
| 62 | messages.success(request, f'Item "{item.name}" updated.') |
| 63 | return redirect("items:detail", slug=item.slug) |
| 64 | else: |
| 65 | form = ItemForm(instance=item) |
| 66 | |
| 67 | return render(request, "items/item_form.html", {"form": form, "item": item, "title": "Edit Item"}) |
| 68 | |
| 69 | |
| 70 | @login_required |
| 71 | def item_delete(request, slug): |
| 72 | P.ITEM_DELETE.check(request.user) |
| 73 | item = get_object_or_404(Item, slug=slug, deleted_at__isnull=True) |
| 74 | |
| 75 | if request.method == "POST": |
| 76 | item.soft_delete(user=request.user) |
| 77 | messages.success(request, f'Item "{item.name}" deleted.') |
| 78 | |
| 79 | if request.headers.get("HX-Request"): |
| 80 | from django.http import HttpResponse |
| 81 | |
| 82 | return HttpResponse(status=200, headers={"HX-Redirect": "/items/"}) |
| 83 | |
| 84 | return redirect("items:list") |
| 85 | |
| 86 | return render(request, "items/item_confirm_delete.html", {"item": item}) |
| --- a/items/views.py | |
| +++ b/items/views.py | |
| @@ -1,86 +0,0 @@ | |
| --- organization/admin.py | ||
| +++ organization/admin.py | ||
| @@ -20,10 +20,11 @@ | ||
| 20 | 20 | |
| 21 | 21 | @admin.register(Team) |
| 22 | 22 | class TeamAdmin(BaseCoreAdmin): |
| 23 | 23 | list_display = ("name", "slug", "organization", "created_at") |
| 24 | 24 | search_fields = ("name", "slug") |
| 25 | + list_filter = ("created_at",) | |
| 25 | 26 | filter_horizontal = ("members",) |
| 26 | 27 | |
| 27 | 28 | |
| 28 | 29 | @admin.register(OrganizationMember) |
| 29 | 30 | class OrganizationMemberAdmin(BaseCoreAdmin): |
| 30 | 31 |
| --- organization/admin.py | |
| +++ organization/admin.py | |
| @@ -20,10 +20,11 @@ | |
| 20 | |
| 21 | @admin.register(Team) |
| 22 | class TeamAdmin(BaseCoreAdmin): |
| 23 | list_display = ("name", "slug", "organization", "created_at") |
| 24 | search_fields = ("name", "slug") |
| 25 | filter_horizontal = ("members",) |
| 26 | |
| 27 | |
| 28 | @admin.register(OrganizationMember) |
| 29 | class OrganizationMemberAdmin(BaseCoreAdmin): |
| 30 |
| --- organization/admin.py | |
| +++ organization/admin.py | |
| @@ -20,10 +20,11 @@ | |
| 20 | |
| 21 | @admin.register(Team) |
| 22 | class TeamAdmin(BaseCoreAdmin): |
| 23 | list_display = ("name", "slug", "organization", "created_at") |
| 24 | search_fields = ("name", "slug") |
| 25 | list_filter = ("created_at",) |
| 26 | filter_horizontal = ("members",) |
| 27 | |
| 28 | |
| 29 | @admin.register(OrganizationMember) |
| 30 | class OrganizationMemberAdmin(BaseCoreAdmin): |
| 31 |
+1
-1
| --- pages/admin.py | ||
| +++ pages/admin.py | ||
| @@ -5,8 +5,8 @@ | ||
| 5 | 5 | from .models import Page |
| 6 | 6 | |
| 7 | 7 | |
| 8 | 8 | @admin.register(Page) |
| 9 | 9 | class PageAdmin(BaseCoreAdmin): |
| 10 | - list_display = ("name", "slug", "is_published", "created_at") | |
| 10 | + list_display = ("name", "slug", "is_published", "created_at", "created_by") | |
| 11 | 11 | list_filter = ("is_published",) |
| 12 | 12 | search_fields = ("name", "slug", "content") |
| 13 | 13 |
| --- pages/admin.py | |
| +++ pages/admin.py | |
| @@ -5,8 +5,8 @@ | |
| 5 | from .models import Page |
| 6 | |
| 7 | |
| 8 | @admin.register(Page) |
| 9 | class PageAdmin(BaseCoreAdmin): |
| 10 | list_display = ("name", "slug", "is_published", "created_at") |
| 11 | list_filter = ("is_published",) |
| 12 | search_fields = ("name", "slug", "content") |
| 13 |
| --- pages/admin.py | |
| +++ pages/admin.py | |
| @@ -5,8 +5,8 @@ | |
| 5 | from .models import Page |
| 6 | |
| 7 | |
| 8 | @admin.register(Page) |
| 9 | class PageAdmin(BaseCoreAdmin): |
| 10 | list_display = ("name", "slug", "is_published", "created_at", "created_by") |
| 11 | list_filter = ("is_published",) |
| 12 | search_fields = ("name", "slug", "content") |
| 13 |
+5
-4
| --- projects/admin.py | ||
| +++ projects/admin.py | ||
| @@ -11,16 +11,17 @@ | ||
| 11 | 11 | raw_id_fields = ("team",) |
| 12 | 12 | |
| 13 | 13 | |
| 14 | 14 | @admin.register(Project) |
| 15 | 15 | class ProjectAdmin(BaseCoreAdmin): |
| 16 | - list_display = ("name", "slug", "visibility", "organization", "created_at") | |
| 17 | - list_filter = ("visibility",) | |
| 18 | - search_fields = ("name", "slug") | |
| 16 | + list_display = ("name", "slug", "visibility", "created_at", "created_by") | |
| 17 | + list_filter = ("visibility", "created_at") | |
| 18 | + search_fields = ("name", "slug", "description") | |
| 19 | 19 | inlines = [ProjectTeamInline] |
| 20 | 20 | |
| 21 | 21 | |
| 22 | 22 | @admin.register(ProjectTeam) |
| 23 | 23 | class ProjectTeamAdmin(BaseCoreAdmin): |
| 24 | 24 | list_display = ("project", "team", "role", "created_at") |
| 25 | - list_filter = ("role",) | |
| 25 | + list_filter = ("role", "team") | |
| 26 | + search_fields = ("project__name", "team__name") | |
| 26 | 27 | raw_id_fields = ("project", "team") |
| 27 | 28 |
| --- projects/admin.py | |
| +++ projects/admin.py | |
| @@ -11,16 +11,17 @@ | |
| 11 | raw_id_fields = ("team",) |
| 12 | |
| 13 | |
| 14 | @admin.register(Project) |
| 15 | class ProjectAdmin(BaseCoreAdmin): |
| 16 | list_display = ("name", "slug", "visibility", "organization", "created_at") |
| 17 | list_filter = ("visibility",) |
| 18 | search_fields = ("name", "slug") |
| 19 | inlines = [ProjectTeamInline] |
| 20 | |
| 21 | |
| 22 | @admin.register(ProjectTeam) |
| 23 | class ProjectTeamAdmin(BaseCoreAdmin): |
| 24 | list_display = ("project", "team", "role", "created_at") |
| 25 | list_filter = ("role",) |
| 26 | raw_id_fields = ("project", "team") |
| 27 |
| --- projects/admin.py | |
| +++ projects/admin.py | |
| @@ -11,16 +11,17 @@ | |
| 11 | raw_id_fields = ("team",) |
| 12 | |
| 13 | |
| 14 | @admin.register(Project) |
| 15 | class ProjectAdmin(BaseCoreAdmin): |
| 16 | list_display = ("name", "slug", "visibility", "created_at", "created_by") |
| 17 | list_filter = ("visibility", "created_at") |
| 18 | search_fields = ("name", "slug", "description") |
| 19 | inlines = [ProjectTeamInline] |
| 20 | |
| 21 | |
| 22 | @admin.register(ProjectTeam) |
| 23 | class ProjectTeamAdmin(BaseCoreAdmin): |
| 24 | list_display = ("project", "team", "role", "created_at") |
| 25 | list_filter = ("role", "team") |
| 26 | search_fields = ("project__name", "team__name") |
| 27 | raw_id_fields = ("project", "team") |
| 28 |
+3
-3
| --- pyproject.toml | ||
| +++ pyproject.toml | ||
| @@ -51,11 +51,11 @@ | ||
| 51 | 51 | [tool.ruff.lint] |
| 52 | 52 | select = ["E", "F", "I", "W", "UP", "B", "SIM", "N"] |
| 53 | 53 | ignore = ["E501"] |
| 54 | 54 | |
| 55 | 55 | [tool.ruff.lint.isort] |
| 56 | -known-first-party = ["config", "core", "auth1", "organization", "items", "projects", "pages", "fossil", "testdata", "ctl"] | |
| 56 | +known-first-party = ["config", "core", "accounts", "organization", "projects", "pages", "fossil", "testdata", "ctl"] | |
| 57 | 57 | |
| 58 | 58 | [tool.ruff.format] |
| 59 | 59 | quote-style = "double" |
| 60 | 60 | |
| 61 | 61 | [tool.pytest.ini_options] |
| @@ -64,18 +64,18 @@ | ||
| 64 | 64 | python_classes = ["Test*"] |
| 65 | 65 | python_functions = ["test_*"] |
| 66 | 66 | addopts = "-v --tb=short --strict-markers" |
| 67 | 67 | |
| 68 | 68 | [tool.coverage.run] |
| 69 | -source = ["core", "auth1", "organization", "items", "projects", "pages", "fossil"] | |
| 69 | +source = ["core", "accounts", "organization", "projects", "pages", "fossil"] | |
| 70 | 70 | omit = ["*/migrations/*", "*/tests/*", "*/testdata/*", "manage.py", "startup.py"] |
| 71 | 71 | |
| 72 | 72 | [tool.coverage.report] |
| 73 | 73 | fail_under = 80 |
| 74 | 74 | show_missing = true |
| 75 | 75 | |
| 76 | 76 | [tool.hatch.build.targets.wheel] |
| 77 | -packages = ["ctl", "core", "auth1", "organization", "items", "projects", "pages", "fossil", "config"] | |
| 77 | +packages = ["ctl", "core", "accounts", "organization", "projects", "pages", "fossil", "config"] | |
| 78 | 78 | |
| 79 | 79 | [build-system] |
| 80 | 80 | requires = ["hatchling"] |
| 81 | 81 | build-backend = "hatchling.build" |
| 82 | 82 | |
| 83 | 83 | ADDED templates/accounts/login.html |
| 84 | 84 | ADDED templates/accounts/ssh_keys.html |
| 85 | 85 | DELETED templates/auth1/login.html |
| 86 | 86 | DELETED templates/auth1/ssh_keys.html |
| --- pyproject.toml | |
| +++ pyproject.toml | |
| @@ -51,11 +51,11 @@ | |
| 51 | [tool.ruff.lint] |
| 52 | select = ["E", "F", "I", "W", "UP", "B", "SIM", "N"] |
| 53 | ignore = ["E501"] |
| 54 | |
| 55 | [tool.ruff.lint.isort] |
| 56 | known-first-party = ["config", "core", "auth1", "organization", "items", "projects", "pages", "fossil", "testdata", "ctl"] |
| 57 | |
| 58 | [tool.ruff.format] |
| 59 | quote-style = "double" |
| 60 | |
| 61 | [tool.pytest.ini_options] |
| @@ -64,18 +64,18 @@ | |
| 64 | python_classes = ["Test*"] |
| 65 | python_functions = ["test_*"] |
| 66 | addopts = "-v --tb=short --strict-markers" |
| 67 | |
| 68 | [tool.coverage.run] |
| 69 | source = ["core", "auth1", "organization", "items", "projects", "pages", "fossil"] |
| 70 | omit = ["*/migrations/*", "*/tests/*", "*/testdata/*", "manage.py", "startup.py"] |
| 71 | |
| 72 | [tool.coverage.report] |
| 73 | fail_under = 80 |
| 74 | show_missing = true |
| 75 | |
| 76 | [tool.hatch.build.targets.wheel] |
| 77 | packages = ["ctl", "core", "auth1", "organization", "items", "projects", "pages", "fossil", "config"] |
| 78 | |
| 79 | [build-system] |
| 80 | requires = ["hatchling"] |
| 81 | build-backend = "hatchling.build" |
| 82 | |
| 83 | DDED templates/accounts/login.html |
| 84 | DDED templates/accounts/ssh_keys.html |
| 85 | ELETED templates/auth1/login.html |
| 86 | ELETED templates/auth1/ssh_keys.html |
| --- pyproject.toml | |
| +++ pyproject.toml | |
| @@ -51,11 +51,11 @@ | |
| 51 | [tool.ruff.lint] |
| 52 | select = ["E", "F", "I", "W", "UP", "B", "SIM", "N"] |
| 53 | ignore = ["E501"] |
| 54 | |
| 55 | [tool.ruff.lint.isort] |
| 56 | known-first-party = ["config", "core", "accounts", "organization", "projects", "pages", "fossil", "testdata", "ctl"] |
| 57 | |
| 58 | [tool.ruff.format] |
| 59 | quote-style = "double" |
| 60 | |
| 61 | [tool.pytest.ini_options] |
| @@ -64,18 +64,18 @@ | |
| 64 | python_classes = ["Test*"] |
| 65 | python_functions = ["test_*"] |
| 66 | addopts = "-v --tb=short --strict-markers" |
| 67 | |
| 68 | [tool.coverage.run] |
| 69 | source = ["core", "accounts", "organization", "projects", "pages", "fossil"] |
| 70 | omit = ["*/migrations/*", "*/tests/*", "*/testdata/*", "manage.py", "startup.py"] |
| 71 | |
| 72 | [tool.coverage.report] |
| 73 | fail_under = 80 |
| 74 | show_missing = true |
| 75 | |
| 76 | [tool.hatch.build.targets.wheel] |
| 77 | packages = ["ctl", "core", "accounts", "organization", "projects", "pages", "fossil", "config"] |
| 78 | |
| 79 | [build-system] |
| 80 | requires = ["hatchling"] |
| 81 | build-backend = "hatchling.build" |
| 82 | |
| 83 | DDED templates/accounts/login.html |
| 84 | DDED templates/accounts/ssh_keys.html |
| 85 | ELETED templates/auth1/login.html |
| 86 | ELETED templates/auth1/ssh_keys.html |
| --- a/templates/accounts/login.html | ||
| +++ b/templates/accounts/login.html | ||
| @@ -0,0 +1,36 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% load static %} | |
| 3 | +{% block title %}Sign In — Fossilrecontent %} | |
| 4 | +<div class="flex min-h-[80vh] items-center justify-center"> | |
| 5 | + <div class="w-full max-w-sm space-y-8"> | |
| 6 | + <div class="flex flex-col items-center"> | |
| 7 | + <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-12 w-auto mb-6"> | |
| 8 | + <h2 class="text-center text-3xl font-bold tracking-tight text-gray-100">Sign in</h2> | |
| 9 | + <p class="mt-2 text-center text-sm text-gray-400">Fossilrepo Django + HTMX</p> | |
| 10 | + </div> | |
| 11 | + | |
| 12 | + {% if form.errors %} | |
| 13 | + <div class="rounded-md bg-red-900/50 border border-red-700 p-4"> | |
| 14 | + <p class="text-sm text-red-300">Invalid username or password.</p> | |
| 15 | + </div> | |
| 16 | + {% endif %} | |
| 17 | + | |
| 18 | + /div> | |
| 19 | + {% endif %} | |
| 20 | + | |
| 21 | + <form method="post" class="space-y-6"> | |
| 22 | + {% csrf_token %} | |
| 23 | + <div> | |
| 24 | + <label for="id_username" class="block text-sm font-medium text-gray-300">Username</label> | |
| 25 | + <div class="mt-1">{{ form.username }}</div> | |
| 26 | + </div> | |
| 27 | + <div> | |
| 28 | + <label for="id_password" class="block text-sm font-medium text-gray-300">Password</label> | |
| 29 | + <div class="mt-1">{{ form.password }v> | |
| 30 | + {% endif %} | |
| 31 | + <button type="submit" | |
| 32 | + class="w-full rounded-md bg-brand px-3 py-2 text-sm font-semibold text-white shadow-sm-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-gray-950 transition-colors"> | |
| 33 | + Sign in | |
| 34 | + </button> | |
| 35 | + </form> | |
| 36 | + </ |
| --- a/templates/accounts/login.html | |
| +++ b/templates/accounts/login.html | |
| @@ -0,0 +1,36 @@ | |
| --- a/templates/accounts/login.html | |
| +++ b/templates/accounts/login.html | |
| @@ -0,0 +1,36 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load static %} |
| 3 | {% block title %}Sign In — Fossilrecontent %} |
| 4 | <div class="flex min-h-[80vh] items-center justify-center"> |
| 5 | <div class="w-full max-w-sm space-y-8"> |
| 6 | <div class="flex flex-col items-center"> |
| 7 | <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-12 w-auto mb-6"> |
| 8 | <h2 class="text-center text-3xl font-bold tracking-tight text-gray-100">Sign in</h2> |
| 9 | <p class="mt-2 text-center text-sm text-gray-400">Fossilrepo Django + HTMX</p> |
| 10 | </div> |
| 11 | |
| 12 | {% if form.errors %} |
| 13 | <div class="rounded-md bg-red-900/50 border border-red-700 p-4"> |
| 14 | <p class="text-sm text-red-300">Invalid username or password.</p> |
| 15 | </div> |
| 16 | {% endif %} |
| 17 | |
| 18 | /div> |
| 19 | {% endif %} |
| 20 | |
| 21 | <form method="post" class="space-y-6"> |
| 22 | {% csrf_token %} |
| 23 | <div> |
| 24 | <label for="id_username" class="block text-sm font-medium text-gray-300">Username</label> |
| 25 | <div class="mt-1">{{ form.username }}</div> |
| 26 | </div> |
| 27 | <div> |
| 28 | <label for="id_password" class="block text-sm font-medium text-gray-300">Password</label> |
| 29 | <div class="mt-1">{{ form.password }v> |
| 30 | {% endif %} |
| 31 | <button type="submit" |
| 32 | class="w-full rounded-md bg-brand px-3 py-2 text-sm font-semibold text-white shadow-sm-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-gray-950 transition-colors"> |
| 33 | Sign in |
| 34 | </button> |
| 35 | </form> |
| 36 | </ |
| --- a/templates/accounts/ssh_keys.html | ||
| +++ b/templates/accounts/ssh_keys.html | ||
| @@ -0,0 +1,18 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}SSH Keys — Fossilrepo{% endblocack to Profile</a> | |
| 3 | +</div> | |
| 4 | +<h1 class="text-2xl font-bold text-gray-100 mb-6">SSH Keys</h1> | |
| 5 | + | |
| 6 | +<div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6"> | |
| 7 | + <h2 class="text-lg font-semibold text-gray-200 mb-4">Add SSH Key</h2> | |
| 8 | + <form method="post" class="space-y-4"> | |
| 9 | + {% csrf_token %} | |
| 10 | + <div> | |
| 11 | + <label for="title" class="block text-sm font-medium text-gray-300 mb-1">Title</label> | |
| 12 | + <input type="text" name="title" id="title" required placeholder="e.g. Work laptop" | |
| 13 | + 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"> | |
| 14 | + </div> | |
| 15 | + <div> | |
| 16 | + <label for="public_key" class="block text-sm font-medium text-gray-300 mb-1">Public Key</label> | |
| 17 | + <textarea name="public_key" id="public_key" rows="4" required | |
| 18 | + placeho |
| --- a/templates/accounts/ssh_keys.html | |
| +++ b/templates/accounts/ssh_keys.html | |
| @@ -0,0 +1,18 @@ | |
| --- a/templates/accounts/ssh_keys.html | |
| +++ b/templates/accounts/ssh_keys.html | |
| @@ -0,0 +1,18 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}SSH Keys — Fossilrepo{% endblocack to Profile</a> |
| 3 | </div> |
| 4 | <h1 class="text-2xl font-bold text-gray-100 mb-6">SSH Keys</h1> |
| 5 | |
| 6 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6"> |
| 7 | <h2 class="text-lg font-semibold text-gray-200 mb-4">Add SSH Key</h2> |
| 8 | <form method="post" class="space-y-4"> |
| 9 | {% csrf_token %} |
| 10 | <div> |
| 11 | <label for="title" class="block text-sm font-medium text-gray-300 mb-1">Title</label> |
| 12 | <input type="text" name="title" id="title" required placeholder="e.g. Work laptop" |
| 13 | 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"> |
| 14 | </div> |
| 15 | <div> |
| 16 | <label for="public_key" class="block text-sm font-medium text-gray-300 mb-1">Public Key</label> |
| 17 | <textarea name="public_key" id="public_key" rows="4" required |
| 18 | placeho |
D
templates/auth1/login.html
-36
| --- a/templates/auth1/login.html | ||
| +++ b/templates/auth1/login.html | ||
| @@ -1,36 +0,0 @@ | ||
| 1 | -{% extends "base.html" %} | |
| 2 | -{% load static %} | |
| 3 | -{% block title %}Sign In — Fossilrecontent %} | |
| 4 | -<div class="flex min-h-[80vh] items-center justify-center"> | |
| 5 | - <div class="w-full max-w-sm space-y-8"> | |
| 6 | - <div class="flex flex-col items-center"> | |
| 7 | - <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-12 w-auto mb-6"> | |
| 8 | - <h2 class="text-center text-3xl font-bold tracking-tight text-gray-100">Sign in</h2> | |
| 9 | - <p class="mt-2 text-center text-sm text-gray-400">Fossilrepo Django + HTMX</p> | |
| 10 | - </div> | |
| 11 | - | |
| 12 | - {% if form.errors %} | |
| 13 | - <div class="rounded-md bg-red-900/50 border border-red-700 p-4"> | |
| 14 | - <p class="text-sm text-red-300">Invalid username or password.</p> | |
| 15 | - </div> | |
| 16 | - {% endif %} | |
| 17 | - | |
| 18 | - /div> | |
| 19 | - {% endif %} | |
| 20 | - | |
| 21 | - <form method="post" class="space-y-6"> | |
| 22 | - {% csrf_token %} | |
| 23 | - <div> | |
| 24 | - <label for="id_username" class="block text-sm font-medium text-gray-300">Username</label> | |
| 25 | - <div class="mt-1">{{ form.username }}</div> | |
| 26 | - </div> | |
| 27 | - <div> | |
| 28 | - <label for="id_password" class="block text-sm font-medium text-gray-300">Password</label> | |
| 29 | - <div class="mt-1">{{ form.password }v> | |
| 30 | - {% endif %} | |
| 31 | - <button type="submit" | |
| 32 | - class="w-full rounded-md bg-brand px-3 py-2 text-sm font-semibold text-white shadow-sm-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-gray-950 transition-colors"> | |
| 33 | - Sign in | |
| 34 | - </button> | |
| 35 | - </form> | |
| 36 | - </ |
| --- a/templates/auth1/login.html | |
| +++ b/templates/auth1/login.html | |
| @@ -1,36 +0,0 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load static %} |
| 3 | {% block title %}Sign In — Fossilrecontent %} |
| 4 | <div class="flex min-h-[80vh] items-center justify-center"> |
| 5 | <div class="w-full max-w-sm space-y-8"> |
| 6 | <div class="flex flex-col items-center"> |
| 7 | <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-12 w-auto mb-6"> |
| 8 | <h2 class="text-center text-3xl font-bold tracking-tight text-gray-100">Sign in</h2> |
| 9 | <p class="mt-2 text-center text-sm text-gray-400">Fossilrepo Django + HTMX</p> |
| 10 | </div> |
| 11 | |
| 12 | {% if form.errors %} |
| 13 | <div class="rounded-md bg-red-900/50 border border-red-700 p-4"> |
| 14 | <p class="text-sm text-red-300">Invalid username or password.</p> |
| 15 | </div> |
| 16 | {% endif %} |
| 17 | |
| 18 | /div> |
| 19 | {% endif %} |
| 20 | |
| 21 | <form method="post" class="space-y-6"> |
| 22 | {% csrf_token %} |
| 23 | <div> |
| 24 | <label for="id_username" class="block text-sm font-medium text-gray-300">Username</label> |
| 25 | <div class="mt-1">{{ form.username }}</div> |
| 26 | </div> |
| 27 | <div> |
| 28 | <label for="id_password" class="block text-sm font-medium text-gray-300">Password</label> |
| 29 | <div class="mt-1">{{ form.password }v> |
| 30 | {% endif %} |
| 31 | <button type="submit" |
| 32 | class="w-full rounded-md bg-brand px-3 py-2 text-sm font-semibold text-white shadow-sm-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-gray-950 transition-colors"> |
| 33 | Sign in |
| 34 | </button> |
| 35 | </form> |
| 36 | </ |
| --- a/templates/auth1/login.html | |
| +++ b/templates/auth1/login.html | |
| @@ -1,36 +0,0 @@ | |
D
templates/auth1/ssh_keys.html
-59
| --- a/templates/auth1/ssh_keys.html | ||
| +++ b/templates/auth1/ssh_keys.html | ||
| @@ -1,59 +0,0 @@ | ||
| 1 | -{% extends "base.html" %} | |
| 2 | -{% block title %}SSH Keys — Fossilrepo{% endblock %} | |
| 3 | - | |
| 4 | -{% block content %} | |
| 5 | -<h1 class="text-2xl font-bold text-gray-100 mb-6">SSH Keys</h1> | |
| 6 | - | |
| 7 | -<div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6"> | |
| 8 | - <h2 class="text-lg font-semibold text-gray-200 mb-4">Add SSH Key</h2> | |
| 9 | - <form method="post" class="space-y-4"> | |
| 10 | - {% csrf_token %} | |
| 11 | - <div> | |
| 12 | - <label for="title" class="block text-sm font-medium text-gray-300 mb-1">Title</label> | |
| 13 | - <input type="text" name="title" id="title" required placeholder="e.g. Work laptop" | |
| 14 | - 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"> | |
| 15 | - </div> | |
| 16 | - <div> | |
| 17 | - <label for="public_key" class="block text-sm font-medium text-gray-300 mb-1">Public Key</label> | |
| 18 | - <textarea name="public_key" id="public_key" rows="4" required | |
| 19 | - placeholder="ssh-ed25519 AAAA... user@host" | |
| 20 | - class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm font-mono px-3 py-2 focus:border-brand focus:ring-brand"></textarea> | |
| 21 | - <p class="mt-1 text-xs text-gray-500">Paste your public key (usually from ~/.ssh/id_ed25519.pub)</p> | |
| 22 | - </div> | |
| 23 | - <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover"> | |
| 24 | - Add Key | |
| 25 | - </button> | |
| 26 | - </form> | |
| 27 | -</div> | |
| 28 | - | |
| 29 | -{% if keys %} | |
| 30 | -<div class="rounded-lg bg-gray-800 border border-gray-700"> | |
| 31 | - <div class="p-4 border-b border-gray-700"> | |
| 32 | - <h2 class="text-lg font-semibold text-gray-200">Your Keys</h2> | |
| 33 | - </div> | |
| 34 | - <div class="divide-y divide-gray-700"> | |
| 35 | - {% for key in keys %} | |
| 36 | - <div class="p-4 flex items-center justify-between"> | |
| 37 | - <div> | |
| 38 | - <div class="text-sm font-medium text-gray-200">{{ key.title }}</div> | |
| 39 | - <div class="text-xs text-gray-500 font-mono mt-1">{{ key.fingerprint }}</div> | |
| 40 | - <div class="text-xs text-gray-500 mt-1"> | |
| 41 | - {{ key.key_type|upper }} · Added {{ key.created_at|timesince }} ago | |
| 42 | - {% if key.last_used_at %}· Last used {{ key.last_used_at|timesince }} ago{% endif %} | |
| 43 | - </div> | |
| 44 | - </div> | |
| 45 | - <form hx-post="{% url 'auth1:ssh_key_delete' pk=key.pk %}" hx-confirm="Delete SSH key '{{ key.title }}'?"> | |
| 46 | - {% csrf_token %} | |
| 47 | - <button type="submit" class="text-sm text-red-400 hover:text-red-300">Delete</button> | |
| 48 | - </form> | |
| 49 | - </div> | |
| 50 | - {% endfor %} | |
| 51 | - </div> | |
| 52 | -</div> | |
| 53 | -{% else %} | |
| 54 | -<div class="text-center py-12 text-gray-500"> | |
| 55 | - <p class="text-sm">No SSH keys added yet.</p> | |
| 56 | - <p class="text-xs mt-1">Add an SSH key to clone and push Fossil repos over SSH.</p> | |
| 57 | -</div> | |
| 58 | -{% endif %} | |
| 59 | -{% endblock %} |
| --- a/templates/auth1/ssh_keys.html | |
| +++ b/templates/auth1/ssh_keys.html | |
| @@ -1,59 +0,0 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}SSH Keys — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-6">SSH Keys</h1> |
| 6 | |
| 7 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6"> |
| 8 | <h2 class="text-lg font-semibold text-gray-200 mb-4">Add SSH Key</h2> |
| 9 | <form method="post" class="space-y-4"> |
| 10 | {% csrf_token %} |
| 11 | <div> |
| 12 | <label for="title" class="block text-sm font-medium text-gray-300 mb-1">Title</label> |
| 13 | <input type="text" name="title" id="title" required placeholder="e.g. Work laptop" |
| 14 | 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"> |
| 15 | </div> |
| 16 | <div> |
| 17 | <label for="public_key" class="block text-sm font-medium text-gray-300 mb-1">Public Key</label> |
| 18 | <textarea name="public_key" id="public_key" rows="4" required |
| 19 | placeholder="ssh-ed25519 AAAA... user@host" |
| 20 | class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm font-mono px-3 py-2 focus:border-brand focus:ring-brand"></textarea> |
| 21 | <p class="mt-1 text-xs text-gray-500">Paste your public key (usually from ~/.ssh/id_ed25519.pub)</p> |
| 22 | </div> |
| 23 | <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover"> |
| 24 | Add Key |
| 25 | </button> |
| 26 | </form> |
| 27 | </div> |
| 28 | |
| 29 | {% if keys %} |
| 30 | <div class="rounded-lg bg-gray-800 border border-gray-700"> |
| 31 | <div class="p-4 border-b border-gray-700"> |
| 32 | <h2 class="text-lg font-semibold text-gray-200">Your Keys</h2> |
| 33 | </div> |
| 34 | <div class="divide-y divide-gray-700"> |
| 35 | {% for key in keys %} |
| 36 | <div class="p-4 flex items-center justify-between"> |
| 37 | <div> |
| 38 | <div class="text-sm font-medium text-gray-200">{{ key.title }}</div> |
| 39 | <div class="text-xs text-gray-500 font-mono mt-1">{{ key.fingerprint }}</div> |
| 40 | <div class="text-xs text-gray-500 mt-1"> |
| 41 | {{ key.key_type|upper }} · Added {{ key.created_at|timesince }} ago |
| 42 | {% if key.last_used_at %}· Last used {{ key.last_used_at|timesince }} ago{% endif %} |
| 43 | </div> |
| 44 | </div> |
| 45 | <form hx-post="{% url 'auth1:ssh_key_delete' pk=key.pk %}" hx-confirm="Delete SSH key '{{ key.title }}'?"> |
| 46 | {% csrf_token %} |
| 47 | <button type="submit" class="text-sm text-red-400 hover:text-red-300">Delete</button> |
| 48 | </form> |
| 49 | </div> |
| 50 | {% endfor %} |
| 51 | </div> |
| 52 | </div> |
| 53 | {% else %} |
| 54 | <div class="text-center py-12 text-gray-500"> |
| 55 | <p class="text-sm">No SSH keys added yet.</p> |
| 56 | <p class="text-xs mt-1">Add an SSH key to clone and push Fossil repos over SSH.</p> |
| 57 | </div> |
| 58 | {% endif %} |
| 59 | {% endblock %} |
| --- a/templates/auth1/ssh_keys.html | |
| +++ b/templates/auth1/ssh_keys.html | |
| @@ -1,59 +0,0 @@ | |
+1
-1
| --- templates/includes/nav.html | ||
| +++ templates/includes/nav.html | ||
| @@ -47,11 +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 | - <form method="post" action="{% url 'auth1: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> | |
| 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 | 53 | </div> |
| 54 | 54 | </div> |
| 55 | 55 | </div> |
| 56 | 56 | </div> |
| 57 | 57 | </div> |
| 58 | 58 | |
| 59 | 59 | DELETED templates/items/item_confirm_delete.html |
| 60 | 60 | DELETED templates/items/item_detail.html |
| 61 | 61 | DELETED templates/items/item_form.html |
| 62 | 62 | DELETED templates/items/item_list.html |
| 63 | 63 | DELETED templates/items/partials/item_table.html |
| --- templates/includes/nav.html | |
| +++ templates/includes/nav.html | |
| @@ -47,11 +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 'auth1: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 | </div> |
| 58 | |
| 59 | ELETED templates/items/item_confirm_delete.html |
| 60 | ELETED templates/items/item_detail.html |
| 61 | ELETED templates/items/item_form.html |
| 62 | ELETED templates/items/item_list.html |
| 63 | ELETED templates/items/partials/item_table.html |
| --- templates/includes/nav.html | |
| +++ templates/includes/nav.html | |
| @@ -47,11 +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 | </div> |
| 58 | |
| 59 | ELETED templates/items/item_confirm_delete.html |
| 60 | ELETED templates/items/item_detail.html |
| 61 | ELETED templates/items/item_form.html |
| 62 | ELETED templates/items/item_list.html |
| 63 | ELETED templates/items/partials/item_table.html |
D
templates/items/item_confirm_delete.html
-28
| --- a/templates/items/item_confirm_delete.html | ||
| +++ b/templates/items/item_confirm_delete.html | ||
| @@ -1,28 +0,0 @@ | ||
| 1 | -{% extends "base.html" %} | |
| 2 | -{% block title %}Delete {{ item.name }} — Fossilrepo{% endblock %} | |
| 3 | - | |
| 4 | -{% block content %} | |
| 5 | -<div class="mb-6"> | |
| 6 | - <a href="{% url 'items:detail' slug=item.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ item.name }}</a> | |
| 7 | -</div> | |
| 8 | - | |
| 9 | -<div class="mx-auto max-w-lg"> | |
| 10 | - <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 11 | - <h2 class="text-lg font-semibold text-gray-100">Delete Item</h2> | |
| 12 | - <p class="mt-2 text-sm text-gray-400"> | |
| 13 | - Are you sure you want to delete <strong class="text-gray-100">{{ item.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered. | |
| 14 | - </p> | |
| 15 | - <form method="post" class="mt-6 flex justify-end gap-3"> | |
| 16 | - {% csrf_token %} | |
| 17 | - <a href="{% url 'items:detail' slug=item.slug %}" | |
| 18 | - 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"> | |
| 19 | - Cancel | |
| 20 | - </a> | |
| 21 | - <button type="submit" | |
| 22 | - class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> | |
| 23 | - Delete | |
| 24 | - </button> | |
| 25 | - </form> | |
| 26 | - </div> | |
| 27 | -</div> | |
| 28 | -{% endblock %} |
| --- a/templates/items/item_confirm_delete.html | |
| +++ b/templates/items/item_confirm_delete.html | |
| @@ -1,28 +0,0 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Delete {{ item.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'items:detail' slug=item.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ item.name }}</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-lg"> |
| 10 | <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 11 | <h2 class="text-lg font-semibold text-gray-100">Delete Item</h2> |
| 12 | <p class="mt-2 text-sm text-gray-400"> |
| 13 | Are you sure you want to delete <strong class="text-gray-100">{{ item.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered. |
| 14 | </p> |
| 15 | <form method="post" class="mt-6 flex justify-end gap-3"> |
| 16 | {% csrf_token %} |
| 17 | <a href="{% url 'items:detail' slug=item.slug %}" |
| 18 | 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"> |
| 19 | Cancel |
| 20 | </a> |
| 21 | <button type="submit" |
| 22 | class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> |
| 23 | Delete |
| 24 | </button> |
| 25 | </form> |
| 26 | </div> |
| 27 | </div> |
| 28 | {% endblock %} |
| --- a/templates/items/item_confirm_delete.html | |
| +++ b/templates/items/item_confirm_delete.html | |
| @@ -1,28 +0,0 @@ | |
D
templates/items/item_detail.html
-70
| --- a/templates/items/item_detail.html | ||
| +++ b/templates/items/item_detail.html | ||
| @@ -1,70 +0,0 @@ | ||
| 1 | -{% extends "base.html" %} | |
| 2 | -{% block title %}{{ item.name }} — Fossilrepo{% endblock %} | |
| 3 | - | |
| 4 | -{% block content %} | |
| 5 | -<div class="mb-6"> | |
| 6 | - <a href="{% url 'items:list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Items</a> | |
| 7 | -</div> | |
| 8 | - | |
| 9 | -<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> | |
| 10 | - <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between"> | |
| 11 | - <div> | |
| 12 | - <h1 class="text-2xl font-bold text-gray-100">{{ item.name }}</h1> | |
| 13 | - <p class="mt-1 text-sm text-gray-400">{{ item.slug }}</p> | |
| 14 | - </div> | |
| 15 | - <div class="mt-4 flex gap-3 sm:mt-0"> | |
| 16 | - {% if perms.items.change_item %} | |
| 17 | - <a href="{% url 'items:update' slug=item.slug %}" | |
| 18 | - class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 19 | - Edit | |
| 20 | - </a> | |
| 21 | - {% endif %} | |
| 22 | - {% if perms.items.delete_item %} | |
| 23 | - <a href="{% url 'items:delete' slug=item.slug %}" | |
| 24 | - class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> | |
| 25 | - Delete | |
| 26 | - </a> | |
| 27 | - {% endif %} | |
| 28 | - </div> | |
| 29 | - </div> | |
| 30 | - | |
| 31 | - <div class="border-t border-gray-700 px-6 py-5"> | |
| 32 | - <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2"> | |
| 33 | - <div> | |
| 34 | - <dt class="text-sm font-medium text-gray-400">Price</dt> | |
| 35 | - <dd class="mt-1 text-sm text-gray-100">${{ item.price }}</dd> | |
| 36 | - </div> | |
| 37 | - <div> | |
| 38 | - <dt class="text-sm font-medium text-gray-400">SKU</dt> | |
| 39 | - <dd class="mt-1 text-sm text-gray-100">{{ item.sku|default:"—" }}</dd> | |
| 40 | - </div> | |
| 41 | - <div> | |
| 42 | - <dt class="text-sm font-medium text-gray-400">Status</dt> | |
| 43 | - <dd class="mt-1 text-sm"> | |
| 44 | - {% if item.is_active %} | |
| 45 | - <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span> | |
| 46 | - {% else %} | |
| 47 | - <span class="inline-flex rounded-full bg-gray-700 px-2 text-xs font-semibold leading-5 text-gray-300">Inactive</span> | |
| 48 | - {% endif %} | |
| 49 | - </dd> | |
| 50 | - </div> | |
| 51 | - <div> | |
| 52 | - <dt class="text-sm font-medium text-gray-400">GUID</dt> | |
| 53 | - <dd class="mt-1 text-sm text-gray-400 font-mono">{{ item.guid }}</dd> | |
| 54 | - </div> | |
| 55 | - <div class="sm:col-span-2"> | |
| 56 | - <dt class="text-sm font-medium text-gray-400">Description</dt> | |
| 57 | - <dd class="mt-1 text-sm text-gray-100">{{ item.description|default:"No description." }}</dd> | |
| 58 | - </div> | |
| 59 | - <div> | |
| 60 | - <dt class="text-sm font-medium text-gray-400">Created</dt> | |
| 61 | - <dd class="mt-1 text-sm text-gray-400">{{ item.created_at|date:"N j, Y g:i a" }} by {{ item.created_by|default:"system" }}</dd> | |
| 62 | - </div> | |
| 63 | - <div> | |
| 64 | - <dt class="text-sm font-medium text-gray-400">Updated</dt> | |
| 65 | - <dd class="mt-1 text-sm text-gray-400">{{ item.updated_at|date:"N j, Y g:i a" }}</dd> | |
| 66 | - </div> | |
| 67 | - </dl> | |
| 68 | - </div> | |
| 69 | -</div> | |
| 70 | -{% endblock %} |
| --- a/templates/items/item_detail.html | |
| +++ b/templates/items/item_detail.html | |
| @@ -1,70 +0,0 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ item.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'items:list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Items</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 10 | <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between"> |
| 11 | <div> |
| 12 | <h1 class="text-2xl font-bold text-gray-100">{{ item.name }}</h1> |
| 13 | <p class="mt-1 text-sm text-gray-400">{{ item.slug }}</p> |
| 14 | </div> |
| 15 | <div class="mt-4 flex gap-3 sm:mt-0"> |
| 16 | {% if perms.items.change_item %} |
| 17 | <a href="{% url 'items:update' slug=item.slug %}" |
| 18 | class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 19 | Edit |
| 20 | </a> |
| 21 | {% endif %} |
| 22 | {% if perms.items.delete_item %} |
| 23 | <a href="{% url 'items:delete' slug=item.slug %}" |
| 24 | class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> |
| 25 | Delete |
| 26 | </a> |
| 27 | {% endif %} |
| 28 | </div> |
| 29 | </div> |
| 30 | |
| 31 | <div class="border-t border-gray-700 px-6 py-5"> |
| 32 | <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2"> |
| 33 | <div> |
| 34 | <dt class="text-sm font-medium text-gray-400">Price</dt> |
| 35 | <dd class="mt-1 text-sm text-gray-100">${{ item.price }}</dd> |
| 36 | </div> |
| 37 | <div> |
| 38 | <dt class="text-sm font-medium text-gray-400">SKU</dt> |
| 39 | <dd class="mt-1 text-sm text-gray-100">{{ item.sku|default:"—" }}</dd> |
| 40 | </div> |
| 41 | <div> |
| 42 | <dt class="text-sm font-medium text-gray-400">Status</dt> |
| 43 | <dd class="mt-1 text-sm"> |
| 44 | {% if item.is_active %} |
| 45 | <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span> |
| 46 | {% else %} |
| 47 | <span class="inline-flex rounded-full bg-gray-700 px-2 text-xs font-semibold leading-5 text-gray-300">Inactive</span> |
| 48 | {% endif %} |
| 49 | </dd> |
| 50 | </div> |
| 51 | <div> |
| 52 | <dt class="text-sm font-medium text-gray-400">GUID</dt> |
| 53 | <dd class="mt-1 text-sm text-gray-400 font-mono">{{ item.guid }}</dd> |
| 54 | </div> |
| 55 | <div class="sm:col-span-2"> |
| 56 | <dt class="text-sm font-medium text-gray-400">Description</dt> |
| 57 | <dd class="mt-1 text-sm text-gray-100">{{ item.description|default:"No description." }}</dd> |
| 58 | </div> |
| 59 | <div> |
| 60 | <dt class="text-sm font-medium text-gray-400">Created</dt> |
| 61 | <dd class="mt-1 text-sm text-gray-400">{{ item.created_at|date:"N j, Y g:i a" }} by {{ item.created_by|default:"system" }}</dd> |
| 62 | </div> |
| 63 | <div> |
| 64 | <dt class="text-sm font-medium text-gray-400">Updated</dt> |
| 65 | <dd class="mt-1 text-sm text-gray-400">{{ item.updated_at|date:"N j, Y g:i a" }}</dd> |
| 66 | </div> |
| 67 | </dl> |
| 68 | </div> |
| 69 | </div> |
| 70 | {% endblock %} |
| --- a/templates/items/item_detail.html | |
| +++ b/templates/items/item_detail.html | |
| @@ -1,70 +0,0 @@ | |
D
templates/items/item_form.html
-42
| --- a/templates/items/item_form.html | ||
| +++ b/templates/items/item_form.html | ||
| @@ -1,42 +0,0 @@ | ||
| 1 | -{% extends "base.html" %} | |
| 2 | -{% block title %}{{ title }} — Fossilrepo{% endblock %} | |
| 3 | - | |
| 4 | -{% block content %} | |
| 5 | -<div class="mb-6"> | |
| 6 | - <a href="{% url 'items:list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Items</a> | |
| 7 | -</div> | |
| 8 | - | |
| 9 | -<div class="mx-auto max-w-2xl"> | |
| 10 | - <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1> | |
| 11 | - | |
| 12 | - <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 13 | - {% csrf_token %} | |
| 14 | - | |
| 15 | - {% for field in form %} | |
| 16 | - <div> | |
| 17 | - <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> | |
| 18 | - {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} | |
| 19 | - </label> | |
| 20 | - <div class="mt-1">{{ field }}</div> | |
| 21 | - {% if field.errors %} | |
| 22 | - <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> | |
| 23 | - {% endif %} | |
| 24 | - {% if field.help_text %} | |
| 25 | - <p class="mt-1 text-sm text-gray-400">{{ field.help_text }}</p> | |
| 26 | - {% endif %} | |
| 27 | - </div> | |
| 28 | - {% endfor %} | |
| 29 | - | |
| 30 | - <div class="flex justify-end gap-3 pt-4"> | |
| 31 | - <a href="{% url 'items:list' %}" | |
| 32 | - 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"> | |
| 33 | - Cancel | |
| 34 | - </a> | |
| 35 | - <button type="submit" | |
| 36 | - class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 37 | - {% if item %}Update{% else %}Create{% endif %} | |
| 38 | - </button> | |
| 39 | - </div> | |
| 40 | - </form> | |
| 41 | -</div> | |
| 42 | -{% endblock %} |
| --- a/templates/items/item_form.html | |
| +++ b/templates/items/item_form.html | |
| @@ -1,42 +0,0 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ title }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'items:list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Items</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-2xl"> |
| 10 | <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1> |
| 11 | |
| 12 | <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 13 | {% csrf_token %} |
| 14 | |
| 15 | {% for field in form %} |
| 16 | <div> |
| 17 | <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> |
| 18 | {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} |
| 19 | </label> |
| 20 | <div class="mt-1">{{ field }}</div> |
| 21 | {% if field.errors %} |
| 22 | <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> |
| 23 | {% endif %} |
| 24 | {% if field.help_text %} |
| 25 | <p class="mt-1 text-sm text-gray-400">{{ field.help_text }}</p> |
| 26 | {% endif %} |
| 27 | </div> |
| 28 | {% endfor %} |
| 29 | |
| 30 | <div class="flex justify-end gap-3 pt-4"> |
| 31 | <a href="{% url 'items:list' %}" |
| 32 | 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"> |
| 33 | Cancel |
| 34 | </a> |
| 35 | <button type="submit" |
| 36 | class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 37 | {% if item %}Update{% else %}Create{% endif %} |
| 38 | </button> |
| 39 | </div> |
| 40 | </form> |
| 41 | </div> |
| 42 | {% endblock %} |
| --- a/templates/items/item_form.html | |
| +++ b/templates/items/item_form.html | |
| @@ -1,42 +0,0 @@ | |
D
templates/items/item_list.html
-29
| --- a/templates/items/item_list.html | ||
| +++ b/templates/items/item_list.html | ||
| @@ -1,29 +0,0 @@ | ||
| 1 | -{% extends "base.html" %} | |
| 2 | -{% block title %}Items — Fossilrepo{% endblock %} | |
| 3 | - | |
| 4 | -{% block content %} | |
| 5 | -<div class="md:flex md:items-center md:justify-between mb-6"> | |
| 6 | - <h1 class="text-2xl font-bold text-gray-100">Items</h1> | |
| 7 | - {% if perms.items.add_item %} | |
| 8 | - <a href="{% url 'items:create' %}" | |
| 9 | - class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 10 | - New Item | |
| 11 | - </a> | |
| 12 | - {% endif %} | |
| 13 | -</div> | |
| 14 | - | |
| 15 | -<div class="mb-4"> | |
| 16 | - <input type="search" | |
| 17 | - name="search" | |
| 18 | - value="{{ search }}" | |
| 19 | - placeholder="Search items..." | |
| 20 | - class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" | |
| 21 | - hx-get="{% url 'items:list' %}" | |
| 22 | - hx-trigger="input changed delay:300ms, search" | |
| 23 | - hx-target="#item-table" | |
| 24 | - hx-swap="outerHTML" | |
| 25 | - hx-push-url="true" /> | |
| 26 | -</div> | |
| 27 | - | |
| 28 | -{% include "items/partials/item_table.html" %} | |
| 29 | -{% endblock %} |
| --- a/templates/items/item_list.html | |
| +++ b/templates/items/item_list.html | |
| @@ -1,29 +0,0 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Items — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="md:flex md:items-center md:justify-between mb-6"> |
| 6 | <h1 class="text-2xl font-bold text-gray-100">Items</h1> |
| 7 | {% if perms.items.add_item %} |
| 8 | <a href="{% url 'items:create' %}" |
| 9 | class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 10 | New Item |
| 11 | </a> |
| 12 | {% endif %} |
| 13 | </div> |
| 14 | |
| 15 | <div class="mb-4"> |
| 16 | <input type="search" |
| 17 | name="search" |
| 18 | value="{{ search }}" |
| 19 | placeholder="Search items..." |
| 20 | class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 21 | hx-get="{% url 'items:list' %}" |
| 22 | hx-trigger="input changed delay:300ms, search" |
| 23 | hx-target="#item-table" |
| 24 | hx-swap="outerHTML" |
| 25 | hx-push-url="true" /> |
| 26 | </div> |
| 27 | |
| 28 | {% include "items/partials/item_table.html" %} |
| 29 | {% endblock %} |
| --- a/templates/items/item_list.html | |
| +++ b/templates/items/item_list.html | |
| @@ -1,29 +0,0 @@ | |
D
templates/items/partials/item_table.html
-44
| --- a/templates/items/partials/item_table.html | ||
| +++ b/templates/items/partials/item_table.html | ||
| @@ -1,44 +0,0 @@ | ||
| 1 | -<div id="item-table"> | |
| 2 | - <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 3 | - <table class="min-w-full divide-y divide-gray-700"> | |
| 4 | - <thead class="bg-gray-900"> | |
| 5 | - <tr> | |
| 6 | - <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Name</th> | |
| 7 | - <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">SKU</th> | |
| 8 | - <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Price</th> | |
| 9 | - <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Status</th> | |
| 10 | - <th class="px-6 py-3 text-right text-xs font-medium uppercase text-gray-400">Actions</th> | |
| 11 | - </tr> | |
| 12 | - </thead> | |
| 13 | - <tbody class="divide-y divide-gray-700 bg-gray-800"> | |
| 14 | - {% for item in items %} | |
| 15 | - <tr class="hover:bg-gray-700/50"> | |
| 16 | - <td class="px-6 py-4 whitespace-nowrap"> | |
| 17 | - <a href="{% url 'items:detail' slug=item.slug %}" class="text-brand-light hover:text-brand font-medium"> | |
| 18 | - {{ item.name }} | |
| 19 | - </a> | |
| 20 | - </td> | |
| 21 | - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ item.sku|default:"—" }}</td> | |
| 22 | - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-100">${{ item.price }}</td> | |
| 23 | - <td class="px-6 py-4 whitespace-nowrap"> | |
| 24 | - {% if item.is_active %} | |
| 25 | - <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span> | |
| 26 | - {% else %} | |
| 27 | - <span class="inline-flex rounded-full bg-gray-700 px-2 text-xs font-semibold leading-5 text-gray-300">Inactive</span> | |
| 28 | - {% endif %} | |
| 29 | - </td> | |
| 30 | - <td class="px-6 py-4 whitespace-nowrap text-right text-sm"> | |
| 31 | - {% if perms.items.change_item %} | |
| 32 | - <a href="{% url 'items:update' slug=item.slug %}" class="text-brand-light hover:text-brand">Edit</a> | |
| 33 | - {% endif %} | |
| 34 | - </td> | |
| 35 | - </tr> | |
| 36 | - {% empty %} | |
| 37 | - <tr> | |
| 38 | - <td colspan="5" class="px-6 py-8 text-center text-sm text-gray-400">No items found.</td> | |
| 39 | - </tr> | |
| 40 | - {% endfor %} | |
| 41 | - </tbody> | |
| 42 | - </table> | |
| 43 | - </div> | |
| 44 | -</div> |
| --- a/templates/items/partials/item_table.html | |
| +++ b/templates/items/partials/item_table.html | |
| @@ -1,44 +0,0 @@ | |
| 1 | <div id="item-table"> |
| 2 | <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 3 | <table class="min-w-full divide-y divide-gray-700"> |
| 4 | <thead class="bg-gray-900"> |
| 5 | <tr> |
| 6 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Name</th> |
| 7 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">SKU</th> |
| 8 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Price</th> |
| 9 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Status</th> |
| 10 | <th class="px-6 py-3 text-right text-xs font-medium uppercase text-gray-400">Actions</th> |
| 11 | </tr> |
| 12 | </thead> |
| 13 | <tbody class="divide-y divide-gray-700 bg-gray-800"> |
| 14 | {% for item in items %} |
| 15 | <tr class="hover:bg-gray-700/50"> |
| 16 | <td class="px-6 py-4 whitespace-nowrap"> |
| 17 | <a href="{% url 'items:detail' slug=item.slug %}" class="text-brand-light hover:text-brand font-medium"> |
| 18 | {{ item.name }} |
| 19 | </a> |
| 20 | </td> |
| 21 | <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ item.sku|default:"—" }}</td> |
| 22 | <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-100">${{ item.price }}</td> |
| 23 | <td class="px-6 py-4 whitespace-nowrap"> |
| 24 | {% if item.is_active %} |
| 25 | <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span> |
| 26 | {% else %} |
| 27 | <span class="inline-flex rounded-full bg-gray-700 px-2 text-xs font-semibold leading-5 text-gray-300">Inactive</span> |
| 28 | {% endif %} |
| 29 | </td> |
| 30 | <td class="px-6 py-4 whitespace-nowrap text-right text-sm"> |
| 31 | {% if perms.items.change_item %} |
| 32 | <a href="{% url 'items:update' slug=item.slug %}" class="text-brand-light hover:text-brand">Edit</a> |
| 33 | {% endif %} |
| 34 | </td> |
| 35 | </tr> |
| 36 | {% empty %} |
| 37 | <tr> |
| 38 | <td colspan="5" class="px-6 py-8 text-center text-sm text-gray-400">No items found.</td> |
| 39 | </tr> |
| 40 | {% endfor %} |
| 41 | </tbody> |
| 42 | </table> |
| 43 | </div> |
| 44 | </div> |
| --- a/templates/items/partials/item_table.html | |
| +++ b/templates/items/partials/item_table.html | |
| @@ -1,44 +0,0 @@ | |
+4
-20
| --- testdata/management/commands/seed.py | ||
| +++ testdata/management/commands/seed.py | ||
| @@ -1,11 +1,10 @@ | ||
| 1 | 1 | import logging |
| 2 | 2 | |
| 3 | 3 | from django.contrib.auth.models import Group, Permission, User |
| 4 | 4 | from django.core.management.base import BaseCommand |
| 5 | 5 | |
| 6 | -from items.models import Item | |
| 7 | 6 | from organization.models import Organization, OrganizationMember, Team |
| 8 | 7 | from pages.models import Page |
| 9 | 8 | from projects.models import Project, ProjectTeam |
| 10 | 9 | |
| 11 | 10 | logger = logging.getLogger(__name__) |
| @@ -21,27 +20,26 @@ | ||
| 21 | 20 | if options["flush"]: |
| 22 | 21 | self.stdout.write("Flushing data...") |
| 23 | 22 | Page.all_objects.all().delete() |
| 24 | 23 | ProjectTeam.all_objects.all().delete() |
| 25 | 24 | Project.all_objects.all().delete() |
| 26 | - Item.all_objects.all().delete() | |
| 27 | 25 | Team.all_objects.all().delete() |
| 28 | 26 | OrganizationMember.all_objects.all().delete() |
| 29 | 27 | Organization.all_objects.all().delete() |
| 30 | 28 | |
| 31 | 29 | # Groups and permissions |
| 32 | 30 | admin_group, _ = Group.objects.get_or_create(name="Administrators") |
| 33 | 31 | viewer_group, _ = Group.objects.get_or_create(name="Viewers") |
| 34 | 32 | |
| 35 | - # Admin group gets all permissions for items, org, and projects | |
| 36 | - for app_label in ["items", "organization", "projects", "pages"]: | |
| 33 | + # Admin group gets all permissions for org, projects, and pages | |
| 34 | + for app_label in ["organization", "projects", "pages"]: | |
| 37 | 35 | perms = Permission.objects.filter(content_type__app_label=app_label) |
| 38 | 36 | admin_group.permissions.add(*perms) |
| 39 | 37 | |
| 40 | - # Viewer group gets view permissions for items, org, and projects | |
| 38 | + # Viewer group gets view permissions for org, projects, and pages | |
| 41 | 39 | view_perms = Permission.objects.filter( |
| 42 | - content_type__app_label__in=["items", "organization", "projects", "pages"], | |
| 40 | + content_type__app_label__in=["organization", "projects", "pages"], | |
| 43 | 41 | codename__startswith="view_", |
| 44 | 42 | ) |
| 45 | 43 | viewer_group.permissions.set(view_perms) |
| 46 | 44 | |
| 47 | 45 | # Superuser |
| @@ -108,24 +106,10 @@ | ||
| 108 | 106 | ProjectTeam.objects.get_or_create(project=backend, team=reviewers, defaults={"role": "read"}) |
| 109 | 107 | if docs: |
| 110 | 108 | ProjectTeam.objects.get_or_create(project=docs, team=contributors, defaults={"role": "write"}) |
| 111 | 109 | ProjectTeam.objects.get_or_create(project=docs, team=reviewers, defaults={"role": "write"}) |
| 112 | 110 | |
| 113 | - # Sample items | |
| 114 | - items_data = [ | |
| 115 | - {"name": "Widget Alpha", "price": "29.99", "sku": "WGT-001", "description": "A versatile alpha widget."}, | |
| 116 | - {"name": "Widget Beta", "price": "49.99", "sku": "WGT-002", "description": "Enhanced beta widget with extra features."}, | |
| 117 | - {"name": "Gadget Pro", "price": "199.99", "sku": "GDG-001", "description": "Professional-grade gadget."}, | |
| 118 | - {"name": "Starter Kit", "price": "9.99", "sku": "KIT-001", "description": "Everything you need to get started."}, | |
| 119 | - {"name": "Premium Bundle", "price": "399.99", "sku": "BDL-001", "description": "Our best items in one bundle."}, | |
| 120 | - ] | |
| 121 | - for data in items_data: | |
| 122 | - Item.objects.get_or_create( | |
| 123 | - sku=data["sku"], | |
| 124 | - defaults={**data, "created_by": admin_user}, | |
| 125 | - ) | |
| 126 | - | |
| 127 | 111 | # Sample docs pages |
| 128 | 112 | pages_data = [ |
| 129 | 113 | { |
| 130 | 114 | "name": "Getting Started", |
| 131 | 115 | "content": "# Getting Started\n\nWelcome to Fossilrepo. This guide covers initial setup and configuration.\n\n## Prerequisites\n\n- Docker and Docker Compose\n- A domain name (for SSL)\n- S3-compatible storage (for backups)\n\n## Quick Start\n\n1. Clone the repository\n2. Copy `.env.example` to `.env`\n3. Run `fossilrepo-ctl reconfigure`\n4. Run `fossilrepo-ctl start`\n", |
| 132 | 116 |
| --- testdata/management/commands/seed.py | |
| +++ testdata/management/commands/seed.py | |
| @@ -1,11 +1,10 @@ | |
| 1 | import logging |
| 2 | |
| 3 | from django.contrib.auth.models import Group, Permission, User |
| 4 | from django.core.management.base import BaseCommand |
| 5 | |
| 6 | from items.models import Item |
| 7 | from organization.models import Organization, OrganizationMember, Team |
| 8 | from pages.models import Page |
| 9 | from projects.models import Project, ProjectTeam |
| 10 | |
| 11 | logger = logging.getLogger(__name__) |
| @@ -21,27 +20,26 @@ | |
| 21 | if options["flush"]: |
| 22 | self.stdout.write("Flushing data...") |
| 23 | Page.all_objects.all().delete() |
| 24 | ProjectTeam.all_objects.all().delete() |
| 25 | Project.all_objects.all().delete() |
| 26 | Item.all_objects.all().delete() |
| 27 | Team.all_objects.all().delete() |
| 28 | OrganizationMember.all_objects.all().delete() |
| 29 | Organization.all_objects.all().delete() |
| 30 | |
| 31 | # Groups and permissions |
| 32 | admin_group, _ = Group.objects.get_or_create(name="Administrators") |
| 33 | viewer_group, _ = Group.objects.get_or_create(name="Viewers") |
| 34 | |
| 35 | # Admin group gets all permissions for items, org, and projects |
| 36 | for app_label in ["items", "organization", "projects", "pages"]: |
| 37 | perms = Permission.objects.filter(content_type__app_label=app_label) |
| 38 | admin_group.permissions.add(*perms) |
| 39 | |
| 40 | # Viewer group gets view permissions for items, org, and projects |
| 41 | view_perms = Permission.objects.filter( |
| 42 | content_type__app_label__in=["items", "organization", "projects", "pages"], |
| 43 | codename__startswith="view_", |
| 44 | ) |
| 45 | viewer_group.permissions.set(view_perms) |
| 46 | |
| 47 | # Superuser |
| @@ -108,24 +106,10 @@ | |
| 108 | ProjectTeam.objects.get_or_create(project=backend, team=reviewers, defaults={"role": "read"}) |
| 109 | if docs: |
| 110 | ProjectTeam.objects.get_or_create(project=docs, team=contributors, defaults={"role": "write"}) |
| 111 | ProjectTeam.objects.get_or_create(project=docs, team=reviewers, defaults={"role": "write"}) |
| 112 | |
| 113 | # Sample items |
| 114 | items_data = [ |
| 115 | {"name": "Widget Alpha", "price": "29.99", "sku": "WGT-001", "description": "A versatile alpha widget."}, |
| 116 | {"name": "Widget Beta", "price": "49.99", "sku": "WGT-002", "description": "Enhanced beta widget with extra features."}, |
| 117 | {"name": "Gadget Pro", "price": "199.99", "sku": "GDG-001", "description": "Professional-grade gadget."}, |
| 118 | {"name": "Starter Kit", "price": "9.99", "sku": "KIT-001", "description": "Everything you need to get started."}, |
| 119 | {"name": "Premium Bundle", "price": "399.99", "sku": "BDL-001", "description": "Our best items in one bundle."}, |
| 120 | ] |
| 121 | for data in items_data: |
| 122 | Item.objects.get_or_create( |
| 123 | sku=data["sku"], |
| 124 | defaults={**data, "created_by": admin_user}, |
| 125 | ) |
| 126 | |
| 127 | # Sample docs pages |
| 128 | pages_data = [ |
| 129 | { |
| 130 | "name": "Getting Started", |
| 131 | "content": "# Getting Started\n\nWelcome to Fossilrepo. This guide covers initial setup and configuration.\n\n## Prerequisites\n\n- Docker and Docker Compose\n- A domain name (for SSL)\n- S3-compatible storage (for backups)\n\n## Quick Start\n\n1. Clone the repository\n2. Copy `.env.example` to `.env`\n3. Run `fossilrepo-ctl reconfigure`\n4. Run `fossilrepo-ctl start`\n", |
| 132 |
| --- testdata/management/commands/seed.py | |
| +++ testdata/management/commands/seed.py | |
| @@ -1,11 +1,10 @@ | |
| 1 | import logging |
| 2 | |
| 3 | from django.contrib.auth.models import Group, Permission, User |
| 4 | from django.core.management.base import BaseCommand |
| 5 | |
| 6 | from organization.models import Organization, OrganizationMember, Team |
| 7 | from pages.models import Page |
| 8 | from projects.models import Project, ProjectTeam |
| 9 | |
| 10 | logger = logging.getLogger(__name__) |
| @@ -21,27 +20,26 @@ | |
| 20 | if options["flush"]: |
| 21 | self.stdout.write("Flushing data...") |
| 22 | Page.all_objects.all().delete() |
| 23 | ProjectTeam.all_objects.all().delete() |
| 24 | Project.all_objects.all().delete() |
| 25 | Team.all_objects.all().delete() |
| 26 | OrganizationMember.all_objects.all().delete() |
| 27 | Organization.all_objects.all().delete() |
| 28 | |
| 29 | # Groups and permissions |
| 30 | admin_group, _ = Group.objects.get_or_create(name="Administrators") |
| 31 | viewer_group, _ = Group.objects.get_or_create(name="Viewers") |
| 32 | |
| 33 | # Admin group gets all permissions for org, projects, and pages |
| 34 | for app_label in ["organization", "projects", "pages"]: |
| 35 | perms = Permission.objects.filter(content_type__app_label=app_label) |
| 36 | admin_group.permissions.add(*perms) |
| 37 | |
| 38 | # Viewer group gets view permissions for org, projects, and pages |
| 39 | view_perms = Permission.objects.filter( |
| 40 | content_type__app_label__in=["organization", "projects", "pages"], |
| 41 | codename__startswith="view_", |
| 42 | ) |
| 43 | viewer_group.permissions.set(view_perms) |
| 44 | |
| 45 | # Superuser |
| @@ -108,24 +106,10 @@ | |
| 106 | ProjectTeam.objects.get_or_create(project=backend, team=reviewers, defaults={"role": "read"}) |
| 107 | if docs: |
| 108 | ProjectTeam.objects.get_or_create(project=docs, team=contributors, defaults={"role": "write"}) |
| 109 | ProjectTeam.objects.get_or_create(project=docs, team=reviewers, defaults={"role": "write"}) |
| 110 | |
| 111 | # Sample docs pages |
| 112 | pages_data = [ |
| 113 | { |
| 114 | "name": "Getting Started", |
| 115 | "content": "# Getting Started\n\nWelcome to Fossilrepo. This guide covers initial setup and configuration.\n\n## Prerequisites\n\n- Docker and Docker Compose\n- A domain name (for SSL)\n- S3-compatible storage (for backups)\n\n## Quick Start\n\n1. Clone the repository\n2. Copy `.env.example` to `.env`\n3. Run `fossilrepo-ctl reconfigure`\n4. Run `fossilrepo-ctl start`\n", |
| 116 |