Source code for caps.models.Share

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] class Meta: abstract = True unique_together = (("receiver", "target", "emitter"),)
[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] class Meta: abstract = True unique_together = (("origin", "receiver", "target"),)
[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)