diff --git a/core/migrations/0007_pin_private.py b/core/migrations/0007_pin_private.py new file mode 100644 index 0000000..23ff90a --- /dev/null +++ b/core/migrations/0007_pin_private.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.26 on 2020-02-11 05:32 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_remove_pin_origin'), + ] + + operations = [ + migrations.AddField( + model_name='pin', + name='private', + field=models.BooleanField(default=False), + ), + ] diff --git a/core/migrations/0008_board_private.py b/core/migrations/0008_board_private.py new file mode 100644 index 0000000..ace315c --- /dev/null +++ b/core/migrations/0008_board_private.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.26 on 2020-02-11 08:38 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0007_pin_private'), + ] + + operations = [ + migrations.AddField( + model_name='board', + name='private', + field=models.BooleanField(default=False), + ), + ] diff --git a/core/models.py b/core/models.py index a61c822..5d8a60b 100644 --- a/core/models.py +++ b/core/models.py @@ -76,6 +76,7 @@ class Board(models.Model): submitter = models.ForeignKey(User) name = models.CharField(max_length=128, blank=False, null=False) + private = models.BooleanField(default=False, blank=False) pins = models.ManyToManyField("Pin", related_name="pins", blank=True) published = models.DateTimeField(auto_now_add=True) @@ -83,6 +84,7 @@ class Board(models.Model): class Pin(models.Model): submitter = models.ForeignKey(User) + private = models.BooleanField(default=False, blank=False) url = models.CharField(null=True, blank=True, max_length=256) referer = models.CharField(null=True, blank=True, max_length=256) description = models.TextField(blank=True, null=True) diff --git a/core/permissions.py b/core/permissions.py index 0df1293..b46750c 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -21,6 +21,19 @@ class IsOwnerOrReadOnly(permissions.IsAuthenticatedOrReadOnly): return getattr(obj, self.__owner_field_name) == request.user +class OwnerOnlyIfPrivate(permissions.BasePermission): + def __init__(self, owner_field_name="owner"): + self.__owner_field_name = owner_field_name + + def __call__(self): + return self + + def has_object_permission(self, request, view, obj): + if getattr(obj, "private"): + return request.user == getattr(obj, self.__owner_field_name) + return True + + class OwnerOnly(permissions.IsAuthenticatedOrReadOnly): def has_permission(self, request, view): diff --git a/core/serializers.py b/core/serializers.py index 6cef857..76f58e6 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.db.models import Q from rest_framework import serializers from rest_framework.exceptions import ValidationError from taggit.models import Tag @@ -9,6 +10,22 @@ from django_images.models import Thumbnail from users.serializers import UserSerializer +def filter_private_pin(request, query): + if request.user.is_authenticated: + query = query.exclude(~Q(submitter=request.user), private=True) + else: + query = query.exclude(private=True) + return query.select_related('image', 'submitter') + + +def filter_private_board(request, query): + if request.user.is_authenticated: + query = query.exclude(~Q(submitter=request.user), private=True) + else: + query = query.exclude(private=True) + return query + + class ThumbnailSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Thumbnail @@ -72,6 +89,7 @@ class PinSerializer(serializers.HyperlinkedModelSerializer): model = Pin fields = ( settings.DRF_URL_FIELD_NAME, + "private", "id", "submitter", "url", @@ -151,6 +169,7 @@ class BoardSerializer(serializers.HyperlinkedModelSerializer): settings.DRF_URL_FIELD_NAME, "id", "name", + "private", "pins", "pins_detail", "published", @@ -164,7 +183,9 @@ class BoardSerializer(serializers.HyperlinkedModelSerializer): } submitter = UserSerializer(read_only=True) - pins_detail = PinSerializer(source="pins", many=True, read_only=True) + pins_detail = serializers.SerializerMethodField( + read_only=True, + ) pins = serializers.HyperlinkedRelatedField( write_only=True, queryset=Pin.objects.all(), @@ -187,6 +208,12 @@ class BoardSerializer(serializers.HyperlinkedModelSerializer): help_text="only patch method works for this field" ) + def get_pins_detail(self, instance): + query = instance.pins.all() + request = self.context['request'] + query = filter_private_pin(request, query) + return [PinSerializer(pin, context=self.context).data for pin in query] + @staticmethod def _get_list(pins_id): return tuple(Pin.objects.filter(id__in=pins_id)) @@ -194,10 +221,11 @@ class BoardSerializer(serializers.HyperlinkedModelSerializer): def update(self, instance: Board, validated_data): pins_to_add = validated_data.pop("pins_to_add", []) pins_to_remove = validated_data.pop("pins_to_remove", []) - if Board.objects.filter( + board = Board.objects.filter( submitter=instance.submitter, name=validated_data.get('name', None) - ).exists(): + ).first() + if board.id != instance.id: raise ValidationError( detail={'name': "Board with this name already exists"} ) diff --git a/core/tests/api.py b/core/tests/api.py index fa93080..28703ab 100644 --- a/core/tests/api.py +++ b/core/tests/api.py @@ -5,11 +5,17 @@ import mock from rest_framework import status from rest_framework.test import APITestCase -from django_images.models import Thumbnail from taggit.models import Tag from .helpers import create_image, create_user, create_pin -from core.models import Pin, Image +from core.models import Pin, Image, Board + + +def _teardown_models(): + Pin.objects.all().delete() + Image.objects.all().delete() + Tag.objects.all().delete() + Board.objects.all().delete() def mock_requests_get(url, **kwargs): @@ -29,6 +35,117 @@ class ImageTests(APITestCase): self.assertEqual(response.status_code, 403, response.data) +class BoardPrivacyTests(APITestCase): + + def setUp(self): + super(BoardPrivacyTests, self).setUp() + self.owner = create_user("default") + self.non_owner = create_user("non_owner") + + self.private_board = Board.objects.create( + name="test_board", + submitter=self.owner, + private=True, + ) + self.board_url = reverse("board-detail", kwargs={"pk": self.private_board.pk}) + self.boards_url = reverse("board-list") + + def tearDown(self): + _teardown_models() + + def test_should_non_owner_and_anonymous_user_has_no_permission_to_list_private_board(self): + resp = self.client.get(self.boards_url) + self.assertEqual(len(resp.data), 0, resp.data) + + self.client.login(username=self.non_owner.username, password='password') + resp = self.client.get(self.boards_url) + self.assertEqual(len(resp.data), 0, resp.data) + + def test_should_owner_has_permission_to_list_private_board(self): + self.client.login(username=self.non_owner.username, password='password') + resp = self.client.get(self.boards_url) + self.assertEqual(len(resp.data), 0, resp.data) + + def test_should_non_owner_and_anonymous_user_has_no_permission_to_view_private_board(self): + resp = self.client.get(self.board_url) + self.assertEqual(resp.status_code, 404) + + self.client.login(username=self.non_owner.username, password='password') + resp = self.client.get(self.board_url) + self.assertEqual(resp.status_code, 404) + + def test_should_owner_has_permission_to_view_private_board(self): + self.client.login(username=self.owner.username, password='password') + resp = self.client.get(self.board_url) + self.assertEqual(resp.status_code, 200) + + +class PinPrivacyTests(APITestCase): + + def setUp(self): + super(PinPrivacyTests, self).setUp() + self.owner = create_user("default") + self.non_owner = create_user("non_owner") + + with mock.patch('requests.get', mock_requests_get): + image = Image.objects.create_for_url('http://a.com/b.png') + self.private_pin = Pin.objects.create( + submitter=self.owner, + image=image, + private=True, + ) + self.private_pin_url = reverse("pin-detail", kwargs={"pk": self.private_pin.pk}) + + self.board = Board.objects.create(name="test_board", submitter=self.owner) + self.board.pins.add(self.private_pin) + self.board.save() + self.board_url = reverse("board-detail", kwargs={"pk": self.board.pk}) + + def tearDown(self): + _teardown_models() + + def test_should_non_owner_and_anonymous_user_has_no_permission_to_list_private_pin(self): + resp = self.client.get(reverse("pin-list")) + self.assertEqual(len(resp.data['results']), 0, resp.data) + + self.client.login(username=self.non_owner.username, password='password') + resp = self.client.get(reverse("pin-list")) + self.assertEqual(len(resp.data['results']), 0, resp.data) + + def test_should_non_owner_and_anonymous_user_has_no_permission_to_list_private_pin_in_board(self): + resp = self.client.get(self.board_url) + self.assertEqual(len(resp.data['pins_detail']), 0, resp.data) + self.client.login(username=self.non_owner.username, password='password') + + resp = self.client.get(self.board_url) + self.assertEqual(len(resp.data['pins_detail']), 0, resp.data) + + def test_should_owner_user_has_permission_to_list_private_pin_in_board(self): + self.client.login(username=self.owner.username, password='password') + resp = self.client.get(self.board_url) + self.assertEqual(len(resp.data['pins_detail']), 1, resp.data) + + def test_should_owner_user_has_permission_to_list_private_pin(self): + self.client.login(username=self.owner.username, password='password') + resp = self.client.get(reverse("pin-list")) + self.assertEqual(len(resp.data['results']), 1, resp.data) + + def test_should_owner_has_permission_to_view_private_pin(self): + self.client.login(username=self.owner.username, password='password') + resp = self.client.get(self.private_pin_url) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data['id'], self.private_pin.id) + + def test_should_anonymous_user_has_no_permission_to_view_private_pin(self): + resp = self.client.get(self.private_pin_url) + self.assertEqual(resp.status_code, 404) + + def test_should_non_owner_has_no_permission_to_view_private_pin(self): + self.client.login(username=self.non_owner.username, password='password') + resp = self.client.get(self.private_pin_url) + self.assertEqual(resp.status_code, 404) + + class PinTests(APITestCase): _JSON_TYPE = "application/json" @@ -38,9 +155,7 @@ class PinTests(APITestCase): self.client.login(username=self.user.username, password='password') def tearDown(self): - Pin.objects.all().delete() - Image.objects.all().delete() - Tag.objects.all().delete() + _teardown_models() @mock.patch('requests.get', mock_requests_get) def test_should_create_pin(self): @@ -49,6 +164,7 @@ class PinTests(APITestCase): referer = 'http://testserver.com/' post_data = { 'url': url, + 'private': False, 'referer': referer, 'description': 'That\'s an Apple!' } diff --git a/core/views.py b/core/views.py index 543486b..277db09 100644 --- a/core/views.py +++ b/core/views.py @@ -8,7 +8,8 @@ from taggit.models import Tag from core import serializers as api from core.models import Image, Pin, Board -from core.permissions import IsOwnerOrReadOnly +from core.permissions import IsOwnerOrReadOnly, OwnerOnlyIfPrivate +from core.serializers import filter_private_pin, filter_private_board class ImageViewSet(mixins.CreateModelMixin, GenericViewSet): @@ -20,36 +21,45 @@ class ImageViewSet(mixins.CreateModelMixin, GenericViewSet): class PinViewSet(viewsets.ModelViewSet): - queryset = Pin.objects.all().select_related('image', 'submitter') serializer_class = api.PinSerializer filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter) filter_fields = ("submitter__username", 'tags__name', ) ordering_fields = ('-id', ) ordering = ('-id', ) - permission_classes = [IsOwnerOrReadOnly("submitter"), ] + permission_classes = [IsOwnerOrReadOnly("submitter"), OwnerOnlyIfPrivate("submitter")] + + def get_queryset(self): + query = Pin.objects.all() + request = self.request + return filter_private_pin(request, query) class BoardViewSet(viewsets.ModelViewSet): - queryset = Board.objects.all() serializer_class = api.BoardSerializer filter_backends = (DjangoFilterBackend, OrderingFilter) filter_fields = ("submitter__username", ) ordering_fields = ('-id', ) ordering = ('-id', ) - permission_classes = [IsOwnerOrReadOnly("submitter"), ] + permission_classes = [IsOwnerOrReadOnly("submitter"), OwnerOnlyIfPrivate("submitter")] + + def get_queryset(self): + return filter_private_board(self.request, Board.objects.all()) class BoardAutoCompleteViewSet( mixins.ListModelMixin, viewsets.GenericViewSet, ): - queryset = Board.objects.all() serializer_class = api.BoardAutoCompleteSerializer filter_backends = (DjangoFilterBackend, OrderingFilter) filter_fields = ("submitter__username", ) ordering_fields = ('-id', ) ordering = ('-id', ) pagination_class = None + permission_classes = [OwnerOnlyIfPrivate("submitter"), ] + + def get_queryset(self): + return filter_private_board(self.request, Board.objects.all()) class TagAutoCompleteViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): @@ -67,8 +77,8 @@ class TagAutoCompleteViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): drf_router = routers.DefaultRouter() -drf_router.register(r'pins', PinViewSet) +drf_router.register(r'pins', PinViewSet, basename="pin") drf_router.register(r'images', ImageViewSet) -drf_router.register(r'boards', BoardViewSet) +drf_router.register(r'boards', BoardViewSet, basename="board") drf_router.register(r'tags-auto-complete', TagAutoCompleteViewSet) -drf_router.register(r'boards-auto-complete', BoardAutoCompleteViewSet) +drf_router.register(r'boards-auto-complete', BoardAutoCompleteViewSet, base_name="board") diff --git a/pinry-spa/src/components/BoardEdit.vue b/pinry-spa/src/components/BoardEdit.vue index 373368a..ecb34b3 100644 --- a/pinry-spa/src/components/BoardEdit.vue +++ b/pinry-spa/src/components/BoardEdit.vue @@ -17,6 +17,13 @@ maxlength="128" > + + + + {{ createModel.form.private.value?"only visible to yourself":"visible to everyone" }} +
@@ -30,6 +37,13 @@ maxlength="128" > + + + + {{ editModel.form.private.value?"only visible to yourself":"visible to everyone" }} +
@@ -56,7 +70,7 @@ import API from './api'; import ModelForm from './utils/ModelForm'; import bus from './utils/bus'; -const fields = ['name']; +const fields = ['name', 'private']; export default { name: 'BoardEditModal', @@ -108,7 +122,10 @@ export default { }, createBoard() { const self = this; - const promise = API.Board.create(this.createModel.form.name.value); + const promise = API.Board.create( + this.createModel.form.name.value, + this.createModel.form.private.value, + ); promise.then( (resp) => { bus.bus.$emit(bus.events.refreshBoards); diff --git a/pinry-spa/src/components/Boards.vue b/pinry-spa/src/components/Boards.vue index c7fda3b..e0341cd 100644 --- a/pinry-spa/src/components/Boards.vue +++ b/pinry-spa/src/components/Boards.vue @@ -76,6 +76,7 @@ function createBoardItem(board) { } boardItem.id = board.id; boardItem.name = board.name; + boardItem.private = board.private; boardItem.total_pins = pins4Board.length; if (previewImage.image.thumbnail.image !== null) { boardItem.preview_image_url = pinHandler.escapeUrl( diff --git a/pinry-spa/src/components/Pins.vue b/pinry-spa/src/components/Pins.vue index de0c7ea..990b880 100644 --- a/pinry-spa/src/components/Pins.vue +++ b/pinry-spa/src/components/Pins.vue @@ -90,6 +90,7 @@ function createImageItem(pin) { image.url = pinHandler.escapeUrl(pin.image.thumbnail.image); image.id = pin.id; image.owner_id = pin.submitter.id; + image.private = pin.private; image.description = pin.description; image.tags = pin.tags; image.author = pin.submitter.username; diff --git a/pinry-spa/src/components/api.js b/pinry-spa/src/components/api.js index 3e07936..d64e46b 100644 --- a/pinry-spa/src/components/api.js +++ b/pinry-spa/src/components/api.js @@ -4,9 +4,9 @@ import storage from './utils/storage'; const API_PREFIX = '/api/v2/'; const Board = { - create(name) { + create(name, private_ = false) { const url = `${API_PREFIX}boards/`; - const data = { name }; + const data = { name, private: private_ }; return new Promise( (resolve, reject) => { axios.post(url, data).then( diff --git a/pinry-spa/src/components/pin_edit/PinCreateModal.vue b/pinry-spa/src/components/pin_edit/PinCreateModal.vue index 4b27bf7..0abfaa9 100644 --- a/pinry-spa/src/components/pin_edit/PinCreateModal.vue +++ b/pinry-spa/src/components/pin_edit/PinCreateModal.vue @@ -27,6 +27,13 @@ > + + + {{ pinModel.form.private.value?"only visible to yourself":"visible to everyone" }} + + @@ -107,7 +114,7 @@ function isURLBlank(url) { return url !== null && url === ''; } -const fields = ['url', 'referer', 'description', 'tags']; +const fields = ['url', 'referer', 'description', 'tags', 'private']; export default { name: 'PinCreateModal', @@ -160,11 +167,13 @@ export default { this.pinModel.form.referer.value = this.existedPin.referer; this.pinModel.form.description.value = this.existedPin.description; this.pinModel.form.tags.value = this.existedPin.tags; + this.pinModel.form.private.value = this.existedPin.private; } if (this.fromUrl) { this.pinModel.form.url.value = this.fromUrl.url; this.pinModel.form.referer.value = this.fromUrl.referer; this.pinModel.form.description.value = this.fromUrl.description; + this.pinModel.form.private.value = false; } }, methods: { @@ -216,7 +225,7 @@ export default { savePin() { const self = this; const data = this.pinModel.asDataByFields( - ['referer', 'description', 'tags'], + ['referer', 'description', 'tags', 'private'], ); const promise = API.Pin.updateById(this.existedPin.id, data); promise.then( @@ -239,7 +248,7 @@ export default { promise = API.Pin.createFromURL(data); } else { const data = this.pinModel.asDataByFields( - ['referer', 'description', 'tags'], + ['referer', 'description', 'tags', 'private'], ); data.image_by_id = this.formUpload.imageId; promise = API.Pin.createFromUploaded(data);