Make it possible to fully delete unlisted/unrated extensions #81
@ -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(
|
||||||
|
@ -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 = {
|
||||||
|
@ -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)
|
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
|
@ -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
|
self.STATUSES.APPROVED,
|
||||||
or self.is_disabled
|
self.STATUSES.AWAITING_REVIEW,
|
||||||
or self.status
|
|
||||||
in (
|
|
||||||
self.STATUSES.APPROVED,
|
|
||||||
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(
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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()
|
||||||
|
@ -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,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -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)
|
||||||
|
@ -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(
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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',
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
@ -45,7 +45,6 @@ class FileTest(TestCase):
|
|||||||
'status': 2,
|
'status': 2,
|
||||||
'hash': 'foobar',
|
'hash': 'foobar',
|
||||||
'size_bytes': 7149,
|
'size_bytes': 7149,
|
||||||
'date_deleted': None,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -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)
|
||||||
|
@ -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]
|
||||||
|
@ -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,
|
||||||
),
|
),
|
||||||
|
@ -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)
|
||||||
|
@ -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'
|
||||||
|
Loading…
Reference in New Issue
Block a user