approval.models.monitoring API documentation

# approval.models.monitoring

View Source

      1import logging
      2from typing import Type, Optional, Iterable, Any
      3from uuid import uuid4
      4
      5from annoying.fields import AutoOneToOneField
      6from django.conf import settings
      7from django.contrib.auth.models import AbstractUser
      8from django.core.exceptions import ValidationError
      9from django.core.serializers.json import DjangoJSONEncoder
     10from django.db import models
     11from django.db.models.base import ModelBase
     12from django.utils import timezone
     13from django.utils.functional import cached_property
     14from django.utils.translation import gettext_lazy as _, pgettext_lazy
     15
     16from approval.models import MonitoredModel
     17from approval.signals import pre_approval, post_approval
     18
     19logger = logging.getLogger("approval")
     20
     21
     22class SandboxMeta(ModelBase):
     23    """Metaclass to create a dynamic sandbox model."""
     24
     25    def __new__(cls, name: str, bases: tuple, attrs: dict, **kwargs) -> Type:
     26        # Names
     27        source_model: Type[models.Model] = attrs.get("base", None)
     28        source_name: str = f"{source_model._meta.model_name}"
     29        source_app: str = f"{source_model._meta.app_label}"
     30        source_fqmn: str = f"{source_model._meta.app_label}.{source_model._meta.model_name}"
     31        reverse_name: str = f"moderated_{source_model._meta.model_name.lower()}_approval"
     32        table_name: str = f"{source_model._meta.db_table}_approval"
     33        source_verbose_name: str = source_model._meta.verbose_name
     34        permission_names: dict = {"moderate": f"moderate_{table_name}"}
     35
     36        # Dynamic class replacing the original class.
     37        class DynamicSandbox(models.Model):
     38            """
     39            Base class model to monitor changes on a source Model.
     40
     41            Notes:
     42                To define a model holding changes detected on a source model,
     43                the developer must declare a model class inheriting from
     44                `Approval`. For it to function properly, the developer must
     45                provide some configuration in the form of 5 attributes.
     46            """
     47
     48            MODERATION_STATUS: tuple = (
     49                (None, pgettext_lazy("approval.moderation", "Pending")),
     50                (False, pgettext_lazy("approval.moderation", "Rejected")),
     51                (True, pgettext_lazy("approval.moderation", "Approved")),
     52            )
     53            DRAFT_STATUS: tuple = (
     54                (False, pgettext_lazy("approval.draft", "Draft")),
     55                (True, pgettext_lazy("approval.draft", "Waiting for moderation")),
     56            )
     57
     58            base: Type[models.Model] = attrs.get("base", None)
     59            approval_fields: list[str] = attrs.get("approval_fields", [])
     60            approval_store_fields: list[str] = attrs.get("approval_store_fields", [])
     61            approval_default: dict[str, object] = attrs.get("approval_default", {})
     62            auto_approve_staff: bool = attrs.get("auto_approve_staff", True)
     63            auto_approve_new: bool = attrs.get("auto_approve_new", False)
     64            auto_approve_by_request: bool = attrs.get("auto_approve_by_request", True)
     65
     66            uuid = models.UUIDField(default=uuid4, verbose_name=_("UUID"))
     67            source = AutoOneToOneField(
     68                source_fqmn, null=False, on_delete=models.CASCADE, related_name="approval"
     69            )
     70            sandbox = models.JSONField(
     71                default=dict,
     72                blank=False,
     73                encoder=DjangoJSONEncoder,
     74                verbose_name=pgettext_lazy("approval_entry", "Data"),
     75            )
     76            approved = models.BooleanField(
     77                default=None,
     78                null=True,
     79                choices=MODERATION_STATUS,
     80                verbose_name=pgettext_lazy("approval_entry", "Moderated"),
     81            )
     82            moderator = models.ForeignKey(
     83                settings.AUTH_USER_MODEL,
     84                default=None,
     85                blank=True,
     86                null=True,
     87                related_name=reverse_name,
     88                verbose_name=pgettext_lazy("approval_entry", "Moderated by"),
     89                on_delete=models.CASCADE,
     90            )
     91            draft = models.BooleanField(
     92                default=True,
     93                choices=DRAFT_STATUS,
     94                verbose_name=pgettext_lazy("approval_entry", "Draft"),
     95            )
     96            approval_date = models.DateTimeField(
     97                null=True, verbose_name=pgettext_lazy("approval_entry", "Moderated at")
     98            )
     99            info = models.TextField(blank=True, verbose_name=pgettext_lazy("approval", "Reason"))
    100            updated = models.DateTimeField(
    101                auto_now=True, verbose_name=pgettext_lazy("approval_entry", "Updated")
    102            )
    103
    104            class Meta:
    105                abstract = False
    106                db_table = table_name
    107                app_label = source_app
    108                verbose_name = pgettext_lazy("approval", "{name} approval").format(
    109                    name=source_verbose_name
    110                )
    111                verbose_name_plural = pgettext_lazy("approval", "{name} approvals").format(
    112                    name=source_verbose_name
    113                )
    114                permissions = [[permission_names["moderate"], f"can moderate {source_name}"]]
    115
    116            def save(self, **options):
    117                return super().save(**options)
    118
    119            def __str__(self):
    120                return f"{self.source} approval status: {self.get_approved_display()}"
    121
    122            def __repr__(self):
    123                return f"{self._meta.model_name}(uuid={self.uuid}, ...)"
    124
    125            # API
    126            def _update_source(self, default: bool = False, save: bool = False) -> bool:
    127                """
    128                Validate pending changes and apply them to source instance.
    129
    130                Keyword Args:
    131                    default:
    132                        If `True`, reset source fields with their default values, as
    133                        specified by the attribute `ApprovalModel.approval_default`.
    134                    save:
    135                        If `True`, the source instance is saved in the database.
    136
    137                Returns:
    138                    `True` if source could be updated with no issue, meaning data set into
    139                    the fields in source is correct (correct type and values).
    140                    `False` if the data put into the source fields can not be validated
    141                    by Django.
    142                """
    143                original = dict()  # Revert to target values in case of failure
    144                if default is False:
    145                    for field in self._get_fields_data():
    146                        original[field] = getattr(self.source, field)
    147                        setattr(self.source, field, self.sandbox["fields"][field])
    148                    for field in self._get_store_fields_data():
    149                        original[field] = getattr(self.source, field)
    150                        setattr(self.source, field, self.sandbox["store"][field])
    151                    logger.debug(
    152                        pgettext_lazy("approval", f"Updated monitored object fields using sandbox.")
    153                    )
    154                else:
    155                    for field in self.approval_default.keys():
    156                        setattr(self.source, field, self.approval_default[field])
    157                    logger.debug(
    158                        pgettext_lazy(
    159                            "approval", f"Updated monitored object fields using default values."
    160                        )
    161                    )
    162
    163                try:
    164                    self.source.clean_fields()  # use Django field validation on new values
    165                    if save:
    166                        self.source._ignore_approval = True
    167                        self.source.save()
    168                        logger.debug(
    169                            pgettext_lazy("approval", f"Updated monitored object was persisted.")
    170                        )
    171                    return True
    172                except ValidationError as exc:
    173                    for key in exc.message_dict:
    174                        logger.debug(
    175                            pgettext_lazy(
    176                                "approval", "Field {name} could not be persisted to model."
    177                            ).format(name=key)
    178                        )
    179                        if hasattr(self.source, key):
    180                            setattr(self.source, key, original[key])
    181                    if save:
    182                        self.source._ignore_approval = True
    183                        self.source.save()
    184                    return False
    185
    186            def _update_sandbox(self, slot: str = None, source: MonitoredModel = None) -> None:
    187                """
    188                Copy source monitored field values into the sandbox.
    189
    190                Keyword Args:
    191                    slot:
    192                        Field values can be saved in various slots of
    193                        the same approval instance. Slots are arbitrary names.
    194                        Default slot is `None`.
    195                    source:
    196                        Can be a reference of another object of the same model
    197                        as `self.source`, for example to use it as a template.
    198                """
    199                slot: str = slot or "fields"
    200                source: MonitoredModel = source or self.source
    201                # Save monitored fields into the defined slot.
    202                fields: list = self._get_field_names()
    203                values: dict[str, Any] = {
    204                    k: getattr(source, k) for k in fields if hasattr(source, k)
    205                }
    206                self.sandbox[slot] = values
    207                # Save store/restore fields into the "store" slot.
    208                fields: list[str] = self._get_store_field_names()
    209                values: dict[str, Any] = {
    210                    k: getattr(source, k) for k in fields if hasattr(source, k)
    211                }
    212                self.sandbox["store"] = values
    213                # Mark the changes as Pending
    214                self.approved = None
    215                self.save()
    216                logger.debug(
    217                    pgettext_lazy("approval", "Sandbox for {source} was updated.").format(
    218                        source=source
    219                    )
    220                )
    221
    222            def _needs_approval(self) -> bool:
    223                """
    224                Get whether the status of the object really needs an approval.
    225
    226                Returns:
    227                    `True` if the status of the object can be auto-approved, `False` otherwise.
    228
    229                In this case, the diff is `None` when the target object was updated
    230                but none of the monitored fields was changed.
    231                """
    232                return self._get_diff() is not None
    233
    234            @cached_property
    235            def _get_valid_fields(self) -> Optional[set[str]]:
    236                """
    237                Return the names of the data fields that can be used to update the source.
    238
    239                Returns:
    240                    A list of field names that are relevant to the source (useful
    241                    when the original model changed after a migration).
    242
    243                Without this method, applying values from the sandbox would fail
    244                if a monitored field was removed from the model.
    245                """
    246                fields = set(self._get_field_names())
    247                source_fields = set(self.source._meta.get_all_field_names())
    248                return fields.intersection(source_fields) or None
    249
    250            def _get_field_names(self) -> list[str]:
    251                """Get the list of monitored field names."""
    252                return self.approval_fields
    253
    254            def _get_store_field_names(self) -> list[str]:
    255                return self.approval_store_fields
    256
    257            def _get_fields_data(self) -> dict[str, Any]:
    258                """
    259                Return a dictionary of the data in the sandbox.
    260
    261                Returns:
    262                    A dictionary with field names as keys and their sandbox value as values.
    263                """
    264                return self.sandbox.get("fields", {})
    265
    266            def _get_store_fields_data(self) -> dict[str, Any]:
    267                """
    268                Return a dictionary of the data in the sandbox store.
    269
    270                Returns:
    271                    A dictionary with field names as keys and their sandbox value as values.
    272                """
    273                return self.sandbox.get("store", {})
    274
    275            def _get_diff(self) -> Optional[list[str]]:
    276                """
    277                Get the fields that have been changed from source to sandbox.
    278
    279                Returns:
    280                    A list of monitored field names that are different in the source.
    281                    `None` if no difference exists between the source and the sandbox.
    282                """
    283                data = self._get_fields_data()
    284                source_data = {
    285                    field: getattr(self.source, field) for field in self._get_field_names()
    286                }
    287                return [key for key in data.keys() if data[key] != source_data[key]] or None
    288
    289            def _can_user_bypass_approval(self, user: AbstractUser) -> bool:
    290                """
    291                Get whether a user can bypass approval rights control.
    292
    293                Args:
    294                    user:
    295                        The user instance to check against.
    296                """
    297                permission_name: str = f"{self.base._meta.app_label}.{permission_names['moderate']}"
    298                return user and user.has_perm(perm=permission_name)
    299
    300            def _auto_process_approval(
    301                self, authors: Iterable = None, update: bool = False
    302            ) -> None:
    303                """
    304                Approve or deny edits automatically.
    305
    306                This method denies or approves according to configuration of
    307                "auto_approve_..." fields.
    308
    309                Args:
    310                    authors:
    311                        The list of users responsible for the change. If the instance
    312                        contains a `request` attribute, the connected user is considered the
    313                        author of the change.
    314                    update:
    315                        Is used to differentiate between new objects and updated ones.
    316                        Is set to `False` when the object is new, `True` otherwise.
    317                """
    318                authorized: bool = any(self._can_user_bypass_approval(author) for author in authors)
    319                optional: bool = not self._needs_approval()
    320
    321                if authorized or optional or (self.auto_approve_new and not update):
    322                    self.approve(user=authors[0], save=True)
    323                if self.auto_approve_staff and any((u.is_staff for u in authors)):
    324                    self.approve(user=authors[0], save=True)
    325                self.auto_process_approval(authors=authors)
    326
    327            def _get_authors(self) -> Iterable:
    328                """
    329                Get the authors of the source instance.
    330
    331                Warnings:
    332                    This method *must* be overriden in the concrete model.
    333                """
    334                raise NotImplemented("You must define _get_authors() in your model.")
    335
    336            def submit_approval(self) -> bool:
    337                """
    338                Sets the status of the object to Waiting for moderation.
    339
    340                In other words, the monitored object will get moderated only
    341                after it's pulled from draft.
    342                """
    343                if self.draft:
    344                    self.draft = False
    345                    self.save()
    346                    logger.debug(
    347                        pgettext_lazy("approval", f"Set sandbox as waiting for moderation.")
    348                    )
    349                    return True
    350                return False
    351
    352            def is_draft(self) -> bool:
    353                """Check whether the object is currently in draft mode."""
    354                return self.draft
    355
    356            def approve(self, user=None, save: bool = False) -> None:
    357                """
    358                Approve pending edits.
    359
    360                Args:
    361                    user:
    362                        Instance of user who moderated the content.
    363                    save:
    364                        If `True`, persist changes to the monitored object.
    365                        If `False`, apply sandbox to the monitored object, but don't save it.
    366                """
    367                pre_approval.send(self.base, instance=self.source, status=self.approved)
    368                self.approval_date = timezone.now()
    369                self.approved = True
    370                self.moderator = user
    371                self.draft = False
    372                self.info = pgettext_lazy(
    373                    "approval_entry", "Congratulations, your edits have been approved."
    374                )
    375                self._update_source(save=True)  # apply changes to monitored object
    376                if save:
    377                    super().save()
    378                post_approval.send(self.base, instance=self.source, status=self.approved)
    379                logger.debug(pgettext_lazy("approval", f"Changes in sandbox were approved."))
    380
    381            def deny(self, user=None, reason: str = None, save: bool = False) -> None:
    382                """
    383                Reject pending edits.
    384
    385                Args:
    386                    user:
    387                        Instance of user who moderated the content.
    388                    reason:
    389                        String explaining why the content was refused.
    390                    save:
    391                        If `True`, persist changes to the monitored object.
    392                        If `False`, apply sandbox to the monitored object, but don't save it.
    393                """
    394                pre_approval.send(self.base, instance=self.source, status=self.approved)
    395                self.moderator = user
    396                self.approved = False
    397                self.draft = False
    398                self.info = reason or pgettext_lazy(
    399                    "approval_entry", "Your edits have been refused."
    400                )
    401                if save:
    402                    self.save()
    403                post_approval.send(self.base, instance=self.source, status=self.approved)
    404                logger.debug(pgettext_lazy("approval", f"Changes in sandbox were rejected."))
    405
    406            def auto_process_approval(self, authors: Iterable = None) -> None:
    407                """
    408                User-defined auto-processing, the developer should override this.
    409
    410                Auto-processing is the choice of action regarding the author
    411                or the state of the changes. This method can choose to auto-approve
    412                for some users or auto-deny changes from inappropriate IPs.
    413
    414                """
    415                return None
    416
    417        return DynamicSandbox
    418
    419
    420class Sandbox:
    421    """
    422    Class providing attributes to configure a Sandbox model.
    423
    424    To use this class, you need to create a model class that inherits from
    425    this class, and use `SandboxBase` as a metaclass:
    426
    427    ```python
    428    class EntryApproval(SandboxModel, metaclass=SandboxBase):
    429        base = Entry
    430        approval_fields = ["description", "content"]
    431        approval_store_fields = ["is_visible"]
    432        approval_default = {"is_visible": False, "description": ""}
    433        auto_approve_staff = False
    434        auto_approve_new = False
    435        auto_approve_by_request = False
    436
    437        def _get_authors(self) -> Iterable:
    438            return [self.source.user]
    439    ```
    440
    441    Attributes:
    442        base:
    443            Monitored model class.
    444        approval_fields (list[str]):
    445            List of model field names on the base model that should
    446            be monitored and should trigger the approval process.
    447        approval_default:
    448            When a new object is created and immediately needs approval,
    449            define the default values for the source while waiting for
    450            approval. For example, for a blog entry, you can set the default `published`
    451            attribute to `False`.
    452        approval_store_fields:
    453            List of model field names that should be stored in the approval
    454            state, even though the field is not monitored. Those fields will
    455            be restored to the object when approved. Generally contains
    456            fields used in approval_default.
    457        auto_approve_staff:
    458            If `True`, changes made by a staff member should be applied
    459            immediately, bypassing moderation.
    460        auto_approve_new:
    461            If `True`, a new object created would bypass the approval
    462            phase and be immediately persisted.
    463        auto_approve_by_request:
    464            If `True` the user in the object's request attribute, if any,
    465            is used to test if the object can be automatically approved.
    466            If `False`, use the default object author only.
    467    """
    468
    469    base: Type[models.Model] = None
    470    approval_fields: list[str] = []
    471    approval_store_fields: list[str] = []
    472    approval_default: dict[str, object] = {}
    473    auto_approve_staff: bool = True
    474    auto_approve_new: bool = False
    475    auto_approve_by_request: bool = True

