Make it possible to fully delete unlisted/unrated extensions #81

Merged
Anna Sirota merged 24 commits from fully-delete-extension into main 2024-04-19 11:00:19 +02:00
23 changed files with 187 additions and 284 deletions
Showing only changes of commit 7949be8e2e - Show all commits

View File

@ -9,13 +9,13 @@ from extended_choices import Choices
from geoip2.errors import GeoIP2Error from geoip2.errors import GeoIP2Error
from constants.base import ABUSE_TYPE, ABUSE_TYPE_EXTENSION, ABUSE_TYPE_REVIEW from constants.base import ABUSE_TYPE, ABUSE_TYPE_EXTENSION, ABUSE_TYPE_REVIEW
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin
import extensions.fields import extensions.fields
User = get_user_model() User = get_user_model()
class AbuseReport(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Model): class AbuseReport(CreatedModifiedMixin, TrackChangesMixin, models.Model):
TYPE = ABUSE_TYPE TYPE = ABUSE_TYPE
REASONS = Choices( REASONS = Choices(

View File

@ -1,5 +1,4 @@
""" """Django settings for blender_extensions project.
Django settings for blender_extensions project.
Generated by 'django-admin startproject' using Django 4.0.6. Generated by 'django-admin startproject' using Django 4.0.6.
@ -192,9 +191,9 @@ LOGGING = {
}, },
}, },
'loggers': { 'loggers': {
'django': {'level': 'WARNING'}, 'django': {'level': 'INFO'},
}, },
'root': {'level': 'WARNING', 'handlers': ['console']}, 'root': {'level': 'INFO', 'handlers': ['console']},
} }
PIPELINE = { PIPELINE = {

View File

@ -3,7 +3,7 @@ import copy
import logging import logging
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models, transaction from django.db import models
from django.shortcuts import reverse from django.shortcuts import reverse
from django.utils import timezone from django.utils import timezone
@ -147,43 +147,3 @@ class TrackChangesMixin(models.Model):
self.name = markdown.sanitize(self.name) self.name = markdown.sanitize(self.name)
if update_fields is not None: if update_fields is not None:
kwargs['update_fields'] = kwargs['update_fields'].union({'name'}) kwargs['update_fields'] = kwargs['update_fields'].union({'name'})
class SoftDeleteMixin(models.Model):
"""Model with soft-deletion functionality."""
class Meta:
abstract = True
date_deleted = models.DateTimeField(null=True, blank=True, editable=False)
@property
def is_deleted(self) -> bool:
return self.date_deleted is not None
@transaction.atomic
def delete(self, hard=False):
if hard:
super().delete()
else:
if hasattr(self, 'file'):
# TODO(oleg) move to an archived directory, add random suffix
self.file.delete()
self.date_deleted = timezone.now()
self.save()
logger.info('%r pk=%r deleted', self.__class__, self.pk)
def delete_queryset(self, request, queryset):
"""Given a queryset, soft-delete it from the database."""
queryset.update(date_deleted=timezone.now())
def undelete(self, save=True):
if not self.date_deleted:
logger.warning('%r pk=%r is not deleted, cannot undelete', self.__class__, self.pk)
return
self.date_deleted = None
if save:
self.save()
logger.info('%r pk=%r undeleted', self.__class__, self.pk)

View File

@ -53,7 +53,6 @@ class ExtensionAdmin(admin.ModelAdmin):
'date_created', 'date_created',
'date_status_changed', 'date_status_changed',
'date_approved', 'date_approved',
'date_deleted',
'date_modified', 'date_modified',
'average_score', 'average_score',
'text_ratings_count', 'text_ratings_count',
@ -76,7 +75,6 @@ class ExtensionAdmin(admin.ModelAdmin):
'date_status_changed', 'date_status_changed',
'date_approved', 'date_approved',
'date_modified', 'date_modified',
'date_deleted',
), ),
'name', 'name',
'slug', 'slug',
@ -135,7 +133,6 @@ class VersionAdmin(admin.ModelAdmin):
'tagline', 'tagline',
'date_created', 'date_created',
'date_modified', 'date_modified',
'date_deleted',
'average_score', 'average_score',
'download_count', 'download_count',
) )
@ -147,7 +144,7 @@ class VersionAdmin(admin.ModelAdmin):
'fields': ( 'fields': (
'id', 'id',
'tagline', 'tagline',
('date_created', 'date_modified', 'date_deleted'), ('date_created', 'date_modified'),
'extension', 'extension',
'version', 'version',
'blender_version_min', 'blender_version_min',
@ -185,8 +182,8 @@ class VersionAdmin(admin.ModelAdmin):
class MaintainerAdmin(admin.ModelAdmin): class MaintainerAdmin(admin.ModelAdmin):
model = Maintainer model = Maintainer
list_display = ('extension', 'user', 'date_deleted') list_display = ('extension', 'user')
readonly_fields = ('extension', 'user', 'date_deleted') readonly_fields = ('extension', 'user')
class LicenseAdmin(admin.ModelAdmin): class LicenseAdmin(admin.ModelAdmin):

View File

@ -76,9 +76,6 @@ class AddPreviewFileForm(forms.ModelForm):
): ):
logger.warning('Found an existing %s pk=%s', model, existing_image.pk) logger.warning('Found an existing %s pk=%s', model, existing_image.pk)
self.instance = existing_image self.instance = existing_image
# Undelete the instance, if necessary
if self.instance.is_deleted:
self.instance.undelete(save=False)
# Fill in missing fields from request and the source file # Fill in missing fields from request and the source file
self.instance.user = self.request.user self.instance.user = self.request.user

