Web   ·   Wiki   ·   Activities   ·   Blog   ·   Lists   ·   Chat   ·   Meeting   ·   Bugs   ·   Git   ·   Translate   ·   Archive   ·   People   ·   Donate
summaryrefslogtreecommitdiffstats
path: root/apps/reviews/models.py
blob: 3a9b4a4d46408b10f3b8ef389ee432383fc46090 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
from datetime import datetime, timedelta
import itertools

from django.conf import settings
from django.core.cache import cache
from django.db import models
from django.utils import translation

import bleach
from celery.decorators import task
from tower import ugettext_lazy as _

import amo.models
from amo.urlresolvers import reverse
from translations.fields import TranslatedField
from translations.models import Translation
from users.models import UserProfile
from versions.models import Version


class ReviewManager(amo.models.ManagerBase):

    def get_query_set(self):
        qs = super(ReviewManager, self).get_query_set()
        return qs.transform(Review.transformer)

    def valid(self):
        """Get all reviews with rating > 0 that aren't replies."""
        return self.filter(reply_to=None, rating__gt=0)

    def latest(self):
        """Get all the latest valid reviews."""
        return self.valid().filter(is_latest=True)


class Review(amo.models.ModelBase):
    addon = models.ForeignKey('addons.Addon', related_name='_reviews')
    version = models.ForeignKey('versions.Version', related_name='reviews',
                                null=True)
    user = models.ForeignKey('users.UserProfile', related_name='_reviews_all')
    reply_to = models.ForeignKey('self', null=True, unique=True,
                                 related_name='replies', db_column='reply_to')

    rating = models.PositiveSmallIntegerField(null=True)
    title = TranslatedField()
    body = TranslatedField()
    ip_address = models.CharField(max_length=255, default='0.0.0.0')

    editorreview = models.BooleanField(default=False)
    flag = models.BooleanField(default=False)
    sandbox = models.BooleanField(default=False)

    # Denormalized fields for easy lookup queries.
    # TODO: index on addon, user, latest
    is_latest = models.BooleanField(default=True, editable=False,
        help_text="Is this the user's latest review for the add-on?")
    previous_count = models.PositiveIntegerField(default=0, editable=False,
        help_text="How many previous reviews by the user for this add-on?")

    objects = ReviewManager()

    class Meta:
        db_table = 'reviews'
        ordering = ('-created',)

    def get_url_path(self):
        return reverse('reviews.detail', args=[self.addon_id, self.id])

    def flush_urls(self):
        urls = ['*/addon/%d/' % self.addon_id,
                '*/addon/%d/reviews/' % self.addon_id,
                '*/addon/%d/reviews/format:rss' % self.addon_id,
                '*/addon/%d/reviews/%d/' % (self.addon_id, self.id),
                '*/user/%d/' % self.user_id, ]
        return urls

    @classmethod
    def fetch_translations(cls, ids, lang):
        if not ids:
            return []

        rv = {}
        ts = Translation.objects.filter(id__in=ids)

        # If a translation exists for the current language, use it.  Otherwise,
        # make do with whatever is available.  (Reviewers only write reviews in
        # their language).
        for id, translations in itertools.groupby(ts, lambda t: t.id):
            locales = dict((t.locale, t) for t in translations)
            if lang in locales:
                rv[id] = locales[lang]
            else:
                rv[id] = locales.itervalues().next()

        return rv.values()

    @classmethod
    def get_replies(cls, reviews):
        reviews = [r.id for r in reviews]
        qs = Review.objects.filter(reply_to__in=reviews)
        return dict((r.reply_to_id, r) for r in qs)

    @staticmethod
    def post_save(sender, instance, created, **kwargs):
        if created:
            Review.post_delete(sender, instance)
            # Avoid slave lag with the delay.
            check_spam.apply_async(args=[instance.id], countdown=60)

    @staticmethod
    def post_delete(sender, instance, **kwargs):
        from . import tasks
        pair = instance.addon_id, instance.user_id
        # Do this immediately so is_latest is correct. Use default to avoid
        # slave lag.
        tasks.update_denorm(pair, using='default')
        tasks.addon_review_aggregates.delay(instance.addon_id, using='default')

    @staticmethod
    def transformer(reviews):
        if not reviews:
            return

        # Attach users.
        user_ids = [r.user_id for r in reviews]
        users = UserProfile.objects.filter(id__in=user_ids)
        user_dict = dict((u.id, u) for u in users)
        for review in reviews:
            review.user = user_dict[review.user_id]

        # Attach translations. Some of these will be picked up by the
        # Translation transformer, but reviews have special requirements
        # (see fetch_translations).
        names = dict((f.attname, f.name)
                     for f in Review._meta.translated_fields)
        ids, trans = {}, {}
        for review in reviews:
            for attname, name in names.items():
                trans_id = getattr(review, attname)
                if getattr(review, name) is None and trans_id is not None:
                    ids[trans_id] = attname
                    trans[trans_id] = review
        translations = Review.fetch_translations(trans.keys(),
                                                 translation.get_language())
        for t in translations:
            setattr(trans[t.id], names[ids[t.id]], t)

        # Attach versions.
        versions = dict((r.version_id, r) for r in reviews)
        for version in Version.objects.filter(id__in=versions.keys()):
            versions[version.id].version = version


