extensions-website/common/model_mixins.py
Anna Sirota caae613747 Make it possible to fully delete unlisted/unrated extensions and versions (#81)
* removes all soft-deletion;
* shows a "Delete extension" button on the draft page in case it can be deleted;
* shows a "Delete version" button on the version page in case it can be deleted;
* a version can be deleted if
  * its file isn't approved, and it doesn't have any ratings;
* an extension can be deleted if
  * it's not listed, and doesn't have any ratings or abuse reports;
  * all it's versions can also be deleted;
* changes default `File.status` from `APPROVED` to `AWAITING_REVIEW`
  With version's file status being `APPROVED` by default, a version can never be deleted, even when the extension is still a draft.
  This change doesn't affect the approval process because
   * when an extension is approved its latest version becomes approved automatically (no change here);
   * when a new version is uploaded to an approved extension, it's approved automatically (this is new).

This allows authors to delete their drafts, freeing the extension slug and making it possible to re-upload the same file.
This also makes it possible to easily fix mistakes during the drafting of a new extension (e.g. delete a version and re-upload it without bumping a version for each typo/mistake in packaging and so on).
(see #78 and #63)

Reviewed-on: #81
2024-04-19 11:00:13 +02:00

155 lines
5.1 KiB
Python

from typing import Set, Tuple, Mapping, Any
import copy
import logging
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.shortcuts import reverse
from django.utils import timezone
from .log_entries import attach_log_entry
from common import markdown
logger = logging.getLogger(__name__)
OldStateType = Mapping[str, Any]
"""Type declaration for the old state of a model instance.
See TrackChangesMixin.pre_save_record().
"""
class CreatedModifiedMixin(models.Model):
"""Add standard date fields to a model."""
class Meta:
abstract = True
date_created = models.DateTimeField(auto_now_add=True)
date_modified = models.DateTimeField(auto_now=True)
def get_logentries_url(self) -> str:
import utils
content_type_id = ContentType.objects.get_for_model(self.__class__).pk
object_id = self.pk
app_label = 'admin'
model = 'logentry'
path = reverse(f'admin:{app_label}_{model}_changelist')
query = utils.urlencode(
{
'content_type__id__exact': content_type_id,
'object_id__exact': object_id,
}
)
return f'{path}?{query}'
class TrackChangesMixin(models.Model):
"""Tracks changes of Django models.
Tracks which fields have changed in the save() function, so that
the model can send signals upon changes in fields.
Only acts on fields listed in self.track_changes_to_fields.
"""
class Meta:
abstract = True
track_changes_to_fields: Set[str]
def _was_modified(self, old_instance: object, update_fields=None) -> bool:
"""Returns True if the record is modified.
Only checks fields listed in self.track_changes_to_fields.
"""
for field in self.track_changes_to_fields:
# If update_fields was given and this field was NOT in it,
# its value definitely won't be changed:
if update_fields is not None and field not in update_fields:
continue
old_val = getattr(old_instance, field, ...)
new_val = getattr(self, field, ...)
if old_val != new_val:
return True
return False
def pre_save_record(self, *args, **kwargs) -> Tuple[bool, OldStateType]:
"""Tracks the previous state of this object.
Only records fields listed in self.track_changes_to_fields.
:returns: (was changed, old state) tuple.
"""
if not self.pk:
return True, {}
try:
db_instance = type(self).objects.get(id=self.pk)
except type(self).DoesNotExist:
return True, {}
update_fields = kwargs.get('update_fields')
was_modified = self._was_modified(db_instance, update_fields=update_fields)
old_instance_data = {
attr: copy.deepcopy(getattr(db_instance, attr)) for attr in self.track_changes_to_fields
}
return was_modified, old_instance_data
def record_status_change(self, was_changed, old_state, **kwargs):
if not was_changed or not self.pk:
return
update_fields = kwargs.get('update_fields')
old_status = old_state.get('status', '')
now = timezone.now()
if old_status and old_status != self.status:
self.date_status_changed = now
if update_fields is not None:
kwargs['update_fields'] = kwargs['update_fields'].union({'date_status_changed'})
if self.status == self.STATUSES.APPROVED:
self.date_approved = now
if update_fields is not None:
kwargs['update_fields'] = kwargs['update_fields'].union({'date_approved'})
self.record_change(was_changed, old_state, **kwargs)
def record_change(self, was_changed, old_state, **kwargs):
if not was_changed or not self.pk:
return
changed_fields = {
field for field in old_state.keys() if getattr(self, field) != old_state[field]
}
message = [
{
'changed': {
'name': str(self._meta.verbose_name),
'object': repr(self),
'fields': list(changed_fields),
'old_state': old_state,
'new_state': {
field: getattr(self, f'get_{field}_display')()
if hasattr(self, f'get_{field}_display')
else getattr(self, field)
for field in changed_fields
},
},
}
]
attach_log_entry(self, message)
def sanitize(self, field_name, was_changed, old_state, **kwargs):
if not was_changed or not self.pk:
return
update_fields = kwargs.get('update_fields')
old_name = old_state.get(field_name)
if self.name and old_name != self.name:
self.name = markdown.sanitize(self.name)
if update_fields is not None:
kwargs['update_fields'] = kwargs['update_fields'].union({'name'})