Skip to content

Instantly share code, notes, and snippets.

@Micrified
Last active November 11, 2024 22:56
Show Gist options
  • Save Micrified/8cd734a22e55c17c9de9d4a41333c4f4 to your computer and use it in GitHub Desktop.
Save Micrified/8cd734a22e55c17c9de9d4a41333c4f4 to your computer and use it in GitHub Desktop.
Hook Example

Hook based example

The following example uses a User-Role relation in order to codify the more complex permission behavior required for Activites. The relation allows both:

  1. A hierarchical page structure for organizing permissions
  2. Fine-grained rules around page permissions, no matter their position in the hierarchy.

Basics

We begin by defining a Role type listing all possible roles that a user may have in Activities. Furthermore, we define the Perm type to enumerate possible permissions

class Role(Enum):
    """Available user roles"""
    PO = "Programme Manager"
    AM = "Activity Manager"
    TO = "Technical Officer"
    CO = "Contract Officer"
    @classmethod
    def all(cls):
        return [(k.value, k.name) for k in cls]

class Perm(IntFlag):
    """Permission flags"""
    Empty   = 0
    View    = 1
    Edit    = 2
    Publish = 4
    Delete  = 8
    All     = 15

There also has to be a way to relate a Role to a User. For that, we define snippets. Because different pages (e.g. ProgrammePage, and ActivityPage) have different member sets (a ProgammePage has only PM roles available, while ActivityPage has all the others), we define a category for each:

@register_snippet
class ProgrammeMember(Member):
    page = models.ForeignKey(ProgrammePage, on_delete=models.CASCADE)
    def __str__(self):
        return "[%s] %s" % (self.role, self.user.username)
    class Meta:
        verbose_name_plural="Programme Members"

@register_snippet
class ActivityMember(Member):
    page = models.ForeignKey(ActivityPage, on_delete=models.CASCADE)
    def __str__(self):
        return "[%s] %s" % (self.role, self.user.username)
    class Meta:
        verbose_name_plural="Activity Members"

To add user's to an Activity or Programme, simply visit the Snippets section and assign them a role and page under the respective page type.

ControlledPage

Next, we define a ControlledPage class as an abstract class which requires that any derived page expose:

  1. A list of members for that page instance
  2. A map of permissions that are afforded to the various roles possible for that page type
class ControlledPage(Page):
    class Meta:
        abstract = True
    def members(self, user):
        pass
    def permissions_for_role(self):
        return {}
    @staticmethod
    def has_permission(page, perm, user):
        # ...

This is best demonstrated by example, for say, an ActivityPage:

class ActivityPage(ControlledPage):
    desc = RichTextField(blank=True)

    def members(self, user):
        return ActivityMember.objects.filter( # Derived class filters own member set
            page=self,
            user=user
        )

    def permissions_for_role(self): # Derived class defines own permissions
        return {
            Role.PO: Perm.All,
            Role.AM: Perm.All,
            Role.TO: Perm.All ^ Perm.Publish,
            Role.CO: Perm.All ^ Perm.Publish
        }

    content_panels = Page.content_panels + [
        FieldPanel('desc'),
    ]

This allows each page to (1) refer to their own member set (e.g. ActivityMember) and (2) define their own permissions within the class (should make maintenance easy).

Permission Resolution

Finally, we tie the permission mechanism into Wagtail. The main permission resolver is in the ControlledPage class, and is titled: has_permission:

class ControlledPage(Page):
    # ...
    @staticmethod
    def has_permission(page, perm, user):
        if 'admin' == user.username:
            return True
        if not issubclass(type(page.specific), ControlledPage):
            return False
        ps = page.specific.permissions_for_role()
        ms = page.specific.members(user=user)
        if len(ms) > 0 and perm in ps.get(Role(ms[0].role), Perm.Empty):
            return True
        return ControlledPage.has_permission(page.get_parent(), perm, user)

It first determines if the current user has a role within the page. If the user does not, perhaps they are a member of a parent page. For example, a Programme Manager might be trying to edit an Activity, for which they are not a direct member. In this case, a recursive search is performed on the parent page. If no derived page of ControlledPage is found, or a suitable permission is located, then the search ceases. We finally link this into the Wagtail admin using hooks:

@hooks.register('before_edit_page')
def before_edit_page(request, page):
    if ControlledPage.has_permission(page, Perm.Edit, request.user):
        return request
    else:
        return permission_denied(request, PermissionDenied("Insufficient privileges!"))
        
# Extend to other hooks here

Benefits of this approach should be:

  1. You can easily add user roles in the admin
  2. You get the desired behavior not afforded by the default Wagtail CMS permission structure

Some additional work is needed to make this work seamlessly. For one, each user should be able to access the admin. This means making them an Editor by default should be done. Their ability to edit pages within the admin panel is restricted by the hooks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment