From 7e763e8c072d57eaf15ec2b47271918309145c9e Mon Sep 17 00:00:00 2001 From: Finn Stutzenstein Date: Tue, 24 Nov 2020 09:56:20 +0100 Subject: [PATCH] Minumum amount of votes renamed votes_amount to max_votes_amount --- .../models/assignments/assignment-poll.ts | 3 +- .../assignment-poll-vote.component.html | 5 +- .../assignment-poll-vote.component.ts | 10 +- .../poll-form/poll-form.component.html | 18 ++- .../poll-form/poll-form.component.ts | 15 +- .../app/site/polls/models/view-base-poll.ts | 10 +- .../assignment-poll-slide-data.ts | 3 +- .../migrations/0018_votes_amount.py | 26 ++++ server/openslides/assignments/models.py | 10 +- server/openslides/assignments/projector.py | 3 +- server/openslides/assignments/serializers.py | 3 +- server/openslides/assignments/views.py | 13 +- .../integration/assignments/test_polls.py | 133 ++++++++++++++++-- 13 files changed, 212 insertions(+), 40 deletions(-) create mode 100644 server/openslides/assignments/migrations/0018_votes_amount.py diff --git a/client/src/app/shared/models/assignments/assignment-poll.ts b/client/src/app/shared/models/assignments/assignment-poll.ts index 4711b99ae..0a99b254b 100644 --- a/client/src/app/shared/models/assignments/assignment-poll.ts +++ b/client/src/app/shared/models/assignments/assignment-poll.ts @@ -41,7 +41,8 @@ export class AssignmentPoll extends BasePoll< public id: number; public assignment_id: number; - public votes_amount: number; + public min_votes_amount: number; + public max_votes_amount: number; public allow_multiple_votes_per_candidate: boolean; public global_yes: boolean; public global_no: boolean; diff --git a/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.html b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.html index 6c84ba026..9d3dbc9c0 100644 --- a/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.html +++ b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.html @@ -28,10 +28,9 @@

-

+

{{ 'Available votes' | translate }}: - - {{ getVotesAvailable(delegation) }}/{{ poll.votes_amount }} + {{ getVotesAvailable(delegation) }}/{{ poll.max_votes_amount }}

diff --git a/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.ts b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.ts index d38d879ab..333909319 100644 --- a/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.ts +++ b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.ts @@ -141,7 +141,7 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective< } public getVotesAvailable(user: ViewUser = this.user): number { - return this.poll.votes_amount - this.getVotesCount(user); + return this.poll.max_votes_amount - this.getVotesCount(user); } private isGlobalOptionSelected(user: ViewUser = this.user): boolean { @@ -177,12 +177,12 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective< } if (this.poll.isMethodY || this.poll.isMethodN) { - const votesAmount = this.poll.votes_amount; + const maxVotesAmount = this.poll.max_votes_amount; const tmpVoteRequest = this.poll.options .map(option => option.id) .reduce((o, n) => { o[n] = 0; - if (votesAmount === 1) { + if (maxVotesAmount === 1) { if (n === optionId && this.voteRequestData[user.id].votes[n] !== 1) { o[n] = 1; } @@ -195,11 +195,11 @@ export class AssignmentPollVoteComponent extends BasePollVoteComponentDirective< // check if you can still vote const countedVotes = Object.keys(tmpVoteRequest).filter(key => tmpVoteRequest[key]).length; - if (countedVotes <= votesAmount) { + if (countedVotes <= maxVotesAmount) { this.voteRequestData[user.id].votes = tmpVoteRequest; // if you have no options anymore, try to send - if (this.getVotesCount(user) === votesAmount) { + if (this.getVotesCount(user) === maxVotesAmount) { this.submitVote(user); } } else { diff --git a/client/src/app/site/polls/components/poll-form/poll-form.component.html b/client/src/app/site/polls/components/poll-form/poll-form.component.html index f2c4b248f..14aeafebb 100644 --- a/client/src/app/site/polls/components/poll-form/poll-form.component.html +++ b/client/src/app/site/polls/components/poll-form/poll-form.component.html @@ -57,13 +57,25 @@ {{ 'This field is required.' | translate }} - + + + + + + diff --git a/client/src/app/site/polls/components/poll-form/poll-form.component.ts b/client/src/app/site/polls/components/poll-form/poll-form.component.ts index 61591f350..64aa00509 100644 --- a/client/src/app/site/polls/components/poll-form/poll-form.component.ts +++ b/client/src/app/site/polls/components/poll-form/poll-form.component.ts @@ -133,8 +133,8 @@ export class PollFormComponent } if (this.data instanceof ViewAssignmentPoll) { - if (this.data.assignment && !this.data.votes_amount) { - this.data.votes_amount = this.data.assignment.open_posts; + if (this.data.assignment && !this.data.max_votes_amount) { + this.data.max_votes_amount = this.data.assignment.open_posts; } if (!this.data.pollmethod) { this.data.pollmethod = this.configService.instant('assignment_poll_method'); @@ -279,6 +279,14 @@ export class PollFormComponent if (data.pollmethod === 'Y' || data.pollmethod === 'N') { this.pollValues.push([this.pollService.getVerboseNameForKey('votes_amount'), data.votes_amount]); this.pollValues.push([this.pollService.getVerboseNameForKey('global_yes'), data.global_yes]); + this.pollValues.push([ + this.pollService.getVerboseNameForKey('max_votes_amount'), + data.max_votes_amount + ]); + this.pollValues.push([ + this.pollService.getVerboseNameForKey('min_votes_amount'), + data.min_votes_amount + ]); this.pollValues.push([this.pollService.getVerboseNameForKey('global_no'), data.global_no]); this.pollValues.push([this.pollService.getVerboseNameForKey('global_abstain'), data.global_abstain]); } @@ -292,7 +300,8 @@ export class PollFormComponent pollmethod: ['', Validators.required], onehundred_percent_base: ['', Validators.required], majority_method: ['', Validators.required], - votes_amount: [1, [Validators.required, Validators.min(1)]], + max_votes_amount: [1, [Validators.required, Validators.min(1)]], + min_votes_amount: [1, [Validators.required, Validators.min(1)]], groups_id: [], global_yes: [false], global_no: [false], diff --git a/client/src/app/site/polls/models/view-base-poll.ts b/client/src/app/site/polls/models/view-base-poll.ts index c9285cf98..3741fb785 100644 --- a/client/src/app/site/polls/models/view-base-poll.ts +++ b/client/src/app/site/polls/models/view-base-poll.ts @@ -39,16 +39,18 @@ export const PollTypeVerbose = { }; export const PollPropertyVerbose = { - majority_method: 'Required majority', - onehundred_percent_base: '100% base', + majority_method: _('Required majority'), + onehundred_percent_base: _('100% base'), type: _('Voting type'), pollmethod: _('Voting method'), - state: 'State', + state: _('State'), groups: _('Entitled to vote'), votes_amount: _('Amount of votes'), global_yes: _('General approval'), global_no: _('General rejection'), - global_abstain: _('General abstain') + global_abstain: _('General abstain'), + max_votes_amount: _('Maximum amount of votes'), + min_votes_amount: _('Minimum amount of votes') }; export const MajorityMethodVerbose = { diff --git a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts index de4512813..7b7c87a5b 100644 --- a/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts +++ b/client/src/app/slides/assignments/assignment-poll/assignment-poll-slide-data.ts @@ -9,7 +9,8 @@ export interface AssignmentPollSlideData extends BasePollSlideData { title: string; type: PollType; pollmethod: AssignmentPollMethod; - votes_amount: number; + max_votes_amount: number; + min_votes_amount: number; description: string; state: PollState; onehundred_percent_base: PercentBase; diff --git a/server/openslides/assignments/migrations/0018_votes_amount.py b/server/openslides/assignments/migrations/0018_votes_amount.py new file mode 100644 index 000000000..5a3558e88 --- /dev/null +++ b/server/openslides/assignments/migrations/0018_votes_amount.py @@ -0,0 +1,26 @@ +# Generated by Django 2.2.15 on 2020-11-24 08:12 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("assignments", "0017_vote_to_y"), + ] + + operations = [ + migrations.RenameField( + model_name="assignmentpoll", + old_name="votes_amount", + new_name="max_votes_amount", + ), + migrations.AddField( + model_name="assignmentpoll", + name="min_votes_amount", + field=models.IntegerField( + default=1, validators=[django.core.validators.MinValueValidator(1)] + ), + ), + ] diff --git a/server/openslides/assignments/models.py b/server/openslides/assignments/models.py index b8fa9e91f..887b5110c 100644 --- a/server/openslides/assignments/models.py +++ b/server/openslides/assignments/models.py @@ -14,6 +14,7 @@ from openslides.utils.autoupdate import inform_changed_data from openslides.utils.exceptions import OpenSlidesError from openslides.utils.manager import BaseManager from openslides.utils.models import RESTModelMixin +from openslides.utils.rest_api import ValidationError from ..utils.models import CASCADE_AND_AUTOUPDATE, SET_NULL_AND_AUTOUPDATE from .access_permissions import ( @@ -377,7 +378,7 @@ class AssignmentPoll(RESTModelMixin, BasePoll): decimal_places=6, ) - votes_amount = models.IntegerField(default=1, validators=[MinValueValidator(1)]) + max_votes_amount = models.IntegerField(default=1, validators=[MinValueValidator(1)]) """ For "votes" mode: The amount of votes a voter can give. """ allow_multiple_votes_per_candidate = models.BooleanField(default=False) @@ -447,6 +448,13 @@ class AssignmentPoll(RESTModelMixin, BasePoll): get_amount_global_abstain, set_amount_global_abstain ) + def save(self, *args, **kwargs): + if self.max_votes_amount < self.min_votes_amount: + raise ValidationError( + {"detail": "max votes must be larger or equal to min votes"} + ) + super().save(*args, **kwargs) + def create_options(self, skip_autoupdate=False): related_users = AssignmentRelatedUser.objects.filter( assignment__id=self.assignment.id diff --git a/server/openslides/assignments/projector.py b/server/openslides/assignments/projector.py index 1cd07523d..a07e4d73d 100644 --- a/server/openslides/assignments/projector.py +++ b/server/openslides/assignments/projector.py @@ -60,7 +60,8 @@ async def assignment_poll_slide( "title", "type", "pollmethod", - "votes_amount", + "min_votes_amount", + "max_votes_amount", "description", "state", "onehundred_percent_base", diff --git a/server/openslides/assignments/serializers.py b/server/openslides/assignments/serializers.py index 2ca605e5c..7bcca9043 100644 --- a/server/openslides/assignments/serializers.py +++ b/server/openslides/assignments/serializers.py @@ -93,7 +93,8 @@ class AssignmentPollSerializer(BasePollSerializer): "assignment", "description", "pollmethod", - "votes_amount", + "min_votes_amount", + "max_votes_amount", "allow_multiple_votes_per_candidate", "global_yes", "amount_global_yes", diff --git a/server/openslides/assignments/views.py b/server/openslides/assignments/views.py index ef072754d..ad9e00803 100644 --- a/server/openslides/assignments/views.py +++ b/server/openslides/assignments/views.py @@ -377,7 +377,7 @@ class AssignmentPollViewSet(BasePollViewSet): - ids should be integers of valid option ids for this poll - amounts must be 0 or 1, if poll.allow_multiple_votes_per_candidate is False - if an option is not given, 0 is assumed - - The sum of all amounts must be grater than 0 and <= poll.votes_amount + - The sum of all amounts must be >= poll.min_votes_amount and <= poll.max_votes_amount YN/YNA: {: 'Y' | 'N' [|'A']} @@ -471,11 +471,18 @@ class AssignmentPollViewSet(BasePollViewSet): ) amount_sum += amount - if amount_sum > poll.votes_amount: + if amount_sum > poll.max_votes_amount: raise ValidationError( { "detail": "You can give a maximum of {0} votes", - "args": [poll.votes_amount], + "args": [poll.max_votes_amount], + } + ) + if amount_sum < poll.min_votes_amount: + raise ValidationError( + { + "detail": "You must give a minimum of {0} votes", + "args": [poll.min_votes_amount], } ) # return, if there is a global vote, because we dont have to check option presence diff --git a/server/tests/integration/assignments/test_polls.py b/server/tests/integration/assignments/test_polls.py index 1bbdfff20..d6b5ddfa0 100644 --- a/server/tests/integration/assignments/test_polls.py +++ b/server/tests/integration/assignments/test_polls.py @@ -136,7 +136,8 @@ class CreateAssignmentPoll(TestCase): self.assertEqual(poll.amount_global_no, None) self.assertEqual(poll.amount_global_abstain, None) self.assertFalse(poll.allow_multiple_votes_per_candidate) - self.assertEqual(poll.votes_amount, 1) + self.assertEqual(poll.min_votes_amount, 1) + self.assertEqual(poll.max_votes_amount, 1) self.assertEqual(poll.assignment.id, self.assignment.id) self.assertEqual(poll.description, "") self.assertTrue(poll.options.exists()) @@ -157,7 +158,8 @@ class CreateAssignmentPoll(TestCase): "global_no": False, "global_abstain": False, "allow_multiple_votes_per_candidate": True, - "votes_amount": 5, + "min_votes_amount": 5, + "max_votes_amount": 8, "description": "test_description_ieM8ThuasoSh8aecai8p", }, ) @@ -171,7 +173,8 @@ class CreateAssignmentPoll(TestCase): self.assertFalse(poll.global_no) self.assertFalse(poll.global_abstain) self.assertTrue(poll.allow_multiple_votes_per_candidate) - self.assertEqual(poll.votes_amount, 5) + self.assertEqual(poll.min_votes_amount, 5) + self.assertEqual(poll.max_votes_amount, 8) self.assertEqual(poll.description, "test_description_ieM8ThuasoSh8aecai8p") def test_no_candidates(self): @@ -521,6 +524,44 @@ class CreateAssignmentPoll(TestCase): self.assertFalse(AssignmentPoll.objects.exists()) self.assertFalse(AssignmentVote.objects.exists()) + def test_create_with_unmatched_votes_amount(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_9FP4m2f2k09f4gni2sqq", + "pollmethod": AssignmentPoll.POLLMETHOD_VOTES, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YNA, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + "min_votes_amount": 5, + "max_votes_amount": 4, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + self.assertFalse(AssignmentVote.objects.exists()) + + def test_create_with_equal_votes_amount(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_9FP4m2f2k09f4gni2sqq", + "pollmethod": AssignmentPoll.POLLMETHOD_VOTES, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YNA, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + "min_votes_amount": 4, + "max_votes_amount": 4, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_201_CREATED) + self.assertTrue(AssignmentPoll.objects.exists()) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.min_votes_amount, 4) + self.assertEqual(poll.max_votes_amount, 4) + class UpdateAssignmentPoll(TestCase): """ @@ -697,7 +738,8 @@ class UpdateAssignmentPoll(TestCase): "global_no": True, "global_abstain": False, "allow_multiple_votes_per_candidate": True, - "votes_amount": 42, + "min_votes_amount": 32, + "max_votes_amount": 42, }, ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) @@ -711,7 +753,32 @@ class UpdateAssignmentPoll(TestCase): self.assertEqual(poll.amount_global_no, Decimal("0")) self.assertEqual(poll.amount_global_abstain, None) self.assertTrue(poll.allow_multiple_votes_per_candidate) - self.assertEqual(poll.votes_amount, 42) + self.assertEqual(poll.min_votes_amount, 32) + self.assertEqual(poll.max_votes_amount, 42) + + def test_patch_unmatched_votes_amounts(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + { + "min_votes_amount": 50, + "max_votes_amount": 42, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + + def test_patch_equal_votes_amounts(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + { + "min_votes_amount": 42, + "max_votes_amount": 42, + }, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + self.assertTrue(AssignmentPoll.objects.exists()) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.min_votes_amount, 42) + self.assertEqual(poll.max_votes_amount, 42) def test_patch_majority_method_state_not_created(self): self.poll.state = 2 @@ -1268,7 +1335,7 @@ class VoteAssignmentPollNamedY(VoteAssignmentPollBaseTestClass): def setup_for_multiple_votes(self): self.poll.allow_multiple_votes_per_candidate = True - self.poll.votes_amount = 3 + self.poll.max_votes_amount = 3 self.poll.save() self.add_candidate() @@ -1446,7 +1513,7 @@ class VoteAssignmentPollNamedY(VoteAssignmentPollBaseTestClass): self.assertEqual(option2.no, Decimal("0")) self.assertEqual(option2.abstain, Decimal("0")) - def test_multiple_votes_wrong_amount(self): + def test_multiple_votes_wrong_max_amount(self): self.setup_for_multiple_votes() self.start_poll() response = self.client.post( @@ -1457,6 +1524,19 @@ class VoteAssignmentPollNamedY(VoteAssignmentPollBaseTestClass): self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + def test_multiple_votes_wrong_min_amount(self): + self.setup_for_multiple_votes() + self.poll.min_votes_amount = 2 + self.poll.save() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"data": {"1": 1}}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + def test_too_many_options(self): self.setup_for_multiple_votes() self.start_poll() @@ -1896,6 +1976,12 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): type=BasePoll.TYPE_PSEUDOANONYMOUS, ) + def setup_for_multiple_votes(self): + self.poll.allow_multiple_votes_per_candidate = True + self.poll.max_votes_amount = 3 + self.poll.save() + self.add_candidate() + def test_start_poll(self): response = self.client.post( reverse("assignmentpoll-start", args=[self.poll.pk]) @@ -2189,7 +2275,7 @@ class VoteAssignmentPollPseudoanonymousY(VoteAssignmentPollBaseTestClass): for vote in poll.get_votes(): self.assertIsNone(vote.user) - def test_multiple_votes_wrong_amount(self): + def test_multiple_votes_wrong_max_amount(self): self.setup_for_multiple_votes() self.start_poll() response = self.client.post( @@ -2200,6 +2286,19 @@ class VoteAssignmentPollPseudoanonymousY(VoteAssignmentPollBaseTestClass): self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + def test_multiple_votes_wrong_min_amount(self): + self.setup_for_multiple_votes() + self.poll.min_votes_amount = 2 + self.poll.save() + self.start_poll() + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"data": {"1": 1}}, + format="json", + ) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + def test_too_many_options(self): self.setup_for_multiple_votes() self.start_poll() @@ -2627,7 +2726,8 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "type": AssignmentPoll.TYPE_NAMED, "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, - "votes_amount": 1, + "min_votes_amount": 1, + "max_votes_amount": 1, "votescast": "1.000000", "votesinvalid": "0.000000", "votesvalid": "1.000000", @@ -2696,7 +2796,8 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "groups_id": [GROUP_DELEGATE_PK], "options_id": [1], "id": 1, - "votes_amount": 1, + "min_votes_amount": 1, + "max_votes_amount": 1, "user_has_voted": user == self.user, "user_has_voted_for_delegations": [], }, @@ -2751,7 +2852,8 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "state": 4, "title": self.poll.title, "type": "named", - "votes_amount": 1, + "min_votes_amount": 1, + "max_votes_amount": 1, "votescast": "1.000000", "votesinvalid": "0.000000", "votesvalid": "1.000000", @@ -2827,7 +2929,8 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "voted_id": [self.user.id], "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, - "votes_amount": 1, + "min_votes_amount": 1, + "max_votes_amount": 1, "votescast": "1.000000", "votesinvalid": "0.000000", "votesvalid": "1.000000", @@ -2878,7 +2981,8 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "groups_id": [GROUP_DELEGATE_PK], "options_id": [1], "id": 1, - "votes_amount": 1, + "min_votes_amount": 1, + "max_votes_amount": 1, "user_has_voted": user == self.user, "user_has_voted_for_delegations": [], }, @@ -2933,7 +3037,8 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "state": 4, "title": self.poll.title, "type": AssignmentPoll.TYPE_PSEUDOANONYMOUS, - "votes_amount": 1, + "min_votes_amount": 1, + "max_votes_amount": 1, "votescast": "1.000000", "votesinvalid": "0.000000", "votesvalid": "1.000000",