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