added testing for named and pseudoanonymous assignment voting

added queries count tests for assignment and motion polls and votes
This commit is contained in:
jsangmeister 2019-10-29 12:58:37 +01:00 committed by FinnStutzenstein
parent ce171980e8
commit 5fa8341614
9 changed files with 1388 additions and 60 deletions

View File

@ -1,5 +1,8 @@
import json
from typing import Any, Dict, List
from ..poll.access_permissions import BaseVoteAccessPermissions
from ..poll.views import BasePoll
from ..utils.access_permissions import BaseAccessPermissions
from ..utils.auth import async_has_perm
@ -50,13 +53,35 @@ class AssignmentPollAccessPermissions(BaseAccessPermissions):
async def get_restricted_data(
self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]:
return full_data
"""
Poll-managers have full access, even during an active poll.
Non-published polls will be restricted:
- Remove votes* values from the poll
- Remove yes/no/abstain fields from options
- Remove voted_id field from the poll
"""
if await async_has_perm(user_id, "assignments.can_manage_polls"):
data = full_data
else:
data = []
for poll in full_data:
if poll["state"] != BasePoll.STATE_PUBLISHED:
poll = json.loads(
json.dumps(poll)
) # copy, so we can remove some fields.
del poll["votesvalid"]
del poll["votesinvalid"]
del poll["votescast"]
del poll["voted_id"]
for option in poll["options"]:
del option["yes"]
del option["no"]
del option["abstain"]
data.append(poll)
return data
class AssignmentVoteAccessPermissions(BaseAccessPermissions):
class AssignmentVoteAccessPermissions(BaseVoteAccessPermissions):
base_permission = "assignments.can_see"
async def get_restricted_data(
self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]:
return full_data
manage_permission = "assignments.can_manage"

View File

