Skip to content

Roles and URL Generation in Neapolitan and Beyond

This document explains how Neapolitan currently handles roles, how to add custom roles today, and a design for a new package that preserves ergonomics while making extensibility first-class.

Note

Yep, this was generated by an AI in a chat. I want to keep it for future reference.

Part 1 — Neapolitan today

1.1 What the Role enum does

Neapolitan defines a single Role enum.Enum with members LIST, DETAIL, CREATE, UPDATE, DELETE. Each member carries behavior:

  • handlers() — HTTP verb → method name on the view (e.g. {"get": "list"}).
  • extra_initkwargs() — per-role defaults for the view instance (e.g. template suffix).
  • url_name_component — the name used in URL reversing (usually same as enum value).
  • url_pattern(view_cls) — returns the URL path pattern string for that role.
  • get_url(view_cls) — returns a django.urls.path() that binds the role to the view via as_view(role=...).
  • reverse(view, object=None) / maybe_reverse(...) — helpers for reverse().

1.2 What CRUDView does

CRUDView is a class (inherits from django.views.generic.View) that provides the concrete handlers: - list, detail, show_form, process_form, confirm_delete, process_deletion, plus plumbing for queryset/forms/pagination/templates.

It exposes: - as_view(role: Role, **initkwargs) — builds the view callable for a single role. Internally it: 1) creates self = cls(**{**role.extra_initkwargs(), **initkwargs}) 2) sets self.role = role 3) calls self.setup(request, *args, **kwargs) 4) wires HTTP methods to handlers per role.handlers() (e.g. self.get = self.list) 5) returns self.dispatch(...)

  • get_urls(roles=None) — builds the URL patterns for a view by iterating roles and calling role.get_url(cls). If roles is None, it uses iter(Role) (i.e. the five defaults). You can pass a subset to omit URLs:

urlpatterns += CRUDView.get_urls(roles={Role.LIST, Role.DETAIL, Role.CREATE, Role.DELETE})

1.3 Flow summary

CRUDView.get_urls(...)  →  Role.get_url(view_cls)  →  path(Role.url_pattern(view_cls), view_cls.as_view(role=Role.X), name=...)
                                                              └── as_view wires handlers per Role.handlers() and dispatches

1.4 Adding custom roles today (without changing Neapolitan)

Because Python enums cannot be subclassed, you can’t extend Role directly. But you can add custom roles by providing a class that mimics the Role interface and reuses Neapolitan’s helper methods. This is the approach described in Custom Roles #73.

CustomRole base class (from the issue’s approach):

from abc import ABC, abstractmethod
from neapolitan.views import Role

class CustomRole(ABC):
    def __eq__(self, other):
        return self.__class__ == other.__class__

    def __hash__(self):
        return hash(self.__class__)

    @abstractmethod
    def handlers(self):
        raise NotImplementedError

    def extra_initkwargs(self):
        return {}

    @property
    @abstractmethod
    def url_name_component(self):
        raise NotImplementedError

    @abstractmethod
    def url_pattern(self, view_cls):
        raise NotImplementedError

# Reuse Neapolitan’s URL helper methods by attaching them:
setattr(CustomRole, "get_url", Role.get_url)
setattr(CustomRole, "reverse", Role.reverse)
setattr(CustomRole, "maybe_reverse", Role.maybe_reverse)

Example custom role:

class MarkQualifiedRole(CustomRole):
    permission_name = "leadtool.mark_lead_qualified"
    url_name_component = "markqualified"

    def handlers(self):
        return {"post": "mark_qualified"}

    def url_pattern(self, view_cls):
        url_base = view_cls.url_base
        url_kwarg = view_cls.lookup_url_kwarg or view_cls.lookup_field
        path_converter = view_cls.path_converter
        return f"{url_base}/<{path_converter}:{url_kwarg}>/mark_qualified/"

Using it in a view’s URL set:

class LeadBackofficeView(CRUDView):
    def mark_qualified(self, request, *args, **kwargs):
        self.object = self.get_object()
        self.object.status = LeadStatus.PRE_QUALIFIED
        self.object.save()
        return HttpResponseRedirect(reverse("lead-list"))

    @classonlymethod
    def get_urls(cls, roles=None):
        return super().get_urls(roles=[
            Role.LIST,
            Role.CREATE,
            Role.DELETE,
            MarkQualifiedRole(),
        ])

Upgrade safety test (from the issue):

from unittest import TestCase
from neapolitan.views import Role
from django_backoffice.role import CustomRole

