Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
677823b
django unfold
fpintoppb Oct 20, 2025
a32ba5d
Django unfold changes
fpintoppb Oct 20, 2025
4f7ed96
django unfold index and sidebar
fpintoppb Oct 20, 2025
f82cff3
SCA - Django Unfold and Improvements
fpintoppb Oct 20, 2025
bac0f8b
fix small errors
fpintoppb Oct 20, 2025
3042344
adds sbom_uid
fpintoppb Oct 20, 2025
55d713c
improvements fro django unfold migration
fpintoppb Oct 20, 2025
4377252
migrations
fpintoppb Oct 20, 2025
b8d15ad
fixes vulns finding
fpintoppb Oct 20, 2025
587d97f
ui improvements and ReverseReadonlyMixin
fpintoppb Oct 21, 2025
83b4d05
ui improvements
fpintoppb Oct 21, 2025
3f6d672
removes comment
fpintoppb Oct 21, 2025
115f03e
adds sidebar items
fpintoppb Oct 21, 2025
05bb311
python 3.11
fpintoppb Oct 21, 2025
02371d9
3.11-slim
fpintoppb Oct 21, 2025
01142d4
3.11-slim-buster
fpintoppb Oct 21, 2025
3e74060
3.11-slim-trixie
fpintoppb Oct 21, 2025
3dbd277
fixes dockerfile
fpintoppb Oct 21, 2025
90c92e8
fixes tests
fpintoppb Oct 21, 2025
a73c6bd
reformat with black
fpintoppb Oct 21, 2025
584d8ce
adds hvac
fpintoppb Oct 21, 2025
ade88bc
fixes test
fpintoppb Oct 21, 2025
769d122
fixes test
fpintoppb Oct 21, 2025
cd1e587
Log In button
fpintoppb Oct 21, 2025
84fe29a
fixes login test
fpintoppb Oct 21, 2025
534893d
dkron
fpintoppb Oct 21, 2025
e4eb254
dkron test fix
fpintoppb Oct 21, 2025
c1ed5cc
use filters
fpintoppb Oct 21, 2025
d33de89
fix: adds missing installed app, adds sca to sidebar and removes add …
fpintoppb Oct 27, 2025
0154045
black
fpintoppb Oct 27, 2025
9da933c
form submit
fpintoppb Oct 27, 2025
be06d88
rm import
fpintoppb Oct 27, 2025
2ec0d24
indent
fpintoppb Oct 27, 2025
fb46f81
ui color changes
fpintoppb Oct 28, 2025
e864a87
adds missing files
fpintoppb Oct 28, 2025
3f30729
adds result-list-wrapper
fpintoppb Oct 29, 2025
9c4aff8
fixes import
fpintoppb Oct 30, 2025
0dcd955
download sbom
fpintoppb Oct 30, 2025
56276b0
fixes tests
fpintoppb Oct 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.9'
python-version: '3.11'

- name: Set-up environment
run: pip install -r surface/requirements_test.txt
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/run_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.9
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: 3.9
python-version: 3.11

- name: Install black
run: pip install black==22.8.0
Expand Down Expand Up @@ -56,7 +56,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9, 3.11]
python-version: [3.11]
database:
- db: mysql
url: mysql://root:root@127.0.0.1:8877/surface
Expand Down
6 changes: 3 additions & 3 deletions dev/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ RUN --mount=type=bind,target=/tmpapp \
python /run.py /tmpapp/surface/requirements_prod.txt \
/tmpapp/surface/requirements_psql.txt > /requirements_full.txt

FROM python:3.9-slim-buster as builder
FROM python:3.11-slim-trixie as builder

RUN apt-get update \
&& apt-get install --no-install-recommends -y \
libmariadbclient-dev \
libmariadb-dev \
libpq-dev \
build-essential \
libldap2-dev \
Expand All @@ -27,7 +27,7 @@ RUN pip wheel -w /wheels -r requirements_full.txt

############################################################################

FROM python:3.9-slim-buster as main
FROM python:3.11-slim-trixie as main

RUN apt-get update \
&& apt-get install --no-install-recommends -y \
Expand Down
34 changes: 11 additions & 23 deletions e2e/test_dkron.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,40 +13,28 @@
@pytest.mark.webtest
def test_dkron_admin_views(prep_dkron, test_cfg, live_server):
test_cfg.driver.get(f"{live_server.url}/login/")
assert "Login" in test_cfg.driver.title
assert "Log in" in test_cfg.driver.title

test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(
test_cfg.username
)
test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(
f"{test_cfg.password}"
)
test_cfg.driver.find_element(
by=By.XPATH, value='//button[text()="Submit"]'
).send_keys(Keys.ENTER)
test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(test_cfg.username)
test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(f"{test_cfg.password}")
test_cfg.driver.find_element(by=By.ID, value="login-form").submit()