logger = <Logger approval (DEBUG)>
class SandboxMeta(django.db.models.base.ModelBase): View Source

     23class SandboxMeta(ModelBase):
     24    """Metaclass to create a dynamic sandbox model."""
     25
     26    def __new__(cls, name: str, bases: tuple, attrs: dict, **kwargs) -> Type:
     27        # Names
     28        source_model: Type[models.Model] = attrs.get("base", None)
     29        source_name: str = f"{source_model._meta.model_name}"
     30        source_app: str = f"{source_model._meta.app_label}"
     31        source_fqmn: str = f"{source_model._meta.app_label}.{source_model._meta.model_name}"
     32        reverse_name: str = f"moderated_{source_model._meta.model_name.lower()}_approval"
     33        table_name: str = f"{source_model._meta.db_table}_approval"
     34        source_verbose_name: str = source_model._meta.verbose_name
     35        permission_names: dict = {"moderate": f"moderate_{table_name}"}
     36
     37        # Dynamic class replacing the original class.
     38        class DynamicSandbox(models.Model):
     39            """
     40            Base class model to monitor changes on a source Model.
     41
     42            Notes:
     43                To define a model holding changes detected on a source model,
     44                the developer must declare a model class inheriting from
     45                `Approval`. For it to function properly, the developer must
     46                provide some configuration in the form of 5 attributes.
     47            """
     48
     49            MODERATION_STATUS: tuple = (
     50                (None, pgettext_lazy("approval.moderation", "Pending")),
     51                (False, pgettext_lazy("approval.moderation", "Rejected")),
     52                (True, pgettext_lazy("approval.moderation", "Approved")),
     53            )
     54            DRAFT_STATUS: tuple = (
     55                (False, pgettext_lazy("approval.draft", "Draft")),
     56                (True, pgettext_lazy("approval.draft", "Waiting for moderation")),
     57            )
     58
     59            base: Type[models.Model] = attrs.get("base", None)
     60            approval_fields: list[str] = attrs.get("approval_fields", [])
     61            approval_store_fields: list[str] = attrs.get("approval_store_fields", [])
     62            approval_default: dict[str, object] = attrs.get("approval_default", {})
     63            auto_approve_staff: bool = attrs.get("auto_approve_staff", True)
     64            auto_approve_new: bool = attrs.get("auto_approve_new", False)
     65            auto_approve_by_request: bool = attrs.get("auto_approve_by_request", True)
     66
     67            uuid = models.UUIDField(default=uuid4, verbose_name=_("UUID"))
     68            source = AutoOneToOneField(
     69                source_fqmn, null=False, on_delete=models.CASCADE, related_name="approval"
     70            )
     71            sandbox = models.JSONField(
     72                default=dict,
     73                blank=False,
     74                encoder=DjangoJSONEncoder,
     75                verbose_name=pgettext_lazy("approval_entry", "Data"),
     76            )
     77            approved = models.BooleanField(
     78                default=None,
     79                null=True,
     80                choices=MODERATION_STATUS,
     81                verbose_name=pgettext_lazy("approval_entry", "Moderated"),
     82            )
     83            moderator = models.ForeignKey(
     84                settings.AUTH_USER_MODEL,
     85                default=None,
     86                blank=True,
     87                null=True,
     88                related_name=reverse_name,
     89                verbose_name=pgettext_lazy("approval_entry", "Moderated by"),
     90                on_delete=models.CASCADE,
     91            )
     92            draft = models.BooleanField(
     93                default=True,
     94                choices=DRAFT_STATUS,
     95                verbose_name=pgettext_lazy("approval_entry", "Draft"),
     96            )
     97            approval_date = models.DateTimeField(
     98                null=True, verbose_name=pgettext_lazy("approval_entry", "Moderated at")
     99            )
    100            info = models.TextField(blank=True, verbose_name=pgettext_lazy("approval", "Reason"))
    101            updated = models.DateTimeField(
    102                auto_now=True, verbose_name=pgettext_lazy("approval_entry", "Updated")
    103            )
    104
    105            class Meta:
    106                abstract = False
    107                db_table = table_name
    108                app_label = source_app
    109                verbose_name = pgettext_lazy("approval", "{name} approval").format(
    110                    name=source_verbose_name
    111                )
    112                verbose_name_plural = pgettext_lazy("approval", "{name} approvals").format(
    113                    name=source_verbose_name
    114                )
    115                permissions = [[permission_names["moderate"], f"can moderate {source_name}"]]
    116
    117            def save(self, **options):
    118                return super().save(**options)
    119
    120            def __str__(self):
    121                return f"{self.source} approval status: {self.get_approved_display()}"
    122
    123            def __repr__(self):
    124                return f"{self._meta.model_name}(uuid={self.uuid}, ...)"
    125
    126            # API
    127            def _update_source(self, default: bool = False, save: bool = False) -> bool:
    128                """
    129                Validate pending changes and apply them to source instance.
    130
    131                Keyword Args:
    132                    default:
    133                        If `True`, reset source fields with their default values, as
    134                        specified by the attribute `ApprovalModel.approval_default`.
    135                    save:
    136                        If `True`, the source instance is saved in the database.
    137
    138                Returns:
    139                    `True` if source could be updated with no issue, meaning data set into
    140                    the fields in source is correct (correct type and values).
    141                    `False` if the data put into the source fields can not be validated
    142                    by Django.
    143                """
    144                original = dict()  # Revert to target values in case of failure
    145                if default is False:
    146                    for field in self._get_fields_data():
    147                        original[field] = getattr(self.source, field)
    148                        setattr(self.source, field, self.sandbox["fields"][field])
    149                    for field in self._get_store_fields_data():
    150                        original[field] = getattr(self.source, field)
    151                        setattr(self.source, field, self.sandbox["store"][field])
    152                    logger.debug(
    153                        pgettext_lazy("approval", f"Updated monitored object fields using sandbox.")
    154                    )
    155                else:
    156                    for field in self.approval_default.keys():
    157                        setattr(self.source, field, self.approval_default[field])
    158                    logger.debug(
    159                        pgettext_lazy(
    160                            "approval", f"Updated monitored object fields using default values."
    161                        )
    162                    )
    163
    164                try:
    165                    self.source.clean_fields()  # use Django field validation on new values
    166                    if save:
    167                        self.source._ignore_approval = True
    168                        self.source.save()
    169                        logger.debug(
    170                            pgettext_lazy("approval", f"Updated monitored object was persisted.")
    171                        )
    172                    return True
    173                except ValidationError as exc:
    174                    for key in exc.message_dict:
    175                        logger.debug(
    176                            pgettext_lazy(
    177                                "approval", "Field {name} could not be persisted to model."
    178                            ).format(name=key)
    179                        )
    180                        if hasattr(self.source, key):
    181                            setattr(self.source, key, original[key])
    182                    if save:
    183                        self.source._ignore_approval = True
    184                        self.source.save()
    185                    return False
    186
    187            def _update_sandbox(self, slot: str = None, source: MonitoredModel = None) -> None:
    188                """
    189                Copy source monitored field values into the sandbox.
    190
    191                Keyword Args:
    192                    slot:
    193                        Field values can be saved in various slots of
    194                        the same approval instance. Slots are arbitrary names.
    195                        Default slot is `None`.
    196                    source:
    197                        Can be a reference of another object of the same model
    198                        as `self.source`, for example to use it as a template.
    199                """
    200                slot: str = slot or "fields"
    201                source: MonitoredModel = source or self.source
    202                # Save monitored fields into the defined slot.
    203                fields: list = self._get_field_names()
    204                values: dict[str, Any] = {
    205                    k: getattr(source, k) for k in fields if hasattr(source, k)
    206                }
    207                self.sandbox[slot] = values
    208                # Save store/restore fields into the "store" slot.
    209                fields: list[str] = self._get_store_field_names()
    210                values: dict[str, Any] = {
    211                    k: getattr(source, k) for k in fields if hasattr(source, k)
    212                }
    213                self.sandbox["store"] = values
    214                # Mark the changes as Pending
    215                self.approved = None
    216                self.save()
    217                logger.debug(
    218                    pgettext_lazy("approval", "Sandbox for {source} was updated.").format(
    219                        source=source
    220                    )
    221                )
    222
    223            def _needs_approval(self) -> bool:
    224                """
    225                Get whether the status of the object really needs an approval.
    226
    227                Returns:
    228                    `True` if the status of the object can be auto-approved, `False` otherwise.
    229
    230                In this case, the diff is `None` when the target object was updated
    231                but none of the monitored fields was changed.
    232                """
    233                return self._get_diff() is not None
    234
    235            @cached_property
    236            def _get_valid_fields(self) -> Optional[set[str]]:
    237                """
    238                Return the names of the data fields that can be used to update the source.
    239
    240                Returns:
    241                    A list of field names that are relevant to the source (useful
    242                    when the original model changed after a migration).
    243
    244                Without this method, applying values from the sandbox would fail
    245                if a monitored field was removed from the model.
    246                """
    247                fields = set(self._get_field_names())
    248                source_fields = set(self.source._meta.get_all_field_names())
    249                return fields.intersection(source_fields) or None
    250
    251            def _get_field_names(self) -> list[str]:
    252                """Get the list of monitored field names."""
    253                return self.approval_fields
    254
    255            def _get_store_field_names(self) -> list[str]:
    256                return self.approval_store_fields
    257
    258            def _get_fields_data(self) -> dict[str, Any]:
    259                """
    260                Return a dictionary of the data in the sandbox.
    261
    262                Returns:
    263                    A dictionary with field names as keys and their sandbox value as values.
    264                """
    265                return self.sandbox.get("fields", {})
    266
    267            def _get_store_fields_data(self) -> dict[str, Any]:
    268                """
    269                Return a dictionary of the data in the sandbox store.
    270
    271                Returns:
    272                    A dictionary with field names as keys and their sandbox value as values.
    273                """
    274                return self.sandbox.get("store", {})
    275
    276            def _get_diff(self) -> Optional[list[str]]:
    277                """
    278                Get the fields that have been changed from source to sandbox.
    279
    280                Returns:
    281                    A list of monitored field names that are different in the source.
    282                    `None` if no difference exists between the source and the sandbox.
    283                """
    284                data = self._get_fields_data()
    285                source_data = {
    286                    field: getattr(self.source, field) for field in self._get_field_names()
    287                }
    288                return [key for key in data.keys() if data[key] != source_data[key]] or None
    289
    290            def _can_user_bypass_approval(self, user: AbstractUser) -> bool:
    291                """
    292                Get whether a user can bypass approval rights control.
    293
    294                Args:
    295                    user:
    296                        The user instance to check against.
    297                """
    298                permission_name: str = f"{self.base._meta.app_label}.{permission_names['moderate']}"
    299                return user and user.has_perm(perm=permission_name)
    300
    301            def _auto_process_approval(
    302                self, authors: Iterable = None, update: bool = False
    303            ) -> None:
    304                """
    305                Approve or deny edits automatically.
    306
    307                This method denies or approves according to configuration of
    308                "auto_approve_..." fields.
    309
    310                Args:
    311                    authors:
    312                        The list of users responsible for the change. If the instance
    313                        contains a `request` attribute, the connected user is considered the
    314                        author of the change.
    315                    update:
    316                        Is used to differentiate between new objects and updated ones.
    317                        Is set to `False` when the object is new, `True` otherwise.
    318                """
    319                authorized: bool = any(self._can_user_bypass_approval(author) for author in authors)
    320                optional: bool = not self._needs_approval()
    321
    322                if authorized or optional or (self.auto_approve_new and not update):
    323                    self.approve(user=authors[0], save=True)
    324                if self.auto_approve_staff and any((u.is_staff for u in authors)):
    325                    self.approve(user=authors[0], save=True)
    326                self.auto_process_approval(authors=authors)
    327
    328            def _get_authors(self) -> Iterable:
    329                """
    330                Get the authors of the source instance.
    331
    332                Warnings:
    333                    This method *must* be overriden in the concrete model.
    334                """
    335                raise NotImplemented("You must define _get_authors() in your model.")
    336
    337            def submit_approval(self) -> bool:
    338                """
    339                Sets the status of the object to Waiting for moderation.
    340
    341                In other words, the monitored object will get moderated only
    342                after it's pulled from draft.
    343                """
    344                if self.draft:
    345                    self.draft = False
    346                    self.save()
    347                    logger.debug(
    348                        pgettext_lazy("approval", f"Set sandbox as waiting for moderation.")
    349                    )
    350                    return True
    351                return False
    352
    353            def is_draft(self) -> bool:
    354                """Check whether the object is currently in draft mode."""
    355                return self.draft
    356
    357            def approve(self, user=None, save: bool = False) -> None:
    358                """
    359                Approve pending edits.
    360
    361                Args:
    362                    user:
    363                        Instance of user who moderated the content.
    364                    save:
    365                        If `True`, persist changes to the monitored object.
    366                        If `False`, apply sandbox to the monitored object, but don't save it.
    367                """
    368                pre_approval.send(self.base, instance=self.source, status=self.approved)
    369                self.approval_date = timezone.now()
    370                self.approved = True
    371                self.moderator = user
    372                self.draft = False
    373                self.info = pgettext_lazy(
    374                    "approval_entry", "Congratulations, your edits have been approved."
    375                )
    376                self._update_source(save=True)  # apply changes to monitored object
    377                if save:
    378                    super().save()
    379                post_approval.send(self.base, instance=self.source, status=self.approved)
    380                logger.debug(pgettext_lazy("approval", f"Changes in sandbox were approved."))
    381
    382            def deny(self, user=None, reason: str = None, save: bool = False) -> None:
    383                """
    384                Reject pending edits.
    385
    386                Args:
    387                    user:
    388                        Instance of user who moderated the content.
    389                    reason:
    390                        String explaining why the content was refused.
    391                    save:
    392                        If `True`, persist changes to the monitored object.
    393                        If `False`, apply sandbox to the monitored object, but don't save it.
    394                """
    395                pre_approval.send(self.base, instance=self.source, status=self.approved)
    396                self.moderator = user
    397                self.approved = False
    398                self.draft = False
    399                self.info = reason or pgettext_lazy(
    400                    "approval_entry", "Your edits have been refused."
    401                )
    402                if save:
    403                    self.save()
    404                post_approval.send(self.base, instance=self.source, status=self.approved)
    405                logger.debug(pgettext_lazy("approval", f"Changes in sandbox were rejected."))
    406
    407            def auto_process_approval(self, authors: Iterable = None) -> None:
    408                """
    409                User-defined auto-processing, the developer should override this.
    410
    411                Auto-processing is the choice of action regarding the author
    412                or the state of the changes. This method can choose to auto-approve
    413                for some users or auto-deny changes from inappropriate IPs.
    414
    415                """
    416                return None
    417
    418        return DynamicSandbox

