Skip to content

Administration Sections

MedUX provides a dedicated administration area at /administration/ for plugin-contributed admin UIs — user/group/role management, system tools, tenant-level configuration screens, and anything else that is too rich for the generic /settings/ page.

This page explains how the area is structured, how a plugin contributes a new section to it, how access is gated in a multi-tenant setup, and how the user-visible left rail is built.

When to Use Which Extension Point

MedUX has two overlapping extension points for "configuration"-shaped plugin surfaces. Pick the right one up front:

You want to add... Use
A single form with a handful of fields that persist via scoped settings conjunto.settings.interfaces.ISettingsForm (rendered on the /settings/ page)
A full admin screen: list tables, modal CRUD, wizards, multi-step flows medux.common.api.interfaces.IAdministrationSection (this page)
Just raw URL patterns under /adm/<namespace>/ without menu/permission metadata medux.common.api.interfaces.IAdministrationURL (the older, thinner interface)

IAdministrationURL is the legacy minimal hook and still works — IAdministrationSection is a superset that additionally carries menu metadata and a uniform permission-gating helper, so new plugins should prefer it.

The Moving Parts

Four things cooperate to make an administration screen appear:

  1. IAdministrationSection plugin — declares URL patterns, a namespace, display metadata, and the required permission. This is the plugin developer's entry point.
  2. URL collection loop in src/medux/common/urls/__init__.py merges every IAdministrationURL and every IAdministrationSection plugin into one path("administration/", include((adm_urlpatterns, "adm"), namespace="adm")) tree. Multiple plugins may contribute to the same namespace (e.g. tenant/); their patterns are concatenated.
  3. Left-rail sidebar, rendered by medux/common/templates/common/administration.html. It overrides conjunto's sidebar block and loops over the "administration" menu registry, so every IMenuItem(menu="administration") entry — directly or as a nested child — appears in the rail on every administration page.
  4. TenantAdminRequiredMixin enforces the tenant-admin contract on views that need it, using user_is_tenant_admin() as the authoritative check.

The first two are the data layer, the last two are the UI layer. A plugin opts into each independently — you can ship URLs without a menu entry, or a menu entry without being tenant-gated, or both.

Contributing a Section — Minimum Recipe

Place plugin code in your plugin app. The example below is the real "User roles" section that ships with medux.common and lives in src/medux/common/views/roles.py.

1. Declare the Section

# myplugin/views/admin.py
from django.urls import path
from django.utils.translation import gettext_lazy as _

from medux.common.api.interfaces import (
    IAdministrationSection,
    TenantAdminRequiredMixin,
)


class MyAdminSection(IAdministrationSection):
    """Example administration section."""

    namespace = "myplugin"         # mounted at /administration/myplugin/
    name = "widgets"               # unique slug
    title = _("Widgets")
    icon = "puzzle"                # Tabler icon name
    weight = 50
    parent_slug = "tenant"         # nest under the "Tenant" rail group
    url_name = "widget-list"       # first URL (the entry point)
    tenant_admin_required = True   # only tenant admins may enter

    urlpatterns = [
        path("widgets/", WidgetListView.as_view(), name="widget-list"),
        path("widgets/<int:pk>/edit/", WidgetEditView.as_view(), name="widget-edit"),
    ]

Important: the plugin class must be imported at startup so that GDAPS's __init_subclass__ hook can register it. The common MedUX convention is to import it from your app's urls.py:

# myplugin/urls.py
from . import views  # noqa: F401  — registers IAdministrationSection subclasses

2. Guard the Views

Views that should only be reachable by tenant administrators inherit from TenantAdminRequiredMixin:

from django.views.generic import ListView
from conjunto.tenants.mixins import TenantRequiredMixin
from medux.common.api.interfaces import TenantAdminRequiredMixin


class WidgetListView(TenantAdminRequiredMixin, TenantRequiredMixin, ListView):
    model = Widget
    template_name = "myplugin/admin/widget_list.html"

Order matters: TenantRequiredMixin (from conjunto) redirects to the tenant picker if no tenant is bound on the request, and the admin check happens only once a tenant is available. Always put TenantRequiredMixin after the admin check in the MRO.

3. Wire the Rail Entry

A plugin may want the section to show up on the administration rail on the left. Add an IMenuItem:

# myplugin/menus.py
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from conjunto.menu import IMenuItem


class WidgetsAdmin(IMenuItem):
    title = _("Widgets")
    slug = "myplugin__widgets"
    parent = "tenant"   # nest under the TenantAdmin rail group
    url = reverse_lazy("adm:myplugin:widget-list")
    icon = "puzzle"
    weight = 50
    permission_required = "common.change_tenant"

menu vs. parent are mutually exclusive. Conjunto's menu registry raises ValueError when both are set, because a child's menu is determined by walking up the parent chain. If you want a top-level entry, set menu = "administration" and omit parent; if you want a nested entry, set parent and omit menu.

The Permission Model

user_is_tenant_admin(request)

Defined in medux/common/api/interfaces.py. It OR-combines three independent signals:

  1. Superuserrequest.user.is_superuser short-circuits to True.
  2. Legacy permissionrequest.user.has_perm("common.change_tenant").
  3. Conjunto role membership — a TenantMembership row with role="admin" for request.tenant.

The permission string is exported as TENANT_ADMIN_PERMISSION.

TenantAdminRequiredMixin

A view mixin whose dispatch calls user_is_tenant_admin(request) and raises PermissionDenied if it returns False.

IAdministrationSection.has_permission(request)

A classmethod that AND-combines permission_required (an optional Django permission string) with tenant_admin_required (a bool that gates on user_is_tenant_admin). Superusers always pass.

Multi-Tenant Considerations

Use request.tenant (bound by ConjuntoMiddleware) as the single source of truth for "which tenant am I in right now". When querying TenantMembership rows, wrap writes in conjunto.tenants.context.override_tenant(request.tenant).

Reference: The User Roles Section

src/medux/common/views/roles.py ships a complete working example:

  • UserRolesAdministrationSection — the IAdministrationSection plugin with two URLs under /administration/tenant/
  • UserRolesListView — lists users, renders roles as Tabler badges, exposes a "Manage roles" modal button per row
  • ManageUserRolesView — modal FormView with dynamic role choices
  • HTMX success flow — modal returns 204 with HX-Trigger: user_roles:changed, list wrapper refetches itself

Read it end-to-end when designing your own section — it exercises every building block on this page.