assert "Home | Surface" == test_cfg.driver.title
assert "Dashboard | Surface Security" == test_cfg.driver.title

test_cfg.driver.get(f"{live_server.url}/dkron/job/add/")
assert "Add job | Surface" == test_cfg.driver.title
assert "Add job | Surface Security" == test_cfg.driver.title

test_cfg.driver.find_element(by=By.ID, value="id_name").send_keys(TEST_IO["name"])
test_cfg.driver.find_element(by=By.ID, value="id_schedule").send_keys(
TEST_IO["schedule"]
)
test_cfg.driver.find_element(by=By.ID, value="id_command").send_keys(
TEST_IO["command"]
)
test_cfg.driver.find_element(by=By.ID, value="id_description").send_keys(
TEST_IO["description"]
)
test_cfg.driver.find_element(by=By.ID, value="id_schedule").send_keys(TEST_IO["schedule"])
test_cfg.driver.find_element(by=By.ID, value="id_command").send_keys(TEST_IO["command"])
test_cfg.driver.find_element(by=By.ID, value="id_description").send_keys(TEST_IO["description"])
test_cfg.driver.find_element(by=By.ID, value="id_use_shell").click()
test_cfg.driver.find_element(by=By.ID, value="id_notify_on_error").click()

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

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

for k, v in TEST_IO.items():
assert v == test_cfg.driver.find_element(by=By.CLASS_NAME, value=f"field-{k}").text
assert v == test_cfg.driver.find_element(by=By.CLASS_NAME, value=f"field-{k}").text
18 changes: 5 additions & 13 deletions e2e/test_login.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@
import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys


@pytest.mark.webtest
def test_login(live_server, test_cfg):
test_cfg.driver.get(f"{live_server.url}/login/")
assert "Login" in test_cfg.driver.title
assert "Log in" in test_cfg.driver.title

test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(
test_cfg.username
)
test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(
f"{test_cfg.password}"
)
test_cfg.driver.find_element(
by=By.XPATH, value='//button[text()="Submit"]'
).send_keys(Keys.ENTER)

