diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 93dc2a9f4433f1a9c7d4d05e3c892fddaf79f60c..f4cd85cf4deffcffb7d8664d465a9549225b23d7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,6 +2,7 @@ image: python:3.6 stages: + - test - release publish: @@ -17,4 +18,15 @@ publish: only: - master tags: - - deploy \ No newline at end of file + - deploy + +test: + stage: test + script: + - pip install .[dev] + - python -m unittest djangoldp_polls.tests.runner + except: + - master + - tags + tags: + - test \ No newline at end of file diff --git a/djangoldp_polls/admin.py b/djangoldp_polls/admin.py deleted file mode 100644 index f6e818604a03ee0ad601c161142e328a872d3739..0000000000000000000000000000000000000000 --- a/djangoldp_polls/admin.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.contrib import admin -from guardian.admin import GuardedModelAdmin -from django.db import models -from .models import Poll - -admin.site.register(Poll, GuardedModelAdmin) - diff --git a/djangoldp_polls/djangoldp_urls.py b/djangoldp_polls/djangoldp_urls.py index 86ca1efb399956accccd1dbe9e51bfacab1335f3..0b56692cf5726586b28c5f25ff674cd1b9329462 100644 --- a/djangoldp_polls/djangoldp_urls.py +++ b/djangoldp_polls/djangoldp_urls.py @@ -16,8 +16,8 @@ Including another URLconf """djangoldp project URL Configuration""" from django.conf.urls import url,include -from .views import TotalVotes from djangoldp.models import Model +from djangoldp_polls.views import TotalVotes, CanVoteOnPollViewSet from djangoldp_polls.models import PollOption urlpatterns = [ @@ -28,4 +28,5 @@ urlpatterns = [ []), fields=Model.get_meta(PollOption, 'serializer_fields',[]), nested_fields=Model.get_meta(PollOption, 'nested_fields', []))), -] \ No newline at end of file + url(r'^polls/(?P<pk>[0-9]+)/can_vote/', CanVoteOnPollViewSet.as_view()) +] diff --git a/djangoldp_polls/migrations/0011_auto_20200923_1355.py b/djangoldp_polls/migrations/0011_auto_20200923_1355.py new file mode 100644 index 0000000000000000000000000000000000000000..c2723b16a61a7c4a5bf37e801e834c6a36aebcda --- /dev/null +++ b/djangoldp_polls/migrations/0011_auto_20200923_1355.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.16 on 2020-09-23 13:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('djangoldp_polls', '0010_auto_20200923_0939'), + ] + + operations = [ + migrations.AlterModelOptions( + name='poll', + options={'default_permissions': ('add', 'change', 'delete', 'view', 'control')}, + ), + migrations.AlterModelOptions( + name='polloption', + options={'default_permissions': ('add', 'change', 'delete', 'view', 'control')}, + ), + migrations.AlterModelOptions( + name='tag', + options={'default_permissions': ('add', 'change', 'delete', 'view', 'control')}, + ), + migrations.AlterModelOptions( + name='vote', + options={'default_permissions': ('add', 'change', 'delete', 'view', 'control')}, + ), + ] diff --git a/djangoldp_polls/models.py b/djangoldp_polls/models.py index 39cf542435f7ae5cedd9239c2e478eb0b6648766..e076e2b5615a309e272d41dd52d2ba02791f009a 100644 --- a/djangoldp_polls/models.py +++ b/djangoldp_polls/models.py @@ -1,9 +1,10 @@ from django.conf import settings from django.db import models -from djangoldp.models import Model -from django.db.models import Sum -from django.contrib.auth import get_user_model +from django.http import Http404 +from rest_framework import serializers +from djangoldp.models import Model +from djangoldp.views import LDPViewSet from djangoldp_conversation.models import Conversation from djangoldp_circle.models import Circle @@ -17,7 +18,7 @@ from djangoldp_circle.models import Circle class Tag (Model): name = models.CharField(max_length=250,verbose_name="Name") - class Meta : + class Meta(Model.Meta): serializer_fields = ['@id','name'] anonymous_perms = ['view'] authenticated_perms = ['inherit','add'] @@ -29,7 +30,7 @@ class Tag (Model): class PollOption (Model): name = models.CharField(max_length=250,verbose_name="Options available for a vote") - class Meta : + class Meta(Model.Meta): serializer_fields = ['@id','name'] nested_fields = ['userVote','relatedPollOptions'] anonymous_perms = ['view','add'] @@ -54,7 +55,7 @@ class Poll (Model): debate = models.ManyToManyField(Conversation, related_name='polls', blank=True) circle = models.ForeignKey(Circle, null=True, related_name="polls", on_delete=models.SET_NULL) - class Meta : + class Meta(Model.Meta): serializer_fields = ['@id','created_at','debate','pollOptions','votes','author','title','image','circle',\ 'hostingOrganisation','startDate','endDate','shortDescription','longDescription','tags'] nested_fields = ['tags','votes','pollOptions','debate','circle'] @@ -65,18 +66,39 @@ class Poll (Model): return self.title +# I know this shouldn't live here, but putting it in views results in circular dependency problems +# https://git.startinblox.com/djangoldp-packages/djangoldp/issues/278 +class VoteViewSet(LDPViewSet): + def is_safe_create(self, user, validated_data, *args, **kwargs): + try: + if 'poll' in validated_data.keys(): + poll = Poll.objects.get(urlid=validated_data['poll']['urlid']) + else: + poll = self.get_parent() + + if Vote.objects.filter(relatedPoll=poll, user=user).exists(): + raise serializers.ValidationError('You may only vote on this poll once!') + + except Poll.DoesNotExist: + return True + except (KeyError, AttributeError): + raise Http404('circle not specified with urlid') + + return True + class Vote (Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='votes',null=True,blank=True, on_delete=models.SET_NULL) chosenOption = models.ForeignKey(PollOption, related_name='userVote', on_delete=models.CASCADE) relatedPoll = models.ForeignKey(Poll, related_name='votes', on_delete=models.CASCADE) - class Meta : + class Meta(Model.Meta): auto_author = "user" serializer_fields = ['@id','chosenOption','relatedPoll'] nested_fields = [] anonymous_perms = ['view','add','change'] authenticated_perms = ['inherit','add'] + view_set = VoteViewSet def __str__(self): - return self.chosenOption.__str__() \ No newline at end of file + return self.chosenOption.__str__() diff --git a/djangoldp_polls/tests/__init__.py b/djangoldp_polls/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/djangoldp_polls/tests/models.py b/djangoldp_polls/tests/models.py new file mode 100644 index 0000000000000000000000000000000000000000..4e534d6061fc4b06dbfb3b1df4ee8414302b8e57 --- /dev/null +++ b/djangoldp_polls/tests/models.py @@ -0,0 +1,12 @@ +from django.contrib.auth.models import AbstractUser + +from djangoldp.models import Model + + +class User(AbstractUser, Model): + + class Meta(AbstractUser.Meta, Model.Meta): + serializer_fields = ['@id', 'username', 'first_name', 'last_name', 'email'] + anonymous_perms = ['view', 'add'] + authenticated_perms = ['inherit', 'change'] + owner_perms = ['inherit'] diff --git a/djangoldp_polls/tests/runner.py b/djangoldp_polls/tests/runner.py new file mode 100644 index 0000000000000000000000000000000000000000..9a2a55ef9c0d0d04d9a000846609cdcd7bf454ea --- /dev/null +++ b/djangoldp_polls/tests/runner.py @@ -0,0 +1,41 @@ +import sys + +import django +from django.conf import settings +from djangoldp.tests import settings_default + +settings.configure(default_settings=settings_default, + DJANGOLDP_PACKAGES=['djangoldp_polls', 'djangoldp_circle', 'djangoldp_conversation' 'djangoldp_polls.tests', ], + INSTALLED_APPS=('django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.admin', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'guardian', + 'djangoldp_polls', + 'djangoldp_polls.tests', + 'djangoldp_circle', + 'djangoldp_conversation', + 'djangoldp', + ), + SITE_URL='http://happy-dev.fr', + BASE_URL='http://happy-dev.fr', + REST_FRAMEWORK={ + 'DEFAULT_PAGINATION_CLASS': 'djangoldp.pagination.LDPPagination', + 'PAGE_SIZE': 5 + }, + SEND_BACKLINKS=False, + JABBER_DEFAULT_HOST=None, + ) + +django.setup() +from django.test.runner import DiscoverRunner + +test_runner = DiscoverRunner(verbosity=1) + +failures = test_runner.run_tests([ + 'djangoldp_polls.tests.tests_votes', +]) +if failures: + sys.exit(failures) diff --git a/djangoldp_polls/tests/tests_votes.py b/djangoldp_polls/tests/tests_votes.py new file mode 100644 index 0000000000000000000000000000000000000000..5827435c2e0e963baeff898b0f6ca8ef7e01d85b --- /dev/null +++ b/djangoldp_polls/tests/tests_votes.py @@ -0,0 +1,79 @@ +import uuid +import json +from datetime import datetime, timedelta +from django.conf import settings +from rest_framework.test import APITestCase, APIClient + +from djangoldp_polls.models import Poll, PollOption, Vote +from djangoldp_polls.tests.models import User + + +class PermissionsTestCase(APITestCase): + def setUp(self): + self.client = APIClient() + self.setUpLoggedInUser() + + def setUpLoggedInUser(self): + self.user = User(email='test@mactest.co.uk', first_name='Test', last_name='Mactest', username='test', + password='glass onion') + self.user.save() + self.client.force_authenticate(user=self.user) + + def setUpPoll(self): + self.poll_option_a = PollOption.objects.create(name='Yes') + self.poll_option_b = PollOption.objects.create(name='No') + self.poll = self._get_poll('Test') + + def _get_poll(self, title): + poll = Poll.objects.create(endDate=datetime.now(), title=title, hostingOrganisation='Test', + shortDescription='Hello', longDescription='Hello World') + poll.pollOptions.add(self.poll_option_a, self.poll_option_b) + return poll + + def _get_poll_post_request(self, option): + return { + '@context': settings.LDP_RDF_CONTEXT, + 'choiceValue': '', + 'chosenOption': {'@id': option.urlid} + } + + def test_can_vote_view(self): + # I should be able to vote + self.setUpPoll() + response = self.client.get('/polls/{}/can_vote/'.format(self.poll.pk)) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, True) + + # I should not be able to vote + Vote.objects.create(user=self.user, relatedPoll=self.poll, chosenOption=self.poll_option_a) + response = self.client.get('/polls/{}/can_vote/'.format(self.poll.pk)) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, False) + + def test_post_vote(self): + # post on one poll + self.setUpPoll() + body = self._get_poll_post_request(self.poll_option_a) + response = self.client.post('/polls/{}/votes/'.format(self.poll.pk), json.dumps(body), content_type='application/ld+json') + self.assertEqual(response.status_code, 201) + + # post on another + other_poll = self._get_poll('Second Poll') + body = self._get_poll_post_request(self.poll_option_b) + response = self.client.post('/polls/{}/votes/'.format(other_poll.pk), json.dumps(body), + content_type='application/ld+json') + self.assertEqual(response.status_code, 201) + + def test_post_vote_duplicate(self): + # post once + self.setUpPoll() + body = self._get_poll_post_request(self.poll_option_a) + response = self.client.post('/polls/{}/votes/'.format(self.poll.pk), json.dumps(body), + content_type='application/ld+json') + self.assertEqual(response.status_code, 201) + + # post again - should be rejected + body = self._get_poll_post_request(self.poll_option_b) + response = self.client.post('/polls/{}/votes/'.format(self.poll.pk), json.dumps(body), + content_type='application/ld+json') + self.assertEqual(response.status_code, 400) diff --git a/djangoldp_polls/views.py b/djangoldp_polls/views.py index 38895c353c5da3164e81d21d7fce2a958e7a9c9d..37c7631fe6c780c7d58f45dec5d55093e72116d7 100644 --- a/djangoldp_polls/views.py +++ b/djangoldp_polls/views.py @@ -4,12 +4,30 @@ from djangoldp.views import LDPViewSet from datetime import datetime from rest_framework import status from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from .models import Poll,Vote from .serializers import PollOptionSerializer +class CanVoteOnPollViewSet(APIView): + permission_classes = (IsAuthenticated,) + + def get(self, request, pk): + '''returns True if the user can vote, or False if they have already voted''' + try: + poll = Poll.objects.get(pk=pk) + can_vote = True + if Vote.objects.filter(relatedPoll=poll, user=request.user).exists(): + can_vote = False + return Response(can_vote, status=status.HTTP_200_OK) + + except Poll.DoesNotExist: + return Response(data={'error': {'poll': ['Could not find poll with this ID!']}}, + status=status.HTTP_404_NOT_FOUND) + + class FuturePollViewset(LDPViewSet): model = Poll @@ -40,3 +58,8 @@ class TotalVotes(LDPViewSet): def get_queryset(self, *args, **kwargs): poll = self._get_poll_or_404() return poll.pollOptions.all() + + def get_serializer_class(self): + # NOTE: this is required because currently DjangoLDP overrides the serializer_class during __init__ + # https://git.startinblox.com/djangoldp-packages/djangoldp/issues/241 + return PollOptionSerializer