from __future__ import annotations
import uuid
from collections.abc import Iterable
from typing import Any
from django.db import models
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _
from caps.utils import get_lazy_relation
from .agent import Agent
from .capability import Capability, CapabilityQuerySet, CanMany
from .capability_set import CapabilitySet
from .nested import NestedModelBase
__all__ = (
"ShareQuerySet",
"Share",
)
class ShareBase(NestedModelBase):
nested_class = Capability
@classmethod
def create_nested_class(cls, new_class, name, attrs={}):
"""Provide `Share` ForeignKey on nested Capability model."""
return super(ShareBase, cls).create_nested_class(
new_class,
name,
{
"Share": models.ForeignKey(
new_class,
models.CASCADE,
null=True,
blank=True,
db_index=True,
related_name="capabilities",
verbose_name=_("Share"),
),
**attrs,
},
)
[docs]
class ShareQuerySet(models.QuerySet):
"""QuerySet for Share classes."""
[docs]
def available(self, agent: Agent | Iterable[Agent] | None = None) -> ShareQuerySet:
"""Return available Shares based on expiration and eventual user."""
if agent is not None:
self = self.agent(agent)
return self.filter(Q(expiration__isnull=True) | Q(expiration__lt=tz.now()))
[docs]
def agent(self, agent: Agent | Iterable[Agent]):
"""
Filter Shares that agent is either receiver or
emitter..
"""
if isinstance(agent, Agent):
return self.filter(Q(emitter=agent) | Q(receiver=agent))
return self.filter(Q(emitter__in=agent) | Q(receiver__in=agent))
[docs]
def emitter(self, agent: Agent | Iterable[Agent]) -> ShareQuerySet:
"""Shares for the provided emitter(s)."""
if isinstance(agent, Agent):
return self.filter(emitter=agent)
return self.filter(emitter__in=agent)
[docs]
def receiver(self, agent: Agent | Iterable[Agent]) -> ShareQuerySet:
"""Shares for the provided receiver(s)."""
if isinstance(agent, Agent):
return self.filter(receiver=agent)
return self.filter(receiver__in=agent)
[docs]
def access(self, receiver: Agent | Iterable[Agent] | None, uuid: uuid.UUID) -> ShareQuerySet:
"""Share by uuid and receiver(s).
Note that ``receiver`` is provided as first parameter in order to enforce its usage. It however can be ``None``: this only
should be used when queryset has already been filtered by receiver.
:param receiver: the agent that retrieving the Share.
:param uuid: the Share uuid to fetch.
:yield DoesNotExist: when the Share is not found.
"""
if receiver:
self = self.receiver(receiver)
return self.get(uuid=uuid)
[docs]
def accesses(self, receiver: Agent | Iterable[Agent] | None, uuids: Iterable[uuid.UUID]) -> ShareQuerySet:
"""Shares by many uuid and receiver(s).
Please accesser to :py:meth:`ShareQuerySet.access` for more information.
:param receiver: the agent that retrieving the Share.
:param uuids: an iterable of uuids to fetch
"""
if receiver:
self = self.receiver(receiver)
return self.filter(uuid__in=uuids)
[docs]
def can(self, permissions: CanMany) -> ShareQuerySet:
"""Filter Shares with the provided permission(s).
This call :py:meth:`.capability.CapabilityQuerySet.can`, using the same parameters. Providing
multiple values will make an OR conditional.
If you want to filter based on AND please use :py:meth:`can_all`.
:param permissions: permissions to look for
"""
query = self.model.Capability.objects.can(permissions)
return self.filter(capabilities__in=query).distinct()
[docs]
def can_all(self, permissions: CanMany) -> ShareQuerySet:
"""
Filter Shares with all the provided permissions.
:param permissions: permissions to look for, same argument type as :py:meth:`.capability.CapabilityQuerySet.can`.
"""
for q in self.can_all_q(permissions):
self = self.filter(q)
return self
[docs]
def can_all_q(self, permissions: CanMany | None) -> list[Q]:
"""Return Q lookup for all permissions."""
return CapabilityQuerySet.can_all_q(
permissions, "capabilities__permission__", model=self.model.get_object_class()
)
[docs]
def bulk_create(self, objs, *a, **kw):
"""Check that objects are valid when saving models in bulk."""
for obj in objs:
obj.is_valid()
return super().bulk_create(objs, *a, **kw)
# TODO: bulk_update -> is_valid()
[docs]
class Share(CapabilitySet, models.Model, metaclass=ShareBase):
"""Share are the entry point to access an :py:class:`Object`.
Share provides a set of capabilities for specific receiver.
The concrete sub-model MUST provide the ``target`` foreign key to an
Object.
There are two kind of Share:
- root: the root Share from which all other Shares to object
are derived. Created from the :py:meth:`create` class method. It has no :py:attr:`origin`
and **there can be only one root Share per :py:class:`Object` instance.
- derived: Share derived from root or another derived. Created
from the :py:meth:`derive` method.
This class enforce fields validation at `save()` and `bulk_create()`.
Concrete Share and Capability
---------------------------------
This model is implemented as an abstract in order to have a Share
specific to each model (see :py:class:`Object` abstract model).
The related :py:class:`Capability` subclass is created at the same
time as the concrete implementing Share model. The same mechanism
also applies on :py:class:`Object` for Share, as they both use
base metaclass :py:class:`NestedBase`. They thus can be customized
if required as this example shows:
.. code-block:: python
from caps.models import Share, Capability
class MyShare(Share):
class Capability(Capability):
Share = models.ForeignKey(MyShare, models.CASCADE, null=True, related_name="capabilities")
# custom code here...
target = models.ForeignKey(MyObject, models.CASCADE)
"""
uuid = models.UUIDField(_("Share"), default=uuid.uuid4, db_index=True)
"""Public Share used in API and with the external world."""
origin = models.ForeignKey(
"self",
models.CASCADE,
blank=True,
null=True,
related_name="derived",
verbose_name=_("Source Share"),
)
"""Source Share in Shares chain."""
depth = models.PositiveIntegerField(
_("Share limit"), default=0, help_text=_("The amount of time a Share can be re-shared.")
)
"""Share chain's current depth."""
emitter = models.ForeignKey(
Agent, models.CASCADE, verbose_name=_("Emitter"), related_name="emit_Shares", db_index=True
)
"""Agent receiving capability."""
receiver = models.ForeignKey(
Agent, models.CASCADE, verbose_name=_("Receiver"), related_name="Shares", db_index=True
)
"""Agent receiving capability."""
expiration = models.DateTimeField(
_("Expiration"),
null=True,
blank=True,
help_text=_("Defines an expiration date after which the Share is not longer valid."),
)
"""Date of expiration."""
objects = ShareQuerySet.as_manager()
[docs]
@classmethod
def get_object_class(cls):
"""Return related Object class."""
return cls.target.field.related_model
[docs]
@classmethod
def create_root(cls, emitter: Agent, target: object, **kwargs) -> Share:
"""Create and save a new root Share.
There can be only one root Share per object.
New capabilities will be created by cloning default ones (which are those without an assigned Share).
:param emitter: the owner of the object, as it is emitter of the root Share.
:param target: target :py:class:`.object.Object` instance.
:param **kwargs: Share's initial arguments.
:return: the created root Share.
:yield ValueError: when ``origin`` is provided or a root Share already exists.
"""
if "origin" in kwargs:
raise ValueError(
'attribute "origin" can not be passed as an argument to ' "`create()`: you should use derive instead"
)
if target.Shares.exists():
raise ValueError("A Share already exists for this object.")
self = cls(emitter=emitter, receiver=emitter, target=target, **kwargs)
self.save()
# clone initial capabilities
capabilities = list(self.Capability.objects.initials())
for cap in capabilities:
cap.Share = self
cap.pk = None
self.Capability.objects.bulk_create(capabilities)
return self
[docs]
def is_valid(self, raises: bool = False) -> bool:
"""Check Share values validity, throwing exception on invalid
values.
:returns True if valid, otherwise raise ValueError
:yield ValueError: when Share is invaldi
"""
if self.origin:
# FIXME
if self.origin.receiver != self.emitter:
raise ValueError("origin's receiver and self's emitter are different")
if self.origin.depth >= self.depth:
if raises:
raise ValueError("origin's depth is higher than self's")
else:
return False
return True
[docs]
def is_derived(self, other: Share) -> bool:
if other.depth <= self.depth or self.target != other.target:
return False
return super().is_derived(other)
[docs]
def get_capabilities(self) -> CapabilityQuerySet:
return self.capabilities.all()
[docs]
def derive(
self,
receiver: Agent | int,
caps: CapabilitySet.Caps = None,
raises: bool = False,
defaults: dict[str, Any] = {},
**kwargs,
) -> Share:
"""Create a new Share derived from self.
:param caps: select capabilities to be derived.
:param raises: raises PermissionDenied error instead of silent it.
:param defaults: Capability instances' initial arguments.
:param **kwargs: initial arguments of the new set.
"""
kwargs = self._get_derived_kwargs(receiver, kwargs)
obj = type(self).objects.create(**kwargs)
capabilities = self.derive_caps(caps, raises=raises, defaults={**defaults, "Share": obj})
self.Capability.objects.bulk_create(capabilities)
return obj
[docs]
async def aderive(
self, caps: CapabilitySet.Caps = None, raises: bool = False, defaults: dict[str, Any] = {}, **kwargs
) -> Share:
"""Async version of :py:meth:`derive`."""
kwargs = self._get_derived_kwargs(kwargs)
obj = await type(self).objects.acreate(**kwargs)
capabilities = self.derive_caps(caps, raises=raises, defaults={**defaults, "Share": obj})
await self.Capability.objects.abulk_create(capabilities)
return obj
def _get_derived_kwargs(self, receiver: int | Agent, kwargs):
"""Return initial argument for a derived Share from self."""
r_key = "receiver_id" if isinstance(receiver, int) else "receiver"
e_key, emitter = get_lazy_relation(self, "receiver", "emitter")
return {
**kwargs,
"emitter": emitter,
r_key: receiver,
e_key: emitter,
"depth": self.depth + 1,
"origin": self,
"target": self.target,
}
[docs]
def get_absolute_url(self) -> str:
"""
Return url to the related object.
:yield ValueError: when related object class has no `detail_url_name` provided.
"""
url_name = self.target.detail_url_name
if not url_name:
raise ValueError("Missing attribute `detail_url_name` on target object.")
return reverse(url_name, kwargs={"uuid": self.uuid})
[docs]
def save(self, *a, **kw):
self.is_valid(raises=True)
return super().save(*a, **kw)