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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ dynamic_nav
@@ -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,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DynamicNavConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "dynamic_nav"
7
+ verbose_name = "Dynamic Navigation"
@@ -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)