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
 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):
 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:
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