class TestCustomRole(TestCase):
    def test_has_role_methods(self):
        role_method_names = [
            method_name
            for method_name in Role.__dict__
            if not method_name.startswith("_")  # No private members and magic methods
            and not method_name.isupper()       # no enum members
        ]
        for method_name in role_method_names:
            with self.subTest(f"Has method {method_name}"):
                self.assertTrue(
                    hasattr(CustomRole, method_name),
                    f"{CustomRole.__name__} is missing method {method_name} of {Role.__name__}",
                )

Notes:

  • This works because CRUDView.get_urls() accepts an iterable of “roles”, and your CustomRole provides the same get_url(...) API used by enum members.
  • Equality/hash on the class lets instances behave well in sets (e.g. when passing roles={...}).
  • Neapolitan 24.8 changed role equality semantics to support “proxying” default roles with custom ones, making this pattern smoother.

Part 2 — Designing a new package (extensible by default)

2.1 Goals

  • Keep the ergonomics of Neapolitan (easy defaults, get_urls(), clean as_view wiring).
  • Make custom roles first-class (no enum hacks, no setattr shims).
  • Clean separation: roles own URL + binding behavior, views own data/form/render behavior.

2.2 Proposed core concepts

RoleHandler (subclassable object) - Single source of truth for a role’s behavior (URL pattern, handlers, reverse helpers, extra init kwargs). - Implements the interface Neapolitan’s Role provides today, but as a normal class.

Sketch:

class RoleHandler:
    name: str                 # e.g. "list", "create" — used in URL names
    def handlers(self) -> dict: ...
    def extra_initkwargs(self) -> dict: return {}
    def url_pattern(self, view_cls) -> str: ...
    def get_url(self, view_cls): ...
    def reverse(self, view, obj=None): ...
    def maybe_reverse(self, view, obj=None): ...

Default roles wrapper

  • Provide a small, convenient container for the standard five roles.
  • Could be:
    • an Enum Roles whose members are RoleHandler instances (for dot-notation and iteration), or
    • a simple list/dict, if you prefer fewer moving parts.

Sketch:

class Roles(Enum):
    LIST   = RoleHandler(...)
    DETAIL = RoleHandler(...)
    CREATE = RoleHandler(...)
    UPDATE = RoleHandler(...)
    DELETE = RoleHandler(...)
    @classmethod
    def all(cls): return [cls.LIST.value, cls.DETAIL.value, cls.CREATE.value, cls.UPDATE.value, cls.DELETE.value]

CRUDView (view base that consumes handlers)

  • Mirrors Neapolitan’s CRUDView public API and lifecycle (queryset/forms/templates/pagination).
  • get_urls(roles=None) accepts either:
    • None → defaults (e.g. Roles.all()), or
    • any iterable of RoleHandler instances (including custom subclasses).
  • as_view(role: RoleHandler, **initkwargs) wires handlers from role.handlers() just like Neapolitan.

Sketch:

class MyCRUDView(View):
    @classonlymethod
    def get_urls(cls, roles=None):
        roles = roles or Roles.all()
        return [role.get_url(cls) for role in roles]

    @classonlymethod
    def as_view(cls, role: RoleHandler, **initkwargs):
        # same wiring pattern Neapolitan uses; role supplies extra_initkwargs and handlers()
        ...

2.3 Why this balances ergonomics and flexibility

  • For users who want defaults: urlpatterns += MyCRUDView.get_urls() “just works”, exactly like Neapolitan.
  • For users who need custom behavior: define class ExportRole(RoleHandler): ... and pass it into get_urls([...]) alongside defaults.
  • For teams that like dot-notation: keep the tiny Roles enum wrapper; internally it’s just instances, so no subclassing pain.

2.4 Migration path from Neapolitan

  • You can emulate the current Role API with RoleHandler so templates and helpers (e.g. maybe_reverse) stay familiar.
  • You can still provide Role-like defaults to minimize user-facing change (Roles.LIST, etc.).
  • Over time, encourage users to pass instances (RoleHandlers) rather than enum members, but keep both working.

Appendix — Pros/Cons Snapshot

  • Enum-only (Neapolitan today)
  • Pros: super clean defaults; dot-notation; easy iteration; immutability.
  • Cons: cannot subclass; custom roles need interface shims; more fragile on upgrades.

  • RoleHandler instances + optional Enum wrapper (proposed)

  • Pros: first-class extensibility; simpler mental model for custom roles; keeps ergonomics via small wrapper.
  • Cons: one extra layer (the handler class); you must maintain the wrapper (Enum/list) for defaults.