Add API to post an add-on review

This commit is contained in:
Mathieu Pillard 2016-09-02 13:29:32 +02:00
Родитель 8a0618495d
Коммит 6fa70318ea
17 изменённых файлов: 554 добавлений и 43 удалений

Просмотреть файл

@ -60,3 +60,18 @@ This endpoint allows you to fetch a review by its id.
:>json object user: Object holding information about the user who posted the review.
:>json string user.url: The user profile URL.
:>json string user.name: The user name.
----
Post
----
.. review-list-addon:
This endpoint allows you to post a new review for a given add-on and version.
.. http:post:: /api/v3/addons/addon/(int:id|string:slug|string:guid)/reviews/
:<json string|null body: The text of the review.
:<json string|null: The title of the review.
:<json int rating: The rating the user wants to give as part of the review (required).
:<json int version: The add-on version id the review applies to.

Просмотреть файл

@ -4,7 +4,7 @@ import json
from olympia import amo
from olympia.activity.tests.test_serializers import LogMixin
from olympia.amo.tests import (
addon_factory, user_factory, TestCase)
addon_factory, APITestClient, user_factory, TestCase)
from olympia.amo.urlresolvers import reverse
from olympia.addons.models import AddonUser
from olympia.addons.utils import generate_addon_guid
@ -123,6 +123,8 @@ class ReviewNotesViewSetDetailMixin(LogMixin):
class TestReviewNotesViewSetDetail(ReviewNotesViewSetDetailMixin, TestCase):
client_class = APITestClient
def setUp(self):
super(TestReviewNotesViewSetDetail, self).setUp()
self.addon = addon_factory(
@ -155,6 +157,8 @@ class TestReviewNotesViewSetDetail(ReviewNotesViewSetDetailMixin, TestCase):
class TestReviewNotesViewSetList(ReviewNotesViewSetDetailMixin, TestCase):
client_class = APITestClient
def setUp(self):
super(TestReviewNotesViewSetList, self).setUp()
self.addon = addon_factory(

Просмотреть файл

@ -34,5 +34,6 @@ class VersionReviewNotesViewSet(AddonChildMixin, RetrieveModelMixin,
def check_object_permissions(self, request, obj):
"""Check object permissions against the Addon, not the ActivityLog."""
# Just loading the add-on object should trigger permission checks.
# Just loading the add-on object triggers permission checks, because
# the implementation in AddonChildMixin calls AddonViewSet.get_object()
self.get_addon_object()

Просмотреть файл

@ -15,7 +15,7 @@ from pyquery import PyQuery as pq
from waffle.testutils import override_switch
from olympia import amo
from olympia.amo.tests import ESTestCase, TestCase
from olympia.amo.tests import APITestClient, ESTestCase, TestCase
from olympia.amo.helpers import numberfmt, urlparams
from olympia.amo.tests import addon_factory, version_factory
from olympia.amo.urlresolvers import reverse
@ -1704,6 +1704,8 @@ class AddonAndVersionViewSetDetailMixin(object):
class TestAddonViewSetDetail(AddonAndVersionViewSetDetailMixin, TestCase):
client_class = APITestClient
def setUp(self):
super(TestAddonViewSetDetail, self).setUp()
self.addon = addon_factory(
@ -1724,6 +1726,8 @@ class TestAddonViewSetDetail(AddonAndVersionViewSetDetailMixin, TestCase):
class TestVersionViewSetDetail(AddonAndVersionViewSetDetailMixin, TestCase):
client_class = APITestClient
def setUp(self):
super(TestVersionViewSetDetail, self).setUp()
self.addon = addon_factory(
@ -1821,6 +1825,8 @@ class TestVersionViewSetDetail(AddonAndVersionViewSetDetailMixin, TestCase):
class TestVersionViewSetList(AddonAndVersionViewSetDetailMixin, TestCase):
client_class = APITestClient
def setUp(self):
super(TestVersionViewSetList, self).setUp()
self.addon = addon_factory(
@ -1950,6 +1956,8 @@ class TestVersionViewSetList(AddonAndVersionViewSetDetailMixin, TestCase):
class TestAddonViewSetFeatureCompatibility(TestCase):
client_class = APITestClient
def setUp(self):
super(TestAddonViewSetFeatureCompatibility, self).setUp()
self.addon = addon_factory(
@ -1982,6 +1990,8 @@ class TestAddonViewSetFeatureCompatibility(TestCase):
class TestAddonViewSetEulaPolicy(TestCase):
client_class = APITestClient
def setUp(self):
super(TestAddonViewSetEulaPolicy, self).setUp()
self.addon = addon_factory(
@ -2017,6 +2027,8 @@ class TestAddonViewSetEulaPolicy(TestCase):
class TestAddonSearchView(ESTestCase):
client_class = APITestClient
fixtures = ['base/users']
def setUp(self):
@ -2337,6 +2349,8 @@ class TestAddonSearchView(ESTestCase):
class TestAddonFeaturedView(TestCase):
client_class = APITestClient
def setUp(self):
self.url = reverse('addon-featured')

Просмотреть файл

@ -691,7 +691,7 @@ class AddonVersionViewSet(AddonChildMixin, RetrieveModelMixin,
AllowRelatedObjectPermissions('addon', AddonViewSet.permission_classes)
]
serializer_class = VersionSerializer
# Since permission checks are dont on the parent add-on, we rely on
# Since permission checks are done on the parent add-on, we rely on
# queryset filtering to hide non-valid versions. get_queryset() might
# override this if we are asked to see non-valid versions explicitly.
queryset = Version.objects.filter(

Просмотреть файл

@ -22,12 +22,15 @@ from django.test.client import Client, RequestFactory
from django.test.utils import override_settings
from django.conf import urls as django_urls
from django.utils import translation
from django.utils.encoding import force_bytes
from django.utils.importlib import import_module
from django.utils.http import urlencode
import mock
import pytest
from dateutil.parser import parse as dateutil_parser
from rest_framework.views import APIView
from rest_framework.test import APIClient
from waffle.models import Flag, Sample, Switch
from olympia import amo
@ -247,6 +250,9 @@ class TestClient(Client):
else:
raise AttributeError
class APITestClient(APIClient):
def generate_api_token(self, user, **payload_overrides):
"""
Creates a jwt token for this user.
@ -274,6 +280,17 @@ class TestClient(Client):
"""
self.defaults.pop('HTTP_AUTHORIZATION', None)
def get(self, path, data=None, **extra):
# Work around DRF #4458 since we're running an old version that does
# not have this fix yet.
r = {
'QUERY_STRING': urlencode(data or {}, doseq=True),
}
if not data and '?' in path:
r['QUERY_STRING'] = force_bytes(path.split('?')[1])
r.update(extra)
return self.generic('GET', path, **r)
Mocked_ES = mock.patch('olympia.amo.search.get_es', spec=True)

Просмотреть файл

@ -76,6 +76,20 @@ class AllowAddonAuthor(BasePermission):
return obj.authors.filter(pk=request.user.pk).exists()
class AllowOwner(BasePermission):
"""
Permission class to use when you are dealing with a model instance that has
a "user" FK pointing to an UserProfile, and you want only the corresponding
user to be able to access your instance.
"""
def has_permission(self, request, view):
return request.user.is_authenticated()
def has_object_permission(self, request, view, obj):
return ((obj == request.user) or
(getattr(obj, 'user', None) == request.user))
class AllowReviewer(BasePermission):
"""Allow addons reviewer access.
@ -115,7 +129,20 @@ class AllowReviewerUnlisted(AllowReviewer):
return not obj.is_listed and self.has_permission(request, view)
class AllowReadOnlyIfReviewedAndListed(BasePermission):
class AllowIfReviewedAndListed(BasePermission):
"""
Allow access when the object's is_public() method and is_listed property
both return True.
"""
def has_permission(self, request, view):
return True
def has_object_permission(self, request, view, obj):
return (obj.is_reviewed() and not obj.disabled_by_user and
obj.is_listed and self.has_permission(request, view))
class AllowReadOnlyIfReviewedAndListed(AllowIfReviewedAndListed):
"""
Allow access when the object's is_public() method and is_listed property
both return True and the request HTTP method is GET/OPTIONS/HEAD.
@ -123,10 +150,6 @@ class AllowReadOnlyIfReviewedAndListed(BasePermission):
def has_permission(self, request, view):
return request.method in SAFE_METHODS
def has_object_permission(self, request, view, obj):
return (obj.is_reviewed() and not obj.disabled_by_user and
obj.is_listed and self.has_permission(request, view))
class ByHttpMethod(BasePermission):
"""

Просмотреть файл

@ -14,7 +14,7 @@ from rest_framework_jwt.settings import api_settings
from rest_framework_jwt.views import refresh_jwt_token
from olympia.amo.helpers import absolutify
from olympia.amo.tests import TestCase, WithDynamicEndpoints
from olympia.amo.tests import APITestClient, TestCase, WithDynamicEndpoints
from olympia.api.authentication import (
JSONWebTokenAuthentication, JWTKeyAuthentication)
from olympia.api.tests.test_jwt_auth import JWTAuthKeyTester
@ -38,6 +38,7 @@ class JWTKeyAuthTestView(APIView):
class TestJWTKeyAuthentication(JWTAuthKeyTester):
fixtures = ['base/addon_3615']
client_class = APITestClient
def setUp(self):
super(TestJWTKeyAuthentication, self).setUp()
@ -146,6 +147,7 @@ class TestJWTKeyAuthentication(JWTAuthKeyTester):
class TestJWTKeyAuthProtectedView(WithDynamicEndpoints, JWTAuthKeyTester):
fixtures = ['base/addon_3615']
client_class = APITestClient
def setUp(self):
super(TestJWTKeyAuthProtectedView, self).setUp()
@ -190,6 +192,7 @@ class TestJWTKeyAuthProtectedView(WithDynamicEndpoints, JWTAuthKeyTester):
class TestJSONWebTokenAuthentication(TestCase):
fixtures = ['base/addon_3615']
client_class = APITestClient
def setUp(self):
super(TestJSONWebTokenAuthentication, self).setUp()

Просмотреть файл

@ -10,7 +10,7 @@ from rest_framework.views import APIView
from olympia.access.models import Group, GroupUser
from olympia.addons.models import Addon
from olympia.api.permissions import (
AllowAddonAuthor, AllowNone, AllowReadOnlyIfReviewedAndListed,
AllowAddonAuthor, AllowNone, AllowOwner, AllowReadOnlyIfReviewedAndListed,
AllowReviewer, AllowReviewerUnlisted, AnyOf, ByHttpMethod, GroupPermission)
from olympia.amo.tests import TestCase, WithDynamicEndpoints
from olympia.users.models import UserProfile
@ -161,6 +161,44 @@ class TestAllowAddonAuthor(TestCase):
self.request, myview, self.addon)
class TestAllowOwner(TestCase):
fixtures = ['base/users']
def setUp(self):
self.permission = AllowOwner()
self.anonymous = AnonymousUser()
self.user = UserProfile.objects.get(pk=999)
self.request = RequestFactory().get('/')
self.request.user = self.anonymous
def test_has_permission_anonymous(self):
assert not self.permission.has_permission(self.request, 'myview')
def test_has_permission_user(self):
self.request.user = self.user
assert self.permission.has_permission(self.request, 'myview')
def test_has_object_permission_user(self):
self.request.user = self.user
obj = Mock()
obj.user = self.user
assert self.permission.has_object_permission(
self.request, 'myview', obj)
def test_has_object_permission_no_user_on_obj(self):
self.request.user = self.user
obj = Mock()
assert not self.permission.has_object_permission(
self.request, 'myview', obj)
def test_has_object_permission_different_user(self):
self.request.user = self.user
obj = Mock()
obj.user = UserProfile.objects.get(pk=20)
assert not self.permission.has_object_permission(
self.request, 'myview', obj)
class TestAllowReviewer(TestCase):
fixtures = ['base/users']

Просмотреть файл

@ -8,12 +8,12 @@ from django.core.urlresolvers import reverse
from django.test import override_settings
import mock
from rest_framework.test import APIClient
from rest_framework_jwt.serializers import VerifyJSONWebTokenSerializer
from olympia.accounts import verify, views
from olympia.accounts.tests.test_views import BaseAuthenticationView
from olympia.amo.tests import addon_factory, ESTestCase, TestCase
from olympia.amo.tests import (
addon_factory, APITestClient, ESTestCase, TestCase)
from olympia.users.models import UserProfile
FXA_CONFIG = {
@ -25,6 +25,7 @@ FXA_CONFIG = {
class TestInternalAddonSearchView(ESTestCase):
client_class = APITestClient
fixtures = ['base/users']
def setUp(self):
@ -172,6 +173,7 @@ class TestInternalAddonSearchView(ESTestCase):
@override_settings(FXA_CONFIG={'internal': FXA_CONFIG})
class TestLoginStartView(TestCase):
client_class = APITestClient
def setUp(self):
super(TestLoginStartView, self).setUp()
@ -259,6 +261,7 @@ endpoint_overrides = [
FXA_CONFIG={'internal': FXA_CONFIG},
CORS_ENDPOINT_OVERRIDES=endpoint_overrides)
class TestLoginView(BaseAuthenticationView):
client_class = APITestClient
view_name = 'internal-login'
def setUp(self):
@ -276,7 +279,7 @@ class TestLoginView(BaseAuthenticationView):
return self.client.post(self.url, kwargs)
def options(self, url, origin):
return APIClient(HTTP_ORIGIN=origin).options(url)
return self.client_class(HTTP_ORIGIN=origin).options(url)
def test_no_code_provided(self):
response = self.post(code='')

Просмотреть файл

@ -1641,7 +1641,10 @@ REST_FRAMEWORK = {
# Use our pagination class by default, which allows clients to request a
# different page size.
'DEFAULT_PAGINATION_CLASS': (
'olympia.api.paginator.CustomPageNumberPagination')
'olympia.api.paginator.CustomPageNumberPagination'),
# Use json by default when using APIClient.
'TEST_REQUEST_DEFAULT_FORMAT': 'json',
}
# This is the DSN to the local Sentry service. It might be overridden in

Просмотреть файл

@ -0,0 +1,12 @@
from rest_framework.permissions import BasePermission
from olympia.reviews.helpers import user_can_delete_review
class CanDeleteReviewPermission(BasePermission):
"""A DRF permission class wrapping user_can_delete_review()."""
def has_permission(self, request, view):
return request.user.is_authenticated()
def has_object_permission(self, request, view, obj):
return user_can_delete_review(request, obj)

Просмотреть файл

@ -1,7 +1,9 @@
from rest_framework import serializers
from rest_framework.relations import PrimaryKeyRelatedField
from olympia.reviews.models import Review
from olympia.users.serializers import BaseUserSerializer
from olympia.versions.models import Version
class BaseReviewSerializer(serializers.ModelSerializer):
@ -9,9 +11,13 @@ class BaseReviewSerializer(serializers.ModelSerializer):
# translation for each review - it's essentially useless. Because of that
# we use a simple CharField in the API, hiding the fact that it's a
# TranslatedField underneath.
body = serializers.CharField()
title = serializers.CharField()
user = BaseUserSerializer()
body = serializers.CharField(allow_null=True, required=False)
title = serializers.CharField(allow_null=True, required=False)
user = BaseUserSerializer(read_only=True)
# Version queryset is unfiltered, the version is checked more thoroughly
# in the validate() method.
version = PrimaryKeyRelatedField(queryset=Version.unfiltered)
class Meta:
model = Review
@ -24,9 +30,41 @@ class BaseReviewSerializer(serializers.ModelSerializer):
data['version'] = unicode(obj.version.version) if obj.version else None
return data
def validate_version(self, version):
addon = self.context['view'].get_addon_object()
if (version.addon_id != addon.pk or
not version.is_public()):
raise serializers.ValidationError(
'Version does not exist on this add-on or is not public.')
return version
def validate(self, data):
data = super(BaseReviewSerializer, self).validate(data)
# Get the add-on pk from the URL, no need to pass it as POST data since
# the URL is always going to have it.
data['addon'] = self.context['view'].get_addon_object()
# Get the user from the request, don't allow clients to pick one
# themselves.
data['user'] = self.context['request'].user
if data['addon'].authors.filter(pk=data['user'].pk).exists():
raise serializers.ValidationError(
'An add-on author can not leave a review on its own add-on.')
if Review.objects.filter(
addon=data['addon'], user=data['user'],
version=data['version']).exists():
raise serializers.ValidationError(
'The same user can not leave a review on the same version more'
' than once.')
return data
class ReviewSerializer(BaseReviewSerializer):
reply = BaseReviewSerializer(read_only=True)
rating = serializers.IntegerField(min_value=1, max_value=5)
class Meta:
model = Review

Просмотреть файл

@ -0,0 +1,33 @@
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory
from mock import mock
from olympia.amo.tests import TestCase
from olympia.reviews.permissions import CanDeleteReviewPermission
from olympia.users.models import UserProfile
class TestCanDeleteReviewPermission(TestCase):
def setUp(self):
self.request = RequestFactory().get('/')
self.request.user = AnonymousUser()
self.perm = CanDeleteReviewPermission()
def test_has_permission_anonymous(self):
assert not self.perm.has_permission(self.request, None)
def test_has_permission_authenticated(self):
self.request.user = UserProfile()
assert self.perm.has_permission(self.request, None)
@mock.patch('olympia.reviews.permissions.user_can_delete_review')
def test_has_object_permission(self, user_can_delete_review_mock):
user_can_delete_review_mock.return_value = True
assert self.perm.has_object_permission(self.request, None, object())
@mock.patch('olympia.reviews.permissions.user_can_delete_review')
def test_has_object_permission_false(self, user_can_delete_review_mock):
user_can_delete_review_mock.return_value = False
assert not self.perm.has_object_permission(
self.request, None, object())

Просмотреть файл

@ -7,9 +7,12 @@ import mock
from pyquery import PyQuery as pq
from rest_framework.exceptions import ParseError
from olympia import amo
from olympia.addons.utils import generate_addon_guid
from olympia.amo import helpers
from olympia.amo.tests import addon_factory, TestCase, MobileTest, user_factory
from olympia.amo.tests import (
addon_factory, APITestClient, TestCase, MobileTest, version_factory,
user_factory)
from olympia.access.models import Group, GroupUser
from olympia.addons.models import Addon, AddonUser
from olympia.devhub.models import ActivityLog
@ -593,6 +596,8 @@ class TestMobileReviews(MobileTest, TestCase):
class TestReviewViewSetGet(TestCase):
client_class = APITestClient
def setUp(self):
self.addon = addon_factory(
guid=generate_addon_guid(), name=u'My Addôn', slug='my-addon')
@ -631,13 +636,8 @@ class TestReviewViewSetGet(TestCase):
def test_list_unknown_addon(self):
self.url = reverse(
'addon-review-list', kwargs={'addon_pk': self.addon.pk + 42})
# We don't bother checking for the add-on existence, so this should
# just result in an empty list.
response = self.client.get(self.url)
assert response.status_code == 200
data = json.loads(response.content)
assert data['count'] == 0
assert data['results'] == []
assert response.status_code == 404
def test_list_addon_guid(self):
self.url = reverse(
@ -730,3 +730,283 @@ class TestReviewViewSetGet(TestCase):
response = self.client.get(self.url)
assert response.status_code == 404
class TestReviewViewSetPost(TestCase):
client_class = APITestClient
def setUp(self):
self.addon = addon_factory(
guid=generate_addon_guid(), name=u'My Addôn', slug='my-addon')
self.url = reverse(
'addon-review-list', kwargs={'addon_pk': self.addon.pk})
def test_post_anonymous(self):
response = self.client.post(self.url, {
'body': u'test bodyé', 'title': None, 'rating': 5})
assert response.status_code == 401
def test_post_no_version(self):
self.user = user_factory()
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'body': u'test bodyé', 'title': None, 'rating': 5})
assert response.status_code == 400
assert response.data['version'] == [u'This field is required.']
def test_post_version_string(self):
self.user = user_factory()
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'body': u'test bodyé', 'title': None, 'rating': 5,
'version': self.addon.current_version.version})
assert response.status_code == 400
assert response.data['version'] == [
'Incorrect type. Expected pk value, received unicode.']
def test_post_logged_in(self):
self.user = user_factory()
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'body': u'test bodyé', 'title': u'blahé', 'rating': 5,
'version': self.addon.current_version.pk})
assert response.status_code == 201
review = Review.objects.latest('pk')
assert review.pk == response.data['id']
assert unicode(review.body) == response.data['body'] == u'test bodyé'
assert review.rating == response.data['rating'] == 5
assert review.user == self.user
assert unicode(review.title) == response.data['title'] == u'blahé'
assert review.reply_to is None
assert review.addon == self.addon
assert review.version == self.addon.current_version
assert response.data['version'] == review.version.version
def test_post_rating_float(self):
self.user = user_factory()
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'body': u'test bodyé', 'title': None, 'rating': 4.5,
'version': self.addon.current_version.pk})
assert response.status_code == 400
assert response.data['rating'] == ['A valid integer is required.']
def test_post_rating_too_big(self):
self.user = user_factory()
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'body': u'test bodyé', 'title': None, 'rating': 6,
'version': self.addon.current_version.pk})
assert response.status_code == 400
assert response.data['rating'] == [
'Ensure this value is less than or equal to 5.']
def test_post_rating_too_low(self):
self.user = user_factory()
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'body': u'test bodyé', 'title': None, 'rating': 0,
'version': self.addon.current_version.pk})
assert response.status_code == 400
assert response.data['rating'] == [
'Ensure this value is greater than or equal to 1.']
def test_post_rating_no_title(self):
self.user = user_factory()
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'body': u'test bodyé', 'title': None, 'rating': 5,
'version': self.addon.current_version.pk})
assert response.status_code == 201
review = Review.objects.latest('pk')
assert review.pk == response.data['id']
assert unicode(review.body) == response.data['body'] == u'test bodyé'
assert review.rating == response.data['rating'] == 5
assert review.user == self.user
assert review.title is None
assert response.data['title'] is None
assert review.reply_to is None
assert review.addon == self.addon
assert review.version == self.addon.current_version
assert response.data['version'] == review.version.version
def test_no_body_or_title_just_rating(self):
self.user = user_factory()
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'body': None, 'title': None, 'rating': 5,
'version': self.addon.current_version.pk})
assert response.status_code == 201
review = Review.objects.latest('pk')
assert review.pk == response.data['id']
assert review.body is None
assert response.data['body'] is None
assert review.rating == response.data['rating'] == 5
assert review.user == self.user
assert review.title is None
assert response.data['title'] is None
assert review.reply_to is None
assert review.addon == self.addon
assert review.version == self.addon.current_version
assert response.data['version'] == review.version.version
def test_omit_body_and_title_completely_just_rating(self):
self.user = user_factory()
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'rating': 5, 'version': self.addon.current_version.pk})
assert response.status_code == 201
review = Review.objects.latest('pk')
assert review.pk == response.data['id']
assert review.body is None
assert response.data['body'] is None
assert review.rating == response.data['rating'] == 5
assert review.user == self.user
assert review.title is None
assert response.data['title'] is None
assert review.reply_to is None
assert review.addon == self.addon
assert review.version == self.addon.current_version
assert response.data['version'] == review.version.version
def test_post_rating_rating_required(self):
self.user = user_factory()
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'body': u'test bodyé', 'title': None,
'version': self.addon.current_version.pk})
assert response.status_code == 400
assert response.data['rating'] == ['This field is required.']
def test_post_no_such_addon_id(self):
self.url = reverse(
'addon-review-list', kwargs={'addon_pk': self.addon.pk + 42})
self.user = user_factory()
self.client.login_api(self.user)
response = self.client.post(self.url, {
'body': 'test body', 'title': None, 'rating': 5,
'version': self.addon.current_version.pk})
assert response.status_code == 404
def test_post_version_not_linked_to_the_right_addon(self):
addon2 = addon_factory()
self.url = reverse(
'addon-review-list', kwargs={'addon_pk': self.addon.pk})
self.user = user_factory()
self.client.login_api(self.user)
response = self.client.post(self.url, {
'body': 'test body', 'title': None, 'rating': 5,
'version': addon2.current_version.pk})
assert response.status_code == 400
assert response.data['version'] == [
'Version does not exist on this add-on or is not public.']
def test_post_deleted_addon(self):
version_pk = self.addon.current_version.pk
self.addon.delete()
self.user = user_factory()
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'body': u'test bodyé', 'title': None, 'rating': 5,
'version': version_pk})
assert response.status_code == 404
def test_post_deleted_version(self):
old_version_pk = self.addon.current_version.pk
old_version = self.addon.current_version
new_version = version_factory(addon=self.addon)
old_version.delete()
# Just in case, make sure the add-on is still public.
self.addon.reload()
assert self.addon.current_version == new_version
assert self.addon.status
self.user = user_factory()
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'body': u'test bodyé', 'title': None, 'rating': 5,
'version': old_version_pk})
assert response.status_code == 400
assert response.data['version'] == [
'Version does not exist on this add-on or is not public.']
def test_post_disabled_version(self):
self.addon.current_version.update(created=self.days_ago(1))
new_version = version_factory(addon=self.addon)
old_version = self.addon.current_version
old_version.files.update(status=amo.STATUS_DISABLED)
assert self.addon.current_version == new_version
assert self.addon.status == amo.STATUS_PUBLIC
self.user = user_factory()
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'body': u'test bodyé', 'title': None, 'rating': 5,
'version': old_version.pk})
assert response.status_code == 400
assert response.data['version'] == [
'Version does not exist on this add-on or is not public.']
def test_post_not_public_addon(self):
version_pk = self.addon.current_version.pk
self.addon.update(status=amo.STATUS_NULL)
self.user = user_factory()
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'body': u'test bodyé', 'title': None, 'rating': 5,
'version': version_pk})
assert response.status_code == 403
def test_post_logged_in_but_is_addon_author(self):
self.user = user_factory()
self.addon.addonuser_set.create(user=self.user)
self.client.login_api(self.user)
assert not Review.objects.exists()
response = self.client.post(self.url, {
'body': u'test bodyé', 'title': None, 'rating': 5,
'version': self.addon.current_version.pk})
assert response.status_code == 400
assert response.data['non_field_errors'] == [
'An add-on author can not leave a review on its own add-on.']
def test_post_twice_different_version(self):
self.user = user_factory()
self.client.login_api(self.user)
Review.objects.create(
addon=self.addon, version=self.addon.current_version, rating=1,
body='My review', user=self.user)
second_version = version_factory(addon=self.addon)
response = self.client.post(self.url, {
'body': u'My ôther review', 'title': None, 'rating': 2,
'version': second_version.pk})
assert response.status_code == 201
assert Review.objects.count() == 2
def test_post_twice_same_version(self):
# Posting a review more than once for the same version is not allowed.
self.user = user_factory()
self.client.login_api(self.user)
Review.objects.create(
addon=self.addon, version=self.addon.current_version, rating=1,
body='My review', user=self.user)
response = self.client.post(self.url, {
'body': u'My ôther review', 'title': None, 'rating': 2,
'version': self.addon.current_version.pk})
assert response.status_code == 400
assert response.data['non_field_errors'] == [
'The same user can not leave a review on the same version more'
' than once.']

Просмотреть файл

@ -14,8 +14,9 @@ from django.utils.translation import ugettext as _
import commonware.log
from mobility.decorators import mobile_template
from rest_framework.exceptions import ParseError
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
from rest_framework.permissions import AllowAny
from rest_framework.mixins import (
CreateModelMixin, ListModelMixin, RetrieveModelMixin)
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.viewsets import GenericViewSet
from waffle.decorators import waffle_switch
@ -28,11 +29,14 @@ from olympia.amo.utils import render, paginate, send_mail as amo_send_mail
from olympia.access import acl
from olympia.addons.decorators import addon_view_factory
from olympia.addons.models import Addon
from olympia.addons.views import AddonViewSet
from olympia.api.permissions import ByHttpMethod
from olympia.addons.views import AddonChildMixin
from olympia.api.permissions import (
AllowIfReviewedAndListed, AllowOwner, AnyOf, ByHttpMethod,
GroupPermission)
from .helpers import user_can_delete_review
from .models import Review, ReviewFlag, GroupedRating, Spam
from .permissions import CanDeleteReviewPermission
from .serializers import ReviewSerializer
from . import forms
@ -321,31 +325,51 @@ def spam(request):
dict(buckets=buckets, review_perms=dict(is_admin=True)))
class ReviewViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet):
class ReviewViewSet(AddonChildMixin, CreateModelMixin, ListModelMixin,
RetrieveModelMixin, GenericViewSet):
permission_classes = [
ByHttpMethod({
'get': AllowAny,
'head': AllowAny,
'options': AllowAny, # Needed for CORS.
# Deletion requires a specific permission check.
'delete': CanDeleteReviewPermission,
# To post a review you just need to be authenticated.
'post': IsAuthenticated,
# To edit a review you need to be the author or be an admin.
'patch': AnyOf(AllowOwner, GroupPermission('Addons', 'Edit')),
'put': AnyOf(AllowOwner, GroupPermission('Addons', 'Edit')),
}),
]
serializer_class = ReviewSerializer
queryset = Review.objects.all()
def get_addon_object(self):
# When loading the add-on, pass a specific permission class - the
# default from AddonViewSet is too restrictive, we are not modifying
# the add-on itself so we don't need all the permission checks it does.
return super(ReviewViewSet, self).get_addon_object(
permission_classes=[AllowIfReviewedAndListed])
def check_permissions(self, request):
if 'addon_pk' in self.kwargs:
# In addition to the regular permission checks that are made, we
# need to verify that the add-on exists, is public and listed. Just
# loading the addon should be enough to do that, since
# AddonChildMixin implementation calls AddonViewSet.get_object().
self.get_addon_object()
# Proceed with the regular permission checks.
return super(ReviewViewSet, self).check_permissions(request)
def filter_queryset(self, qs):
if self.action == 'list':
# No need to load the actual add-on or user. Just filter the
# queryset according to what the addon_pk/user_pk parameter we got
# looks like - if we end up with an empty list it's fine.
if 'addon_pk' in self.kwargs:
addon_identifier = self.kwargs.get('addon_pk')
lookup_field = AddonViewSet(
request=self.request,
kwargs={'pk': self.kwargs['addon_pk']}
).get_lookup_field(addon_identifier)
qs = qs.filter(
**{'addon__%s' % lookup_field: addon_identifier})
qs = qs.filter(addon=self.get_addon_object())
elif 'account_pk' in self.kwargs:
qs = qs.filter(user=self.kwargs.get('account_pk'))
else:
@ -365,5 +389,7 @@ class ReviewViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet):
qs = super(ReviewViewSet, self).get_queryset()
# The serializer needs user, reply and version, so use
# prefetch_related() to avoid extra queries (avoid select_related() as
# we need crazy joins already)
return qs.prefetch_related('reply', 'user', 'version')
# we need crazy joins already). Also avoid loading addon since we don't
# need it, we already loaded it for permission checks through the pk
# specified in the URL.
return qs.defer('addon').prefetch_related('reply', 'user', 'version')

Просмотреть файл

@ -81,7 +81,8 @@ class BaseUploadVersionCase(SigningAPITestCase):
return getattr(self.client, method.lower())(
url, data,
HTTP_AUTHORIZATION=self.authorization())
HTTP_AUTHORIZATION=self.authorization(),
format='multipart')
def make_admin(self, user):
admin_group = Group.objects.create(name='Admin', rules='*:*')