Anna Sirota
caae613747
* 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
239 lines
8.3 KiB
Python
239 lines
8.3 KiB
Python
import logging
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.db import models
|
|
from django.template.defaultfilters import truncatechars
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.urls import reverse
|
|
|
|
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin
|
|
from common.templatetags import common
|
|
from constants.base import RATING_STATUS_CHOICES, RATING_SCORE_CHOICES
|
|
from utils import send_mail
|
|
|
|
User = get_user_model()
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class RatingManager(models.Manager):
|
|
# TODO: figure out how to retrieve reviews "annotated" with replies, if any
|
|
@property
|
|
def listed(self):
|
|
return self.filter(status=self.model.STATUSES.APPROVED, reply_to__isnull=True)
|
|
|
|
@property
|
|
def unlisted(self):
|
|
return self.exclude(status=self.models.STATUSES.APPROVED, reply_to__isnull=True)
|
|
|
|
@property
|
|
def listed_texts(self):
|
|
return self.listed.filter(text__isnull=False)
|
|
|
|
|
|
class Rating(CreatedModifiedMixin, TrackChangesMixin, models.Model):
|
|
track_changes_to_fields = {'status'}
|
|
|
|
STATUSES = RATING_STATUS_CHOICES
|
|
SCORES = RATING_SCORE_CHOICES
|
|
|
|
extension = models.ForeignKey(
|
|
'extensions.Extension', related_name='ratings', on_delete=models.CASCADE
|
|
)
|
|
version = models.ForeignKey(
|
|
'extensions.Version', related_name='ratings', on_delete=models.CASCADE
|
|
)
|
|
user = models.ForeignKey(User, related_name='ratings', on_delete=models.CASCADE)
|
|
reply_to = models.OneToOneField(
|
|
'self',
|
|
null=True,
|
|
related_name='reply',
|
|
on_delete=models.CASCADE,
|
|
)
|
|
|
|
score = models.PositiveSmallIntegerField(null=True, choices=SCORES)
|
|
text = models.TextField(null=True)
|
|
ip_address = models.GenericIPAddressField(protocol='both', null=True)
|
|
|
|
status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.APPROVED)
|
|
|
|
# Denormalized fields for easy lookup queries.
|
|
is_latest = models.BooleanField(
|
|
default=True,
|
|
editable=False,
|
|
help_text="Is this the user's latest rating for the add-on?",
|
|
)
|
|
previous_count = models.PositiveIntegerField(
|
|
default=0,
|
|
editable=False,
|
|
help_text='How many previous ratings by the user for this add-on?',
|
|
)
|
|
|
|
objects = RatingManager()
|
|
|
|
class Meta:
|
|
ordering = ('-date_created',)
|
|
indexes = [
|
|
models.Index(fields=('version',), name='rating_version_id'),
|
|
models.Index(fields=('user',), name='rating_user_idx'),
|
|
models.Index(
|
|
fields=('reply_to', 'is_latest', 'date_created'),
|
|
name='rating_latest_idx',
|
|
),
|
|
models.Index(fields=('ip_address',), name='rating_ip_address_idx'),
|
|
]
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=('version', 'user', 'reply_to'), name='rating_one_review_per_user_key'
|
|
),
|
|
]
|
|
|
|
def __str__(self):
|
|
return truncatechars(str(self.text), 10)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
user_responsible = kwargs.pop('user_responsible', None)
|
|
super().__init__(*args, **kwargs)
|
|
if user_responsible is not None:
|
|
self.user_responsible = user_responsible
|
|
|
|
@classmethod
|
|
def get_for(cls, user_id: int, extension_id: int):
|
|
"""Get rating left by a given user for a given extension."""
|
|
return cls.objects.filter(
|
|
reply_to=None,
|
|
user_id=user_id,
|
|
extension_id=extension_id,
|
|
).first()
|
|
|
|
@property
|
|
def is_listed(self) -> bool:
|
|
return self.status == self.STATUSES.APPROVED
|
|
|
|
@property
|
|
def user_responsible(self):
|
|
"""Return user responsible for the current changes being made on this
|
|
model. Only set by the views when they are about to save a Review
|
|
instance, to track if the original author or an admin was responsible
|
|
for the change.
|
|
|
|
Having this as a @property with a setter makes update_or_create() work,
|
|
otherwise it rejects the property, causing an error.
|
|
"""
|
|
return self._user_responsible
|
|
|
|
@user_responsible.setter
|
|
def user_responsible(self, value):
|
|
self._user_responsible = value
|
|
|
|
def approve(self, user):
|
|
for flag in self.ratingflag_set.all():
|
|
flag.delete()
|
|
self.status = self.STATUSES.APPROVED
|
|
# We've already logged what we want to log, no need to pass
|
|
# user_responsible=user.
|
|
self.save()
|
|
|
|
@classmethod
|
|
def get_replies(cls, ratings):
|
|
ratings = [r.id for r in ratings]
|
|
qs = Rating.objects.filter(reply_to__in=ratings)
|
|
return {r.reply_to_id: r for r in qs}
|
|
|
|
def send_notification_email(self):
|
|
if self.reply_to:
|
|
# It's a reply.
|
|
reply_url = common.url(
|
|
'extensions.ratings.detail',
|
|
self.extension.slug,
|
|
self.reply_to.pk,
|
|
add_prefix=False,
|
|
)
|
|
data = {
|
|
'name': self.extension.name,
|
|
'reply': self.text,
|
|
'rating_url': common.absolutify(reply_url),
|
|
}
|
|
recipients = [self.reply_to.user.email]
|
|
subject = 'Blender Extensions: Developer Reply: %s' % self.extension.name
|
|
template = 'ratings/emails/reply_review.ltxt'
|
|
perm_setting = 'reply'
|
|
else:
|
|
# It's a new rating.
|
|
rating_url = common.url(
|
|
'extensions.ratings.detail', self.extension.slug, self.pk, add_prefix=False
|
|
)
|
|
data = {
|
|
'name': self.extension.name,
|
|
'rating': self,
|
|
'rating_url': common.absolutify(rating_url),
|
|
}
|
|
recipients = [author.email for author in self.extension.authors.all()]
|
|
subject = 'Blender Extensions: New User Rating: %s' % self.extension.name
|
|
template = 'ratings/emails/new_rating.txt'
|
|
perm_setting = 'new_review'
|
|
send_mail(
|
|
subject,
|
|
template,
|
|
data,
|
|
recipient_list=recipients,
|
|
perm_setting=perm_setting,
|
|
)
|
|
|
|
def post_save(sender, instance, created, **kwargs):
|
|
if kwargs.get('raw'):
|
|
return
|
|
|
|
if getattr(instance, 'user_responsible', None):
|
|
# user_responsible is not a field on the model, so it's not
|
|
# persistent: it's just something the views will set temporarily
|
|
# when manipulating a Rating that indicates a real user made that
|
|
# change.
|
|
action = 'New' if created else 'Edited'
|
|
if instance.reply_to:
|
|
log.info(f'{action} reply to {instance.reply_to_id}: {instance.pk}')
|
|
else:
|
|
log.info(f'{action} rating: {instance.pk}')
|
|
|
|
# For new ratings and new replies we want to send an email.
|
|
if created:
|
|
instance.send_notification_email()
|
|
|
|
def get_report_url(self):
|
|
return reverse(
|
|
'abuse:report-ratings',
|
|
args=[
|
|
self.extension.type_slug,
|
|
self.extension.slug,
|
|
self.version.version,
|
|
self.id,
|
|
],
|
|
)
|
|
|
|
|
|
class RatingFlag(CreatedModifiedMixin, models.Model):
|
|
SPAM = 'review_flag_reason_spam'
|
|
LANGUAGE = 'review_flag_reason_language'
|
|
SUPPORT = 'review_flag_reason_bug_support'
|
|
OTHER = 'review_flag_reason_other'
|
|
FLAGS = (
|
|
(SPAM, _('Spam or otherwise non-review content')),
|
|
(LANGUAGE, _('Inappropriate language/dialog')),
|
|
(SUPPORT, _('Misplaced bug report or support request')),
|
|
(OTHER, _('Other (please specify)')),
|
|
)
|
|
|
|
rating = models.ForeignKey(Rating, on_delete=models.CASCADE)
|
|
user = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
|
|
flag = models.CharField(max_length=64, default=OTHER, choices=FLAGS)
|
|
note = models.CharField(max_length=100, blank=True, default='')
|
|
|
|
class Meta:
|
|
indexes = [
|
|
models.Index(fields=('user',), name='ratingflag_user_idx'),
|
|
models.Index(fields=('rating',), name='ratingflag_rating_idx'),
|
|
models.Index(fields=('date_modified',), name='ratingflag_date_modified_idx'),
|
|
]
|
|
constraints = [
|
|
models.UniqueConstraint(fields=('rating', 'user'), name='ratingflag_review_user_key')
|
|
]
|