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"
>
+
+