Skip to content

Commit de807bf

Browse files
committed
feat: Django 6.0
1 parent 33900f2 commit de807bf

7 files changed

Lines changed: 286 additions & 6 deletions

File tree

django_admin_shellx_custom_admin/admin.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,75 @@
1+
from functools import update_wrapper
2+
13
from django.contrib import admin
2-
from django.urls import reverse
4+
from django.contrib.admin import autodiscover
5+
from django.urls import NoReverseMatch, re_path, reverse, reverse_lazy
36

47

58
class CustomAdminSite(admin.AdminSite):
9+
def _resolve_terminal_admin_url(self, app):
10+
try:
11+
return reverse("admin:django_admin_shellx_terminalcommand_terminal")
12+
except NoReverseMatch:
13+
pass
14+
15+
try:
16+
changelist_url = reverse(
17+
"admin:django_admin_shellx_terminalcommand_changelist"
18+
)
19+
return f"{changelist_url.rstrip('/')}/terminal/"
20+
except NoReverseMatch:
21+
pass
22+
23+
for model in app.get("models", []):
24+
if model.get("object_name") == "TerminalCommand" and model.get("admin_url"):
25+
return f"{model['admin_url'].rstrip('/')}/terminal/"
26+
27+
return None
28+
29+
def get_urls(self):
30+
# Ensure admin modules are registered before URL patterns are built.
31+
# Some projects resolve admin URLs very early, which can otherwise
32+
# produce an empty registry and missing model/app routes.
33+
autodiscover()
34+
35+
urls = super().get_urls()
36+
37+
def wrap(view, cacheable=False):
38+
def wrapper(*args, **kwargs):
39+
return self.admin_view(view, cacheable)(*args, **kwargs)
40+
41+
wrapper.admin_site = self
42+
wrapper.login_url = reverse_lazy("admin:login", current_app=self.name)
43+
return update_wrapper(wrapper, view)
44+
45+
# Keep a permissive app_list route available to avoid reverse failures
46+
# when admin URLs were resolved before all apps were registered.
47+
if not any(getattr(pattern, "name", None) == "app_list" for pattern in urls):
48+
fallback = re_path(
49+
r"^(?P<app_label>.+)/$", wrap(self.app_index), name="app_list"
50+
)
51+
if self.final_catch_all_view and urls:
52+
urls.insert(-1, fallback)
53+
else:
54+
urls.append(fallback)
55+
56+
return urls
57+
658
def get_app_list(self, request, app_label=None):
759
app_list = super().get_app_list(request, app_label)
860

961
for app in app_list:
1062
if app["app_label"] == "django_admin_shellx":
63+
terminal_url = self._resolve_terminal_admin_url(app)
64+
if not terminal_url:
65+
break
66+
1167
app["models"].insert(
1268
0,
1369
{
1470
"name": "Terminal",
1571
"object_name": "Terminal",
16-
"admin_url": reverse(
17-
"admin:django_admin_shellx_terminalcommand_terminal"
18-
),
72+
"admin_url": terminal_url,
1973
"view_only": True,
2074
},
2175
)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "django-admin-shellx"
7-
version = "0.3.3"
7+
version = "0.4.0"
88
description = "A Django Admin Shell"
99
readme = "README.md"
1010
requires-python = ">=3.12"

tests/test_commands.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,67 @@ async def test_command_increments_execution_count(settings, superuser_logged_in)
166166
assert terminal_command.execution_count == 2
167167

