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.

lmata 2026-04-07 06:46 trunk
Commit 607da99a073ab957f971df4bbd60c501e32bfd2ce8c9d3d19d1f66d336973f13
+2 -3
--- CLAUDE.md
+++ CLAUDE.md
@@ -26,13 +26,12 @@
2626
## Repository Structure
2727
2828
```
2929
fossilrepo/
3030
├── core/ # Base models, permissions, shared utilities
31
-├── auth1/ # Authentication
31
+├── accounts/ # Authentication
3232
├── organization/ # Org/team management
33
-├── items/ # Repo item models
3433
├── config/ # Django settings
3534
├── templates/ # Django + HTMX templates
3635
├── static/ # Static assets
3736
├── docker/ # Caddy, Litestream container configs
3837
├── fossil-platform/ # Old exploration (Flask + React), kept for reference
@@ -45,10 +44,10 @@
4544
4645
- Prefer `Edit` over rewriting whole files.
4746
- Run `ruff check .` and `ruff format --check .` before committing.
4847
- Never expose integer PKs in URLs or templates -- use `slug` or `guid`.
4948
- 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()`.
5150
- HTMX partials: check `request.headers.get("HX-Request")` to return partial vs full page.
5251
- CSRF: HTMX requests include CSRF token via `htmx:configRequest` event in `base.html`.
5352
- Tests: pytest + real Postgres, assert against DB state. Both allowed and denied permission cases.
5453
- Fossil is the source of truth; Git remotes are downstream mirrors.
5554
5655
ADDED accounts/__init__.py
5756
ADDED accounts/apps.py
5857
ADDED accounts/forms.py
5958
ADDED accounts/migrations/__init__.py
6059
ADDED accounts/tests.py
6160
ADDED accounts/urls.py
6261
ADDED accounts/views.py
6362
DELETED auth1/__init__.py
6463
DELETED auth1/apps.py
6564
DELETED auth1/forms.py
6665
DELETED auth1/migrations/__init__.py
6766
DELETED auth1/tests.py
6867
DELETED auth1/urls.py
6968
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

--- 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"
--- 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