models.signals.post_save.connect(Review.post_save, sender=Review)
models.signals.post_delete.connect(Review.post_delete, sender=Review)


# TODO: translate old flags.
class ReviewFlag(amo.models.ModelBase):
    FLAGS = (
        ('spam', _('Spam or otherwise non-review content')),
        ('language', _('Inappropriate language/dialog')),
        ('bug_support', _('Misplaced bug report or support request')),
        ('review_flag_reason_other', _('Other (please specify)')),
    )

    review = models.ForeignKey(Review)
    user = models.ForeignKey('users.UserProfile')
    flag = models.CharField(max_length=64, default='review_flag_reason_other',
                            choices=FLAGS, db_column='flag_name')
    note = models.CharField(max_length=100, db_column='flag_notes', blank=True,
                           default='')

    class Meta:
        db_table = 'reviews_moderation_flags'
        unique_together = (('review', 'user'),)

    def flush_urls(self):
        return self.review.flush_urls()


class GroupedRating(object):
    """
    Group an add-on's ratings so we can have a graph of rating counts.

    SELECT rating, COUNT(rating) FROM reviews where addon=:id
    """
    # Non-critical data, so we always leave it in memcached.  Updated through
    # cron daily, so we cache for two days.

    @classmethod
    def key(cls, addon):
        return '%s:%s:%s' % (settings.CACHE_PREFIX, cls.__name__, addon)

    @classmethod
    def get(cls, addon):
        return cache.get(cls.key(addon))

    @classmethod
    def set(cls, addon, using=None):
        q = (Review.objects.latest().filter(addon=addon).using(using)
             .values_list('rating').annotate(models.Count('rating')))
        counts = dict(q)
        ratings = [(rating, counts.get(rating, 0)) for rating in range(1, 6)]
        two_days = 60 * 60 * 24 * 2
        cache.set(cls.key(addon), ratings, two_days)


class Spam(object):

    def __init__(self):
        from caching.invalidation import get_redis_backend
        self.redis = get_redis_backend()

    def add(self, review, reason):
        reason = 'amo:review:spam:%s' % reason
        self.redis.sadd(reason, review.id)
        self.redis.sadd('amo:review:spam:reasons', reason)

    def reasons(self):
        return self.redis.smembers('amo:review:spam:reasons')


@task
def check_spam(review_id):
    spam = Spam()
    review = Review.objects.using('default').get(id=review_id)
    thirty_days = datetime.now() - timedelta(days=30)
    others = (Review.objects.no_cache().exclude(id=review.id)
              .filter(user=review.user, created__gte=thirty_days))
    if len(others) > 10:
        spam.add(review, 'numbers')
    if bleach.url_re.search(review.body.localized_string):
        spam.add(review, 'urls')
    for other in others:
        if ((review.title and review.title == other.title)
            or review.body == other.body):
            spam.add(review, 'matches')
            break