168168
await communicator.disconnect()
169+
170+
171+
@pytest.mark.asyncio
172+
@pytest.mark.django_db(transaction=True)
173+
async def test_save_history_ignores_unmapped_prompt(superuser_logged_in):
174+
communicator = DefaultTimeoutWebsocketCommunicator(
175+
TerminalConsumer.as_asgi(), "/testws/"
176+
)
177+
cast(Any, communicator.scope)["user"] = superuser_logged_in
178+
connected, _ = await communicator.connect()
179+
assert connected
180+
181+
# Ensure we go past the initial bash messages returned (shell startup)
182+
await communicator.receive_from()
183+
184+
await communicator.send_to(
185+
text_data=json.dumps(
186+
{
187+
"action": "save_history",
188+
"data": {"command": "this output does not contain a shell prompt"},
189+
}
190+
)
191+
)
192+
await communicator.receive_from()
193+
194+
log_entry_count, _ = await get_log_entry()
195+
terminal_command_count, _ = await get_terminal_command()
196+
197+
assert log_entry_count == 0
198+
assert terminal_command_count == 0
199+
200+
await communicator.disconnect()
201+
202+
203+
@pytest.mark.asyncio
204+
@pytest.mark.django_db(transaction=True)
205+
async def test_save_history_ignores_reverse_search(superuser_logged_in):
206+
communicator = DefaultTimeoutWebsocketCommunicator(
207+
TerminalConsumer.as_asgi(), "/testws/"
208+
)
209+
cast(Any, communicator.scope)["user"] = superuser_logged_in
210+
connected, _ = await communicator.connect()
211+
assert connected
212+
213+
# Ensure we go past the initial bash messages returned (shell startup)
214+
await communicator.receive_from()
215+
216+
await communicator.send_to(
217+
text_data=json.dumps(
218+
{
219+
"action": "save_history",
220+
"data": {"command": "(reverse-i-search)`ls': ls"},
221+
}
222+
)
223+
)
224+
await communicator.receive_from()
225+
226+
log_entry_count, _ = await get_log_entry()
227+
terminal_command_count, _ = await get_terminal_command()
228+
229+
assert log_entry_count == 0
230+
assert terminal_command_count == 0
231+
232+
await communicator.disconnect()

tests/test_custom_admin.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import pytest
2+
from django.contrib.auth import get_user_model
3+
from django.test import override_settings
4+
from django.urls import NoReverseMatch, clear_url_caches, path, reverse
5+
6+
from django_admin_shellx_custom_admin.admin import CustomAdminSite
7+
8+
pytestmark = pytest.mark.django_db
9+
10+
urlpatterns = []
11+
12+
13+
def test_custom_admin_includes_fallback_app_list_route():
14+
site = CustomAdminSite(name="admin")
15+
urls = site.get_urls()
16+
app_list_patterns = [
17+
pattern for pattern in urls if getattr(pattern, "name", None) == "app_list"
18+
]
19+
20+
assert app_list_patterns
21+
assert "app_label" in str(app_list_patterns[-1].pattern)
22+
23+
24+
def test_custom_admin_resolves_terminal_url_from_model_entry(monkeypatch):
25+
site = CustomAdminSite(name="admin")
26+
27+
app = {
28+
"app_label": "django_admin_shellx",
29+
"models": [
30+
{
31+
"object_name": "TerminalCommand",
32+
"admin_url": "/admin/django_admin_shellx/terminalcommand/",
33+
}
34+
],
35+
}
36+
37+
def always_fail_reverse(*_args, **_kwargs):
38+
raise NoReverseMatch("missing")
39+
40+
monkeypatch.setattr(
41+
"django_admin_shellx_custom_admin.admin.reverse", always_fail_reverse
42+
)
43+
44+
assert ( # pylint: disable=protected-access
45+
site._resolve_terminal_admin_url(app)
46+
== "/admin/django_admin_shellx/terminalcommand/terminal/"
47+
)
48+
49+
50+
@override_settings(ROOT_URLCONF="tests.test_custom_admin")
51+
def test_custom_admin_app_list_reverse_after_late_registration():
52+
global urlpatterns # pylint: disable=global-statement
53+
54+
site = CustomAdminSite(name="custom_admin")
55+
56+
try:
57+
# Resolve admin URLs before any models are registered, to emulate projects
58+
# where admin URLs are loaded early.
59+
urlpatterns = [path("admin/", site.urls)]
60+
clear_url_caches()
61+
62+
site.register(get_user_model())
63+
64+
assert (
65+
reverse("custom_admin:app_list", kwargs={"app_label": "auth"})
66+
== "/admin/auth/"
67+
)
68+
finally:
69+
urlpatterns = []
70+
clear_url_caches()