--- 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
--- 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 ]
--- 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 @@
7474
7575
```
7676
fossilrepo/
7777
|-- config/ # Django settings, URLs, Celery
7878
|-- core/ # Base models, permissions, middleware
79
-|-- auth1/ # Session-based auth
79
+|-- accounts/ # Session-based auth
8080
|-- organization/ # Org + member management
81
-|-- items/ # Example CRUD app (reference only)
8281
|-- docker/ # Fossil-specific: Caddyfile, litestream.yml
8382
|-- templates/ # HTMX templates
8483
|-- _old_fossilrepo/ # Original server/sync/cli code (being ported)
8584
+-- docs/ # Architecture guides
8685
```
@@ -89,19 +88,19 @@
8988
9089
## What's Already Built
9190
9291
| Layer | What's there |
9392
|---|---|
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 |
9594
| Data | Postgres 16, `Tracking` base model (version, created/updated/deleted by+at, soft deletes, history) |
9695
| API | Django views returning HTML (full pages + HTMX partials) |
9796
| Permissions | Group-based via `P` enum, checked in every view |
9897
| Async | Celery worker + beat, Redis broker |
9998
| Admin | Django Admin with `BaseCoreAdmin` (import/export, tracking fields) |
10099
| Infra | Docker Compose: postgres, redis, celery-worker, celery-beat, mailpit |
101100
| 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 |
103102
| Frontend | HTMX 2.0 + Alpine.js 3 + Tailwind CSS, server-rendered templates |
104103
105104
---
106105
107106
## App Structure
@@ -108,13 +107,12 @@
108107
109108
| App | Purpose |
110109
|---|---|
111110
| `config` | Django settings, URLs, Celery configuration |
112111
| `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 |
114113
| `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) |
116114
| `testdata` | `seed` management command for development data |
117115
118116
---
119117
120118
## Conventions
@@ -134,12 +132,12 @@
134132
135133
**`BaseCoreModel(Tracking)`** (abstract) -- named entities:
136134
```python
137135
from core.models import BaseCoreModel
138136
139
-class Item(BaseCoreModel):
140
- price = models.DecimalField(...)
137
+class Project(BaseCoreModel):
138
+ visibility = models.CharField(...)
141139
```
142140
Adds: `guid` (UUID), `name`, `slug` (auto-generated, unique), `description`.
143141
144142
**Soft deletes:** call `obj.soft_delete(user=request.user)`, never `.delete()`.
145143
@@ -151,28 +149,28 @@
151149
152150
Views return full pages for normal requests, HTMX partials for `HX-Request`:
153151
154152
```python
155153
@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()
159157
160158
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})
162160
163
- return render(request, "items/item_list.html", {"items": items})
161
+ return render(request, "projects/project_list.html", {"projects": projects})
164162
```
165163
166164
**URL patterns** follow CRUD convention:
167165
```python
168166
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"),
174172
]
175173
```
176174
177175
---
178176
@@ -181,18 +179,18 @@
181179
Group-based. Never user-based. Checked in every view.
182180
183181
```python
184182
from core.permissions import P
185183
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
188186
```
189187
190188
Template guards:
191189
```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>
194192
{% endif %}
195193
```
196194
197195
---
198196
@@ -200,13 +198,13 @@
200198
201199
All admin classes inherit `BaseCoreAdmin`:
202200
```python
203201
from core.admin import BaseCoreAdmin
204202
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")
208206
search_fields = ("name", "slug")
209207
```
210208
211209
`BaseCoreAdmin` provides: audit fields as readonly, `created_by`/`updated_by` auto-set, import/export.
212210
@@ -233,21 +231,21 @@
233231
234232
pytest + real Postgres. Assert against database state.
235233
236234
```python
237235
@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", ...
242240
})
243241
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
246244
247245
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"))
249247
assert response.status_code == 403
250248
```
251249
252250
Both allowed AND denied permission cases for every endpoint.
253251
254252
--- 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
--- config/settings.py
+++ config/settings.py
@@ -57,13 +57,12 @@
5757
"corsheaders",
5858
"constance",
5959
"constance.backends.database",
6060
# Project apps
6161
"core",
62
- "auth1",
62
+ "accounts",
6363
"organization",
64
- "items",
6564
"projects",
6665
"pages",
6766
"fossil",
6867
"testdata",
6968
]
7069
--- 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 @@
218218
219219
urlpatterns = [
220220
path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)),
221221
path("status/", status_page, name="status"),
222222
path("dashboard/", include("core.urls")),
223
- path("auth/", include("auth1.urls")),
223
+ path("auth/", include("accounts.urls")),
224224
path("settings/", include("organization.urls")),
225225
path("projects/", include("projects.urls")),
226226
path("projects/<slug:slug>/fossil/", include("fossil.urls")),
227227
path("kb/", include("pages.urls")),
228
- path("items/", include("items.urls")),
229228
path("oauth/callback/github/", _oauth_github_callback, name="oauth_github_callback_global"),
230229
path("oauth/callback/gitlab/", _oauth_gitlab_callback, name="oauth_gitlab_callback_global"),
231230
path("admin/", admin.site.urls),
232231
path("health/", health_check, name="health"),
233232
]
234233
--- 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 @@
1515
@pytest.fixture
1616
def viewer_user(db):
1717
user = User.objects.create_user(username="viewer", email="[email protected]", password="testpass123")
1818
group, _ = Group.objects.get_or_create(name="Viewers")
1919
view_perms = Permission.objects.filter(
20
- content_type__app_label__in=["items", "organization", "projects", "pages"],
20
+ content_type__app_label__in=["organization", "projects", "pages"],
2121
codename__startswith="view_",
2222
)
2323
group.permissions.set(view_perms)
2424
user.groups.add(group)
2525
return user
2626
--- 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
--- core/admin.py
+++ core/admin.py
@@ -3,10 +3,15 @@
33
44
55
class BaseCoreAdmin(ImportExportMixin, admin.ModelAdmin):
66
"""Base admin class for all Fossilrepo models. Provides audit field handling and import/export."""
77
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
+
813
def get_readonly_fields(self, request, obj=None):
914
base = tuple(self.readonly_fields or ())
1015
return base + ("version", "created_at", "created_by", "updated_at", "updated_by", "deleted_at", "deleted_by")
1116
1217
def get_raw_id_fields(self, request):
1318
--- 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
--- core/permissions.py
+++ core/permissions.py
@@ -43,16 +43,10 @@
4343
PAGE_VIEW = "pages.view_page"
4444
PAGE_ADD = "pages.add_page"
4545
PAGE_CHANGE = "pages.change_page"
4646
PAGE_DELETE = "pages.delete_page"
4747
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
-
5448
def check(self, user, raise_error=True):
5549
"""Check if user has this permission. Superusers always pass."""
5650
if not user or not user.is_authenticated:
5751
if raise_error:
5852
raise PermissionDenied("Authentication required.")
5953
--- 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 @@
33
register = template.Library()
44
55
66
@register.simple_tag(takes_context=True)
77
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 %}"""
99
user = context.get("user") or context["request"].user
1010
if not user or not user.is_authenticated:
1111
return False
1212
if user.is_superuser:
1313
return True
1414
--- 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 @@
88
99
class TrackingModelTest(TestCase):
1010
"""Test the Tracking abstract model via a concrete model that uses it."""
1111
1212
def setUp(self):
13
- from items.models import Item
13
+ from organization.models import Organization
14
+ from projects.models import Project
1415
1516
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)
1719
1820
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)
2426
2527
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)
3133
3234
def test_created_at_auto_set(self):
33
- self.assertIsNotNone(self.item.created_at)
35
+ self.assertIsNotNone(self.project.created_at)
3436
3537
def test_updated_at_auto_set(self):
36
- self.assertIsNotNone(self.item.updated_at)
38
+ self.assertIsNotNone(self.project.updated_at)
3739
3840
3941
class BaseCoreModelTest(TestCase):
4042
"""Test BaseCoreModel slug generation and UUID."""
4143
4244
def setUp(self):
43
- from items.models import Item
45
+ from organization.models import Organization
46
+ from projects.models import Project
4447
4548
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)
4751
4852
def test_slug_auto_generated(self):
49
- self.assertEqual(self.item.slug, "my-item")
53
+ self.assertEqual(self.project.slug, "my-project")
5054
5155
def test_guid_is_uuid(self):
5256
import uuid
5357
54
- self.assertIsInstance(self.item.guid, uuid.UUID)
58
+ self.assertIsInstance(self.project.guid, uuid.UUID)
5559
5660
def test_slug_uniqueness(self):
57
- from items.models import Item
61
+ from projects.models import Project
5862
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"))
6266
6367
def test_str_returns_name(self):
64
- self.assertEqual(str(self.item), "My Item")
68
+ self.assertEqual(str(self.project), "My Project")
6569
6670
6771
class PermissionsTest(TestCase):
6872
"""Test the P permission enum."""
6973
@@ -70,28 +74,28 @@
7074
def setUp(self):
7175
self.superuser = User.objects.create_superuser(username="super", password="x")
7276
self.regular = User.objects.create_user(username="regular", password="x")
7377
7478
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))
7781
7882
def test_regular_user_without_perm_denied(self):
7983
from django.core.exceptions import PermissionDenied
8084
8185
with self.assertRaises(PermissionDenied):
82
- P.ITEM_ADD.check(self.regular)
86
+ P.PROJECT_ADD.check(self.regular)
8387
8488
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))
8690
8791
def test_unauthenticated_user_denied(self):
8892
from django.contrib.auth.models import AnonymousUser
8993
from django.core.exceptions import PermissionDenied
9094
9195
with self.assertRaises(PermissionDenied):
92
- P.ITEM_VIEW.check(AnonymousUser())
96
+ P.PROJECT_VIEW.check(AnonymousUser())
9397
9498
9599
@pytest.mark.django_db
96100
class TestDashboard:
97101
def test_dashboard_requires_login(self, client):
98102
--- 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
--- fossil/admin.py
+++ fossil/admin.py
@@ -1,10 +1,11 @@
11
from django.contrib import admin
22
33
from core.admin import BaseCoreAdmin
44
55
from .models import FossilRepository, FossilSnapshot
6
+from .notifications import Notification, ProjectWatch
67
from .sync_models import GitMirror, SSHKey, SyncLog
78
from .user_keys import UserSSHKey
89
910
1011
class FossilSnapshotInline(admin.TabularInline):
@@ -51,5 +52,29 @@
5152
class UserSSHKeyAdmin(BaseCoreAdmin):
5253
list_display = ("title", "user", "key_type", "fingerprint", "last_used_at", "created_at")
5354
list_filter = ("key_type",)
5455
search_fields = ("title", "user__username", "fingerprint")
5556
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",)
5681
5782
DELETED items/__init__.py
5883
DELETED items/admin.py
5984
DELETED items/apps.py
6085
DELETED items/forms.py
6186
DELETED items/migrations/0001_initial.py
6287
DELETED items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py
6388
DELETED items/migrations/__init__.py
6489
DELETED items/models.py
6590
DELETED items/tests.py
6691
DELETED items/urls.py
6792
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 @@
2020
2121
@admin.register(Team)
2222
class TeamAdmin(BaseCoreAdmin):
2323
list_display = ("name", "slug", "organization", "created_at")
2424
search_fields = ("name", "slug")
25
+ list_filter = ("created_at",)
2526
filter_horizontal = ("members",)
2627
2728
2829
@admin.register(OrganizationMember)
2930
class OrganizationMemberAdmin(BaseCoreAdmin):
3031
--- 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 @@
55
from .models import Page
66
77
88
@admin.register(Page)
99
class PageAdmin(BaseCoreAdmin):
10
- list_display = ("name", "slug", "is_published", "created_at")
10
+ list_display = ("name", "slug", "is_published", "created_at", "created_by")
1111
list_filter = ("is_published",)
1212
search_fields = ("name", "slug", "content")
1313
--- 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
--- projects/admin.py
+++ projects/admin.py
@@ -11,16 +11,17 @@
1111
raw_id_fields = ("team",)
1212
1313
1414
@admin.register(Project)
1515
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")
1919
inlines = [ProjectTeamInline]
2020
2121
2222
@admin.register(ProjectTeam)
2323
class ProjectTeamAdmin(BaseCoreAdmin):
2424
list_display = ("project", "team", "role", "created_at")
25
- list_filter = ("role",)
25
+ list_filter = ("role", "team")
26
+ search_fields = ("project__name", "team__name")
2627
raw_id_fields = ("project", "team")
2728
--- 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 @@
5151
[tool.ruff.lint]
5252
select = ["E", "F", "I", "W", "UP", "B", "SIM", "N"]
5353
ignore = ["E501"]
5454
5555
[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"]
5757
5858
[tool.ruff.format]
5959
quote-style = "double"
6060
6161
[tool.pytest.ini_options]
@@ -64,18 +64,18 @@
6464
python_classes = ["Test*"]
6565
python_functions = ["test_*"]
6666
addopts = "-v --tb=short --strict-markers"
6767
6868
[tool.coverage.run]
69
-source = ["core", "auth1", "organization", "items", "projects", "pages", "fossil"]
69
+source = ["core", "accounts", "organization", "projects", "pages", "fossil"]
7070
omit = ["*/migrations/*", "*/tests/*", "*/testdata/*", "manage.py", "startup.py"]
7171
7272
[tool.coverage.report]
7373
fail_under = 80
7474
show_missing = true
7575
7676
[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"]
7878
7979
[build-system]
8080
requires = ["hatchling"]
8181
build-backend = "hatchling.build"
8282
8383
ADDED templates/accounts/login.html
8484
ADDED templates/accounts/ssh_keys.html
8585
DELETED templates/auth1/login.html
8686
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 }} &middot; Added {{ key.created_at|timesince }} ago
42
- {% if key.last_used_at %}&middot; 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 }} &middot; Added {{ key.created_at|timesince }} ago
42 {% if key.last_used_at %}&middot; 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 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- templates/includes/nav.html
+++ templates/includes/nav.html
@@ -47,11 +47,11 @@
4747
{{ user.get_full_name|default:user.username }}
4848
<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>
4949
</button>
5050
<div x-show="open" @click.outside="open = false" x-transition
5151
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>
5353
</div>
5454
</div>
5555
</div>
5656
</div>
5757
</div>
5858
5959
DELETED templates/items/item_confirm_delete.html
6060
DELETED templates/items/item_detail.html
6161
DELETED templates/items/item_form.html
6262
DELETED templates/items/item_list.html
6363
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">&larr; 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">&larr; 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">&larr; 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">&larr; 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">&larr; 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">&larr; 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 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- testdata/management/commands/seed.py
+++ testdata/management/commands/seed.py
@@ -1,11 +1,10 @@
11
import logging
22
33
from django.contrib.auth.models import Group, Permission, User
44
from django.core.management.base import BaseCommand
55
6
-from items.models import Item
76
from organization.models import Organization, OrganizationMember, Team
87
from pages.models import Page
98
from projects.models import Project, ProjectTeam
109
1110
logger = logging.getLogger(__name__)
@@ -21,27 +20,26 @@
2120
if options["flush"]:
2221
self.stdout.write("Flushing data...")
2322
Page.all_objects.all().delete()
2423
ProjectTeam.all_objects.all().delete()
2524
Project.all_objects.all().delete()
26
- Item.all_objects.all().delete()
2725
Team.all_objects.all().delete()
2826
OrganizationMember.all_objects.all().delete()
2927
Organization.all_objects.all().delete()
3028
3129
# Groups and permissions
3230
admin_group, _ = Group.objects.get_or_create(name="Administrators")
3331
viewer_group, _ = Group.objects.get_or_create(name="Viewers")
3432
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"]:
3735
perms = Permission.objects.filter(content_type__app_label=app_label)
3836
admin_group.permissions.add(*perms)
3937
40
- # Viewer group gets view permissions for items, org, and projects
38
+ # Viewer group gets view permissions for org, projects, and pages
4139
view_perms = Permission.objects.filter(
42
- content_type__app_label__in=["items", "organization", "projects", "pages"],
40
+ content_type__app_label__in=["organization", "projects", "pages"],
4341
codename__startswith="view_",
4442
)
4543
viewer_group.permissions.set(view_perms)
4644
4745
# Superuser
@@ -108,24 +106,10 @@
108106
ProjectTeam.objects.get_or_create(project=backend, team=reviewers, defaults={"role": "read"})
109107
if docs:
110108
ProjectTeam.objects.get_or_create(project=docs, team=contributors, defaults={"role": "write"})
111109
ProjectTeam.objects.get_or_create(project=docs, team=reviewers, defaults={"role": "write"})
112110
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
-
127111
# Sample docs pages
128112
pages_data = [
129113
{
130114
"name": "Getting Started",
131115
"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",
132116
--- 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

Keyboard Shortcuts

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