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.
@@ -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.