django-dynamic-nav 0.1.0__tar.gz
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/PKG-INFO +287 -0
- django_dynamic_nav-0.1.0/README.md +276 -0
- django_dynamic_nav-0.1.0/django_dynamic_nav.egg-info/PKG-INFO +287 -0
- django_dynamic_nav-0.1.0/django_dynamic_nav.egg-info/SOURCES.txt +17 -0
- django_dynamic_nav-0.1.0/django_dynamic_nav.egg-info/dependency_links.txt +1 -0
- django_dynamic_nav-0.1.0/django_dynamic_nav.egg-info/requires.txt +2 -0
- django_dynamic_nav-0.1.0/django_dynamic_nav.egg-info/top_level.txt +1 -0
- django_dynamic_nav-0.1.0/dynamic_nav/__init__.py +1 -0
- django_dynamic_nav-0.1.0/dynamic_nav/admin.py +82 -0
- django_dynamic_nav-0.1.0/dynamic_nav/apps.py +7 -0
- django_dynamic_nav-0.1.0/dynamic_nav/migrations/0001_initial.py +103 -0
- django_dynamic_nav-0.1.0/dynamic_nav/migrations/__init__.py +0 -0
- django_dynamic_nav-0.1.0/dynamic_nav/models.py +221 -0
- django_dynamic_nav-0.1.0/dynamic_nav/permissions.py +151 -0
- django_dynamic_nav-0.1.0/dynamic_nav/serializers.py +77 -0
- django_dynamic_nav-0.1.0/dynamic_nav/urls.py +10 -0
- django_dynamic_nav-0.1.0/dynamic_nav/views.py +83 -0
- django_dynamic_nav-0.1.0/pyproject.toml +26 -0
- django_dynamic_nav-0.1.0/setup.cfg +4 -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,276 @@
|
|
|
1
|
+
# dynamic_nav
|
|
2
|
+
|
|
3
|
+
A standalone Django REST Framework app for a **permissioned, dynamic
|
|
4
|
+
navigation tree**: `Menu` → `SubMenu` → `Page` → `Widget`, where every
|
|
5
|
+
level can be granted to a Django `Group` for specific actions (`view`,
|
|
6
|
+
`update`, `create`, `delete`, `export`).
|
|
7
|
+
|
|
8
|
+
This is a separate app from `dynamic_ui` (the form builder) - it's a
|
|
9
|
+
different concept (navigation/dashboard structure + RBAC) rather than
|
|
10
|
+
form fields, so it doesn't reuse `UIField`.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Add to `INSTALLED_APPS` (needs `django.contrib.contenttypes` and
|
|
15
|
+
`django.contrib.auth`, which are on by default in any Django project):
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
INSTALLED_APPS = [
|
|
19
|
+
...
|
|
20
|
+
"django.contrib.contenttypes",
|
|
21
|
+
"django.contrib.auth",
|
|
22
|
+
"rest_framework",
|
|
23
|
+
"dynamic_nav",
|
|
24
|
+
]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
# project/urls.py
|
|
29
|
+
urlpatterns = [
|
|
30
|
+
...
|
|
31
|
+
path("api/nav/", include("dynamic_nav.urls")),
|
|
32
|
+
]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python manage.py migrate dynamic_nav
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Model
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Menu
|
|
43
|
+
└─ SubMenu
|
|
44
|
+
└─ Page
|
|
45
|
+
└─ Widget (chart / graph / kpi / table / field / text / custom)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Each of the four models can have any number of `ItemPermission` rows,
|
|
49
|
+
each granting a "grantee" (a `Group` by default, or your own `Role`/
|
|
50
|
+
`SubRole` - see "Integrating your own Role/SubRole tables" below) an
|
|
51
|
+
`action`:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from django.contrib.auth.models import Group
|
|
55
|
+
from dynamic_nav.models import Menu, ItemPermission, PermissionAction
|
|
56
|
+
|
|
57
|
+
menu = Menu.objects.create(name="Analytics", slug="analytics")
|
|
58
|
+
sales_team = Group.objects.create(name="SalesTeam")
|
|
59
|
+
|
|
60
|
+
ItemPermission.grant(menu, sales_team, PermissionAction.VIEW)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or just use the Django admin - every level (`Menu`, `SubMenu`, `Page`,
|
|
64
|
+
`Widget`) has a "Permissions" inline where you pick a Group and an action
|
|
65
|
+
directly on that item's edit page.
|
|
66
|
+
|
|
67
|
+
**Important:** permissions are checked per-item, not cascaded
|
|
68
|
+
automatically. If you want a group to see a `Page`, grant `view` on the
|
|
69
|
+
`Menu`, `SubMenu`, and `Page` (and any `Widget`s) it belongs to - this
|
|
70
|
+
keeps the model simple and lets you, e.g., grant view on a menu without
|
|
71
|
+
exposing every submenu under it.
|
|
72
|
+
|
|
73
|
+
Superusers automatically have every action on everything.
|
|
74
|
+
|
|
75
|
+
## Custom table names
|
|
76
|
+
|
|
77
|
+
By default: `dynamic_nav_menu`, `dynamic_nav_submenu`, `dynamic_nav_page`,
|
|
78
|
+
`dynamic_nav_widget`, `dynamic_nav_itempermission`. Override any of them
|
|
79
|
+
in `settings.py`:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
DYNAMIC_NAV_TABLE_NAMES = {
|
|
83
|
+
"Menu": "nav_menus",
|
|
84
|
+
"SubMenu": "nav_submenus",
|
|
85
|
+
"Page": "nav_pages",
|
|
86
|
+
"Widget": "nav_widgets",
|
|
87
|
+
"ItemPermission": "nav_item_permissions",
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Any model you don't list keeps its default name. Then:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python manage.py makemigrations dynamic_nav
|
|
95
|
+
python manage.py migrate dynamic_nav
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`makemigrations` detects the rename against the shipped `0001_initial`
|
|
99
|
+
migration and generates the `AlterModelTable` migration for you.
|
|
100
|
+
|
|
101
|
+
## Integrating your own Role/SubRole tables
|
|
102
|
+
|
|
103
|
+
`ItemPermission`'s "who" side is a generic FK (called the **grantee**), not
|
|
104
|
+
a hardcoded link to Django's `Group`. That means you can grant permissions
|
|
105
|
+
to a `Group`, your own `Role`, your own `SubRole`, a `Team` - any model
|
|
106
|
+
instance with a primary key - without forking this package.
|
|
107
|
+
|
|
108
|
+
**1. Point dynamic_nav at a function that resolves a User into their grantees.**
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
# myapp/permissions.py
|
|
112
|
+
def get_user_grantees(user):
|
|
113
|
+
"""Return every grantee (Group, Role, SubRole, ...) this user should
|
|
114
|
+
be checked against. dynamic_nav unions permissions across all of them."""
|
|
115
|
+
if not user or not user.is_authenticated:
|
|
116
|
+
return []
|
|
117
|
+
grantees = list(user.groups.all()) # keep using Groups too, if you want
|
|
118
|
+
profile = getattr(user, "profile", None) # however your project links User -> Role/SubRole
|
|
119
|
+
if profile:
|
|
120
|
+
if profile.role_id:
|
|
121
|
+
grantees.append(profile.role)
|
|
122
|
+
if profile.subrole_id:
|
|
123
|
+
grantees.append(profile.subrole)
|
|
124
|
+
return grantees
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
# settings.py
|
|
129
|
+
DYNAMIC_NAV_GRANTEE_RESOLVER = "myapp.permissions.get_user_grantees"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
If you don't set this, it defaults to just `user.groups.all()` - existing
|
|
133
|
+
Group-based setups keep working unchanged.
|
|
134
|
+
|
|
135
|
+
**2. Grant permissions to a Role or SubRole exactly like you would a Group,**
|
|
136
|
+
using the `ItemPermission.grant()` helper (works with any grantee type):
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from dynamic_nav.models import Menu, ItemPermission, PermissionAction
|
|
140
|
+
from myapp.models import Role, SubRole
|
|
141
|
+
|
|
142
|
+
sales_manager = SubRole.objects.get(name="SalesManager")
|
|
143
|
+
menu = Menu.objects.get(slug="dashboard")
|
|
144
|
+
|
|
145
|
+
ItemPermission.grant(menu, sales_manager, PermissionAction.VIEW)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**3. That's it.** `MenuTreeView` / `PageDetailView` call your resolver
|
|
149
|
+
automatically via `PermissionResolver`, so a user with `subrole=SalesManager`
|
|
150
|
+
now sees exactly what that SubRole is granted - and you can still mix in
|
|
151
|
+
plain `Group`-based grants for anything not yet migrated to your Role
|
|
152
|
+
system, since `ItemPermission` doesn't care which type of grantee it's
|
|
153
|
+
looking at.
|
|
154
|
+
|
|
155
|
+
**Admin note:** the "Permissions" inline on each item's admin page now
|
|
156
|
+
shows a content-type dropdown + a raw grantee id (since Django admin has
|
|
157
|
+
no built-in widget for an arbitrary second generic FK). If you want a
|
|
158
|
+
nicer picker restricted to just Group/Role/SubRole with autocomplete by
|
|
159
|
+
name, write a custom `ModelForm` for `ItemPermissionInline` in your own
|
|
160
|
+
`admin.py` that swaps `grantee_object_id` for a `ModelChoiceField` filtered
|
|
161
|
+
by the selected `grantee_content_type` - happy to build that out if useful.
|
|
162
|
+
|
|
163
|
+
## Finding out what a Role/SubRole/Group can access (or who can access an item)
|
|
164
|
+
|
|
165
|
+
`item.permissions.all()` gives you raw `ItemPermission` rows for one item,
|
|
166
|
+
but going the *other* direction - "what can this Role access" - needs a
|
|
167
|
+
grantee-side lookup, since the grantee is a generic FK this app doesn't
|
|
168
|
+
index on your model. Two helpers cover both directions:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from dynamic_nav.permissions import list_for_grantee, list_for_item
|
|
172
|
+
|
|
173
|
+
# "What can this SubRole access, and with which actions?"
|
|
174
|
+
for entry in list_for_grantee(sales_manager_subrole):
|
|
175
|
+
print(entry["item"], entry["actions"])
|
|
176
|
+
# <Menu: Dashboard> ['view']
|
|
177
|
+
# <Page: Dashboard / Sales / Revenue> ['view']
|
|
178
|
+
# <Widget: Dashboard / Sales / Revenue / Monthly Revenue> ['update', 'view']
|
|
179
|
+
|
|
180
|
+
# "Who can access this Widget, and with which actions?"
|
|
181
|
+
for entry in list_for_item(monthly_revenue_widget):
|
|
182
|
+
print(entry["grantee"], entry["actions"])
|
|
183
|
+
# <SubRole: Manager / SalesManager> ['update', 'view']
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Each entry groups all actions for the same item/grantee into one row (a
|
|
187
|
+
Role granted both `view` and `update` on the same Widget shows up once,
|
|
188
|
+
not twice).
|
|
189
|
+
|
|
190
|
+
There's also `ItemPermission.for_grantee(grantee)` / `.for_item(item)` if
|
|
191
|
+
you want the raw, ungrouped queryset instead (e.g. to `.delete()` a whole
|
|
192
|
+
set of grants at once), and `ItemPermission.revoke(item, grantee, action)`
|
|
193
|
+
to undo a single `grant()` call.
|
|
194
|
+
|
|
195
|
+
**In the admin:** grants also show up in their own top-level "Item
|
|
196
|
+
permissions" list (not just as inlines on each Menu/SubMenu/Page/Widget),
|
|
197
|
+
filterable by action and by grantee type - so you can filter down to
|
|
198
|
+
"everything granted to a SubRole" or "everything granted on Widgets" from
|
|
199
|
+
one screen.
|
|
200
|
+
|
|
201
|
+
## API
|
|
202
|
+
|
|
203
|
+
### `GET /api/nav/menus/`
|
|
204
|
+
|
|
205
|
+
Returns the full tree, pruned to only the menus/submenus/pages/widgets
|
|
206
|
+
the requesting user's groups have `view` on. Every node includes a
|
|
207
|
+
`permissions` list of the actions that user has on it:
|
|
208
|
+
|
|
209
|
+
```json
|
|
210
|
+
[
|
|
211
|
+
{
|
|
212
|
+
"name": "Analytics",
|
|
213
|
+
"slug": "analytics",
|
|
214
|
+
"icon": "bar-chart",
|
|
215
|
+
"order": 0,
|
|
216
|
+
"permissions": ["view"],
|
|
217
|
+
"submenus": [
|
|
218
|
+
{
|
|
219
|
+
"name": "Sales",
|
|
220
|
+
"slug": "sales",
|
|
221
|
+
"permissions": ["view"],
|
|
222
|
+
"pages": [
|
|
223
|
+
{
|
|
224
|
+
"name": "Sales Overview",
|
|
225
|
+
"slug": "sales-overview",
|
|
226
|
+
"layout": null,
|
|
227
|
+
"permissions": ["view"],
|
|
228
|
+
"widgets": [
|
|
229
|
+
{
|
|
230
|
+
"name": "Revenue Trend",
|
|
231
|
+
"widget_type": "chart",
|
|
232
|
+
"config": {"chart_type": "line", "endpoint": "/api/data/revenue"},
|
|
233
|
+
"permissions": ["view"]
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"name": "Total Deals",
|
|
237
|
+
"widget_type": "kpi",
|
|
238
|
+
"config": {"endpoint": "/api/data/deals-count"},
|
|
239
|
+
"permissions": ["view", "update"]
|
|
240
|
+
}
|
|
241
|
+
]
|
|
242
|
+
}
|
|
243
|
+
]
|
|
244
|
+
}
|
|
245
|
+
]
|
|
246
|
+
}
|
|
247
|
+
]
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The frontend uses `permissions` on each node to decide what to render -
|
|
251
|
+
e.g. only show an "Edit" button on "Total Deals" for users whose list
|
|
252
|
+
includes `"update"`.
|
|
253
|
+
|
|
254
|
+
### `GET /api/nav/pages/<slug>/`
|
|
255
|
+
|
|
256
|
+
Returns one page (with its permission-filtered widgets). Returns `403` if
|
|
257
|
+
the user's groups don't grant `view` on that page, even if they can see
|
|
258
|
+
its parent menu/submenu.
|
|
259
|
+
|
|
260
|
+
## Widget `config`
|
|
261
|
+
|
|
262
|
+
`config` is a free-form JSON field - this app only stores/serves it, it
|
|
263
|
+
doesn't fetch chart data itself. Put whatever your frontend needs there,
|
|
264
|
+
e.g.:
|
|
265
|
+
|
|
266
|
+
```json
|
|
267
|
+
{"chart_type": "line", "endpoint": "/api/data/revenue", "refresh_seconds": 60}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Performance note
|
|
271
|
+
|
|
272
|
+
`MenuTreeView` and `PageDetailView` use `PermissionResolver.preload()` to
|
|
273
|
+
fetch all relevant `ItemPermission` rows in one query per model (4 queries
|
|
274
|
+
total for a whole tree, regardless of its size) rather than querying
|
|
275
|
+
per-node, and reuse `prefetch_related` caches when walking children - so
|
|
276
|
+
response time doesn't degrade as the tree grows.
|