assert "Home | Surface" == test_cfg.driver.title
test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(test_cfg.username)
test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(test_cfg.password)
test_cfg.driver.find_element(by=By.ID, value="login-form").submit()
assert "Dashboard | Surface Security" == test_cfg.driver.title
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ select = [
src = ['surface', 'e2e']

[tool.ruff.isort]
known-first-party = ["theme", "dkron", "django_restful_admin", "slackbot", "dbcleanup", "olympus", "notifications", "ppbenviron", "logbasecommand", "impersonate", "apitokens", "sbomrepo"]
known-first-party = ["dkron", "django_restful_admin", "slackbot", "dbcleanup", "olympus", "notifications", "ppbenviron", "logbasecommand", "impersonate", "apitokens", "sbomrepo"]
182 changes: 181 additions & 1 deletion surface/core_utils/admin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,189 @@
from django_restful_admin import site as rest
import logging
Comment thread
fpintoppb marked this conversation as resolved.
from urllib.parse import quote

from django.apps import apps
from django.db import models
from django.urls import reverse
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from jsoneditor.forms import JSONEditor
from unfold.admin import ModelAdmin

logger = logging.getLogger(__name__)

from django_restful_admin import site as rest

# Register all models for REST API except the registered ones
for model in apps.get_models():
if model in rest._registry:
continue
rest.register(model)


class DefaultModelAdmin(ModelAdmin):
list_filter_submit = True
list_filter_sheet = False
list_fullwidth = True
add_fieldsets = ()
formfield_overrides = {
models.JSONField: {"widget": JSONEditor(attrs={"style": "background-color: white !important;"})}
}

def get_list_display(self, request):
Comment thread
fpintoppb marked this conversation as resolved.
"""
make sure model primary key is always present as first column for standard UX
"""
default_list_display = list(super(DefaultModelAdmin, self).get_list_display(request))

pk = self.model._meta.pk.name
if pk in default_list_display:
default_list_display.remove(pk)
default_list_display.insert(0, self.model._meta.pk.name)

return default_list_display

def get_list_display_links(self, request, list_display):
Comment thread
fpintoppb marked this conversation as resolved.
default_list_display_links = super(DefaultModelAdmin, self).get_list_display_links(request, list_display)

if not default_list_display_links:
default_list_display_links = ("pk",)

return default_list_display_links


class ReverseReadonlyMixin:
"""
Mixin to add a readonly 'reverse' field showing related objects in Django admin.
Should be used with ModelAdmin or a compatible base class.
"""

def get_readonly_fields(self, request, obj=None):
parent_method = getattr(super(), "get_readonly_fields", None)
fields = list(parent_method(request, obj)) if parent_method else []
if obj and "reverse" not in fields:
fields.append("reverse")
return fields

def get_fieldsets(self, request, obj=None):
parent_method = getattr(super(), "get_fieldsets", None)
fieldsets = list(parent_method(request, obj)) if parent_method else []
if not fieldsets:
return fieldsets
label, opts = fieldsets[0]
opts = dict(opts)
opts.setdefault("classes", []).append("tab")
if "fields" in opts:
opts["fields"] = tuple(f for f in opts["fields"] if f != "reverse")
fieldsets[0] = ("General", opts)
if obj:
fieldsets.append(("Relationships", {"classes": ["tab"], "fields": ("reverse",)}))
return tuple(fieldsets)

def reverse(self, obj):
"""Render all relationships for the current object."""
if not obj or not obj.pk:
return format_html("<div>No relationships available for new objects.</div>")
try:
html = self._render_relationships(obj)
return format_html("<div class='relationships-container'>{}</div>", html)
except Exception as e:
return format_html("<div class='text-red-500'>Error loading relationships: {}</div>", str(e))
Comment thread
fpintoppb marked this conversation as resolved.

Comment thread
fpintoppb marked this conversation as resolved.
def _render_relationships(self, obj):
html = []
forward_html = self._get_forward_relationships(obj)
if forward_html:
html.append("<strong>Forward Relationships</strong>")
html.append(forward_html)
reverse_html = self._get_reverse_relationships(obj)
if reverse_html:
html.append("<br><strong>Reverse Relationships</strong>")
html.append(reverse_html)
if not html:
html.append("<div>No relationships found.</div>")
return mark_safe("\n".join(html))

def _get_forward_relationships(self, obj):
html = []
opts = obj._meta
for field in opts.get_fields():
if field.is_relation:
try:
if not field.many_to_many and hasattr(field, "related_model"):
rel_obj = getattr(obj, field.name, None)
if rel_obj:
html.append(
self._format_relation(field, rel_obj, single_obj=True, relation_type="ForeignKey")
)
elif field.many_to_many and not field.auto_created:
manager = getattr(obj, field.name)
rel_objs = manager.all()[:10]
total_count = manager.count()
if rel_objs:
html.append(self._format_relation(field, rel_objs, total_count, "ManyToMany"))
except AttributeError:
continue
return mark_safe("\n".join(html))
Comment thread
fpintoppb marked this conversation as resolved.

def _get_reverse_relationships(self, obj):
html = []
opts = obj._meta
for field in opts.get_fields():
if field.auto_created and field.is_relation:
try:
manager = getattr(obj, field.get_accessor_name())
if field.one_to_many:
rel_objs = manager.all()[:10]
total_count = manager.count()
if rel_objs:
html.append(self._format_relation(field, rel_objs, total_count, "ForeignKey"))
elif field.one_to_one:
rel_obj = getattr(manager, getattr(field.related_model._meta, "model_name", ""), None)
if rel_obj:
html.append(
self._format_relation(field, rel_obj, single_obj=True, relation_type="OneToOne")
)
elif field.many_to_many:
rel_objs = manager.all()[:10]
total_count = manager.count()
if rel_objs:
html.append(self._format_relation(field, rel_objs, total_count, "ManyToMany"))
except AttributeError:
continue
return mark_safe("\n".join(html))

def _format_relation(self, field, related_objs, total_count=None, relation_type=None, single_obj=False):
label = getattr(field, "verbose_name", None)
if not label and hasattr(field, "related_model") and field.related_model:
label = getattr(field.related_model._meta, "verbose_name_plural", str(field.related_model))
label = label.title() if isinstance(label, str) else str(field)
rel_type = relation_type or "Relation"
header = f'<div class="py-1">{label}: <b>{rel_type}</b>'
if total_count is not None:
header += f" ({total_count} total)"
header += "</div>"

def obj_link(obj):
url = self._get_admin_url(obj)
return f'<a href="{url}" style="color: rgb(59, 130, 246);">{obj}</a>' if url else str(obj)

if single_obj:
body = f'<div class="pl-4" style="padding-left:2em">• {obj_link(related_objs)}</div>'
else:
body = "\n".join(
f'<div class="pl-4" style="padding-left:2em">• {obj_link(obj)}</div>' for obj in related_objs
)
if total_count and total_count > 10:
body += (
f'\n<div class="pl-4 text-gray-500" style="padding-left:2em">... and {total_count - 10} more</div>'
)
return mark_safe(header + "\n" + body)

def _get_admin_url(self, obj):
if not obj or not obj.pk:
return None
try:
opts = obj._meta
return reverse(f"admin:{opts.app_label}_{opts.model_name}_change", args=[quote(str(obj.pk))])
except Exception:
return None
Loading