Metadata-Version: 2.4
Name: django-dynamic-nav
Version: 0.2.1
Summary: Permissioned, dynamic navigation tree for Django REST Framework: Menu > SubMenu > Page > Widget, with group/role-based access control.
Author: Chetan Pawar
License: MIT
Requires-Python: >=3.8
Description-Content-Type: text/markdown
Requires-Dist: Django>=3.2
Requires-Dist: djangorestframework>=3.12

# dynamic_nav

A standalone Django REST Framework app for a **permissioned, dynamic navigation tree**: `Menu` → `SubMenu` → `Page` → `Widget`, where every level can be granted to a Django `Group` for specific actions (`view`, `update`, `create`, `delete`, `export`).

This is a separate app from `dynamic_ui` (the form builder) - it's a different concept (navigation/dashboard structure + RBAC) rather than form fields, so it doesn't reuse `UIField`.

## Install

Add to `INSTALLED_APPS` (needs `django.contrib.contenttypes` and `django.contrib.auth`, which are on by default in any Django project):

```python
INSTALLED_APPS = [
    ...
    "django.contrib.contenttypes",
    "django.contrib.auth",
    "rest_framework",
    "dynamic_nav",
]
```

```python
# project/urls.py
urlpatterns = [
    ...
    path("api/nav/", include("dynamic_nav.urls")),
]
```

```bash
python manage.py migrate dynamic_nav
```

## Model

```
Menu
 └─ SubMenu
     └─ Page
         └─ Widget (chart / graph / kpi / table / field / text / custom)
```

Each of the four models can have any number of `ItemPermission` rows, each granting a "grantee" (a `Group` by default, or your own `Role`/ `SubRole` - see "Integrating your own Role/SubRole tables" below) an `action`:

```python
from django.contrib.auth.models import Group
from dynamic_nav.models import Menu, ItemPermission, PermissionAction

menu = Menu.objects.create(name="Analytics", slug="analytics")
sales_team = Group.objects.create(name="SalesTeam")

ItemPermission.grant(menu, sales_team, PermissionAction.VIEW)
```

Or just use the Django admin - every level (`Menu`, `SubMenu`, `Page`, `Widget`) has a "Permissions" inline where you pick a Group and an action directly on that item's edit page.

**Important:** permissions are checked per-item, not cascaded automatically. If you want a group to see a `Page`, grant `view` on the `Menu`, `SubMenu`, and `Page` (and any `Widget`s) it belongs to - this keeps the model simple and lets you, e.g., grant view on a menu without exposing every submenu under it.

Superusers automatically have every action on everything.

## Custom table names

By default: `dynamic_nav_menu`, `dynamic_nav_submenu`, `dynamic_nav_page`, `dynamic_nav_widget`, `dynamic_nav_itempermission`. Override any of them in `settings.py`:

```python
DYNAMIC_NAV_TABLE_NAMES = {
    "Menu": "nav_menus",
    "SubMenu": "nav_submenus",
    "Page": "nav_pages",
    "Widget": "nav_widgets",
    "ItemPermission": "nav_item_permissions",
}
```

Any model you don't list keeps its default name. Then:

```bash
python manage.py makemigrations dynamic_nav
python manage.py migrate dynamic_nav
```

`makemigrations` detects the rename against the shipped `0001_initial`migration and generates the `AlterModelTable` migration for you.

## Integrating your own Role/SubRole tables

`ItemPermission`'s "who" side is a generic FK (called the **grantee**), not a hardcoded link to Django's `Group`. That means you can grant permissions to a `Group`, your own `Role`, your own `SubRole`, a `Team` - any model instance with a primary key - without forking this package.

**1. Point dynamic_nav at a function that resolves a User into their grantees.**

```python
# myapp/permissions.py
def get_user_grantees(user):
    """Return every grantee (Group, Role, SubRole, ...) this user should
    be checked against. dynamic_nav unions permissions across all of them."""
    if not user or not user.is_authenticated:
        return []
    grantees = list(user.groups.all())          # keep using Groups too, if you want
    profile = getattr(user, "profile", None)     # however your project links User -> Role/SubRole
    if profile:
        if profile.role_id:
            grantees.append(profile.role)
        if profile.subrole_id:
            grantees.append(profile.subrole)
    return grantees

"""If you have separate UserMaster and Role tables, there should be no dependency on Django's built-in tables such as auth_user, auth_group, or auth_permission."""
# myapp/permissions.py (for example)
def get_user_grantees(user):
    if not user or not user.is_authenticated:
        return []
    if getattr(user, "role_id", None):
        return [user.role]
    return []

```python
# settings.py
DYNAMIC_NAV_GRANTEE_RESOLVER = "myapp.permissions.get_user_grantees"
```

If you don't set this, it defaults to just `user.groups.all()` - existing Group-based setups keep working unchanged.

**2. Grant permissions to a Role or SubRole exactly like you would a Group**,using the `ItemPermission.grant()` helper (works with any grantee type):

```python
from dynamic_nav.models import Menu, ItemPermission, PermissionAction
from myapp.models import Role, SubRole