Metaclass to create a dynamic sandbox model.

##### Inherited Members

builtins.type
  type
  mro
django.db.models.base.ModelBase
  add_to_class

class Sandbox: View Source

    421class Sandbox:
    422    """
    423    Class providing attributes to configure a Sandbox model.
    424
    425    To use this class, you need to create a model class that inherits from
    426    this class, and use `SandboxBase` as a metaclass:
    427
    428    ```python
    429    class EntryApproval(SandboxModel, metaclass=SandboxBase):
    430        base = Entry
    431        approval_fields = ["description", "content"]
    432        approval_store_fields = ["is_visible"]
    433        approval_default = {"is_visible": False, "description": ""}
    434        auto_approve_staff = False
    435        auto_approve_new = False
    436        auto_approve_by_request = False
    437
    438        def _get_authors(self) -> Iterable:
    439            return [self.source.user]
    440    ```
    441
    442    Attributes:
    443        base:
    444            Monitored model class.
    445        approval_fields (list[str]):
    446            List of model field names on the base model that should
    447            be monitored and should trigger the approval process.
    448        approval_default:
    449            When a new object is created and immediately needs approval,
    450            define the default values for the source while waiting for
    451            approval. For example, for a blog entry, you can set the default `published`
    452            attribute to `False`.
    453        approval_store_fields:
    454            List of model field names that should be stored in the approval
    455            state, even though the field is not monitored. Those fields will
    456            be restored to the object when approved. Generally contains
    457            fields used in approval_default.
    458        auto_approve_staff:
    459            If `True`, changes made by a staff member should be applied
    460            immediately, bypassing moderation.
    461        auto_approve_new:
    462            If `True`, a new object created would bypass the approval
    463            phase and be immediately persisted.
    464        auto_approve_by_request:
    465            If `True` the user in the object's request attribute, if any,
    466            is used to test if the object can be automatically approved.
    467            If `False`, use the default object author only.
    468    """
    469
    470    base: Type[models.Model] = None
    471    approval_fields: list[str] = []
    472    approval_store_fields: list[str] = []
    473    approval_default: dict[str, object] = {}
    474    auto_approve_staff: bool = True
    475    auto_approve_new: bool = False
    476    auto_approve_by_request: bool = True

