approval.models.monitoring

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

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. delete_on_approval: If True, the approval metadata will be deleted upon approval. False by default, since you probably want to see who accepted an entry and when.

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
delete_on_approval: bool = False