Dalai Felinto
713163df61
Note: * Right now I kept the same Reasons and Versions field we use when reporting extensions. * Also I still included the Blender version. While the Blender Version is relevant (I think), we may want a different set of reasons for this. Also, in theory the system was made so users could be reported as well. They are not at the moment. Possible future improvements: * System to report users as well. * Menu entry Report Queue for moderators. * Custom Reason set for reviews.
266 lines
9.1 KiB
Python
266 lines
9.1 KiB
Python
import logging
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.db import models, transaction
|
|
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, SoftDeleteMixin
|
|
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 exclude_deleted(self):
|
|
return self.filter(date_deleted__isnull=True)
|
|
|
|
@property
|
|
def listed(self):
|
|
return self.exclude_deleted.filter(
|
|
status=self.model.STATUSES.APPROVED, reply_to__isnull=True
|
|
)
|
|
|
|
@property
|
|
def unlisted(self):
|
|
return self.exclude_deleted.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, SoftDeleteMixin, 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.exclude_deleted.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()
|
|
|
|
@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
|
|
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')
|
|
]
|