django-dynamic-nav 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_dynamic_nav-0.1.0.dist-info/METADATA +287 -0
- django_dynamic_nav-0.1.0.dist-info/RECORD +14 -0
- django_dynamic_nav-0.1.0.dist-info/WHEEL +5 -0
- django_dynamic_nav-0.1.0.dist-info/top_level.txt +1 -0
- dynamic_nav/__init__.py +1 -0
- dynamic_nav/admin.py +82 -0
- dynamic_nav/apps.py +7 -0
- dynamic_nav/migrations/0001_initial.py +103 -0
- dynamic_nav/migrations/__init__.py +0 -0
- dynamic_nav/models.py +221 -0
- dynamic_nav/permissions.py +151 -0
- dynamic_nav/serializers.py +77 -0
- dynamic_nav/urls.py +10 -0
- dynamic_nav/views.py +83 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-dynamic-nav
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Permissioned, dynamic navigation tree for Django REST Framework: Menu > SubMenu > Page > Widget, with group/role-based access control.
|
|
5
|
+
Author: Chetan Pawar
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: Django>=3.2
|
|
10
|
+
Requires-Dist: djangorestframework>=3.12
|
|
11
|
+
|
|
12
|
+
# dynamic_nav
|
|
13
|
+
|
|
14
|
+
A standalone Django REST Framework app for a **permissioned, dynamic
|
|
15
|
+
navigation tree**: `Menu` → `SubMenu` → `Page` → `Widget`, where every
|
|
16
|
+
level can be granted to a Django `Group` for specific actions (`view`,
|
|
17
|
+
`update`, `create`, `delete`, `export`).
|
|
18
|
+
|
|
19
|
+
This is a separate app from `dynamic_ui` (the form builder) - it's a
|
|
20
|
+
different concept (navigation/dashboard structure + RBAC) rather than
|
|
21
|
+
form fields, so it doesn't reuse `UIField`.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Add to `INSTALLED_APPS` (needs `django.contrib.contenttypes` and
|
|
26
|
+
`django.contrib.auth`, which are on by default in any Django project):
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
INSTALLED_APPS = [
|
|
30
|
+
...
|
|
31
|
+
"django.contrib.contenttypes",
|
|
32
|
+
"django.contrib.auth",
|
|
33
|
+
"rest_framework",
|
|
34
|
+
"dynamic_nav",
|
|
35
|
+
]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
# project/urls.py
|
|
40
|
+
urlpatterns = [
|
|
41
|
+
...
|
|
42
|
+
path("api/nav/", include("dynamic_nav.urls")),
|
|
43
|
+
]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python manage.py migrate dynamic_nav
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Model
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
Menu
|
|
54
|
+
└─ SubMenu
|
|
55
|
+
└─ Page
|
|
56
|
+
└─ Widget (chart / graph / kpi / table / field / text / custom)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Each of the four models can have any number of `ItemPermission` rows,
|
|
60
|
+
each granting a "grantee" (a `Group` by default, or your own `Role`/
|
|
61
|
+
`SubRole` - see "Integrating your own Role/SubRole tables" below) an
|
|
62
|
+
`action`:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from django.contrib.auth.models import Group
|
|
66
|
+
from dynamic_nav.models import Menu, ItemPermission, PermissionAction
|
|
67
|
+
|
|
68
|
+
menu = Menu.objects.create(name="Analytics", slug="analytics")
|
|
69
|
+
sales_team = Group.objects.create(name="SalesTeam")
|
|
70
|
+
|
|
71
|
+
ItemPermission.grant(menu, sales_team, PermissionAction.VIEW)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Or just use the Django admin - every level (`Menu`, `SubMenu`, `Page`,
|
|
75
|
+
`Widget`) has a "Permissions" inline where you pick a Group and an action
|
|
76
|
+
directly on that item's edit page.
|
|
77
|
+
|
|
78
|
+
**Important:** permissions are checked per-item, not cascaded
|
|
79
|
+
automatically. If you want a group to see a `Page`, grant `view` on the
|
|
80
|
+
`Menu`, `SubMenu`, and `Page` (and any `Widget`s) it belongs to - this
|
|
81
|
+
keeps the model simple and lets you, e.g., grant view on a menu without
|
|
82
|
+
exposing every submenu under it.
|
|
83
|
+
|
|
84
|
+
Superusers automatically have every action on everything.
|
|
85
|
+
|
|
86
|
+
## Custom table names
|
|
87
|
+
|
|
88
|
+
By default: `dynamic_nav_menu`, `dynamic_nav_submenu`, `dynamic_nav_page`,
|
|
89
|
+
`dynamic_nav_widget`, `dynamic_nav_itempermission`. Override any of them
|
|
90
|
+
in `settings.py`:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
DYNAMIC_NAV_TABLE_NAMES = {
|
|
94
|
+
"Menu": "nav_menus",
|
|
95
|
+
"SubMenu": "nav_submenus",
|
|
96
|
+
"Page": "nav_pages",
|
|
97
|
+
"Widget": "nav_widgets",
|
|
98
|
+
"ItemPermission": "nav_item_permissions",
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Any model you don't list keeps its default name. Then:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
python manage.py makemigrations dynamic_nav
|
|
106
|
+
python manage.py migrate dynamic_nav
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`makemigrations` detects the rename against the shipped `0001_initial`
|
|
110
|
+
migration and generates the `AlterModelTable` migration for you.
|
|
111
|
+
|
|
112
|
+
## Integrating your own Role/SubRole tables
|
|
113
|
+
|
|
114
|
+
`ItemPermission`'s "who" side is a generic FK (called the **grantee**), not
|
|
115
|
+
a hardcoded link to Django's `Group`. That means you can grant permissions
|
|
116
|
+
to a `Group`, your own `Role`, your own `SubRole`, a `Team` - any model
|
|
117
|
+
instance with a primary key - without forking this package.
|
|
118
|
+
|
|
119
|
+
**1. Point dynamic_nav at a function that resolves a User into their grantees.**
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
# myapp/permissions.py
|
|
123
|
+
def get_user_grantees(user):
|
|
124
|
+
"""Return every grantee (Group, Role, SubRole, ...) this user should
|
|
125
|
+
be checked against. dynamic_nav unions permissions across all of them."""
|
|
126
|
+
if not user or not user.is_authenticated:
|
|
127
|
+
return []
|
|
128
|
+
grantees = list(user.groups.all()) # keep using Groups too, if you want
|
|
129
|
+
profile = getattr(user, "profile", None) # however your project links User -> Role/SubRole
|
|
130
|
+
if profile:
|
|
131
|
+
if profile.role_id:
|
|
132
|
+
grantees.append(profile.role)
|
|
133
|
+
if profile.subrole_id:
|
|
134
|
+
grantees.append(profile.subrole)
|
|
135
|
+
return grantees
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
# settings.py
|
|
140
|
+
DYNAMIC_NAV_GRANTEE_RESOLVER = "myapp.permissions.get_user_grantees"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
If you don't set this, it defaults to just `user.groups.all()` - existing
|
|
144
|
+
Group-based setups keep working unchanged.
|
|
145
|
+
|
|
146
|
+
**2. Grant permissions to a Role or SubRole exactly like you would a Group,**
|
|
147
|
+
using the `ItemPermission.grant()` helper (works with any grantee type):
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from dynamic_nav.models import Menu, ItemPermission, PermissionAction
|
|
151
|
+
from myapp.models import Role, SubRole
|
|
152
|
+
|
|
153
|
+
sales_manager = SubRole.objects.get(name="SalesManager")
|
|
154
|
+
menu = Menu.objects.get(slug="dashboard")
|
|
155
|
+
|
|
156
|
+
ItemPermission.grant(menu, sales_manager, PermissionAction.VIEW)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**3. That's it.** `MenuTreeView` / `PageDetailView` call your resolver
|
|
160
|
+
automatically via `PermissionResolver`, so a user with `subrole=SalesManager`
|
|
161
|
+
now sees exactly what that SubRole is granted - and you can still mix in
|
|
162
|
+
plain `Group`-based grants for anything not yet migrated to your Role
|
|
163
|
+
system, since `ItemPermission` doesn't care which type of grantee it's
|
|
164
|
+
looking at.
|
|
165
|
+
|
|
166
|
+
**Admin note:** the "Permissions" inline on each item's admin page now
|
|
167
|
+
shows a content-type dropdown + a raw grantee id (since Django admin has
|
|
168
|
+
no built-in widget for an arbitrary second generic FK). If you want a
|
|
169
|
+
nicer picker restricted to just Group/Role/SubRole with autocomplete by
|
|
170
|
+
name, write a custom `ModelForm` for `ItemPermissionInline` in your own
|
|
171
|
+
`admin.py` that swaps `grantee_object_id` for a `ModelChoiceField` filtered
|
|
172
|
+
by the selected `grantee_content_type` - happy to build that out if useful.
|
|
173
|
+
|
|
174
|
+
## Finding out what a Role/SubRole/Group can access (or who can access an item)
|
|
175
|
+
|
|
176
|
+
`item.permissions.all()` gives you raw `ItemPermission` rows for one item,
|
|
177
|
+
but going the *other* direction - "what can this Role access" - needs a
|
|
178
|
+
grantee-side lookup, since the grantee is a generic FK this app doesn't
|
|
179
|
+
index on your model. Two helpers cover both directions:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from dynamic_nav.permissions import list_for_grantee, list_for_item
|
|
183
|
+
|
|
184
|
+
# "What can this SubRole access, and with which actions?"
|
|
185
|
+
for entry in list_for_grantee(sales_manager_subrole):
|
|
186
|
+
print(entry["item"], entry["actions"])
|
|
187
|
+
# <Menu: Dashboard> ['view']
|
|
188
|
+
# <Page: Dashboard / Sales / Revenue> ['view']
|
|
189
|
+
# <Widget: Dashboard / Sales / Revenue / Monthly Revenue> ['update', 'view']
|
|
190
|
+
|
|
191
|
+
# "Who can access this Widget, and with which actions?"
|
|
192
|
+
for entry in list_for_item(monthly_revenue_widget):
|
|
193
|
+
print(entry["grantee"], entry["actions"])
|
|
194
|
+
# <SubRole: Manager / SalesManager> ['update', 'view']
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Each entry groups all actions for the same item/grantee into one row (a
|
|
198
|
+
Role granted both `view` and `update` on the same Widget shows up once,
|
|
199
|
+
not twice).
|
|
200
|
+
|
|
201
|
+
There's also `ItemPermission.for_grantee(grantee)` / `.for_item(item)` if
|
|
202
|
+
you want the raw, ungrouped queryset instead (e.g. to `.delete()` a whole
|
|
203
|
+
set of grants at once), and `ItemPermission.revoke(item, grantee, action)`
|
|
204
|
+
to undo a single `grant()` call.
|
|
205
|
+
|
|
206
|
+
**In the admin:** grants also show up in their own top-level "Item
|
|
207
|
+
permissions" list (not just as inlines on each Menu/SubMenu/Page/Widget),
|
|
208
|
+
filterable by action and by grantee type - so you can filter down to
|
|
209
|
+
"everything granted to a SubRole" or "everything granted on Widgets" from
|
|
210
|
+
one screen.
|
|
211
|
+
|
|
212
|
+
## API
|
|
213
|
+
|
|
214
|
+
### `GET /api/nav/menus/`
|
|
215
|
+
|
|
216
|
+
Returns the full tree, pruned to only the menus/submenus/pages/widgets
|
|
217
|
+
the requesting user's groups have `view` on. Every node includes a
|
|
218
|
+
`permissions` list of the actions that user has on it:
|
|
219
|
+
|
|
220
|
+
```json
|
|
221
|
+
[
|
|
222
|
+
{
|
|
223
|
+
"name": "Analytics",
|
|
224
|
+
"slug": "analytics",
|
|
225
|
+
"icon": "bar-chart",
|
|
226
|
+
"order": 0,
|
|
227
|
+
"permissions": ["view"],
|
|
228
|
+
"submenus": [
|
|
229
|
+
{
|
|
230
|
+
"name": "Sales",
|
|
231
|
+
"slug": "sales",
|
|
232
|
+
"permissions": ["view"],
|
|
233
|
+
"pages": [
|
|
234
|
+
{
|
|
235
|
+
"name": "Sales Overview",
|
|
236
|
+
"slug": "sales-overview",
|
|
237
|
+
"layout": null,
|
|
238
|
+
"permissions": ["view"],
|
|
239
|
+
"widgets": [
|
|
240
|
+
{
|
|
241
|
+
"name": "Revenue Trend",
|
|
242
|
+
"widget_type": "chart",
|
|
243
|
+
"config": {"chart_type": "line", "endpoint": "/api/data/revenue"},
|
|
244
|
+
"permissions": ["view"]
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
"name": "Total Deals",
|
|
248
|
+
"widget_type": "kpi",
|
|
249
|
+
"config": {"endpoint": "/api/data/deals-count"},
|
|
250
|
+
"permissions": ["view", "update"]
|
|
251
|
+
}
|
|
252
|
+
]
|
|
253
|
+
}
|
|
254
|
+
]
|
|
255
|
+
}
|
|
256
|
+
]
|
|
257
|
+
}
|
|
258
|
+
]
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The frontend uses `permissions` on each node to decide what to render -
|
|
262
|
+
e.g. only show an "Edit" button on "Total Deals" for users whose list
|
|
263
|
+
includes `"update"`.
|
|
264
|
+
|
|
265
|
+
### `GET /api/nav/pages/<slug>/`
|
|
266
|
+
|
|
267
|
+
Returns one page (with its permission-filtered widgets). Returns `403` if
|
|
268
|
+
the user's groups don't grant `view` on that page, even if they can see
|
|
269
|
+
its parent menu/submenu.
|
|
270
|
+
|
|
271
|
+
## Widget `config`
|
|
272
|
+
|
|
273
|
+
`config` is a free-form JSON field - this app only stores/serves it, it
|
|
274
|
+
doesn't fetch chart data itself. Put whatever your frontend needs there,
|
|
275
|
+
e.g.:
|
|
276
|
+
|
|
277
|
+
```json
|
|
278
|
+
{"chart_type": "line", "endpoint": "/api/data/revenue", "refresh_seconds": 60}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Performance note
|
|
282
|
+
|
|
283
|
+
`MenuTreeView` and `PageDetailView` use `PermissionResolver.preload()` to
|
|
284
|
+
fetch all relevant `ItemPermission` rows in one query per model (4 queries
|
|
285
|
+
total for a whole tree, regardless of its size) rather than querying
|
|
286
|
+
per-node, and reuse `prefetch_related` caches when walking children - so
|
|
287
|
+
response time doesn't degrade as the tree grows.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
dynamic_nav/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
dynamic_nav/admin.py,sha256=SUq5NRcyyI9sDZW2dwM8zveuZ9pcEAMmCq2jtfGAmdI,2698
|
|
3
|
+
dynamic_nav/apps.py,sha256=pbTPr2bwfvOyAJjok_Ssfo8TnhUlhQ_t9AQLpUc99IQ,193
|
|
4
|
+
dynamic_nav/models.py,sha256=C3y7i2DyCIcmw15-fEnl7Klr_uG0iMtSpGvfe6XYugc,7988
|
|
5
|
+
dynamic_nav/permissions.py,sha256=BsyeMZlVEG2Ec2PupROyT9wB0whuMfXTEhBEuUwoyXM,5581
|
|
6
|
+
dynamic_nav/serializers.py,sha256=UHlaEbIBMfCM_MPf_tdGHltlRQr7hZSfgo5VLT7BMvg,2582
|
|
7
|
+
dynamic_nav/urls.py,sha256=ie2OvTd9OqBGvPg8Azu3iPwikYX9GLf802Kq6y04MnI,263
|
|
8
|
+
dynamic_nav/views.py,sha256=lTqbm7iT_8VQb-wK2phmNKRZtGqUI7bWZ4gHhMqbOhU,2935
|
|
9
|
+
dynamic_nav/migrations/0001_initial.py,sha256=mJNfJofI-hK6kmTp_SMvne6xpLhsQ7g-tLVxQE7U9iE,5523
|
|
10
|
+
dynamic_nav/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
django_dynamic_nav-0.1.0.dist-info/METADATA,sha256=y11QCtTyAO_szdQej4ZCUNG3sbBbq7F3Mur59LwL2jA,9924
|
|
12
|
+
django_dynamic_nav-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
django_dynamic_nav-0.1.0.dist-info/top_level.txt,sha256=GFjxsOrpgmHPG0VXtdJwDiV0n9eqoqQMBUeAy2od_PM,12
|
|
14
|
+
django_dynamic_nav-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dynamic_nav
|
dynamic_nav/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
dynamic_nav/admin.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from django.contrib.contenttypes.admin import GenericTabularInline
|
|
3
|
+
|
|
4
|
+
from .models import ItemPermission, Menu, Page, SubMenu, Widget
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ItemPermissionInline(GenericTabularInline):
|
|
8
|
+
"""
|
|
9
|
+
Assign permissions directly on a Menu/SubMenu/Page/Widget's edit page.
|
|
10
|
+
The "target" side (content_type/object_id) is handled automatically by
|
|
11
|
+
GenericTabularInline. The "grantee" side is a *second*, independent
|
|
12
|
+
generic FK (grantee_content_type/grantee_object_id) - the admin has no
|
|
13
|
+
built-in widget for picking an arbitrary model's instance by name, so
|
|
14
|
+
you pick the content type (e.g. "group" or "role") and type in its id.
|
|
15
|
+
For nicer UX, swap grantee_object_id for a custom form field/autocomplete
|
|
16
|
+
scoped to your Role/SubRole models - see README.md.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
model = ItemPermission
|
|
20
|
+
extra = 1
|
|
21
|
+
fields = ("grantee_content_type", "grantee_object_id", "action")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WidgetInline(admin.TabularInline):
|
|
25
|
+
model = Widget
|
|
26
|
+
extra = 1
|
|
27
|
+
fields = ("order", "name", "widget_type", "is_active")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PageInline(admin.StackedInline):
|
|
31
|
+
model = Page
|
|
32
|
+
extra = 1
|
|
33
|
+
show_change_link = True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SubMenuInline(admin.StackedInline):
|
|
37
|
+
model = SubMenu
|
|
38
|
+
extra = 1
|
|
39
|
+
show_change_link = True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@admin.register(Menu)
|
|
43
|
+
class MenuAdmin(admin.ModelAdmin):
|
|
44
|
+
list_display = ("name", "slug", "order", "is_active")
|
|
45
|
+
prepopulated_fields = {"slug": ("name",)}
|
|
46
|
+
inlines = [SubMenuInline, ItemPermissionInline]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@admin.register(SubMenu)
|
|
50
|
+
class SubMenuAdmin(admin.ModelAdmin):
|
|
51
|
+
list_display = ("menu", "name", "slug", "order", "is_active")
|
|
52
|
+
list_filter = ("menu",)
|
|
53
|
+
prepopulated_fields = {"slug": ("name",)}
|
|
54
|
+
inlines = [PageInline, ItemPermissionInline]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@admin.register(Page)
|
|
58
|
+
class PageAdmin(admin.ModelAdmin):
|
|
59
|
+
list_display = ("submenu", "name", "slug", "order", "is_active")
|
|
60
|
+
list_filter = ("submenu__menu", "submenu")
|
|
61
|
+
prepopulated_fields = {"slug": ("name",)}
|
|
62
|
+
inlines = [WidgetInline, ItemPermissionInline]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@admin.register(ItemPermission)
|
|
66
|
+
class ItemPermissionAdmin(admin.ModelAdmin):
|
|
67
|
+
"""
|
|
68
|
+
Standalone list of every grant, independent of the inlines on each
|
|
69
|
+
item's own admin page - useful for answering "what can this Role
|
|
70
|
+
access" or "who can access this Widget" by filtering.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
list_display = ("content_object", "grantee", "action")
|
|
74
|
+
list_filter = ("action", "content_type", "grantee_content_type")
|
|
75
|
+
search_fields = ("object_id", "grantee_object_id")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@admin.register(Widget)
|
|
79
|
+
class WidgetAdmin(admin.ModelAdmin):
|
|
80
|
+
list_display = ("page", "name", "widget_type", "order", "is_active")
|
|
81
|
+
list_filter = ("widget_type", "page")
|
|
82
|
+
inlines = [ItemPermissionInline]
|
dynamic_nav/apps.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Generated by Django 6.1.1 on 2026-09-08 11:19
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
initial = True
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
('contenttypes', '0002_remove_content_type_name'),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
operations = [
|
|
16
|
+
migrations.CreateModel(
|
|
17
|
+
name='Menu',
|
|
18
|
+
fields=[
|
|
19
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
20
|
+
('name', models.CharField(max_length=100)),
|
|
21
|
+
('slug', models.SlugField(max_length=100, unique=True)),
|
|
22
|
+
('icon', models.CharField(blank=True, help_text='Optional icon name for the frontend.', max_length=100)),
|
|
23
|
+
('order', models.PositiveIntegerField(default=0)),
|
|
24
|
+
('is_active', models.BooleanField(default=True)),
|
|
25
|
+
],
|
|
26
|
+
options={
|
|
27
|
+
'db_table': 'dynamic_nav_menu',
|
|
28
|
+
'ordering': ['order', 'id'],
|
|
29
|
+
},
|
|
30
|
+
),
|
|
31
|
+
migrations.CreateModel(
|
|
32
|
+
name='SubMenu',
|
|
33
|
+
fields=[
|
|
34
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
35
|
+
('name', models.CharField(max_length=100)),
|
|
36
|
+
('slug', models.SlugField(max_length=100)),
|
|
37
|
+
('icon', models.CharField(blank=True, max_length=100)),
|
|
38
|
+
('order', models.PositiveIntegerField(default=0)),
|
|
39
|
+
('is_active', models.BooleanField(default=True)),
|
|
40
|
+
('menu', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submenus', to='dynamic_nav.menu')),
|
|
41
|
+
],
|
|
42
|
+
options={
|
|
43
|
+
'db_table': 'dynamic_nav_submenu',
|
|
44
|
+
'ordering': ['order', 'id'],
|
|
45
|
+
},
|
|
46
|
+
),
|
|
47
|
+
migrations.CreateModel(
|
|
48
|
+
name='Page',
|
|
49
|
+
fields=[
|
|
50
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
51
|
+
('name', models.CharField(max_length=100)),
|
|
52
|
+
('slug', models.SlugField(max_length=100)),
|
|
53
|
+
('order', models.PositiveIntegerField(default=0)),
|
|
54
|
+
('is_active', models.BooleanField(default=True)),
|
|
55
|
+
('layout', models.JSONField(blank=True, help_text='Optional grid/layout config for the frontend.', null=True)),
|
|
56
|
+
('submenu', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pages', to='dynamic_nav.submenu')),
|
|
57
|
+
],
|
|
58
|
+
options={
|
|
59
|
+
'db_table': 'dynamic_nav_page',
|
|
60
|
+
'ordering': ['order', 'id'],
|
|
61
|
+
},
|
|
62
|
+
),
|
|
63
|
+
migrations.CreateModel(
|
|
64
|
+
name='Widget',
|
|
65
|
+
fields=[
|
|
66
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
67
|
+
('name', models.CharField(max_length=100)),
|
|
68
|
+
('widget_type', models.CharField(choices=[('chart', 'Chart'), ('graph', 'Graph'), ('kpi', 'KPI'), ('table', 'Table'), ('field', 'Field'), ('text', 'Text'), ('custom', 'Custom')], max_length=20)),
|
|
69
|
+
('order', models.PositiveIntegerField(default=0)),
|
|
70
|
+
('is_active', models.BooleanField(default=True)),
|
|
71
|
+
('config', models.JSONField(blank=True, help_text='Frontend-specific config: chart type, data source endpoint, columns, etc.', null=True)),
|
|
72
|
+
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='widgets', to='dynamic_nav.page')),
|
|
73
|
+
],
|
|
74
|
+
options={
|
|
75
|
+
'db_table': 'dynamic_nav_widget',
|
|
76
|
+
'ordering': ['order', 'id'],
|
|
77
|
+
},
|
|
78
|
+
),
|
|
79
|
+
migrations.CreateModel(
|
|
80
|
+
name='ItemPermission',
|
|
81
|
+
fields=[
|
|
82
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
83
|
+
('object_id', models.PositiveIntegerField()),
|
|
84
|
+
('grantee_object_id', models.PositiveIntegerField()),
|
|
85
|
+
('action', models.CharField(choices=[('view', 'View'), ('update', 'Update'), ('create', 'Create'), ('delete', 'Delete'), ('export', 'Export')], max_length=20)),
|
|
86
|
+
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
|
|
87
|
+
('grantee_content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
|
|
88
|
+
],
|
|
89
|
+
options={
|
|
90
|
+
'db_table': 'dynamic_nav_itempermission',
|
|
91
|
+
'indexes': [models.Index(fields=['content_type', 'object_id'], name='dynamic_nav_content_b9e6a1_idx'), models.Index(fields=['grantee_content_type', 'grantee_object_id'], name='dynamic_nav_grantee_2c978a_idx')],
|
|
92
|
+
'constraints': [models.UniqueConstraint(fields=('content_type', 'object_id', 'grantee_content_type', 'grantee_object_id', 'action'), name='unique_grantee_action_per_nav_item')],
|
|
93
|
+
},
|
|
94
|
+
),
|
|
95
|
+
migrations.AddConstraint(
|
|
96
|
+
model_name='submenu',
|
|
97
|
+
constraint=models.UniqueConstraint(fields=('menu', 'slug'), name='unique_submenu_slug_per_menu'),
|
|
98
|
+
),
|
|
99
|
+
migrations.AddConstraint(
|
|
100
|
+
model_name='page',
|
|
101
|
+
constraint=models.UniqueConstraint(fields=('submenu', 'slug'), name='unique_page_slug_per_submenu'),
|
|
102
|
+
),
|
|
103
|
+
]
|
|
File without changes
|
dynamic_nav/models.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
|
3
|
+
from django.contrib.contenttypes.models import ContentType
|
|
4
|
+
from django.db import models
|
|
5
|
+
|
|
6
|
+
# Lets a project override this app's table names without forking it:
|
|
7
|
+
# DYNAMIC_NAV_TABLE_NAMES = {"Menu": "nav_menus", "Widget": "nav_widgets"}
|
|
8
|
+
# in settings.py. Any model not listed keeps its default dynamic_nav_* name.
|
|
9
|
+
# See the "Custom table names" section in README.md for the migration step
|
|
10
|
+
# this requires.
|
|
11
|
+
_TABLE_OVERRIDES = getattr(settings, "DYNAMIC_NAV_TABLE_NAMES", {})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _table(model_name, default_table):
|
|
15
|
+
return _TABLE_OVERRIDES.get(model_name, default_table)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PermissionAction(models.TextChoices):
|
|
19
|
+
VIEW = "view", "View"
|
|
20
|
+
UPDATE = "update", "Update"
|
|
21
|
+
CREATE = "create", "Create"
|
|
22
|
+
DELETE = "delete", "Delete"
|
|
23
|
+
EXPORT = "export", "Export"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ItemPermission(models.Model):
|
|
27
|
+
"""
|
|
28
|
+
Grants a "grantee" the ability to perform an action (view/update/
|
|
29
|
+
create/delete/export) on a navigation item.
|
|
30
|
+
|
|
31
|
+
Both sides are generic FKs:
|
|
32
|
+
- the target (content_type/object_id) is a Menu, SubMenu, Page, or Widget.
|
|
33
|
+
- the grantee (grantee_content_type/grantee_object_id) is *any* model
|
|
34
|
+
with a primary key: Django's built-in Group, or your own Role,
|
|
35
|
+
SubRole, Team, whatever your project already has. This app doesn't
|
|
36
|
+
need to know its shape - see permissions.py and the "Integrating
|
|
37
|
+
your own Role/SubRole tables" section in README.md for how a User
|
|
38
|
+
gets resolved into a set of grantees.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
content_type = models.ForeignKey(
|
|
42
|
+
ContentType, on_delete=models.CASCADE, related_name="+"
|
|
43
|
+
)
|
|
44
|
+
object_id = models.PositiveIntegerField()
|
|
45
|
+
content_object = GenericForeignKey("content_type", "object_id")
|
|
46
|
+
|
|
47
|
+
grantee_content_type = models.ForeignKey(
|
|
48
|
+
ContentType, on_delete=models.CASCADE, related_name="+"
|
|
49
|
+
)
|
|
50
|
+
grantee_object_id = models.PositiveIntegerField()
|
|
51
|
+
grantee = GenericForeignKey("grantee_content_type", "grantee_object_id")
|
|
52
|
+
|
|
53
|
+
action = models.CharField(max_length=20, choices=PermissionAction.choices)
|
|
54
|
+
|
|
55
|
+
class Meta:
|
|
56
|
+
db_table = _table("ItemPermission", "dynamic_nav_itempermission")
|
|
57
|
+
constraints = [
|
|
58
|
+
models.UniqueConstraint(
|
|
59
|
+
fields=[
|
|
60
|
+
"content_type",
|
|
61
|
+
"object_id",
|
|
62
|
+
"grantee_content_type",
|
|
63
|
+
"grantee_object_id",
|
|
64
|
+
"action",
|
|
65
|
+
],
|
|
66
|
+
name="unique_grantee_action_per_nav_item",
|
|
67
|
+
)
|
|
68
|
+
]
|
|
69
|
+
indexes = [
|
|
70
|
+
models.Index(fields=["content_type", "object_id"]),
|
|
71
|
+
models.Index(fields=["grantee_content_type", "grantee_object_id"]),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
def __str__(self):
|
|
75
|
+
return f"{self.grantee} can {self.action} {self.content_object}"
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def grant(cls, item, grantee, action):
|
|
79
|
+
"""
|
|
80
|
+
Convenience for `ItemPermission.grant(menu, some_role, PermissionAction.VIEW)`
|
|
81
|
+
- works for a Group, a Role, a SubRole, or anything else with a pk.
|
|
82
|
+
Idempotent: granting the same thing twice is a no-op.
|
|
83
|
+
"""
|
|
84
|
+
return cls.objects.get_or_create(
|
|
85
|
+
content_type=ContentType.objects.get_for_model(item),
|
|
86
|
+
object_id=item.pk,
|
|
87
|
+
grantee_content_type=ContentType.objects.get_for_model(grantee),
|
|
88
|
+
grantee_object_id=grantee.pk,
|
|
89
|
+
action=action,
|
|
90
|
+
)[0]
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def revoke(cls, item, grantee, action):
|
|
94
|
+
"""Undo a grant() call. Silently no-ops if it wasn't granted."""
|
|
95
|
+
cls.objects.filter(
|
|
96
|
+
content_type=ContentType.objects.get_for_model(item),
|
|
97
|
+
object_id=item.pk,
|
|
98
|
+
grantee_content_type=ContentType.objects.get_for_model(grantee),
|
|
99
|
+
grantee_object_id=grantee.pk,
|
|
100
|
+
action=action,
|
|
101
|
+
).delete()
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def for_grantee(cls, grantee):
|
|
105
|
+
"""
|
|
106
|
+
All ItemPermission rows for one grantee (a specific Role, SubRole,
|
|
107
|
+
Group, ...) - i.e. "what can this Role/SubRole access, and with
|
|
108
|
+
which actions". Returns a queryset; use list_for_grantee() below
|
|
109
|
+
for a readable summary instead of raw rows.
|
|
110
|
+
"""
|
|
111
|
+
return cls.objects.filter(
|
|
112
|
+
grantee_content_type=ContentType.objects.get_for_model(grantee),
|
|
113
|
+
grantee_object_id=grantee.pk,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def for_item(cls, item):
|
|
118
|
+
"""All ItemPermission rows granted on one item (a specific Menu,
|
|
119
|
+
SubMenu, Page, or Widget) - i.e. "who can access this, and how"."""
|
|
120
|
+
return cls.objects.filter(
|
|
121
|
+
content_type=ContentType.objects.get_for_model(item),
|
|
122
|
+
object_id=item.pk,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class PermissionedItemMixin(models.Model):
|
|
127
|
+
"""Reverse generic relation so `item.permissions.all()` works and
|
|
128
|
+
deleting an item cleans up its ItemPermission rows automatically."""
|
|
129
|
+
|
|
130
|
+
permissions = GenericRelation(ItemPermission)
|
|
131
|
+
|
|
132
|
+
class Meta:
|
|
133
|
+
abstract = True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Menu(PermissionedItemMixin):
|
|
137
|
+
name = models.CharField(max_length=100)
|
|
138
|
+
slug = models.SlugField(unique=True, max_length=100)
|
|
139
|
+
icon = models.CharField(max_length=100, blank=True, help_text="Optional icon name for the frontend.")
|
|
140
|
+
order = models.PositiveIntegerField(default=0)
|
|
141
|
+
is_active = models.BooleanField(default=True)
|
|
142
|
+
|
|
143
|
+
class Meta:
|
|
144
|
+
db_table = _table("Menu", "dynamic_nav_menu")
|
|
145
|
+
ordering = ["order", "id"]
|
|
146
|
+
|
|
147
|
+
def __str__(self):
|
|
148
|
+
return self.name
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class SubMenu(PermissionedItemMixin):
|
|
152
|
+
menu = models.ForeignKey(Menu, related_name="submenus", on_delete=models.CASCADE)
|
|
153
|
+
name = models.CharField(max_length=100)
|
|
154
|
+
slug = models.SlugField(max_length=100)
|
|
155
|
+
icon = models.CharField(max_length=100, blank=True)
|
|
156
|
+
order = models.PositiveIntegerField(default=0)
|
|
157
|
+
is_active = models.BooleanField(default=True)
|
|
158
|
+
|
|
159
|
+
class Meta:
|
|
160
|
+
db_table = _table("SubMenu", "dynamic_nav_submenu")
|
|
161
|
+
ordering = ["order", "id"]
|
|
162
|
+
constraints = [
|
|
163
|
+
models.UniqueConstraint(
|
|
164
|
+
fields=["menu", "slug"], name="unique_submenu_slug_per_menu"
|
|
165
|
+
)
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
def __str__(self):
|
|
169
|
+
return f"{self.menu.name} / {self.name}"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Page(PermissionedItemMixin):
|
|
173
|
+
submenu = models.ForeignKey(SubMenu, related_name="pages", on_delete=models.CASCADE)
|
|
174
|
+
name = models.CharField(max_length=100)
|
|
175
|
+
slug = models.SlugField(max_length=100)
|
|
176
|
+
order = models.PositiveIntegerField(default=0)
|
|
177
|
+
is_active = models.BooleanField(default=True)
|
|
178
|
+
layout = models.JSONField(
|
|
179
|
+
blank=True, null=True, help_text="Optional grid/layout config for the frontend."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
class Meta:
|
|
183
|
+
db_table = _table("Page", "dynamic_nav_page")
|
|
184
|
+
ordering = ["order", "id"]
|
|
185
|
+
constraints = [
|
|
186
|
+
models.UniqueConstraint(
|
|
187
|
+
fields=["submenu", "slug"], name="unique_page_slug_per_submenu"
|
|
188
|
+
)
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
def __str__(self):
|
|
192
|
+
return f"{self.submenu} / {self.name}"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class Widget(PermissionedItemMixin):
|
|
196
|
+
class WidgetType(models.TextChoices):
|
|
197
|
+
CHART = "chart", "Chart"
|
|
198
|
+
GRAPH = "graph", "Graph"
|
|
199
|
+
KPI = "kpi", "KPI"
|
|
200
|
+
TABLE = "table", "Table"
|
|
201
|
+
FIELD = "field", "Field"
|
|
202
|
+
TEXT = "text", "Text"
|
|
203
|
+
CUSTOM = "custom", "Custom"
|
|
204
|
+
|
|
205
|
+
page = models.ForeignKey(Page, related_name="widgets", on_delete=models.CASCADE)
|
|
206
|
+
name = models.CharField(max_length=100)
|
|
207
|
+
widget_type = models.CharField(max_length=20, choices=WidgetType.choices)
|
|
208
|
+
order = models.PositiveIntegerField(default=0)
|
|
209
|
+
is_active = models.BooleanField(default=True)
|
|
210
|
+
config = models.JSONField(
|
|
211
|
+
blank=True,
|
|
212
|
+
null=True,
|
|
213
|
+
help_text="Frontend-specific config: chart type, data source endpoint, columns, etc.",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
class Meta:
|
|
217
|
+
db_table = _table("Widget", "dynamic_nav_widget")
|
|
218
|
+
ordering = ["order", "id"]
|
|
219
|
+
|
|
220
|
+
def __str__(self):
|
|
221
|
+
return f"{self.page} / {self.name}"
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.contrib.contenttypes.models import ContentType
|
|
5
|
+
from django.db.models import Q
|
|
6
|
+
from django.utils.module_loading import import_string
|
|
7
|
+
|
|
8
|
+
from .models import ItemPermission, PermissionAction
|
|
9
|
+
|
|
10
|
+
ALL_ACTIONS = {choice.value for choice in PermissionAction}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def default_get_user_grantees(user):
|
|
14
|
+
"""
|
|
15
|
+
Default grantee resolver: just the user's Django Groups.
|
|
16
|
+
|
|
17
|
+
Override via settings:
|
|
18
|
+
DYNAMIC_NAV_GRANTEE_RESOLVER = "myapp.permissions.get_user_grantees"
|
|
19
|
+
|
|
20
|
+
Your resolver receives the User and returns a list of model instances
|
|
21
|
+
- any mix of Group, your Role, your SubRole, a Team, etc. Each must
|
|
22
|
+
just be a normal model instance with a pk; this app never needs to
|
|
23
|
+
know their shape.
|
|
24
|
+
"""
|
|
25
|
+
if not user or not user.is_authenticated:
|
|
26
|
+
return []
|
|
27
|
+
return list(user.groups.all())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_user_grantees(user):
|
|
31
|
+
path = getattr(settings, "DYNAMIC_NAV_GRANTEE_RESOLVER", None)
|
|
32
|
+
resolver = import_string(path) if path else default_get_user_grantees
|
|
33
|
+
return resolver(user)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def list_for_grantee(grantee):
|
|
37
|
+
"""
|
|
38
|
+
Human-readable summary of everything one Role/SubRole/Group can access.
|
|
39
|
+
|
|
40
|
+
Returns a list of {"item": <Menu|SubMenu|Page|Widget instance>,
|
|
41
|
+
"actions": ["view", "update", ...]}, one entry per item, actions sorted
|
|
42
|
+
and grouped (so a Role granted both view and update on the same Widget
|
|
43
|
+
shows up once, not as two rows).
|
|
44
|
+
|
|
45
|
+
>>> for entry in list_for_grantee(sales_manager_subrole):
|
|
46
|
+
... print(entry["item"], entry["actions"])
|
|
47
|
+
Dashboard ['view']
|
|
48
|
+
Dashboard / Sales / Revenue ['view']
|
|
49
|
+
Dashboard / Sales / Revenue / Monthly Revenue ['update', 'view']
|
|
50
|
+
"""
|
|
51
|
+
rows = ItemPermission.for_grantee(grantee).select_related("content_type")
|
|
52
|
+
grouped = defaultdict(list)
|
|
53
|
+
items = {}
|
|
54
|
+
for row in rows:
|
|
55
|
+
key = (row.content_type_id, row.object_id)
|
|
56
|
+
grouped[key].append(row.action)
|
|
57
|
+
if key not in items:
|
|
58
|
+
items[key] = row.content_object
|
|
59
|
+
return [
|
|
60
|
+
{"item": items[key], "actions": sorted(actions)}
|
|
61
|
+
for key, actions in grouped.items()
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def list_for_item(item):
|
|
66
|
+
"""
|
|
67
|
+
Human-readable summary of everyone who can access one item.
|
|
68
|
+
|
|
69
|
+
Returns a list of {"grantee": <Group|Role|SubRole instance>,
|
|
70
|
+
"actions": [...]}, same grouping behaviour as list_for_grantee().
|
|
71
|
+
"""
|
|
72
|
+
rows = ItemPermission.for_item(item).select_related("grantee_content_type")
|
|
73
|
+
grouped = defaultdict(list)
|
|
74
|
+
grantees = {}
|
|
75
|
+
for row in rows:
|
|
76
|
+
key = (row.grantee_content_type_id, row.grantee_object_id)
|
|
77
|
+
grouped[key].append(row.action)
|
|
78
|
+
if key not in grantees:
|
|
79
|
+
grantees[key] = row.grantee
|
|
80
|
+
return [
|
|
81
|
+
{"grantee": grantees[key], "actions": sorted(actions)}
|
|
82
|
+
for key, actions in grouped.items()
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PermissionResolver:
|
|
87
|
+
"""
|
|
88
|
+
Batch-loads ItemPermission rows for a set of objects against the
|
|
89
|
+
current user's grantees (Groups, Roles, SubRoles, or whatever your
|
|
90
|
+
DYNAMIC_NAV_GRANTEE_RESOLVER returns), so checking permissions across
|
|
91
|
+
a whole menu tree costs a handful of queries (one per model preloaded)
|
|
92
|
+
instead of one per node.
|
|
93
|
+
|
|
94
|
+
Usage:
|
|
95
|
+
resolver = PermissionResolver(request.user)
|
|
96
|
+
resolver.preload(Menu, menus)
|
|
97
|
+
resolver.preload(SubMenu, submenus)
|
|
98
|
+
...
|
|
99
|
+
resolver.can(some_menu, PermissionAction.VIEW)
|
|
100
|
+
resolver.actions_for(some_menu) # -> {"view", "update"}
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, user):
|
|
104
|
+
self.user = user
|
|
105
|
+
self.is_superuser = bool(user and user.is_authenticated and user.is_superuser)
|
|
106
|
+
|
|
107
|
+
# (content_type_id, object_id) for every grantee this user has -
|
|
108
|
+
# e.g. their Groups AND their Role AND their SubRole all at once.
|
|
109
|
+
self._grantee_keys = []
|
|
110
|
+
if not self.is_superuser:
|
|
111
|
+
for grantee in get_user_grantees(user):
|
|
112
|
+
ct = ContentType.objects.get_for_model(type(grantee))
|
|
113
|
+
self._grantee_keys.append((ct.id, grantee.pk))
|
|
114
|
+
|
|
115
|
+
self._map = {} # {(content_type_id, object_id): {actions}}
|
|
116
|
+
|
|
117
|
+
def _grantee_q(self):
|
|
118
|
+
"""One Q object covering all grantee types at once, e.g.
|
|
119
|
+
(grantee_ct=Group AND grantee_id IN [1,2]) OR (grantee_ct=Role AND grantee_id IN [5])."""
|
|
120
|
+
by_ct = defaultdict(list)
|
|
121
|
+
for ct_id, obj_id in self._grantee_keys:
|
|
122
|
+
by_ct[ct_id].append(obj_id)
|
|
123
|
+
q = Q()
|
|
124
|
+
for ct_id, ids in by_ct.items():
|
|
125
|
+
q |= Q(grantee_content_type_id=ct_id, grantee_object_id__in=ids)
|
|
126
|
+
return q
|
|
127
|
+
|
|
128
|
+
def preload(self, model, objects):
|
|
129
|
+
"""Fetch permissions for a batch of instances of one model in a single query."""
|
|
130
|
+
if self.is_superuser or not self._grantee_keys:
|
|
131
|
+
return
|
|
132
|
+
ids = [obj.pk for obj in objects]
|
|
133
|
+
if not ids:
|
|
134
|
+
return
|
|
135
|
+
content_type = ContentType.objects.get_for_model(model)
|
|
136
|
+
rows = ItemPermission.objects.filter(
|
|
137
|
+
self._grantee_q(), content_type=content_type, object_id__in=ids
|
|
138
|
+
).values_list("object_id", "action")
|
|
139
|
+
for object_id, action in rows:
|
|
140
|
+
self._map.setdefault((content_type.id, object_id), set()).add(action)
|
|
141
|
+
|
|
142
|
+
def actions_for(self, obj) -> set:
|
|
143
|
+
if self.is_superuser:
|
|
144
|
+
return set(ALL_ACTIONS)
|
|
145
|
+
if not self._grantee_keys:
|
|
146
|
+
return set()
|
|
147
|
+
content_type = ContentType.objects.get_for_model(obj)
|
|
148
|
+
return self._map.get((content_type.id, obj.pk), set())
|
|
149
|
+
|
|
150
|
+
def can(self, obj, action: str) -> bool:
|
|
151
|
+
return action in self.actions_for(obj)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from rest_framework import serializers
|
|
2
|
+
|
|
3
|
+
from .models import Menu, Page, SubMenu, Widget
|
|
4
|
+
from .permissions import PermissionAction
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class WidgetSerializer(serializers.ModelSerializer):
|
|
8
|
+
permissions = serializers.SerializerMethodField()
|
|
9
|
+
|
|
10
|
+
class Meta:
|
|
11
|
+
model = Widget
|
|
12
|
+
fields = ["name", "widget_type", "order", "config", "permissions"]
|
|
13
|
+
|
|
14
|
+
def get_permissions(self, obj):
|
|
15
|
+
return sorted(self.context["resolver"].actions_for(obj))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PageSerializer(serializers.ModelSerializer):
|
|
19
|
+
widgets = serializers.SerializerMethodField()
|
|
20
|
+
permissions = serializers.SerializerMethodField()
|
|
21
|
+
|
|
22
|
+
class Meta:
|
|
23
|
+
model = Page
|
|
24
|
+
fields = ["name", "slug", "order", "layout", "widgets", "permissions"]
|
|
25
|
+
|
|
26
|
+
def get_widgets(self, obj):
|
|
27
|
+
resolver = self.context["resolver"]
|
|
28
|
+
# obj.widgets.all() reuses the prefetch cache set up by the view -
|
|
29
|
+
# no extra query here.
|
|
30
|
+
visible = [
|
|
31
|
+
w for w in obj.widgets.all()
|
|
32
|
+
if w.is_active and resolver.can(w, PermissionAction.VIEW)
|
|
33
|
+
]
|
|
34
|
+
return WidgetSerializer(visible, many=True, context=self.context).data
|
|
35
|
+
|
|
36
|
+
def get_permissions(self, obj):
|
|
37
|
+
return sorted(self.context["resolver"].actions_for(obj))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SubMenuSerializer(serializers.ModelSerializer):
|
|
41
|
+
pages = serializers.SerializerMethodField()
|
|
42
|
+
permissions = serializers.SerializerMethodField()
|
|
43
|
+
|
|
44
|
+
class Meta:
|
|
45
|
+
model = SubMenu
|
|
46
|
+
fields = ["name", "slug", "icon", "order", "pages", "permissions"]
|
|
47
|
+
|
|
48
|
+
def get_pages(self, obj):
|
|
49
|
+
resolver = self.context["resolver"]
|
|
50
|
+
visible = [
|
|
51
|
+
p for p in obj.pages.all()
|
|
52
|
+
if p.is_active and resolver.can(p, PermissionAction.VIEW)
|
|
53
|
+
]
|
|
54
|
+
return PageSerializer(visible, many=True, context=self.context).data
|
|
55
|
+
|
|
56
|
+
def get_permissions(self, obj):
|
|
57
|
+
return sorted(self.context["resolver"].actions_for(obj))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MenuSerializer(serializers.ModelSerializer):
|
|
61
|
+
submenus = serializers.SerializerMethodField()
|
|
62
|
+
permissions = serializers.SerializerMethodField()
|
|
63
|
+
|
|
64
|
+
class Meta:
|
|
65
|
+
model = Menu
|
|
66
|
+
fields = ["name", "slug", "icon", "order", "submenus", "permissions"]
|
|
67
|
+
|
|
68
|
+
def get_submenus(self, obj):
|
|
69
|
+
resolver = self.context["resolver"]
|
|
70
|
+
visible = [
|
|
71
|
+
sm for sm in obj.submenus.all()
|
|
72
|
+
if sm.is_active and resolver.can(sm, PermissionAction.VIEW)
|
|
73
|
+
]
|
|
74
|
+
return SubMenuSerializer(visible, many=True, context=self.context).data
|
|
75
|
+
|
|
76
|
+
def get_permissions(self, obj):
|
|
77
|
+
return sorted(self.context["resolver"].actions_for(obj))
|
dynamic_nav/urls.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from django.urls import path
|
|
2
|
+
|
|
3
|
+
from .views import MenuTreeView, PageDetailView
|
|
4
|
+
|
|
5
|
+
app_name = "dynamic_nav"
|
|
6
|
+
|
|
7
|
+
urlpatterns = [
|
|
8
|
+
path("menus/", MenuTreeView.as_view(), name="menu-tree"),
|
|
9
|
+
path("pages/<slug:slug>/", PageDetailView.as_view(), name="page-detail"),
|
|
10
|
+
]
|
dynamic_nav/views.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from rest_framework.exceptions import PermissionDenied
|
|
2
|
+
from rest_framework.permissions import IsAuthenticated
|
|
3
|
+
from rest_framework.response import Response
|
|
4
|
+
from rest_framework.views import APIView
|
|
5
|
+
|
|
6
|
+
from .models import Menu, Page, SubMenu, Widget
|
|
7
|
+
from .permissions import PermissionResolver
|
|
8
|
+
from .serializers import MenuSerializer, PageSerializer
|
|
9
|
+
from .models import PermissionAction
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MenuTreeView(APIView):
|
|
13
|
+
"""
|
|
14
|
+
GET /menus/
|
|
15
|
+
|
|
16
|
+
Returns the full navigation tree - menus, submenus, pages, widgets -
|
|
17
|
+
pruned to only what the requesting user's groups grant 'view' on.
|
|
18
|
+
Each node also carries a `permissions` list (e.g. ["view", "update"])
|
|
19
|
+
so the frontend can show/hide edit affordances without another call.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
permission_classes = [IsAuthenticated]
|
|
23
|
+
|
|
24
|
+
def get(self, request):
|
|
25
|
+
menus = list(
|
|
26
|
+
Menu.objects.filter(is_active=True).prefetch_related(
|
|
27
|
+
"submenus__pages__widgets"
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Flatten the tree once so each model's permissions can be
|
|
32
|
+
# preloaded in a single query, instead of one query per node.
|
|
33
|
+
submenus, pages, widgets = [], [], []
|
|
34
|
+
for menu in menus:
|
|
35
|
+
for submenu in menu.submenus.all():
|
|
36
|
+
submenus.append(submenu)
|
|
37
|
+
for page in submenu.pages.all():
|
|
38
|
+
pages.append(page)
|
|
39
|
+
widgets.extend(page.widgets.all())
|
|
40
|
+
|
|
41
|
+
resolver = PermissionResolver(request.user)
|
|
42
|
+
resolver.preload(Menu, menus)
|
|
43
|
+
resolver.preload(SubMenu, submenus)
|
|
44
|
+
resolver.preload(Page, pages)
|
|
45
|
+
resolver.preload(Widget, widgets)
|
|
46
|
+
|
|
47
|
+
visible_menus = [m for m in menus if resolver.can(m, PermissionAction.VIEW)]
|
|
48
|
+
serializer = MenuSerializer(
|
|
49
|
+
visible_menus, many=True, context={"request": request, "resolver": resolver}
|
|
50
|
+
)
|
|
51
|
+
return Response(serializer.data)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PageDetailView(APIView):
|
|
55
|
+
"""
|
|
56
|
+
GET /pages/<slug>/
|
|
57
|
+
|
|
58
|
+
Returns a single page and its permission-filtered widgets. Raises 403
|
|
59
|
+
if the requesting user's groups don't grant 'view' on the page itself.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
permission_classes = [IsAuthenticated]
|
|
63
|
+
|
|
64
|
+
def get(self, request, slug):
|
|
65
|
+
try:
|
|
66
|
+
page = Page.objects.select_related("submenu__menu").prefetch_related(
|
|
67
|
+
"widgets"
|
|
68
|
+
).get(slug=slug, is_active=True)
|
|
69
|
+
except Page.DoesNotExist:
|
|
70
|
+
return Response({"detail": "Page not found."}, status=404)
|
|
71
|
+
|
|
72
|
+
resolver = PermissionResolver(request.user)
|
|
73
|
+
resolver.preload(Page, [page])
|
|
74
|
+
if not resolver.can(page, PermissionAction.VIEW):
|
|
75
|
+
raise PermissionDenied("You do not have permission to view this page.")
|
|
76
|
+
|
|
77
|
+
widgets = list(page.widgets.all())
|
|
78
|
+
resolver.preload(Widget, widgets)
|
|
79
|
+
|
|
80
|
+
serializer = PageSerializer(
|
|
81
|
+
page, context={"request": request, "resolver": resolver}
|
|
82
|
+
)
|
|
83
|
+
return Response(serializer.data)
|