@ -268,8 +268,23 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model
return {"title": self.title}
class AssignmentVoteManager(models.Manager):
"""
Customized model manager to support our get_full_queryset method.
"""
def get_full_queryset(self):
"""
Returns the normal queryset with all assignment votes. In the background we
join and prefetch all related models.
"""
return self.get_queryset().select_related("user", "option", "option__poll")
class AssignmentVote(RESTModelMixin, BaseVote):
access_permissions = AssignmentVoteAccessPermissions()
objects = AssignmentVoteManager()
option = models.ForeignKey(
"AssignmentOption", on_delete=models.CASCADE, related_name="votes"
)
@ -296,11 +311,32 @@ class AssignmentOption(RESTModelMixin, BaseOption):
return self.poll
class AssignmentPollManager(models.Manager):
"""
Customized model manager to support our get_full_queryset method.
"""
def get_full_queryset(self):
"""
Returns the normal queryset with all assignment polls. In the background we
join and prefetch all related models.
"""
return (
self.get_queryset()
.select_related("assignment")
.prefetch_related(
"options", "options__user", "options__votes", "groups", "voted"
)
)
# Meta-TODO: Is this todo resolved?
# TODO: remove the type-ignoring in the next line, after this is solved:
# https://github.com/python/mypy/issues/3855
class AssignmentPoll(RESTModelMixin, BasePoll):
access_permissions = AssignmentPollAccessPermissions()
objects = AssignmentPollManager()
option_class = AssignmentOption
assignment = models.ForeignKey(

View File

@ -428,8 +428,13 @@ class AssignmentPollViewSet(BasePollViewSet):
if not is_int(amount):
raise ValidationError({"detail": "Each amounts must be int"})
amount = int(amount)
if amount < 1:
raise ValidationError({"detail": "At least 1 vote per option"})
if amount < 0:
raise ValidationError(
{"detail": "Negative votes are not allowed"}
)
# skip empty votes
if amount == 0:
continue
if not poll.allow_multiple_votes_per_candidate and amount != 1:
raise ValidationError(
{"detail": "Multiple votes are not allowed"}
@ -485,27 +490,41 @@ class AssignmentPollViewSet(BasePollViewSet):
)
def create_votes(self, data, poll, user=None):
"""
Helper function for handle_(named|pseudoanonymous)_vote
Assumes data is already validated
"""
options = poll.get_options()
if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES:
if isinstance(data, dict):
for option_id, amount in data.items():
# skip empty votes
if amount == 0:
continue
option = options.get(pk=option_id)
vote = AssignmentVote.objects.create(
option=option, user=user, weight=Decimal(amount), value="Y"
)
inform_changed_data(vote, no_delete_on_restriction=True)
else:
else: # global_no or global_abstain
option = options.first()
vote = AssignmentVote.objects.create(
option=option, user=user, weight=Decimal(1), value=data
option=option,
user=user,
weight=Decimal(poll.votes_amount),
value=data,
)
inform_changed_data(vote, no_delete_on_restriction=True)
elif poll.pollmethod in (
AssignmentPoll.POLLMETHOD_YN,
AssignmentPoll.POLLMETHOD_YNA,
):
pass
# TODO
for option_id, result in data.items():
option = options.get(pk=option_id)
vote = AssignmentVote.objects.create(
option=option, user=user, value=result
)
inform_changed_data(vote, no_delete_on_restriction=True)
def handle_named_vote(self, data, poll, user):
"""
@ -514,9 +533,9 @@ class AssignmentPollViewSet(BasePollViewSet):
- Exactly one of the three options must be given
- 'N' is only valid if poll.global_no==True
- 'A' is only valid if poll.global_abstain==True
- amonts must be integer numbers >= 1.
- amounts must be integer numbers >= 1.
- ids should be integers of valid option ids for this poll
- amounts must be one ("1"), if poll.allow_multiple_votes_per_candidate if False
- amounts must be 0 or 1, if poll.allow_multiple_votes_per_candidate is False
- The sum of all amounts must be poll.votes_amount votes
Request data for YN/YNA pollmethod:

View File

@ -1,6 +1,8 @@
import json
from typing import Any, Dict, List
from ..poll.access_permissions import BaseVoteAccessPermissions
from ..poll.views import BasePoll
from ..utils.access_permissions import BaseAccessPermissions
from ..utils.auth import async_has_perm, async_in_some_groups
@ -183,7 +185,6 @@ class StateAccessPermissions(BaseAccessPermissions):
class MotionPollAccessPermissions(BaseAccessPermissions):
base_permission = "motions.can_see"
STATE_PUBLISHED = 4
async def get_restricted_data(
self, full_data: List[Dict[str, Any]], user_id: int
@ -201,7 +202,7 @@ class MotionPollAccessPermissions(BaseAccessPermissions):
else:
data = []
for poll in full_data:
if poll["state"] != self.STATE_PUBLISHED:
if poll["state"] != BasePoll.STATE_PUBLISHED:
poll = json.loads(
json.dumps(poll)
) # copy, so we can remove some fields.
@ -217,26 +218,6 @@ class MotionPollAccessPermissions(BaseAccessPermissions):
return data
class MotionVoteAccessPermissions(BaseAccessPermissions):
class MotionVoteAccessPermissions(BaseVoteAccessPermissions):
base_permission = "motions.can_see"
STATE_PUBLISHED = 4
async def get_restricted_data(
self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]:
"""
Poll-managers have full access, even during an active poll.
Every user can see it's own votes.
If the pollstate is published, everyone can see the votes.
"""
if await async_has_perm(user_id, "motions.can_manage_polls"):
data = full_data
else:
data = [
vote
for vote in full_data
if vote["pollstate"] == self.STATE_PUBLISHED
or vote["user_id"] == user_id
]
return data
manage_permission = "motions.can_manage_polls"

View File

@ -867,12 +867,27 @@ class MotionBlock(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Mode
return {"title": self.title}
class MotionVoteManager(models.Manager):
"""
Customized model manager to support our get_full_queryset method.
"""
def get_full_queryset(self):
"""
Returns the normal queryset with all motion votes. In the background we
join and prefetch all related models.
"""
return self.get_queryset().select_related("user", "option", "option__poll")
class MotionVote(RESTModelMixin, BaseVote):
access_permissions = MotionVoteAccessPermissions()
option = models.ForeignKey(
"MotionOption", on_delete=models.CASCADE, related_name="votes"
)
objects = MotionVoteManager()
class Meta:
default_permissions = ()
@ -891,6 +906,23 @@ class MotionOption(RESTModelMixin, BaseOption):
return self.poll
class MotionPollManager(models.Manager):
"""
Customized model manager to support our get_full_queryset method.
"""
def get_full_queryset(self):
"""
Returns the normal queryset with all motion polls. In the background we
join and prefetch all related models.
"""
return (
self.get_queryset()
.select_related("motion")
.prefetch_related("options", "options__votes", "groups", "voted")
)
# Meta-TODO: Is this todo resolved?
# TODO: remove the type-ignoring in the next line, after this is solved:
# https://github.com/python/mypy/issues/3855
@ -898,6 +930,8 @@ class MotionPoll(RESTModelMixin, BasePoll):
access_permissions = MotionPollAccessPermissions()
option_class = MotionOption
objects = MotionPollManager()
motion = models.ForeignKey(Motion, on_delete=models.CASCADE, related_name="polls")
POLLMETHOD_YN = "YN"

View File

@ -0,0 +1,29 @@
from typing import Any, Dict, List
from ..poll.views import BasePoll
from ..utils.access_permissions import BaseAccessPermissions
from ..utils.auth import async_has_perm
class BaseVoteAccessPermissions(BaseAccessPermissions):
manage_permission = "" # set by subclass
async def get_restricted_data(
self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]:
"""
Poll-managers have full access, even during an active poll.
Every user can see it's own votes.
If the pollstate is published, everyone can see the votes.
"""
if await async_has_perm(user_id, self.manage_permission):
data = full_data
else:
data = [
vote
for vote in full_data
if vote["pollstate"] == BasePoll.STATE_PUBLISHED
or vote["user_id"] == user_id
]
return data

View File

@ -136,9 +136,7 @@ class BasePollViewSet(ModelViewSet):
self.assert_can_vote(poll, request)
if request.user in poll.voted.all():
raise ValidationError(
{"detail": "You have already voted for this poll."}
)
self.permission_denied(request)
self.handle_pseudoanonymous_vote(request.data, poll)
poll.voted.add(request.user)

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,77 @@
from decimal import Decimal
import pytest
from django.contrib.auth import get_user_model
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from openslides.core.config import config
from openslides.motions.models import Motion, MotionPoll, MotionVote
from openslides.motions.models import Motion, MotionOption, MotionPoll, MotionVote
from openslides.poll.models import BasePoll
from openslides.utils.auth import get_group_model
from openslides.utils.autoupdate import inform_changed_data
from tests.common_groups import GROUP_ADMIN_PK, GROUP_DEFAULT_PK, GROUP_DELEGATE_PK
from tests.test_case import TestCase
from ..helpers import count_queries
@pytest.mark.django_db(transaction=False)
def test_motion_poll_db_queries():
"""
Tests that only the following db queries are done:
* 1 request to get the polls,
* 1 request to get all options for all polls,
* 1 request to get all votes for all options,
* 1 request to get all users for all votes,
* 1 request to get all poll groups,
= 5 queries
"""
create_motion_polls()
assert count_queries(MotionPoll.get_elements) == 5
@pytest.mark.django_db(transaction=False)
def test_motion_vote_db_queries():
"""
Tests that only 1 query is done when fetching MotionVotes
"""
create_motion_polls()
assert count_queries(MotionVote.get_elements) == 1
def create_motion_polls():
"""
Creates 1 Motion with 5 polls with 5 options each which have 2 votes each
"""
motion = Motion.objects.create(title="test_motion_wfLrsjEHXBmPplbvQ65N")
group1 = get_group_model().objects.get(pk=1)
group2 = get_group_model().objects.get(pk=2)
for index in range(5):
poll = MotionPoll.objects.create(
motion=motion, title=f"test_title_{index}", pollmethod="YN", type="named"
)
poll.groups.add(group1)
poll.groups.add(group2)
for j in range(5):
option = MotionOption.objects.create(poll=poll)
for k in range(2):
user = get_user_model().objects.create_user(
username=f"test_username_{index}{j}{k}",
password="test_password_kbzj5L8ZtVxBllZzoW6D",
)
MotionVote.objects.create(
user=user,
option=option,
value=("Y" if k == 0 else "N"),
weight=Decimal(1),
)
poll.voted.add(user)
class CreateMotionPoll(TestCase):
"""
@ -816,7 +875,7 @@ class VoteMotionPollPseudoanonymous(TestCase):
response = self.client.post(
reverse("motionpoll-vote", args=[self.poll.pk]), "A", format="json"
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
option = MotionPoll.objects.get().options.get()
self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("1"))