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 adjango.urls.path()
that binds the role to the view viaas_view(role=...)
.reverse(view, object=None)
/maybe_reverse(...)
— helpers forreverse()
.
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 callingrole.get_url(cls)
. Ifroles
isNone
, it usesiter(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 yourCustomRole
provides the sameget_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()
, cleanas_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.
- an Enum
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 fromrole.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 intoget_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 withRoleHandler
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.