extensions-website/ratings/models.py
Dalai Felinto 713163df61 Report a Review option
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.
2024-03-01 16:24:44 +01:00

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