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
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
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.