from __future__ import annotations
import inspect
from collections.abc import Iterable
from typing import TypeAlias
from django.core.exceptions import PermissionDenied
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext as __
from django.utils.translation import gettext_lazy as _
__all__ = ("CapabilityQuerySet", "Capability")
CanOne: TypeAlias = Permission | str | int | tuple[str, ContentType | type]
"""
Describe lookup for a capability's permission. It is used by :py:meth:`CapabilityQuerySet.can` and all related methods (:py:meth:`CapabilityQuerySet.can_one_lookup`, :py:meth:`CapabilityQuerySet.can_q`, etc.)
It either can be:
- A Permission or a permission id;
- A tuple with Permission action, and content type (ContentType instance, or model class);
- A single action combined with provided `model` argument.
An action is the part of the Django's ``Permission.codename`` specifying a verb. Based on the provided model
(or content type), the codename will be constructed as: ``permission_codename = f"{action}_{model_name}"``.
"""
CanMany: TypeAlias = CanOne | Iterable[CanOne]
"""
Describe lookup value for multiple capabilities' description.
It is used by :py:meth:`CapabilityQuerySet.can`
"""
[docs]
class CapabilityQuerySet(models.QuerySet):
"""
Queryset and manager used by Capability models.
It provide `can` filter method + other utilities methods
in order to build up filter's lookups (used by :py:class:`~.Share.Share`).
"""
[docs]
def can(self, permissions: CanMany | None) -> CapabilityQuerySet:
"""Filter using provided permission(s).
Permissions provided as action string are matched with capability's concrete :py:class:`~.object.Object` model:
.. code-block::
# We assume: app.MyObject <- Share <- Capability
# Look up for `app.view_myobject`.
query = Capability.objects.can("view")
# Look up for capabilities with an OR joint on permissions.
permission = Permission.objects.all().first()
query = Capability.objects.can((
'view',
# with an instance of permission
permission,
# with an action and some other model
('change', SomeModel),
))
:param permissions: the permissions to look for.
:yield: from :py:meth:`can_one_lookup`.
"""
return self.filter(self.can_q(permissions, model=self.model.get_object_class()))
[docs]
@classmethod
def can_all_q(cls, permissions: CanMany, paccessix: str = "permission__", model: type | None = None) -> list[Q]:
"""
Return a list of Q objects for each provided permissions, using : py:meth:`can_one_lookup`.
:param permissions: the permissions to look for.
:param paccessix: passed down to :py:meth:`can_one_lookup`.
:param model: passed down to :py:meth:`can_one_lookup`.
"""
if isinstance(permissions, (Permission, str, int, tuple)):
return [Q(**cls.can_one_lookup(permissions, paccessix, model))]
return [Q(**cls.can_one_lookup(perm, paccessix, model)) for perm in permissions]
[docs]
@classmethod
def can_q(cls, permissions: CanMany, paccessix: str = "permission__", model: type | None = None) -> Q:
"""
Return Q lookup for multiple permissions joined with `|`, using :py:meth:`can_one_lookup`.
:param permissions: the permissions to look for.
:param paccessix: passed down to :py:meth:`can_one_lookup`.
:param model: passed down to :py:meth:`can_one_lookup`.
"""
if isinstance(permissions, (Permission, str, int, tuple)):
return Q(**cls.can_one_lookup(permissions, paccessix, model))
q = Q()
for perm in permissions:
q |= Q(**cls.can_one_lookup(perm, paccessix, model))
return q
[docs]
@staticmethod
def can_one_lookup(
permission: CanOne, paccessix: str = "permission__", model: type | None = None
) -> dict[str, str | int]:
"""Return lookup for one permission.
:param permission: to get lookup for.
:param paccessix: paccessix keys with this value.
:param model: model class to use when only action is provided.
:return lookup argument dictionnary.
:yield ValueError: on unsupported ``permission`` types.
"""
if isinstance(permission, Permission):
return {f"{paccessix}id": permission.id}
elif isinstance(permission, int):
return {f"{paccessix}id": permission}
elif isinstance(permission, str):
if model is None:
raise ValueError("You must provide a `model` when specifying a permission codename.")
action, ct = permission, ContentType.objects.get_for_model(model)
elif isinstance(permission, (tuple, list)):
action, ct = permission
if inspect.isclass(ct) and issubclass(ct, models.Model):
ct = ContentType.objects.get_for_model(ct)
elif not isinstance(ct, ContentType):
raise ValueError(f"Invalid type for permission (`{ct}`): it must be a ContentType or model class")
else:
raise ValueError(f"Invalid type for permission: `{type(permission)}`")
return {f"{paccessix}codename": f"{action}_{ct.model}", f"{paccessix}content_type": ct}
[docs]
def initials(self):
"""Filter capabilities used as initial values of a Share."""
return self.filter(Share__isnull=True)
[docs]
class Capability(models.Model):
"""A single capability providing permission for executing a single action.
It is linked to an object by a :py:class:`~.Share.Share`. The Share
is the entry point for user to address/access the object. The capability represent
what he can do.
This model is provided as abstract model whose implementation MUST provide
a ``Share`` foreign key to a :py:class:`~.Share.Share` (reverse
relation: `capabilities`). The foreign key is nullable, ``None`` have special
meaning: it represents default assigned capabilities to newly created root Share instances. They can be fetched using :py:meth:`CapabilityQuerySet.get_initials`.
See :py:class:`~.Share.Share` documentation for more information.
It is recommanded to ``select_related`` permission in order to read
:py:attr:`name` and :py:attr:`codename`.
Derivation
----------
Capability can derived: it means the permission is shared to another agent.
To be allowed to share, :py:attr:`max_derive` must be greater than 0. When sharing
only a lower value is allowed to the new capability's field.
Lets see what it does:
.. code-block:: python
from models import MyObject
permission = Permission.objects.all().first()
cap = MyObject.Capability(permission=permission, max_derive=2)
# `cap` is not saved, we don't core to provide a Share for this example.
# providing no max_derive defaults to 0
cap_1 = cap.derive(0)
assert cap_1.max_derive == 0
# this raises PermissionDenied
cap_1.derive()
# max_derive reduced by 1
cap_1 = cap.derive()
assert cap_1.max_derive == 1
cap_2 = cap_1.derive()
assert cap_2.max_derive == 0
**Note:** You'll more work over Shares for derivation than capabilities themselves.
"""
permission = models.ForeignKey(Permission, models.CASCADE, related_name="capabilities")
""" Related permission """
max_derive = models.PositiveIntegerField(_("Maximum Derivation"), default=0)
""" Maximum allowed derivations. """
objects = CapabilityQuerySet.as_manager()
[docs]
@classmethod
def get_Share_class(cls):
"""Return related Share class."""
return cls.Share.field.related_model
[docs]
@classmethod
def get_object_class(cls):
"""Return related Object class."""
return cls.get_Share_class().target.field.related_model
[docs]
def can_derive(self, max_derive: None | int = None) -> bool:
"""Return True if this capability can be derived."""
return self.max_derive > 0 and (max_derive is None or max_derive < self.max_derive)
[docs]
def derive(self, max_derive: None | int = None, Share=None, **kwargs) -> Capability:
"""Derive a new capability from self (without checking existence in
database).
:param max_derive: when value is None, it will based the value on self's \
py:attr:`max_derive` minus 1.
:param **kwargs: extra initial argument of the new Capability
:return: the new unsaved Capability.
:yield PermissionDenied: when Capability derivation is not allowed.
"""
# disallowed values as they are provided by self.
if "permission" in kwargs or "permission_id" in kwargs:
raise ValueError("Providing `permission_id` or `permission` is forbidden.")
if Share:
if Share.target != self.Share.target:
raise ValueError("New capability's Share must target the same object as current one's.")
kwargs["Share"] = Share
if not self.can_derive(max_derive):
raise PermissionDenied(__("Can not derive capability {}").format(self))
if max_derive is None:
max_derive = self.max_derive - 1
if "permission" in self.__dict__:
kwargs["permission"] = self.permission
else:
kwargs["permission_id"] = self.permission_id
return type(self)(max_derive=max_derive, **kwargs)
[docs]
def is_derived(self, capability: Capability = None) -> bool:
"""Return True if `capability` is derived from this one."""
return self.permission_id == capability.permission_id and self.can_derive(capability.max_derive)
def __str__(self):
return f"{type(self).__name__}(pk={self.pk}, permission={self.permission_id}, max_derive={self.max_derive})"
def __contains__(self, other: Capability):
"""Return True if other `capability` is derived from `self`."""
return self.is_derived(other)
def __eq__(self, other: Capability):
if not isinstance(other, Capability):
return False
if self.pk and other.pk:
return self.pk == other.pk
return self.permission_id == other.permission_id and self.max_derive == other.max_derive