Class providing attributes to configure a Sandbox model.

To use this class, you need to create a model class that inherits from this class, and use `SandboxBase` as a metaclass:

    class EntryApproval(SandboxModel, metaclass=SandboxBase):
        base = Entry
        approval_fields = ["description", "content"]
        approval_store_fields = ["is_visible"]
        approval_default = {"is_visible": False, "description": ""}
        auto_approve_staff = False
        auto_approve_new = False
        auto_approve_by_request = False

        def _get_authors(self) -> Iterable:
            return [self.source.user]

Attributes: base: Monitored model class. approval_fields (list[str]): List of model field names on the base model that should be monitored and should trigger the approval process. approval_default: When a new object is created and immediately needs approval, define the default values for the source while waiting for approval. For example, for a blog entry, you can set the default `published` attribute to `False`. approval_store_fields: List of model field names that should be stored in the approval state, even though the field is not monitored. Those fields will be restored to the object when approved. Generally contains fields used in approval_default. auto_approve_staff: If `True`, changes made by a staff member should be applied immediately, bypassing moderation. auto_approve_new: If `True`, a new object created would bypass the approval phase and be immediately persisted. auto_approve_by_request: If `True` the user in the object's request attribute, if any, is used to test if the object can be automatically approved. If `False`, use the default object author only.

base: Type[django.db.models.base.Model] = None
approval_fields: list[str] = []
approval_store_fields: list[str] = []
approval_default: dict[str, object] = {}
auto_approve_staff: bool = True
auto_approve_new: bool = False
auto_approve_by_request: bool = True