"""
Generic view permission mixins
------------------------------
Here we put the classes that derive from
:py:class:`braces.views._access.AccessMixin` because ``AccessMixin` needs Django
to be fully booted in order to be importable. This is because the module that
``AccessAdmin`` is in imports some things from ``django.contrib.auth`` that want
to load app configs, which aren't available until after django has fully booted.
For the same reason, don't import this file in ``wildewidgets.views.__init.py``,
because anyone importing any widget that is also a JSON view will cause
``AppRegistryNotReady`` exceptions.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from braces.views._access import AccessMixin
from django.contrib.auth.models import Group
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
if TYPE_CHECKING:
from django.db.models import Model
from django.http.request import HttpRequest
[docs]class PermissionRequiredMixin(AccessMixin):
"""
A class-based view mixin that controls access based on user permissions and
group membership.
This mixin provides a flexible permission system that can authorize users based on:
* Django group membership
* Django permissions
* Model-specific permissions
Authorization Rules:
* Django superusers are always authorized
* Anonymous users are never authorized
* Users in any of the groups specified by `groups_required` are authorized
* Users with any of the permissions specified by `permissions_required` are
authorized
* When `model` and `required_model_permissions` are set, the mixin
automatically checks the corresponding model permissions (e.g.,
"app.add_model", "app.change_model")
The mixin also provides helper methods to check specific permissions for
common operations like viewing, creating, updating, and deleting.
Example:
.. code-block:: python
from django.contrib.auth.models import User
from django.views.generic import TemplateView
from wildewidgets import PermissionRequiredMixin
class StaffRequiredView(PermissionRequiredMixin, TemplateView):
template_name = "staff_only.html"
groups_required = ["Staff"]
class UserModelView(PermissionRequiredMixin, TemplateView):
template_name = "user_management.html"
model = User
required_model_permissions = ["view", "change"]
"""
#: Users who are members of the named Django auth groups will be permitted
#: access to the view, and those who are not will be denied access.
groups_required: list[str] | None = None
#: Users who have any of the listed Django model permissions
#: will be permitted access to the view.
permissions_required: list[str] | None = None
#: Used in conjunction with :py:attr:`required_model_permissions`,
#: this is the model we want to act on. This should be set by our
#: our subclass, which is why it is just a type declaration here.
#: This is used by :py:meth:`get_model_permissions`
model: type[Model] | None
#: A list of simple strings like ``view``, ``add``, ``change``, ``delete`` that
#: represent actions that can be done on a model. This is used by
#: py:meth:`get_model_permissions`
required_model_permissions: list[str] | None = None
[docs] def user_can_view(self) -> bool:
"""
Check if the current user has permission to view the model.
This is a convenience method that checks for the "view" model permission.
Returns:
bool: True if the user has view permission, False otherwise
Raises:
ImproperlyConfigured: If `model` is not set
"""
required_perms = self.get_model_permissions(self.model, ["view"])
return self.check_permissions(
groups_required=[], permissions_required=required_perms
)
[docs] def user_can_create(self) -> bool:
"""
Check if the current user has permission to create instances of the model.
This is a convenience method that checks for the "add" model permission.
Returns:
bool: True if the user has create permission, False otherwise
Raises:
ImproperlyConfigured: If `model` is not set
"""
required_perms = self.get_model_permissions(self.model, ["add"])
return self.check_permissions(
groups_required=[], permissions_required=required_perms
)
[docs] def user_can_update(self) -> bool:
"""
Check if the current user has permission to update instances of the model.
This is a convenience method that checks for the "change" model permission.
Returns:
bool: True if the user has update permission, False otherwise
Raises:
ImproperlyConfigured: If `model` is not set
"""
required_perms = self.get_model_permissions(self.model, ["change"])
return self.check_permissions(
groups_required=[], permissions_required=required_perms
)
[docs] def user_can_delete(self) -> bool:
"""
Check if the current user has permission to delete instances of the model.
This is a convenience method that checks for the "delete" model permission.
Returns:
bool: True if the user has delete permission, False otherwise
Raises:
ImproperlyConfigured: If `model` is not set
"""
required_perms = self.get_model_permissions(self.model, ["delete"])
return self.check_permissions(
groups_required=[], permissions_required=required_perms
)
[docs] def get_groups_required(self) -> list[Group]:
"""
Get the list of Django Group objects required for authorization.
This method converts the group names in :py:attr:`groups_required` to actual
Group objects.
Returns:
list[Group]: List of Django Group objects that grant access
"""
if self.groups_required is None:
return []
return list(Group.objects.filter(name__in=self.groups_required))
[docs] def get_permissions_required(self) -> list[str]:
"""
Get the list of permission strings required for authorization.
Returns:
list[str]: List of permission strings that grant access
"""
if self.permissions_required is None:
return []
return self.permissions_required
[docs] def get_model_permissions(
self,
model: Model | type[Model] | None, # noqa: ARG002
perms: list[str],
) -> list[str]:
"""
Convert simple permission names to fully-qualified model permission strings.
This method transforms basic permission types like "view", "add",
"change", "delete" into fully-qualified permission strings like
"app_label.view_modelname".
Args:
model: The model class (ignored - uses self.model instead)
perms: List of basic permission types to convert
Returns:
list[str]: List of fully-qualified permission strings
Raises:
ImproperlyConfigured: If :py:attr:`model` is not set to a valid Django
model class
Example:
>>> # If self.model is User from django.contrib.auth
>>> self.get_model_permissions(None, ["add", "change"])
['auth.add_user', 'auth.change_user']
"""
if self.model is None:
msg = (
"PermissionRequiredMixin.get_model_permissions() requires that "
"self.model is set to a Django model class."
)
raise ImproperlyConfigured(msg)
required_perms = set()
for perm in perms:
full_perm = (
f"{self.model._meta.app_label}.{perm}_"
f"{self.model._meta.object_name.lower()}" # type: ignore[union-attr]
)
required_perms.add(full_perm)
return list(required_perms)
[docs] def check_permissions(
self,
groups_required: list[Group] | None = None,
permissions_required: list[str] | None = None,
) -> bool:
"""
Check if the current user is authorized based on groups and permissions.
This is the core authorization method that implements the following logic:
1. Superusers are always authorized
2. If the user belongs to any required group, they are authorized
3. If the user has any of the required permissions, they are authorized
4. Otherwise, authorization is denied
If model permissions are configured via `model` and
`required_model_permissions`, they are automatically added to the
permissions check.
Keyword Args:
groups_required: custom list of groups to check instead of
:py:meth:`get_groups_required`
permissions_required: custom list of permissions to check
instead of :py:meth:`get_permissions_required`
Returns:
bool: True if the user is authorized, False otherwise
"""
if groups_required is None:
groups_required = [group.name for group in self.get_groups_required()]
if permissions_required is None:
permissions_required = self.get_permissions_required()
if self.required_model_permissions:
model_permissions = self.get_model_permissions(
self.model, self.required_model_permissions
)
permissions_required.extend(model_permissions)
if self.request.user.is_superuser: # type: ignore[union-attr]
# Superusers users are always authorized
return True
user_groups = self.request.user.groups.values_list("name", flat=True) # type: ignore[union-attr]
if set(groups_required).intersection(set(user_groups)):
return True
return any(self.request.user.has_perm(perm) for perm in permissions_required) # type: ignore[union-attr]
[docs] def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
"""
Process the request and enforce permission checks.
This method intercepts all requests to the view and checks if the user
has the required permissions before allowing the request to proceed.
Args:
request: The HTTP request
*args: Positional arguments to pass to the handler
Keyword Arguments:kk
**kwargs: Keyword arguments to pass to the handler
Returns:
Any: The response from the view
Raises:
PermissionDenied: If the user doesn't have the required permissions
"""
self.request = request
if not self.check_permissions():
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)