Skip to content

Commit 5096332

Browse files
committed
feat(admin): improve menu item permissions and serialization
- Add permission checks for menu items and sub-items. - Cache menu permissions on a per-request basis. - Ensure only permitted menu items are serialized. - Introduce logic for determining default and first permitted view IDs. - Update related tests and templates for better handling of active states and permissions. - Fix z-index for navigation styles and improve loading screen layout.
1 parent 7be35a1 commit 5096332

9 files changed

Lines changed: 269 additions & 10 deletions

File tree

src/django_smartbase_admin/audit/tests/test_action_processing.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,23 @@ def test_detail_actions_materialize_current_object_without_mutating_row_action(
454454
"/actions/PublishArticleView/template/",
455455
)
456456

457+
def test_row_target_view_action_preserves_permission(self):
458+
view = FakeAdminView()
459+
action = SBAdminRowAction(
460+
target_view=PublishArticleView,
461+
title="Publish",
462+
icon="Check-correct",
463+
view=view,
464+
permission="delete",
465+
)
466+
467+
processed = view.process_detail_actions(self.request, [action], object_id=123)
468+
469+
self.assertEqual(processed[0].permission, "delete")
470+
self.assertEqual(
471+
view.PublishArticleView._sbadmin_action_attrs["permission"], "delete"
472+
)
473+
457474
def test_detail_action_without_object_modifier_keeps_template_modifier(self):
458475
view = FakeAdminView()
459476
action = SBAdminCustomAction(

src/django_smartbase_admin/engine/actions.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ def __init__(
163163
target_view=None,
164164
action_id=None,
165165
url=None,
166+
permission=None,
166167
css_class=None,
167168
open_in_new_tab=None,
168169
enabled_if=None,
@@ -180,6 +181,7 @@ def __init__(
180181
)
181182
resolved_action_id = action_id if action_id is not None else self.action_id
182183
resolved_url = url if url is not None else self.url
184+
resolved_permission = permission if permission is not None else self.permission
183185
resolved_css_class = css_class if css_class is not None else self.css_class
184186
resolved_open_in_new_tab = (
185187
open_in_new_tab if open_in_new_tab is not None else self.open_in_new_tab
@@ -221,6 +223,7 @@ def __init__(
221223
open_in_new_tab=resolved_open_in_new_tab,
222224
icon=resolved_icon,
223225
sub_actions=resolved_sub_actions,
226+
permission=resolved_permission,
224227
mcp_description=mcp_description,
225228
)
226229

src/django_smartbase_admin/engine/configuration.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -283,12 +283,36 @@ def init_menu_items(self):
283283
menu_item.init_menu_item_static(self.view_map)
284284

285285
def init_menu_items_dynamic(self, request, request_data):
286-
menu_items = [
287-
item.process_and_serialize(request, request_data)[0]
288-
for item in self.menu_items
289-
]
286+
menu_items = []
287+
for item in self.menu_items:
288+
item_dict, _item_active = item.process_and_serialize(request, request_data)
289+
if item_dict is not None:
290+
menu_items.append(item_dict)
290291
request_data.menu_items = menu_items
291292

293+
def get_default_view_id(self, request, request_data):
294+
if self.default_view:
295+
return self.default_view.get_view_id()
296+
return self.get_first_menu_view_id(request, request_data)
297+
298+
def get_first_menu_view_id(self, request, request_data):
299+
for item in self.menu_items:
300+
view_id = self._first_permitted_menu_view_id(item, request, request_data)
301+
if view_id:
302+
return view_id
303+
return None
304+
305+
def _first_permitted_menu_view_id(self, item, request, request_data):
306+
if item.has_menu_permission(request, request_data) and item.get_view_id():
307+
return item.get_view_id()
308+
for sub_item in item.sub_items:
309+
view_id = self._first_permitted_menu_view_id(
310+
sub_item, request, request_data
311+
)
312+
if view_id:
313+
return view_id
314+
return None
315+
292316
def init_view_map(self):
293317
self.view_map.update({view.get_id(): view for view in self.registered_views})
294318
self.view_map.update(

src/django_smartbase_admin/engine/menu_item.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.utils.safestring import SafeString
44

55
from django_smartbase_admin.services.configuration import SBAdminConfigurationService
6+
from django_smartbase_admin.services.request_cache import cache_on_request
67

78
DEFAULT_MENU_ITEM_BADGE_CLASS = "badge badge-simple badge-primary ml-auto"
89

@@ -103,23 +104,82 @@ def render_badge(self, request):
103104
'<span class="{}">{}</span>', self.get_badge_class(request), badge
104105
)
105106

107+
def has_view_menu_permission(self, request):
108+
if not self.view:
109+
return True
110+
111+
has_menu_permission = getattr(self.view, "has_menu_permission", None)
112+
if callable(has_menu_permission):
113+
return has_menu_permission(request)
114+
115+
permission_checks = (
116+
"has_view_permission",
117+
"has_change_permission",
118+
"has_add_permission",
119+
"has_delete_permission",
120+
)
121+
checked_any_permission = False
122+
for permission_check_name in permission_checks:
123+
permission_check = getattr(self.view, permission_check_name, None)
124+
if not callable(permission_check):
125+
continue
126+
checked_any_permission = True
127+
if permission_check(request):
128+
return True
129+
130+
if checked_any_permission:
131+
return False
132+
133+
return True
134+
135+
def has_menu_permission(self, request, request_data):
136+
if not self.view:
137+
return True
138+
139+
cache_key = f"engine.menu_item.has_menu_permission:{self.view.get_id()}"
140+
return cache_on_request(
141+
cache_key,
142+
lambda: self.has_view_menu_permission(request),
143+
request=request,
144+
)
145+
146+
def get_active_menu_view_id(self, request, request_data):
147+
selected_view = getattr(request_data, "selected_view", None) or getattr(
148+
request, "sbadmin_selected_view", None
149+
)
150+
return (
151+
getattr(selected_view, "menu_highlight_view_id", None) or request_data.view
152+
)
153+
106154
def get_subitems_serialized(self, request, request_data):
107155
subitem_active = False
108156
subitems_serialized = []
109157
for item in self.sub_items:
110158
item_dict, item_active = item.process_and_serialize(request, request_data)
159+
if item_dict is None:
160+
continue
111161
subitems_serialized.append(item_dict)
112162
subitem_active = item_active or subitem_active
113163
return subitems_serialized, subitem_active
114164

115165
def process_and_serialize(self, request, request_data):
116166
sub_items, subitem_active = self.get_subitems_serialized(request, request_data)
117-
active = subitem_active or request_data.view == self.get_id()
167+
has_menu_permission = self.has_menu_permission(request, request_data)
168+
# Root menu item without permissions.
169+
if not has_menu_permission and not sub_items:
170+
return None, False
171+
# Root wrapper menu item without visible children.
172+
if not self.view and not sub_items:
173+
return None, False
174+
active = (
175+
subitem_active
176+
or self.get_active_menu_view_id(request, request_data) == self.get_id()
177+
)
118178
json_dict = {
119179
"sub_items": sub_items,
120180
"get_label": self.get_label(),
121181
"get_icon": self.get_icon(),
122-
"get_url": self.get_url(request),
182+
"get_url": self.get_url(request) if has_menu_permission else "",
123183
"get_id": self.get_id(),
124184
"get_view_id": self.get_view_id(),
125185
"get_badge": self.render_badge(request),

src/django_smartbase_admin/engine/request.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def register_autocomplete_view(self, view) -> None:
5757
def refresh_selected_view(self, request):
5858
self.configuration = SBAdminConfigurationService.get_configuration(self)
5959
if not self.view:
60-
self.view = self.configuration.default_view.get_view_id()
60+
self.view = self.configuration.get_default_view_id(request, self)
6161
try:
6262
self.selected_view = self.configuration.view_map[self.view]
6363
except KeyError:

src/django_smartbase_admin/messaging/sb_admin.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,8 +363,7 @@ def has_permission(self, request, obj=None, permission=None):
363363
permission = getattr(permission, "permission", None) or "view"
364364
if permission in ("add", "change", "delete"):
365365
return False
366-
user = getattr(request, "user", None)
367-
return bool(user and user.is_authenticated)
366+
return super().has_permission(request, obj, permission)
368367

369368
def get_queryset(self, request=None):
370369
qs = super().get_queryset(request)

src/django_smartbase_admin/static/sb_admin/src/css/_base.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@
105105
@apply text-dark-900 bg-notice-100;
106106
}
107107

108+
#main-navigation {
109+
z-index: 51;
110+
}
111+
108112
#notification-messages {
109113
@apply z-1000 fixed bottom-72 left-24 right-24 mx-auto max-w-1180 mb-24 flex flex-col items-center gap-16;
110114
max-height: calc(100vh - 6rem);

src/django_smartbase_admin/templates/sb_admin/includes/loading.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{% load i18n %}
22

3-
<div id="page-loading" class="page-loading fixed top-0 bottom-0 right-0 left-260 bg-light">
3+
<div id="page-loading" class="page-loading fixed top-0 bottom-0 right-0 bg-light xl:left-260 left-0">
44
<div class="flex-center w-full h-full flex-col">
55
<div class="loader"></div>
66
<div class="pt-10">{% trans 'Loading...' %}</div>

tests/test_menu_item.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,62 @@
33
from django_smartbase_admin.engine.menu_item import SBAdminMenuItem
44

55

6+
class MenuView:
7+
def __init__(self, view_id, *, url="/view/", module_allowed=True, perms=None):
8+
self.view_id = view_id
9+
self.url = url
10+
self.module_allowed = module_allowed
11+
self.perms = perms or {"view": True}
12+
self.permission_check_count = 0
13+
14+
def get_id(self):
15+
return self.view_id
16+
17+
def get_menu_label(self):
18+
return self.view_id
19+
20+
def get_menu_view_url(self, request):
21+
return self.url
22+
23+
def has_module_permission(self, request):
24+
return self.module_allowed
25+
26+
def get_model_perms(self, request):
27+
self.permission_check_count += 1
28+
return self.perms
29+
30+
def has_view_permission(self, request):
31+
self.permission_check_count += 1
32+
return self.perms.get("view", False)
33+
34+
def has_change_permission(self, request):
35+
self.permission_check_count += 1
36+
return self.perms.get("change", False)
37+
38+
def has_add_permission(self, request):
39+
self.permission_check_count += 1
40+
return self.perms.get("add", False)
41+
42+
def has_delete_permission(self, request):
43+
self.permission_check_count += 1
44+
return self.perms.get("delete", False)
45+
46+
47+
class MenuPermissionView(MenuView):
48+
def __init__(self, view_id, *, menu_allowed):
49+
super().__init__(view_id, module_allowed=False)
50+
self.menu_allowed = menu_allowed
51+
52+
def has_menu_permission(self, request):
53+
return self.menu_allowed
54+
55+
56+
def menu_item_for_view(view):
57+
item = SBAdminMenuItem(view_id=view.get_id())
58+
item.view = view
59+
return item
60+
61+
662
def test_menu_item_serializes_css_class():
763
item = SBAdminMenuItem(
864
label="Feedback",
@@ -14,3 +70,99 @@ def test_menu_item_serializes_css_class():
1470

1571
assert active is False
1672
assert serialized["get_css_class"] == "feedback-menu-item"
73+
74+
75+
def test_menu_item_without_permissions_is_not_serialized():
76+
view = MenuView(
77+
"hidden",
78+
perms={"add": False, "change": False, "delete": False, "view": False},
79+
)
80+
item = menu_item_for_view(view)
81+
82+
serialized, active = item.process_and_serialize(
83+
SimpleNamespace(), SimpleNamespace(view=None)
84+
)
85+
86+
assert serialized is None
87+
assert active is False
88+
89+
90+
def test_parent_menu_item_keeps_only_permitted_children():
91+
visible_view = MenuView("visible", url="/visible/")
92+
hidden_view = MenuView(
93+
"hidden",
94+
perms={"add": False, "change": False, "delete": False, "view": False},
95+
)
96+
parent = SBAdminMenuItem(
97+
label="Settings",
98+
sub_items=[
99+
menu_item_for_view(visible_view),
100+
menu_item_for_view(hidden_view),
101+
],
102+
)
103+
104+
serialized, active = parent.process_and_serialize(
105+
SimpleNamespace(), SimpleNamespace(view="visible")
106+
)
107+
108+
assert active is True
109+
assert len(serialized["sub_items"]) == 1
110+
assert serialized["sub_items"][0]["get_id"] == "visible"
111+
112+
113+
def test_parent_menu_item_with_denied_view_uses_first_child_url():
114+
parent_view = MenuView(
115+
"parent",
116+
url="/parent/",
117+
perms={"add": False, "change": False, "delete": False, "view": False},
118+
)
119+
child_view = MenuView("child", url="/child/")
120+
parent = menu_item_for_view(parent_view)
121+
parent.sub_items = [menu_item_for_view(child_view)]
122+
123+
serialized, active = parent.process_and_serialize(
124+
SimpleNamespace(), SimpleNamespace(view=None)
125+
)
126+
127+
assert active is False
128+
assert serialized["get_url"] == ""
129+
assert serialized["sub_items"][0]["get_url"] == "/child/"
130+
131+
132+
def test_menu_permission_is_cached_per_request():
133+
view = MenuView("cached")
134+
request_data = SimpleNamespace(view=None)
135+
136+
request = SimpleNamespace()
137+
138+
menu_item_for_view(view).process_and_serialize(request, request_data)
139+
menu_item_for_view(view).process_and_serialize(request, request_data)
140+
141+
assert view.permission_check_count == 1
142+
143+
144+
def test_menu_permission_hook_overrides_model_permission_checks():
145+
view = MenuPermissionView("menu-visible", menu_allowed=True)
146+
item = menu_item_for_view(view)
147+
148+
serialized, active = item.process_and_serialize(
149+
SimpleNamespace(), SimpleNamespace(view=None)
150+
)
151+
152+
assert active is False
153+
assert serialized["get_id"] == "menu-visible"
154+
assert view.permission_check_count == 0
155+
156+
157+
def test_menu_item_uses_selected_view_menu_highlight_id_for_active_state():
158+
menu_view = MenuView("connector")
159+
item = menu_item_for_view(menu_view)
160+
request_data = SimpleNamespace(
161+
view="connector_orders",
162+
selected_view=SimpleNamespace(menu_highlight_view_id="connector"),
163+
)
164+
165+
serialized, active = item.process_and_serialize(SimpleNamespace(), request_data)
166+
167+
assert active is True
168+
assert serialized["is_active"] is True

0 commit comments

Comments
 (0)