mirror of
https://github.com/pinry/pinry.git
synced 2026-03-13 15:40:33 +01:00
Merge pull request #178 from pinry/feature/private-pin-and-board
close #52 private pin and board
This commit is contained in:
20
core/migrations/0007_pin_private.py
Normal file
20
core/migrations/0007_pin_private.py
Normal file
@@ -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),
|
||||
),
|
||||
]
|
||||
20
core/migrations/0008_board_private.py
Normal file
20
core/migrations/0008_board_private.py
Normal file
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"}
|
||||
)
|
||||
|
||||
@@ -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!'
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
maxlength="128"
|
||||
>
|
||||
</b-input>
|
||||
</b-field>
|
||||
<b-field label="Privacy Option"
|
||||
:type="createModel.form.private.type"
|
||||
:message="createModel.form.private.error">
|
||||
<b-checkbox v-model="createModel.form.private.value">
|
||||
{{ createModel.form.private.value?"only visible to yourself":"visible to everyone" }}
|
||||
</b-checkbox>
|
||||
</b-field>
|
||||
</div>
|
||||
<div v-if="isEdit">
|
||||
@@ -30,6 +37,13 @@
|
||||
maxlength="128"
|
||||
>
|
||||
</b-input>
|
||||
</b-field>
|
||||
<b-field label="Privacy Option"
|
||||
:type="editModel.form.private.type"
|
||||
:message="editModel.form.private.error">
|
||||
<b-checkbox v-model="editModel.form.private.value">
|
||||
{{ editModel.form.private.value?"only visible to yourself":"visible to everyone" }}
|
||||
</b-checkbox>
|
||||
</b-field>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
>
|
||||
</b-input>
|
||||
</b-field>
|
||||
<b-field label="Privacy Option"
|
||||
:type="pinModel.form.private.type"
|
||||
:message="pinModel.form.private.error">
|
||||
<b-checkbox v-model="pinModel.form.private.value">
|
||||
{{ pinModel.form.private.value?"only visible to yourself":"visible to everyone" }}
|
||||
</b-checkbox>
|
||||
</b-field>
|
||||
<b-field label="Image Referer"
|
||||
:type="pinModel.form.referer.type"
|
||||
:message="pinModel.form.referer.error">
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user