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

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

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

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')
]