sales_manager = SubRole.objects.get(name="SalesManager")
menu = Menu.objects.get(slug="dashboard")

ItemPermission.grant(menu, sales_manager, PermissionAction.VIEW)
```

**3. That's it.** `MenuTreeView` / `PageDetailView` call your resolver automatically via `PermissionResolver`, so a user with `subrole=SalesManager`now sees exactly what that SubRole is granted - and you can still mix in plain `Group`-based grants for anything not yet migrated to your Role system, since `ItemPermission` doesn't care which type of grantee it's looking at.

**Admin note:** the "Permissions" inline on each item's admin page now shows a content-type dropdown + a raw grantee id (since Django admin has no built-in widget for an arbitrary second generic FK). If you want a nicer picker restricted to just Group/Role/SubRole with autocomplete by name, write a custom `ModelForm` for `ItemPermissionInline` in your own `admin.py` that swaps `grantee_object_id` for a `ModelChoiceField` filtered by the selected `grantee_content_type` - happy to build that out if useful.

## Finding out what a Role/SubRole/Group can access (or who can access an item)

`item.permissions.all()` gives you raw `ItemPermission` rows for one item, but going the *other* direction - "what can this Role access" - needs a grantee-side lookup, since the grantee is a generic FK this app doesn't index on your model. Two helpers cover both directions:

```python
from dynamic_nav.permissions import list_for_grantee, list_for_item

# "What can this SubRole access, and with which actions?"
for entry in list_for_grantee(sales_manager_subrole):
    print(entry["item"], entry["actions"])
# <Menu: Dashboard> ['view']
# <Page: Dashboard / Sales / Revenue> ['view']
# <Widget: Dashboard / Sales / Revenue / Monthly Revenue> ['update', 'view']

# "Who can access this Widget, and with which actions?"
for entry in list_for_item(monthly_revenue_widget):
    print(entry["grantee"], entry["actions"])
# <SubRole: Manager / SalesManager> ['update', 'view']
```

Each entry groups all actions for the same item/grantee into one row (a Role granted both `view` and `update` on the same Widget shows up once, not twice).

There's also `ItemPermission.for_grantee(grantee)` / `.for_item(item)` if you want the raw, ungrouped queryset instead (e.g. to `.delete()` a whole set of grants at once), and `ItemPermission.revoke(item, grantee, action)`to undo a single `grant()` call.

**In the admin:** grants also show up in their own top-level "Item permissions" list (not just as inlines on each Menu/SubMenu/Page/Widget), filterable by action and by grantee type - so you can filter down to "everything granted to a SubRole" or "everything granted on Widgets" from one screen.

## API

### `GET /api/nav/menus/`

Returns the full tree, pruned to only the menus/submenus/pages/widgets the requesting user's groups have `view` on. Every node includes a `permissions` list of the actions that user has on it:

```json
[
  {
    "name": "Analytics",
    "slug": "analytics",
    "icon": "bar-chart",
    "order": 0,
    "permissions": ["view"],
    "submenus": [
      {
        "name": "Sales",
        "slug": "sales",
        "permissions": ["view"],
        "pages": [
          {
            "name": "Sales Overview",
            "slug": "sales-overview",
            "layout": null,
            "permissions": ["view"],
            "widgets": [
              {
                "name": "Revenue Trend",
                "widget_type": "chart",
                "config": {"chart_type": "line", "endpoint": "/api/data/revenue"},
                "permissions": ["view"]
              },
              {
                "name": "Total Deals",
                "widget_type": "kpi",
                "config": {"endpoint": "/api/data/deals-count"},
                "permissions": ["view", "update"]
              }
            ]
          }
        ]
      }
    ]
  }
]
```

The frontend uses `permissions` on each node to decide what to render - e.g. only show an "Edit" button on "Total Deals" for users whose list includes `"update"`.

### `GET /api/nav/pages/<slug>/`

Returns one page (with its permission-filtered widgets). Returns `403` if the user's groups don't grant `view` on that page, even if they can see its parent menu/submenu.

## Widget `config`

`config` is a free-form JSON field - this app only stores/serves it, it doesn't fetch chart data itself. Put whatever your frontend needs there, e.g.:

```json
{"chart_type": "line", "endpoint": "/api/data/revenue", "refresh_seconds": 60}
```

## Performance note

`MenuTreeView` and `PageDetailView` use `PermissionResolver.preload()` to fetch all relevant `ItemPermission` rows in one query per model (4 queries total for a whole tree, regardless of its size) rather than querying per-node, and reuse `prefetch_related` caches when walking children - so response time doesn't degrade as the tree grows.te
