Skip to content

Custom Roles #73

Description

@dennisstritzke

Version 24.8 added a Role equality test, stating ‘[…] preparatory to custom roles.’ As I utilise custom roles already, I want to share my take on them.

How are you imagining custom roles to work?

The Use Case

I want to add additional behaviour to a CRUDView, without having to repeat all the niceties of Neapolitan. Imagine a LeadCRUDView where I would like to add a POST /lead/<uuid:pk>/mark_qualified action.

Currently, we are not able to extend the Neapolitan Roles because they are Enum values. To make it work anyways, I created a CustomRole base class (see below), which replicates to Role Enums behaviour.

Observations and Challenges

  1. Consistency: This approach allows arbitrary functionality to be added in a structured manner.
  2. Complexity in CRUDViews: Adding many methods through roles can make CRUDViews unwieldy. Delegating method definitions to the roles themselves would improve legibility but complicate cross-method interactions.
  3. Fragmentation: Role-related logic is currently scattered across:
    (1) Role handlers
    (2) The role itself
    (3) Context data.
  4. Neapolitan should provide Roles, which are extendable by default.

An Example

The 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/"

The view

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(),
        ])

    ...

Base Class and Test

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


setattr(CustomRole, "get_url", Role.get_url)
setattr(CustomRole, "reverse", Role.reverse)
setattr(CustomRole, "maybe_reverse", Role.maybe_reverse)

Test ensuring the Role doesn't break in obvious ways during Neapolitan upgrades.

from unittest import TestCase

from neapolitan.views import Role

from django_backoffice.role import CustomRole


class TestCustomRole(TestCase):
    def test_has_role_methods(self):
        """
        The CustomRole is a way of providing non-standard behaviour to Neapolitan views. If one of the methods is
        outright missing, things will go most likely wrong somewhere.

        This test is mainly a safeguard during upgrades of Neapolitan.
        """
        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__}",
                )

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions