diff --git a/openslides/assignments/views.py b/openslides/assignments/views.py index 5d60a5f59..15cf3e411 100644 --- a/openslides/assignments/views.py +++ b/openslides/assignments/views.py @@ -389,18 +389,28 @@ class AssignmentPollViewSet(BasePollViewSet): for option_id, vote in options_data.items(): option = options.get(pk=int(option_id)) Y = self.parse_decimal_value(vote["Y"], min_value=-2) - AssignmentVote.objects.create(option=option, value="Y", weight=Y) + vote_obj, _ = AssignmentVote.objects.get_or_create(option=option, value="Y") + vote_obj.weight = Y + vote_obj.save() if poll.pollmethod in ( AssignmentPoll.POLLMETHOD_YN, AssignmentPoll.POLLMETHOD_YNA, ): N = self.parse_decimal_value(vote["N"], min_value=-2) - AssignmentVote.objects.create(option=option, value="N", weight=N) + vote_obj, _ = AssignmentVote.objects.get_or_create( + option=option, value="N" + ) + vote_obj.weight = N + vote_obj.save() if poll.pollmethod == AssignmentPoll.POLLMETHOD_YNA: A = self.parse_decimal_value(vote["A"], min_value=-2) - AssignmentVote.objects.create(option=option, value="A", weight=A) + vote_obj, _ = AssignmentVote.objects.get_or_create( + option=option, value="A" + ) + vote_obj.weight = A + vote_obj.save() # Create votes for global no and global abstain first_option = options.first() diff --git a/openslides/motions/views.py b/openslides/motions/views.py index bc98d956b..db473cf78 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -1180,10 +1180,16 @@ class MotionPollViewSet(BasePollViewSet): A = self.parse_decimal_value(data.get("A"), min_value=-2) option = poll.options.get() - MotionVote.objects.create(option=option, value="Y", weight=Y) - MotionVote.objects.create(option=option, value="N", weight=N) + vote, _ = MotionVote.objects.get_or_create(option=option, value="Y") + vote.weight = Y + vote.save() + vote, _ = MotionVote.objects.get_or_create(option=option, value="N") + vote.weight = N + vote.save() if poll.pollmethod == MotionPoll.POLLMETHOD_YNA: - MotionVote.objects.create(option=option, value="A", weight=A) + vote, _ = MotionVote.objects.get_or_create(option=option, value="A") + vote.weight = A + vote.save() if "votesvalid" in data: poll.votesvalid = self.parse_decimal_value(data["votesvalid"], min_value=-2) diff --git a/openslides/poll/views.py b/openslides/poll/views.py index 59064b398..be8d3c8d5 100644 --- a/openslides/poll/views.py +++ b/openslides/poll/views.py @@ -17,6 +17,8 @@ from .models import BasePoll class BasePollViewSet(ModelViewSet): + valid_update_keys = ["majority_method", "onehundred_percent_base"] + def check_view_permissions(self): """ the vote view is checked seperately. For all other views manage permissions @@ -31,18 +33,28 @@ class BasePollViewSet(ModelViewSet): poll = serializer.save() poll.create_options() - def update(self, *args, **kwargs): + def update(self, request, *args, **kwargs): """ Customized view endpoint to update a motion poll. """ poll = self.get_object() - if poll.state != BasePoll.STATE_CREATED: - raise ValidationError( - {"detail": "You can just edit a poll if it was not started."} - ) + partial = kwargs.get("partial", False) + serializer = self.get_serializer(poll, data=request.data, partial=partial) + serializer.is_valid(raise_exception=False) - return super().update(*args, **kwargs) + if poll.state != BasePoll.STATE_CREATED: + invalid_keys = set(serializer.validated_data.keys()) - set( + self.valid_update_keys + ) + if len(invalid_keys): + raise ValidationError( + { + "detail": f"The poll is not in the created state. You can only edit: {', '.join(self.valid_update_keys)}" + } + ) + + return super().update(request, *args, **kwargs) @detail_route(methods=["POST"]) def start(self, request, pk): @@ -118,8 +130,6 @@ class BasePollViewSet(ModelViewSet): For motion polls: Just "Y", "N" or "A" (if pollmethod is "YNA") """ poll = self.get_object() - if poll.state != BasePoll.STATE_STARTED: - raise ValidationError({"detail": "You cannot vote for an unstarted poll"}) if isinstance(request.user, AnonymousUser): self.permission_denied(request) @@ -129,23 +139,37 @@ class BasePollViewSet(ModelViewSet): if not self.has_manage_permissions(): self.permission_denied(request) + if ( + poll.state != BasePoll.STATE_STARTED + and poll.state != BasePoll.STATE_FINISHED + ): + raise ValidationError( + {"detail": "You cannot vote for a poll in this state"} + ) + self.handle_analog_vote(request.data, poll, request.user) # special: change the poll state to finished. poll.state = BasePoll.STATE_FINISHED poll.save() - elif poll.type == BasePoll.TYPE_NAMED: - self.assert_can_vote(poll, request) - self.handle_named_vote(request.data, poll, request.user) - poll.voted.add(request.user) + else: + if poll.state != BasePoll.STATE_STARTED: + raise ValidationError( + {"detail": "You cannot vote for an unstarted poll"} + ) - elif poll.type == BasePoll.TYPE_PSEUDOANONYMOUS: - self.assert_can_vote(poll, request) + if poll.type == BasePoll.TYPE_NAMED: + self.assert_can_vote(poll, request) + self.handle_named_vote(request.data, poll, request.user) + poll.voted.add(request.user) - if request.user in poll.voted.all(): - self.permission_denied(request) - self.handle_pseudoanonymous_vote(request.data, poll) - poll.voted.add(request.user) + elif poll.type == BasePoll.TYPE_PSEUDOANONYMOUS: + self.assert_can_vote(poll, request) + + if request.user in poll.voted.all(): + self.permission_denied(request) + self.handle_pseudoanonymous_vote(request.data, poll) + poll.voted.add(request.user) inform_changed_data(poll) # needed for the changed voted relation return Response() diff --git a/tests/integration/assignments/test_polls.py b/tests/integration/assignments/test_polls.py index 0702409c1..cc8fc0fe9 100644 --- a/tests/integration/assignments/test_polls.py +++ b/tests/integration/assignments/test_polls.py @@ -535,6 +535,40 @@ class UpdateAssignmentPoll(TestCase): self.assertTrue(poll.allow_multiple_votes_per_candidate) self.assertEqual(poll.votes_amount, 42) + def test_patch_majority_method_state_not_created(self): + self.poll.state = 2 + self.poll.save() + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"majority_method": "two_thirds"}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.majority_method, "two_thirds") + + def test_patch_100_percent_base_state_not_created(self): + self.poll.state = 2 + self.poll.save() + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": "cast"}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, "cast") + + def test_patch_wrong_100_percent_base_state_not_created(self): + self.poll.state = 2 + self.poll.pollmethod = AssignmentPoll.POLLMETHOD_YN + self.poll.save() + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YNA}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, "YN") + class VoteAssignmentPollBaseTestClass(TestCase): def advancedSetUp(self): @@ -721,6 +755,39 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) + def test_vote_state_finished(self): + self.start_poll() + self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + { + "options": {"1": {"Y": 5, "N": 0, "A": 1}}, + "votesvalid": "-2", + "votesinvalid": "1", + "votescast": "-1", + }, + ) + self.poll.state = 3 + self.poll.save() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + { + "options": {"1": {"Y": 2, "N": 2, "A": 2}}, + "votesvalid": "4.64", + "votesinvalid": "-2", + "votescast": "3", + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("4.64")) + self.assertEqual(poll.votesinvalid, Decimal("-2")) + self.assertEqual(poll.votescast, Decimal("3")) + self.assertEqual(poll.get_votes().count(), 3) + option = poll.options.get() + self.assertEqual(option.yes, Decimal("2")) + self.assertEqual(option.no, Decimal("2")) + self.assertEqual(option.abstain, Decimal("2")) + class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): def create_poll(self): diff --git a/tests/integration/motions/test_polls.py b/tests/integration/motions/test_polls.py index 4bbe9ef40..6e9c60340 100644 --- a/tests/integration/motions/test_polls.py +++ b/tests/integration/motions/test_polls.py @@ -369,6 +369,40 @@ class UpdateMotionPoll(TestCase): poll = MotionPoll.objects.get() self.assertEqual(poll.title, "test_title_beeFaihuNae1vej2ai8m") + def test_patch_majority_method_state_not_created(self): + self.poll.state = 2 + self.poll.save() + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"majority_method": "two_thirds"}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.majority_method, "two_thirds") + + def test_patch_100_percent_base_state_not_created(self): + self.poll.state = 2 + self.poll.save() + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": "cast"}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, "cast") + + def test_patch_wrong_100_percent_base_state_not_created(self): + self.poll.state = 2 + self.poll.pollmethod = MotionPoll.POLLMETHOD_YN + self.poll.save() + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": MotionPoll.PERCENT_BASE_YNA}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, "YN") + class VoteMotionPollAnalog(TestCase): def setUp(self): @@ -470,6 +504,43 @@ class VoteMotionPollAnalog(TestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + def test_vote_state_finished(self): + self.start_poll() + self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), + { + "Y": "3", + "N": "1", + "A": "5", + "votesvalid": "-2", + "votesinvalid": "1", + "votescast": "-1", + }, + ) + self.poll.state = 3 + self.poll.save() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), + { + "Y": "1", + "N": "2.35", + "A": "-1", + "votesvalid": "4.64", + "votesinvalid": "-2", + "votescast": "3", + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("4.64")) + self.assertEqual(poll.votesinvalid, Decimal("-2")) + self.assertEqual(poll.votescast, Decimal("3")) + self.assertEqual(poll.get_votes().count(), 3) + option = poll.options.get() + self.assertEqual(option.yes, Decimal("1")) + self.assertEqual(option.no, Decimal("2.35")) + self.assertEqual(option.abstain, Decimal("-1")) + class VoteMotionPollNamed(TestCase): def setUp(self):