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