Skip to content

Commit d067971

Browse files
authored
Django 5 and Django-unfold theme (#227)
1 parent d18f083 commit d067971

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+3102
-1125
lines changed

.github/workflows/integration.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
- uses: actions/checkout@v4
1515
- uses: actions/setup-python@v5
1616
with:
17-
python-version: '3.9'
17+
python-version: '3.11'
1818

1919
- name: Set-up environment
2020
run: pip install -r surface/requirements_test.txt

.github/workflows/run_tests.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ jobs:
1717
steps:
1818
- uses: actions/checkout@v4
1919

20-
- name: Set up Python 3.9
20+
- name: Set up Python 3.11
2121
uses: actions/setup-python@v5
2222
with:
23-
python-version: 3.9
23+
python-version: 3.11
2424

2525
- name: Install black
2626
run: pip install black==22.8.0
@@ -56,7 +56,7 @@ jobs:
5656
runs-on: ubuntu-latest
5757
strategy:
5858
matrix:
59-
python-version: [3.9, 3.11]
59+
python-version: [3.11]
6060
database:
6161
- db: mysql
6262
url: mysql://root:root@127.0.0.1:8877/surface

dev/Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ RUN --mount=type=bind,target=/tmpapp \
77
python /run.py /tmpapp/surface/requirements_prod.txt \
88
/tmpapp/surface/requirements_psql.txt > /requirements_full.txt
99

10-
FROM python:3.9-slim-buster as builder
10+
FROM python:3.11-slim-trixie as builder
1111

1212
RUN apt-get update \
1313
&& apt-get install --no-install-recommends -y \
14-
libmariadbclient-dev \
14+
libmariadb-dev \
1515
libpq-dev \
1616
build-essential \
1717
libldap2-dev \
@@ -27,7 +27,7 @@ RUN pip wheel -w /wheels -r requirements_full.txt
2727

2828
############################################################################
2929

30-
FROM python:3.9-slim-buster as main
30+
FROM python:3.11-slim-trixie as main
3131

3232
RUN apt-get update \
3333
&& apt-get install --no-install-recommends -y \

e2e/test_dkron.py

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,40 +13,28 @@
1313
@pytest.mark.webtest
1414
def test_dkron_admin_views(prep_dkron, test_cfg, live_server):
1515
test_cfg.driver.get(f"{live_server.url}/login/")
16-
assert "Login" in test_cfg.driver.title
16+
assert "Log in" in test_cfg.driver.title
1717

18-
test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(
19-
test_cfg.username
20-
)
21-
test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(
22-
f"{test_cfg.password}"
23-
)
24-
test_cfg.driver.find_element(
25-
by=By.XPATH, value='//button[text()="Submit"]'
26-
).send_keys(Keys.ENTER)
18+
test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(test_cfg.username)
19+
test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(f"{test_cfg.password}")
20+
test_cfg.driver.find_element(by=By.ID, value="login-form").submit()
2721

28-
assert "Home | Surface" == test_cfg.driver.title
22+
assert "Dashboard | Surface Security" == test_cfg.driver.title
2923

3024
test_cfg.driver.get(f"{live_server.url}/dkron/job/add/")
31-
assert "Add job | Surface" == test_cfg.driver.title
25+
assert "Add job | Surface Security" == test_cfg.driver.title
3226

3327
test_cfg.driver.find_element(by=By.ID, value="id_name").send_keys(TEST_IO["name"])
34-
test_cfg.driver.find_element(by=By.ID, value="id_schedule").send_keys(
35-
TEST_IO["schedule"]
36-
)
37-
test_cfg.driver.find_element(by=By.ID, value="id_command").send_keys(
38-
TEST_IO["command"]
39-
)
40-
test_cfg.driver.find_element(by=By.ID, value="id_description").send_keys(
41-
TEST_IO["description"]
42-
)
28+
test_cfg.driver.find_element(by=By.ID, value="id_schedule").send_keys(TEST_IO["schedule"])
29+
test_cfg.driver.find_element(by=By.ID, value="id_command").send_keys(TEST_IO["command"])
30+
test_cfg.driver.find_element(by=By.ID, value="id_description").send_keys(TEST_IO["description"])
4331
test_cfg.driver.find_element(by=By.ID, value="id_use_shell").click()
4432
test_cfg.driver.find_element(by=By.ID, value="id_notify_on_error").click()
4533

4634
test_cfg.driver.find_element(by=By.NAME, value="_save").send_keys(Keys.ENTER)
4735

4836
test_cfg.driver.get(f"{live_server.url}/dkron/job")
49-
assert "Select job to change | Surface" == test_cfg.driver.title
37+
assert "Select job to change | Surface Security" == test_cfg.driver.title
5038

5139
for k, v in TEST_IO.items():
52-
assert v == test_cfg.driver.find_element(by=By.CLASS_NAME, value=f"field-{k}").text
40+
assert v == test_cfg.driver.find_element(by=By.CLASS_NAME, value=f"field-{k}").text

e2e/test_login.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,13 @@
11
import pytest
22
from selenium.webdriver.common.by import By
3-
from selenium.webdriver.common.keys import Keys
43

54

65
@pytest.mark.webtest
76
def test_login(live_server, test_cfg):
87
test_cfg.driver.get(f"{live_server.url}/login/")
9-
assert "Login" in test_cfg.driver.title
8+
assert "Log in" in test_cfg.driver.title
109

11-
test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(
12-
test_cfg.username
13-
)
14-
test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(
15-
f"{test_cfg.password}"
16-
)
17-
test_cfg.driver.find_element(
18-
by=By.XPATH, value='//button[text()="Submit"]'
19-
).send_keys(Keys.ENTER)
20-
21-
assert "Home | Surface" == test_cfg.driver.title
10+
test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(test_cfg.username)
11+
test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(test_cfg.password)
12+
test_cfg.driver.find_element(by=By.ID, value="login-form").submit()
13+
assert "Dashboard | Surface Security" == test_cfg.driver.title

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@ select = [
3838
src = ['surface', 'e2e']
3939

4040
[tool.ruff.isort]
41-
known-first-party = ["theme", "dkron", "django_restful_admin", "slackbot", "dbcleanup", "olympus", "notifications", "ppbenviron", "logbasecommand", "impersonate", "apitokens", "sbomrepo"]
41+
known-first-party = ["dkron", "django_restful_admin", "slackbot", "dbcleanup", "olympus", "notifications", "ppbenviron", "logbasecommand", "impersonate", "apitokens", "sbomrepo"]

surface/core_utils/admin.py

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,189 @@
1-
from django_restful_admin import site as rest
1+
import logging
2+
from urllib.parse import quote
3+
24
from django.apps import apps
5+
from django.db import models
6+
from django.urls import reverse
7+
from django.utils.html import format_html
8+
from django.utils.safestring import mark_safe
9+
from jsoneditor.forms import JSONEditor
10+
from unfold.admin import ModelAdmin
11+
12+
logger = logging.getLogger(__name__)
313

14+
from django_restful_admin import site as rest
415

516
# Register all models for REST API except the registered ones
617
for model in apps.get_models():
718
if model in rest._registry:
819
continue
920
rest.register(model)
21+
22+
23+
class DefaultModelAdmin(ModelAdmin):
24+
list_filter_submit = True
25+
list_filter_sheet = False
26+
list_fullwidth = True
27+
add_fieldsets = ()
28+
formfield_overrides = {
29+
models.JSONField: {"widget": JSONEditor(attrs={"style": "background-color: white !important;"})}
30+
}
31+
32+
def get_list_display(self, request):
33+
"""
34+
make sure model primary key is always present as first column for standard UX
35+
"""
36+
default_list_display = list(super(DefaultModelAdmin, self).get_list_display(request))
37+
38+
pk = self.model._meta.pk.name
39+
if pk in default_list_display:
40+
default_list_display.remove(pk)
41+
default_list_display.insert(0, self.model._meta.pk.name)
42+
43+
return default_list_display
44+
45+
def get_list_display_links(self, request, list_display):
46+
default_list_display_links = super(DefaultModelAdmin, self).get_list_display_links(request, list_display)
47+
48+
if not default_list_display_links:
49+
default_list_display_links = ("pk",)
50+
51+
return default_list_display_links
52+
53+
54+
class ReverseReadonlyMixin:
55+
"""
56+
Mixin to add a readonly 'reverse' field showing related objects in Django admin.
57+
Should be used with ModelAdmin or a compatible base class.
58+
"""
59+
60+
def get_readonly_fields(self, request, obj=None):
61+
parent_method = getattr(super(), "get_readonly_fields", None)
62+
fields = list(parent_method(request, obj)) if parent_method else []
63+
if obj and "reverse" not in fields:
64+
fields.append("reverse")
65+
return fields
66+
67+
def get_fieldsets(self, request, obj=None):
68+
parent_method = getattr(super(), "get_fieldsets", None)
69+
fieldsets = list(parent_method(request, obj)) if parent_method else []
70+
if not fieldsets:
71+
return fieldsets
72+
label, opts = fieldsets[0]
73+
opts = dict(opts)
74+
opts.setdefault("classes", []).append("tab")
75+
if "fields" in opts:
76+
opts["fields"] = tuple(f for f in opts["fields"] if f != "reverse")
77+
fieldsets[0] = ("General", opts)
78+
if obj:
79+
fieldsets.append(("Relationships", {"classes": ["tab"], "fields": ("reverse",)}))
80+
return tuple(fieldsets)
81+
82+
def reverse(self, obj):
83+
"""Render all relationships for the current object."""
84+
if not obj or not obj.pk:
85+
return format_html("<div>No relationships available for new objects.</div>")
86+
try:
87+
html = self._render_relationships(obj)
88+
return format_html("<div class='relationships-container'>{}</div>", html)
89+
except Exception as e:
90+
return format_html("<div class='text-red-500'>Error loading relationships: {}</div>", str(e))
91+
92+
def _render_relationships(self, obj):
93+
html = []
94+
forward_html = self._get_forward_relationships(obj)
95+
if forward_html:
96+
html.append("<strong>Forward Relationships</strong>")
97+
html.append(forward_html)
98+
reverse_html = self._get_reverse_relationships(obj)
99+
if reverse_html:
100+
html.append("<br><strong>Reverse Relationships</strong>")
101+
html.append(reverse_html)
102+
if not html:
103+
html.append("<div>No relationships found.</div>")
104+
return mark_safe("\n".join(html))
105+
106+
def _get_forward_relationships(self, obj):
107+
html = []
108+
opts = obj._meta
109+
for field in opts.get_fields():
110+
if field.is_relation:
111+
try:
112+
if not field.many_to_many and hasattr(field, "related_model"):
113+
rel_obj = getattr(obj, field.name, None)
114+
if rel_obj:
115+
html.append(
116+
self._format_relation(field, rel_obj, single_obj=True, relation_type="ForeignKey")
117+
)
118+
elif field.many_to_many and not field.auto_created:
119+
manager = getattr(obj, field.name)
120+
rel_objs = manager.all()[:10]
121+
total_count = manager.count()
122+
if rel_objs:
123+
html.append(self._format_relation(field, rel_objs, total_count, "ManyToMany"))
124+
except AttributeError:
125+
continue
126+
return mark_safe("\n".join(html))
127+
128+
def _get_reverse_relationships(self, obj):
129+
html = []
130+
opts = obj._meta
131+
for field in opts.get_fields():
132+
if field.auto_created and field.is_relation:
133+
try:
134+
manager = getattr(obj, field.get_accessor_name())
135+
if field.one_to_many:
136+
rel_objs = manager.all()[:10]
137+
total_count = manager.count()
138+
if rel_objs:
139+
html.append(self._format_relation(field, rel_objs, total_count, "ForeignKey"))
140+
elif field.one_to_one:
141+
rel_obj = getattr(manager, getattr(field.related_model._meta, "model_name", ""), None)
142+
if rel_obj:
143+
html.append(
144+
self._format_relation(field, rel_obj, single_obj=True, relation_type="OneToOne")
145+
)
146+
elif field.many_to_many:
147+
rel_objs = manager.all()[:10]
148+
total_count = manager.count()
149+
if rel_objs:
150+
html.append(self._format_relation(field, rel_objs, total_count, "ManyToMany"))
151+
except AttributeError:
152+
continue
153+
return mark_safe("\n".join(html))
154+
155+
def _format_relation(self, field, related_objs, total_count=None, relation_type=None, single_obj=False):
156+
label = getattr(field, "verbose_name", None)
157+
if not label and hasattr(field, "related_model") and field.related_model:
158+
label = getattr(field.related_model._meta, "verbose_name_plural", str(field.related_model))
159+
label = label.title() if isinstance(label, str) else str(field)
160+
rel_type = relation_type or "Relation"
161+
header = f'<div class="py-1">{label}: <b>{rel_type}</b>'
162+
if total_count is not None:
163+
header += f" ({total_count} total)"
164+
header += "</div>"
165+
166+
def obj_link(obj):
167+
url = self._get_admin_url(obj)
168+
return f'<a href="{url}" style="color: rgb(59, 130, 246);">{obj}</a>' if url else str(obj)
169+
170+
if single_obj:
171+
body = f'<div class="pl-4" style="padding-left:2em">• {obj_link(related_objs)}</div>'
172+
else:
173+
body = "\n".join(
174+
f'<div class="pl-4" style="padding-left:2em">• {obj_link(obj)}</div>' for obj in related_objs
175+
)
176+
if total_count and total_count > 10:
177+
body += (
178+
f'\n<div class="pl-4 text-gray-500" style="padding-left:2em">... and {total_count - 10} more</div>'
179+
)
180+
return mark_safe(header + "\n" + body)
181+
182+
def _get_admin_url(self, obj):
183+
if not obj or not obj.pk:
184+
return None
185+
try:
186+
opts = obj._meta
187+
return reverse(f"admin:{opts.app_label}_{opts.model_name}_change", args=[quote(str(obj.pk))])
188+
except Exception:
189+
return None

0 commit comments

Comments
 (0)