View File

@ -10,7 +10,7 @@ from django.db.models import F, Q, Count
from django.urls import reverse from django.urls import reverse
from common.fields import FilterableManyToManyField from common.fields import FilterableManyToManyField
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin
from constants.base import ( from constants.base import (
AUTHOR_ROLE_CHOICES, AUTHOR_ROLE_CHOICES,
AUTHOR_ROLE_DEV, AUTHOR_ROLE_DEV,
@ -106,43 +106,33 @@ class License(CreatedModifiedMixin, models.Model):
class ExtensionManager(models.Manager): class ExtensionManager(models.Manager):
@property
def exclude_deleted(self):
return self.filter(date_deleted__isnull=True)
@property @property
def listed(self): def listed(self):
return self.exclude_deleted.filter( return self.filter(
status=self.model.STATUSES.APPROVED, status=self.model.STATUSES.APPROVED,
is_listed=True, is_listed=True,
) )
@property @property
def unlisted(self): def unlisted(self):
return self.exclude_deleted.exclude(status=self.model.STATUSES.APPROVED) return self.exclude(status=self.model.STATUSES.APPROVED)
def authored_by(self, user_id: int): def authored_by(self, user_id: int):
return self.exclude_deleted.filter( return self.filter(maintainer__user_id=user_id)
maintainer__user_id=user_id, maintainer__date_deleted__isnull=True
)
def listed_or_authored_by(self, user_id: int): def listed_or_authored_by(self, user_id: int):
return self.exclude_deleted.filter( return self.filter(
Q(status=self.model.STATUSES.APPROVED) Q(status=self.model.STATUSES.APPROVED) | Q(maintainer__user_id=user_id)
| Q(maintainer__user_id=user_id, maintainer__date_deleted__isnull=True)
).distinct() ).distinct()
class Extension( class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model):
CreatedModifiedMixin, RatingMixin, TrackChangesMixin, SoftDeleteMixin, models.Model
):
track_changes_to_fields = { track_changes_to_fields = {
'status', 'status',
'name', 'name',
'description', 'description',
'support', 'support',
'website', 'website',
'date_deleted',
} }
TYPES = EXTENSION_TYPE_CHOICES TYPES = EXTENSION_TYPE_CHOICES
STATUSES = EXTENSION_STATUS_CHOICES STATUSES = EXTENSION_STATUS_CHOICES
@ -176,7 +166,6 @@ class Extension(
User, User,
through='Maintainer', through='Maintainer',
related_name='extensions', related_name='extensions',
q_filter=Q(maintainer__date_deleted__isnull=True),
) )
team = models.ForeignKey('teams.Team', null=True, blank=True, on_delete=models.SET_NULL) team = models.ForeignKey('teams.Team', null=True, blank=True, on_delete=models.SET_NULL)
@ -190,8 +179,7 @@ class Extension(
ordering = ['-average_score', '-date_created', 'name'] ordering = ['-average_score', '-date_created', 'name']
def __str__(self): def __str__(self):
label_deleted = f'{self.date_deleted and " (DELETED ❌)" or ""}' return f'{self.get_type_display()} "{self.name}"'
return f'{self.get_type_display()} "{self.name}"{label_deleted}'
@property @property
def type_slug(self) -> str: def type_slug(self) -> str:
@ -238,32 +226,18 @@ class Extension(
@property @property
def cannot_be_deleted_reasons(self) -> List[str]: def cannot_be_deleted_reasons(self) -> List[str]:
"""Return a list of reasons why this extension cannot be fully deleted.""" """Return a list of reasons why this extension cannot be deleted."""
reasons = [] reasons = []
if self.abusereport_set.count() > 0:
reasons.append('has_abuse_reports')
if self.ratings.count() > 0: if self.ratings.count() > 0:
reasons.append('has_ratings') reasons.append('has_ratings')
if self.is_listed: if self.is_listed:
reasons.append('is_listed') reasons.append('is_listed')
for v in self.versions.all():
reasons.extend(v.cannot_be_deleted_reasons)
return reasons return reasons
@transaction.atomic
def delete(self):
hard = self.cannot_be_deleted_reasons == []
versions = self.versions.all()
previews = self.previews.all()
# When soft-deleting, filter out already soft-deleted versions and previews
if not hard:
previews = self.previews.filter(date_deleted__isnull=True)
versions = self.versions.filter(date_deleted__isnull=True)
mode = not hard and 'soft-' or ''
for p in previews:
p.delete(hard=hard)
log.warning('%(mode)sdeleting preview file pk=%s source=%s', mode, p.pk, p.source.name)
for v in versions:
v.delete(hard=hard)
log.warning('%(mode)sdeleting extension pk=%s', mode, self.pk)
super().delete(hard=hard)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('extensions:detail', args=[self.type_slug, self.slug]) return reverse('extensions:detail', args=[self.type_slug, self.slug])
@ -316,7 +290,6 @@ class Extension(
"""Retrieve the latest version.""" """Retrieve the latest version."""
return ( return (
self.versions.filter( self.versions.filter(
date_deleted__isnull=True,
file__status__in=self.valid_file_statuses, file__status__in=self.valid_file_statuses,
file__isnull=False, file__isnull=False,
) )
@ -333,7 +306,7 @@ class Extension(
If the add-on has not been created yet or is deleted, it returns None. If the add-on has not been created yet or is deleted, it returns None.
""" """
if not self.id or self.is_deleted: if not self.id:
return None return None
try: try:
return self.version return self.version
@ -343,14 +316,9 @@ class Extension(
def can_request_review(self): def can_request_review(self):
"""Return whether an add-on can request a review or not.""" """Return whether an add-on can request a review or not."""
if ( if self.is_disabled or self.status in (
self.is_deleted
or self.is_disabled
or self.status
in (
self.STATUSES.APPROVED, self.STATUSES.APPROVED,
self.STATUSES.AWAITING_REVIEW, self.STATUSES.AWAITING_REVIEW,
)
): ):
return False return False
@ -381,9 +349,7 @@ class Extension(
"""Return True if given user is listed as a maintainer.""" """Return True if given user is listed as a maintainer."""
if user is None or user.is_anonymous: if user is None or user.is_anonymous:
return False return False
return self.authors.filter( return self.authors.filter(maintainer__user_id=user.pk).exists()
maintainer__user_id=user.pk, maintainer__date_deleted__isnull=True
).exists()
def can_rate(self, user) -> bool: def can_rate(self, user) -> bool:
"""Return True if given user can rate this extension. """Return True if given user can rate this extension.
@ -451,17 +417,13 @@ class Tag(CreatedModifiedMixin, models.Model):
class VersionManager(models.Manager): class VersionManager(models.Manager):
@property
def exclude_deleted(self):
return self.filter(date_deleted__isnull=True)
@property @property
def listed(self): def listed(self):
return self.exclude_deleted.filter(file__status=FILE_STATUS_CHOICES.APPROVED) return self.filter(file__status=FILE_STATUS_CHOICES.APPROVED)
@property @property
def unlisted(self): def unlisted(self):
return self.exclude_deleted.exclude(file__status=FILE_STATUS_CHOICES.APPROVED) return self.exclude(file__status=FILE_STATUS_CHOICES.APPROVED)
def update_or_create(self, *args, **kwargs): def update_or_create(self, *args, **kwargs):
# Stash the ManyToMany to be created after the Version has a valid ID already # Stash the ManyToMany to be created after the Version has a valid ID already
@ -478,11 +440,10 @@ class VersionManager(models.Manager):
return version, result return version, result
class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, SoftDeleteMixin, models.Model): class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model):
track_changes_to_fields = { track_changes_to_fields = {
'blender_version_min', 'blender_version_min',
'blender_version_max', 'blender_version_max',
'date_deleted',
'permissions', 'permissions',
'version', 'version',
'licenses', 'licenses',
@ -563,21 +524,6 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, SoftDeleteMi
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def delete(self, hard=False):
file = self.file
mode = not hard and 'soft-' or ''
args = {
'file_id': file.pk,
'mode': mode,
'source': file.source.name,
'version_id': self.pk,
'version': self.version,
}
log.warning('%(mode)sdeleting file pk=%(file_id)s source=%(source)s', args)
file.delete(hard=hard)
log.warning('%(mode)sdeleting version pk=%(version_id)s "%(version)s"', args)
super().delete(hard=hard)
def set_initial_permissions(self, _permissions): def set_initial_permissions(self, _permissions):
if not _permissions: if not _permissions:
return return
@ -618,21 +564,22 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, SoftDeleteMi
self.tags.add(tag) self.tags.add(tag)
def __str__(self) -> str: def __str__(self) -> str:
label_deleted = f'{self.date_deleted and " (DELETED ❌)" or ""}' return f'{self.extension} v{self.version}'
return f'{self.extension} v{self.version}{label_deleted}'
@property
def is_listed(self): def is_listed(self):
# To be public, a version must not be deleted, must belong to a public # To be public, version file must have a public status.
# extension, and its attached file must have a public status. return self.file is not None and self.file.status == self.file.STATUSES.APPROVED
try:
return ( @property
not self.is_deleted def cannot_be_deleted_reasons(self) -> List[str]:
and self.extension.is_listed """Return a list of reasons why this version cannot be deleted."""
and self.file is not None reasons = []
and self.file.status == self.file.STATUSES.APPROVED if self.ratings.count() > 0:
) reasons.append('version_has_ratings')
except models.ObjectDoesNotExist: if self.is_listed:
return False reasons.append('version_is_listed')
return reasons
@property @property
def pending_rejection(self): def pending_rejection(self):
@ -692,7 +639,7 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, SoftDeleteMi
) )
class Maintainer(CreatedModifiedMixin, SoftDeleteMixin, models.Model): class Maintainer(CreatedModifiedMixin, models.Model):
extension = models.ForeignKey(Extension, on_delete=models.CASCADE) extension = models.ForeignKey(Extension, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
role = models.SmallIntegerField(default=AUTHOR_ROLE_DEV, choices=AUTHOR_ROLE_CHOICES) role = models.SmallIntegerField(default=AUTHOR_ROLE_DEV, choices=AUTHOR_ROLE_CHOICES)
@ -716,6 +663,10 @@ class Preview(CreatedModifiedMixin, models.Model):
ordering = ('position', 'date_created') ordering = ('position', 'date_created')
unique_together = [['extension', 'file']] unique_together = [['extension', 'file']]
@property
def cannot_be_deleted_reasons(self) -> List[str]:
return []
class ExtensionReviewerFlags(models.Model): class ExtensionReviewerFlags(models.Model):
extension = models.OneToOneField( extension = models.OneToOneField(

View File

@ -1,19 +1,43 @@
from typing import Union from typing import Union
import logging
from django.db.models.signals import pre_save, post_save, post_delete from django.core.exceptions import ValidationError
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete
from django.dispatch import receiver from django.dispatch import receiver
import django.dispatch import django.dispatch
import extensions.models import extensions.models
import files.models import files.models
logger = logging.getLogger(__name__)
version_changed = django.dispatch.Signal() version_changed = django.dispatch.Signal()
version_uploaded = django.dispatch.Signal() version_uploaded = django.dispatch.Signal()
@receiver(pre_delete, sender=extensions.models.Extension)
@receiver(pre_delete, sender=extensions.models.Preview)
@receiver(pre_delete, sender=extensions.models.Version)
def _log_extension_delete(sender: object, instance: object, **kwargs: object) -> None:
cannot_be_deleted_reasons = instance.cannot_be_deleted_reasons
# FIXME: remove this temporary sanity check:
# this would break the assumption that admin deletion is "normal" deletion,
# which we'd like to stick to.
if cannot_be_deleted_reasons != []:
# This shouldn't happen: prior validation steps should have taken care of this.
raise ValidationError({'__all__': cannot_be_deleted_reasons})
logger.info('Deleting %s pk=%s "%s"', sender, instance.pk, str(instance))
@receiver(post_delete, sender=extensions.models.Preview) @receiver(post_delete, sender=extensions.models.Preview)
def _delete_file(sender: object, instance: extensions.models.Preview, **kwargs: object) -> None: @receiver(post_delete, sender=extensions.models.Version)
instance.file.delete() def _delete_file(sender: object, instance: object, **kwargs: object) -> None:
f = instance.file
args = {'f_id': f.pk, 'h': f.hash, 'pk': instance.pk, 'sender': sender, 's': f.source.name}
logger.info('Deleting file pk=%(f_id)s s=%(s)s hash=%(h)s linked to %(sender)s pk=%(pk)s', args)
f.delete()
# TODO: this doesn't mean that the file was deleted from disk
@receiver(pre_save, sender=extensions.models.Extension) @receiver(pre_save, sender=extensions.models.Extension)
@ -46,7 +70,6 @@ def extension_should_be_listed(extension):
return ( return (
extension.latest_version is not None extension.latest_version is not None
and extension.latest_version.is_listed and extension.latest_version.is_listed
and extension.latest_version.date_deleted is None
and extension.status == extension.STATUSES.APPROVED and extension.status == extension.STATUSES.APPROVED
) )
@ -81,6 +104,7 @@ def _set_is_listed(
if extension.status == extensions.models.Extension.STATUSES.APPROVED and not new_is_listed: if extension.status == extensions.models.Extension.STATUSES.APPROVED and not new_is_listed:
extension.status = extensions.models.Extension.STATUSES.INCOMPLETE extension.status = extensions.models.Extension.STATUSES.INCOMPLETE
logger.info('Extension pk=%s becomes listed', extension.pk)
extension.is_listed = new_is_listed extension.is_listed = new_is_listed
extension.save() extension.save()

View File

@ -21,7 +21,7 @@
<div class="row ext-version-history pb-3"> <div class="row ext-version-history pb-3">
<div class="col"> <div class="col">
{% for version in extension.versions.exclude_deleted %} {% for version in extension.versions %}
{% if version.is_listed or is_maintainer %} {% if version.is_listed or is_maintainer %}
<details {% if forloop.counter == 1 %}open{% endif %} id="v{{ version.version|slugify }}"> <details {% if forloop.counter == 1 %}open{% endif %} id="v{{ version.version|slugify }}">
<summary> <summary>

View File

@ -1,4 +1,4 @@
from django.test import TestCase from django.test import TransactionTestCase
from common.tests.factories.extensions import create_approved_version, create_version from common.tests.factories.extensions import create_approved_version, create_version
from common.tests.factories.files import FileFactory from common.tests.factories.files import FileFactory
@ -7,37 +7,12 @@ import extensions.models
import files.models import files.models
class DeleteTest(TestCase): class DeleteTest(TransactionTestCase):
fixtures = ['dev', 'licenses'] fixtures = ['dev', 'licenses']
def test_happy_path(self): def test_unlisted_unrated_extension_can_be_deleted_by_author(self):
extension = create_approved_version().extension
url = extension.get_delete_url()
user = extension.authors.first()
self.client.force_login(user)
response = self.client.post(url)
self.assertEqual(response.status_code, 302)
extension.refresh_from_db()
self.assertIsNotNone(extension.date_deleted)
self.assertTrue(all(v.date_deleted is not None for v in extension.versions.all()))
def test_random_user_cant_delete(self):
extension = create_approved_version().extension
url = extension.get_delete_url()
user = UserFactory()
self.client.force_login(user)
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
extension.refresh_from_db()
self.assertIsNone(extension.date_deleted)
self.assertTrue(all(v.date_deleted is None for v in extension.versions.all()))
def test_incomplete_unrated_extension_can_be_fully_deleted_by_author(self):
version = create_version( version = create_version(
file__status=files.models.File.STATUSES.AWAITING_REVIEW,
ratings=[], ratings=[],
extension__previews=[ extension__previews=[
FileFactory( FileFactory(
@ -48,7 +23,10 @@ class DeleteTest(TestCase):
) )
extension = version.extension extension = version.extension
version_file = version.file version_file = version.file
self.assertEqual(extension.status, extension.STATUSES.INCOMPLETE) self.assertEqual(version_file.get_status_display(), 'Awaiting Review')
self.assertEqual(extension.get_status_display(), 'Incomplete')
self.assertFalse(extension.is_listed)
self.assertEqual(extension.cannot_be_deleted_reasons, [])
preview_file = extension.previews.first() preview_file = extension.previews.first()
self.assertIsNotNone(preview_file) self.assertIsNotNone(preview_file)
@ -71,3 +49,66 @@ class DeleteTest(TestCase):
self.assertIsNone(extensions.models.Version.objects.filter(pk=version.pk).first()) self.assertIsNone(extensions.models.Version.objects.filter(pk=version.pk).first())
self.assertIsNone(files.models.File.objects.filter(pk=version_file.pk).first()) self.assertIsNone(files.models.File.objects.filter(pk=version_file.pk).first())
self.assertIsNone(files.models.File.objects.filter(pk=preview_file.pk).first()) self.assertIsNone(files.models.File.objects.filter(pk=preview_file.pk).first())
# TODO: check that files were deleted from storage (create a temp one prior to the check)
def test_publicly_listed_extension_cannot_be_deleted(self):
version = create_approved_version(ratings=[])
self.assertTrue(version.is_listed)
extension = version.extension
self.assertTrue(extension.is_listed)
self.assertEqual(extension.get_status_display(), 'Approved')
self.assertEqual(version.cannot_be_deleted_reasons, ['version_is_listed'])
self.assertEqual(extension.cannot_be_deleted_reasons, ['is_listed', 'version_is_listed'])
url = extension.get_delete_url()
user = extension.authors.first()
self.client.force_login(user)
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
def test_rated_extension_cannot_be_deleted(self):
version = create_version(file__status=files.models.File.STATUSES.AWAITING_REVIEW)
self.assertFalse(version.is_listed)
extension = version.extension
self.assertFalse(extension.is_listed)
self.assertEqual(extension.get_status_display(), 'Incomplete')
self.assertEqual(version.cannot_be_deleted_reasons, ['version_has_ratings'])
self.assertEqual(
extension.cannot_be_deleted_reasons, ['has_ratings', 'version_has_ratings']
)
url = extension.get_delete_url()
user = extension.authors.first()
self.client.force_login(user)
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
def test_reported_extension_cannot_be_deleted(self): # TODO
pass
def test_extension_with_ratings_cannot_be_deleted(self):
version = create_approved_version()
extension = version.extension
self.assertEqual(extension.status, extension.STATUSES.APPROVED)
url = extension.get_delete_url()
user = extension.authors.first()
self.client.force_login(user)
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
def test_random_user_cant_delete(self):
extension = create_approved_version().extension
url = extension.get_delete_url()
user = UserFactory()
self.client.force_login(user)
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
extension.refresh_from_db()

View File

@ -47,7 +47,6 @@ class ExtensionTest(TestCase):
'name': 'Extension name', 'name': 'Extension name',
'status': 1, 'status': 1,
'support': 'https://example.com/', 'support': 'https://example.com/',
'date_deleted': None,
}, },
} }
}, },

View File

@ -98,27 +98,6 @@ class ExtensionDetailViewTest(_BaseTestCase):
self._check_detail_page(response, extension) self._check_detail_page(response, extension)
def test_cannot_view_deleted_extension_anonymously(self):
extension = _create_extension()
extension.delete()
self.assertTrue(extension.is_deleted)
url = extension.get_absolute_url()
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_can_view_deleted_extension_if_staff(self):
staff_user = UserFactory(is_staff=True)
extension = _create_extension()
extension.delete()
self.assertTrue(extension.is_deleted)
self.client.force_login(staff_user)
response = self.client.get(extension.get_absolute_url())
self._check_detail_page(response, extension)
def test_can_view_unlisted_extension_if_maintaner(self): def test_can_view_unlisted_extension_if_maintaner(self):
extension = _create_extension() extension = _create_extension()
@ -210,12 +189,6 @@ class ListedExtensionsTest(_BaseTestCase):
self.extension.save() self.extension.save()
self.assertEqual(self._listed_extensions_count(), 0) self.assertEqual(self._listed_extensions_count(), 0)
def test_soft_delete_only_version(self):
self.version.date_deleted = '1994-01-02 0:0:0+00:00'
self.version.save()
self.assertFalse(self.extension.is_listed)
self.assertEqual(self._listed_extensions_count(), 0)
def test_delete_only_version(self): def test_delete_only_version(self):
self.version.delete() self.version.delete()
self.assertEqual(self._listed_extensions_count(), 0) self.assertEqual(self._listed_extensions_count(), 0)

View File

@ -179,8 +179,15 @@ class DeleteExtensionView(
return context return context
def test_func(self) -> bool: def test_func(self) -> bool:
obj = self.get_object()
# Only maintainers allowed # Only maintainers allowed
return self.get_object().has_maintainer(self.request.user) if not obj.has_maintainer(self.request.user):
return False
# Unless this extension cannot be deleted anymore
cannot_be_deleted_reasons = obj.cannot_be_deleted_reasons
if cannot_be_deleted_reasons != []:
return False
return True
class VersionDeleteView( class VersionDeleteView(

View File

@ -51,9 +51,7 @@ class ExtensionMixin:
"""Fetch an extension by slug in the URL before dispatching the view.""" """Fetch an extension by slug in the URL before dispatching the view."""
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
self.extension = get_object_or_404( self.extension = get_object_or_404(Extension, slug=self.kwargs['slug'])
Extension.objects.exclude_deleted, slug=self.kwargs['slug']
)
return super().dispatch(*args, **kwargs) return super().dispatch(*args, **kwargs)

View File

@ -2,7 +2,6 @@ import logging
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db.models import Q from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.views.generic.list import ListView from django.views.generic.list import ListView
@ -54,8 +53,6 @@ class HomeView(ListedExtensionsView):
def extension_version_download(request, type_slug, slug, version): def extension_version_download(request, type_slug, slug, version):
"""Download an extension version and count downloads.""" """Download an extension version and count downloads."""
extension_version = get_object_or_404(Version, extension__slug=slug, version=version) extension_version = get_object_or_404(Version, extension__slug=slug, version=version)
if extension_version.date_deleted is not None:
raise Http404("This extension version has been deleted")
ExtensionDownload.create_from_request(request, object_id=extension_version.extension_id) ExtensionDownload.create_from_request(request, object_id=extension_version.extension_id)
VersionDownload.create_from_request(request, object_id=extension_version.pk) VersionDownload.create_from_request(request, object_id=extension_version.pk)
return redirect(extension_version.downloadable_signed_url) return redirect(extension_version.downloadable_signed_url)

View File

@ -36,7 +36,6 @@ class FileAdmin(admin.ModelAdmin):
'status', 'status',
'date_status_changed', 'date_status_changed',
'date_approved', 'date_approved',
'date_deleted',
) )
list_display = ('original_name', 'extension', 'user', 'date_created', 'type', 'status', 'is_ok') list_display = ('original_name', 'extension', 'user', 'date_created', 'type', 'status', 'is_ok')
@ -45,7 +44,6 @@ class FileAdmin(admin.ModelAdmin):
readonly_fields = ( readonly_fields = (
'id', 'id',
'date_created', 'date_created',
'date_deleted',
'date_modified', 'date_modified',
'date_approved', 'date_approved',
'date_status_changed', 'date_status_changed',
@ -84,7 +82,6 @@ class FileAdmin(admin.ModelAdmin):
'date_modified', 'date_modified',
'date_status_changed', 'date_status_changed',
'date_approved', 'date_approved',
'date_deleted',
) )
}, },
), ),

View File

@ -5,7 +5,7 @@ import logging
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin
from files.utils import get_sha256, guess_mimetype_from_ext from files.utils import get_sha256, guess_mimetype_from_ext
from constants.base import ( from constants.base import (
FILE_STATUS_CHOICES, FILE_STATUS_CHOICES,
@ -48,8 +48,8 @@ def thumbnail_upload_to(instance, filename):
return path return path
class File(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Model): class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
track_changes_to_fields = {'status', 'size_bytes', 'hash', 'date_deleted'} track_changes_to_fields = {'status', 'size_bytes', 'hash'}
TYPES = FILE_TYPE_CHOICES TYPES = FILE_TYPE_CHOICES
STATUSES = FILE_STATUS_CHOICES STATUSES = FILE_STATUS_CHOICES
@ -103,8 +103,7 @@ class File(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Mode
objects = FileManager() objects = FileManager()
def __str__(self) -> str: def __str__(self) -> str:
label_deleted = f'{self.date_deleted and " (DELETED ❌)" or ""}' return f'{self.original_name} ({self.get_status_display()})'
return f'{self.original_name} ({self.get_status_display()}){label_deleted}'
@property @property
def has_been_validated(self): def has_been_validated(self):

View File

@ -1,6 +1,6 @@
import logging import logging
from django.db.models.signals import pre_save, post_save from django.db.models.signals import pre_save, post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
import files.models import files.models
@ -30,3 +30,8 @@ def _scan_new_file(
return return
schedule_scan(instance) schedule_scan(instance)
@receiver(pre_delete, sender=files.models.File)
def _log_file_delete(sender: object, instance: files.models.File, **kwargs: object) -> None:
logger.info('Deleting file pk=%s source=%s', instance.pk, instance.source.name)

View File

@ -45,7 +45,6 @@ class FileTest(TestCase):
'status': 2, 'status': 2,
'hash': 'foobar', 'hash': 'foobar',
'size_bytes': 7149, 'size_bytes': 7149,
'date_deleted': None,
}, },
} }
}, },

View File

@ -16,12 +16,12 @@ class RatingTypeFilter(admin.SimpleListFilter):
parameter_name = 'type' parameter_name = 'type'
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
""" """Return a list of lookup option tuples.
Returns a list of tuples. The first element in each
tuple is the coded value for the option that will The first element in each tuple is the coded value
appear in the URL query. The second element is the for the option that will appear in the URL query.
human-readable name for the option that will appear The second element is the human-readable name for
in the right sidebar. the option that will appear in the right sidebar.
""" """
return ( return (
('rating', 'User Rating'), ('rating', 'User Rating'),
@ -29,10 +29,10 @@ class RatingTypeFilter(admin.SimpleListFilter):
) )
def queryset(self, request, queryset): def queryset(self, request, queryset):
""" """Return the filtered queryset.
Returns the filtered queryset based on the value
provided in the query string and retrievable via Filter based on the value provided in the query string
`self.value()`. and retrievable via `self.value()`.
""" """
if self.value() == 'rating': if self.value() == 'rating':
return queryset.filter(reply_to__isnull=True) return queryset.filter(reply_to__isnull=True)
@ -53,7 +53,6 @@ class RatingAdmin(admin.ModelAdmin):
readonly_fields = ( readonly_fields = (
'date_created', 'date_created',
'date_modified', 'date_modified',
'date_deleted',
'extension', 'extension',
'version', 'version',
'text', 'text',
@ -63,7 +62,6 @@ class RatingAdmin(admin.ModelAdmin):
fields = ('status',) + readonly_fields fields = ('status',) + readonly_fields
list_display = ( list_display = (
'date_created', 'date_created',
'is_deleted',
'user', 'user',
'ip_address', 'ip_address',
'score', 'score',
@ -71,7 +69,7 @@ class RatingAdmin(admin.ModelAdmin):
'status', 'status',
'truncated_text', 'truncated_text',
) )
list_filter = ('status', RatingTypeFilter, 'score', 'date_deleted') list_filter = ('status', RatingTypeFilter, 'score')
actions = ('delete_selected',) actions = ('delete_selected',)
list_select_related = ('user',) # For extension/reply_to see get_queryset() list_select_related = ('user',) # For extension/reply_to see get_queryset()
@ -94,11 +92,5 @@ class RatingAdmin(admin.ModelAdmin):
is_reply.boolean = True is_reply.boolean = True
is_reply.admin_order_field = 'reply_to' is_reply.admin_order_field = 'reply_to'
def is_deleted(self, obj):
return bool(obj.date_deleted)
is_deleted.boolean = True
is_deleted.admin_order_field = 'date_deleted'
admin.site.register(Rating, RatingAdmin) admin.site.register(Rating, RatingAdmin)

View File

@ -1,12 +1,12 @@
import logging import logging
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models, transaction from django.db import models
from django.template.defaultfilters import truncatechars from django.template.defaultfilters import truncatechars
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.urls import reverse from django.urls import reverse
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin
from common.templatetags import common from common.templatetags import common
from constants.base import RATING_STATUS_CHOICES, RATING_SCORE_CHOICES from constants.base import RATING_STATUS_CHOICES, RATING_SCORE_CHOICES
from utils import send_mail from utils import send_mail
@ -17,28 +17,20 @@ log = logging.getLogger(__name__)
class RatingManager(models.Manager): class RatingManager(models.Manager):
# TODO: figure out how to retrieve reviews "annotated" with replies, if any # TODO: figure out how to retrieve reviews "annotated" with replies, if any
@property
def exclude_deleted(self):
return self.filter(date_deleted__isnull=True)
@property @property
def listed(self): def listed(self):
return self.exclude_deleted.filter( return self.filter(status=self.model.STATUSES.APPROVED, reply_to__isnull=True)
status=self.model.STATUSES.APPROVED, reply_to__isnull=True
)
@property @property
def unlisted(self): def unlisted(self):
return self.exclude_deleted.exclude( return self.exclude(status=self.models.STATUSES.APPROVED, reply_to__isnull=True)
status=self.models.STATUSES.APPROVED, reply_to__isnull=True
)
@property @property
def listed_texts(self): def listed_texts(self):
return self.listed.filter(text__isnull=False) return self.listed.filter(text__isnull=False)
class Rating(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Model): class Rating(CreatedModifiedMixin, TrackChangesMixin, models.Model):
track_changes_to_fields = {'status'} track_changes_to_fields = {'status'}
STATUSES = RATING_STATUS_CHOICES STATUSES = RATING_STATUS_CHOICES
@ -107,7 +99,7 @@ class Rating(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Mo
@classmethod @classmethod
def get_for(cls, user_id: int, extension_id: int): def get_for(cls, user_id: int, extension_id: int):
"""Get rating left by a given user for a given extension.""" """Get rating left by a given user for a given extension."""
return cls.objects.exclude_deleted.filter( return cls.objects.filter(
reply_to=None, reply_to=None,
user_id=user_id, user_id=user_id,
extension_id=extension_id, extension_id=extension_id,
@ -141,25 +133,6 @@ class Rating(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Mo
# user_responsible=user. # user_responsible=user.
self.save() self.save()
@transaction.atomic
def delete(self, user_responsible=None, send_post_save_signal=True):
if user_responsible is None:
user_responsible = self.user
for flag in self.ratingflag_set.all():
flag.delete()
SoftDeleteMixin.delete(self)
log.warning(
'Rating deleted: user pk=%s (%s) deleted id:%s by pk=%s (%s) ("%s")',
user_responsible.pk,
str(user_responsible),
self.pk,
self.user.pk,
str(self.user),
str(self.text),
)
@classmethod @classmethod
def get_replies(cls, ratings): def get_replies(cls, ratings):
ratings = [r.id for r in ratings] ratings = [r.id for r in ratings]

View File

@ -29,7 +29,6 @@ class RatingsViewTest(TestCase):
), ),
RatingFactory( RatingFactory(
version=version, version=version,
date_deleted='2022-01-01 01:01:01Z',
text='this rating is deleted', text='this rating is deleted',
status=Rating.STATUSES.APPROVED, status=Rating.STATUSES.APPROVED,
), ),

View File

@ -18,9 +18,7 @@ class CommentsViewTest(TestCase):
self.assertEqual(len(r.context['object_list']), 1) self.assertEqual(len(r.context['object_list']), 1)
# Deleted extensions don't show up in the approval queue # Deleted extensions don't show up in the approval queue
self.assertIsNone(self.default_version.extension.date_deleted)
self.default_version.extension.delete() self.default_version.extension.delete()
self.assertIsNotNone(self.default_version.extension.date_deleted)
r = self.client.get(reverse('reviewers:approval-queue')) r = self.client.get(reverse('reviewers:approval-queue'))
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertEqual(len(r.context['object_list']), 0) self.assertEqual(len(r.context['object_list']), 0)

View File

@ -18,10 +18,8 @@ class ApprovalQueueView(ListView):
paginate_by = 100 paginate_by = 100
def get_queryset(self): def get_queryset(self):
return ( return Extension.objects.exclude(status=Extension.STATUSES.APPROVED).order_by(
Extension.objects.exclude_deleted '-date_created'
.exclude(status=Extension.STATUSES.APPROVED)
.order_by('-date_created')
) )
template_name = 'reviewers/extensions_review_list.html' template_name = 'reviewers/extensions_review_list.html'