tests/test_views.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,49 @@ def test_toggle_favorite_anonymous(client):
120120
f"toggle_favorite/{tc.id}/"
121121
)
122122
assert res.url == expected_url
123+
124+
125+
def test_toggle_favorite_post_does_not_change_value(admin_client):
126+
tc = TerminalCommandFactory(favorite=False)
127+
res = admin_client.post(
128+
reverse(
129+
"admin:django_admin_shellx_terminalcommand_toggle_favorite",
130+
kwargs={"pk": tc.id},
131+
)
132+
)
133+
134+
assert res.status_code == 200
135+
assert res.json() == {
136+
"status": "error",
137+
"message": "Only GET requests are allowed",
138+
}
139+
tc.refresh_from_db()
140+
assert not tc.favorite
141+
142+
143+
def test_staff_user_allowed_when_superuser_not_required(settings, user_client):
144+
settings.DJANGO_ADMIN_SHELLX_SUPERUSER_ONLY = False
145+
user_client.user.is_staff = True
146+
user_client.user.save()
147+
148+
tc = TerminalCommandFactory(favorite=False)
149+
res = user_client.get(
150+
reverse(
151+
"admin:django_admin_shellx_terminalcommand_toggle_favorite",
152+
kwargs={"pk": tc.id},
153+
)
154+
)
155+
156+
assert res.status_code == 200
157+
tc.refresh_from_db()
158+
assert tc.favorite
159+
160+
161+
def test_admin_index_contains_terminal_link(admin_client):
162+
res = admin_client.get(reverse("admin:index"))
163+
164+
assert res.status_code == 200
165+
terminal_url = reverse("admin:django_admin_shellx_terminalcommand_terminal")
166+
content = res.content.decode()
167+
assert "Terminal" in content
168+
assert terminal_url in content

tests/test_websockets.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
import json
22
from asyncio import sleep
3+
from typing import Any, cast
34

45
import pytest
6+
from channels.db import database_sync_to_async
7+
from channels.testing import WebsocketCommunicator
8+
from django.contrib.admin.models import LogEntry
59

610
from django_admin_shellx.consumers import TerminalConsumer
11+
from django_admin_shellx.models import TerminalCommand
712

13+
from .asgi import application
814
from .conftest import BASIC_BASH_COMMANDS, DefaultTimeoutWebsocketCommunicator
915

1016
pytestmark = pytest.mark.django_db
1117

1218

19+
@database_sync_to_async
20+
def get_history_counts():
21+
return LogEntry.objects.count(), TerminalCommand.objects.count()
22+
23+
1324
@pytest.mark.asyncio
1425
async def test_websocket_rejects_unauthenticated():
1526
communicator = DefaultTimeoutWebsocketCommunicator(
@@ -21,6 +32,41 @@ async def test_websocket_rejects_unauthenticated():
2132
await communicator.disconnect()
2233

2334

35+
@pytest.mark.asyncio
36+
async def test_websocket_rejects_non_superuser_when_required(settings, user_logged_in):
37+
settings.DJANGO_ADMIN_SHELLX_SUPERUSER_ONLY = True
38+
39+
communicator = DefaultTimeoutWebsocketCommunicator(
40+
TerminalConsumer.as_asgi(), "/testws/"
41+
)
42+
cast(Any, communicator.scope)["user"] = user_logged_in
43+
connected, subprotocol = await communicator.connect()
44+
assert not connected
45+
assert subprotocol == 4403
46+
await communicator.disconnect()
47+
48+
49+
@pytest.mark.asyncio
50+
async def test_websocket_rejects_disallowed_origin():
51+
communicator = WebsocketCommunicator(
52+
application,
53+
"/ws/terminal/123/",
54+
headers=[
55+
(b"host", b"localhost:8000"),
56+
(b"origin", b"http://evil.example.com"),
57+
],
58+
)
59+
60+
connected, _ = await communicator.connect()
61+
assert not connected
62+
63+
log_entry_count, terminal_command_count = await get_history_counts()
64+
assert log_entry_count == 0
65+
assert terminal_command_count == 0
66+
67+
await communicator.disconnect()
68+
69+
2470
@pytest.mark.asyncio
2571
async def test_websocket_accepts_authenticated_user(settings, user_logged_in):
2672
settings.DJANGO_ADMIN_SHELLX_SUPERUSER_ONLY = False

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)