From 1246dd54add34df2ab096d5464766a28290bc34b Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Tue, 29 Oct 2019 09:44:19 +0100 Subject: [PATCH] majorities in polls --- .../shared/models/assignments/assignment.ts | 2 +- .../assignment-detail.component.html | 4 +- .../assignment-detail.component.ts | 2 +- openslides/assignments/access_permissions.py | 42 +-- openslides/assignments/config_variables.py | 88 ++---- ...auto_20191017_1040.py => 0008_voting_1.py} | 58 +++- .../assignments/migrations/0009_voting_2.py | 145 +++++++++ .../assignments/migrations/0010_voting_3.py | 23 ++ openslides/assignments/models.py | 85 +++--- openslides/assignments/serializers.py | 81 +++-- openslides/motions/access_permissions.py | 40 +-- openslides/motions/config_variables.py | 77 ++--- ...auto_20191017_1100.py => 0033_voting_1.py} | 31 ++ .../motions/migrations/0034_voting_2.py | 107 +++++++ openslides/motions/models.py | 3 - openslides/motions/serializers.py | 51 ++-- openslides/poll/access_permissions.py | 42 +++ openslides/poll/majority.py | 7 - openslides/poll/models.py | 34 ++- openslides/poll/serializers.py | 89 +++++- tests/integration/assignments/test_polls.py | 289 +++++++++++++----- tests/integration/motions/test_polls.py | 172 ++++++----- 22 files changed, 995 insertions(+), 477 deletions(-) rename openslides/assignments/migrations/{0008_auto_20191017_1040.py => 0008_voting_1.py} (72%) create mode 100644 openslides/assignments/migrations/0009_voting_2.py create mode 100644 openslides/assignments/migrations/0010_voting_3.py rename openslides/motions/migrations/{0033_auto_20191017_1100.py => 0033_voting_1.py} (81%) create mode 100644 openslides/motions/migrations/0034_voting_2.py delete mode 100644 openslides/poll/majority.py diff --git a/client/src/app/shared/models/assignments/assignment.ts b/client/src/app/shared/models/assignments/assignment.ts index 7e763f88a..620126972 100644 --- a/client/src/app/shared/models/assignments/assignment.ts +++ b/client/src/app/shared/models/assignments/assignment.ts @@ -7,7 +7,7 @@ export interface AssignmentWithoutNestedModels extends BaseModelWithAgendaItemAn description: string; open_posts: number; phase: number; // see Openslides constants - poll_description_default: number; + default_poll_description: string; tags_id: number[]; attachments_id: number[]; } diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html index f400d8ae1..8ab276b7a 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html @@ -280,13 +280,13 @@ [form]="assignmentForm" > - +
diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts index ecbc3353a..bf795e250 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts @@ -189,7 +189,7 @@ export class AssignmentDetailComponent extends BaseViewComponent implements OnIn attachments_id: [], title: ['', Validators.required], description: [''], - poll_description_default: [''], + default_poll_description: [''], open_posts: [1, [Validators.required, Validators.min(1)]], agenda_create: [''], agenda_parent_id: [], diff --git a/openslides/assignments/access_permissions.py b/openslides/assignments/access_permissions.py index 96a43f647..9a76c5bd5 100644 --- a/openslides/assignments/access_permissions.py +++ b/openslides/assignments/access_permissions.py @@ -1,8 +1,9 @@ -import json from typing import Any, Dict, List -from ..poll.access_permissions import BaseVoteAccessPermissions -from ..poll.views import BasePoll +from ..poll.access_permissions import ( + BasePollAccessPermissions, + BaseVoteAccessPermissions, +) from ..utils.access_permissions import BaseAccessPermissions from ..utils.auth import async_has_perm @@ -47,39 +48,10 @@ class AssignmentAccessPermissions(BaseAccessPermissions): return data -class AssignmentPollAccessPermissions(BaseAccessPermissions): +class AssignmentPollAccessPermissions(BasePollAccessPermissions): base_permission = "assignments.can_see" - - 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. - 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 + manage_permission = "assignments.can_manage_polls" + additional_fields = ["amount_global_no", "amount_global_abstain"] class AssignmentVoteAccessPermissions(BaseVoteAccessPermissions): diff --git a/openslides/assignments/config_variables.py b/openslides/assignments/config_variables.py index 5a7e40f9a..f63cbbdf1 100644 --- a/openslides/assignments/config_variables.py +++ b/openslides/assignments/config_variables.py @@ -1,7 +1,5 @@ -from django.core.validators import MinValueValidator - +from openslides.assignments.models import AssignmentPoll from openslides.core.config import ConfigVariable -from openslides.poll.majority import majorityMethods def get_config_variables(): @@ -11,87 +9,47 @@ def get_config_variables(): They are grouped in 'Ballot and ballot papers' and 'PDF'. The generator has to be evaluated during app loading (see apps.py). """ - # Ballot and ballot papers + # Polls yield ConfigVariable( - name="assignments_poll_100_percent_base", - default_value="YES_NO_ABSTAIN", + name="assignment_poll_default_100_percent_base", + default_value="YNA", input_type="choice", label="The 100-%-base of an election result consists of", - choices=( - {"value": "YES_NO_ABSTAIN", "display_name": "Yes/No/Abstain per candidate"}, - {"value": "YES_NO", "display_name": "Yes/No per candidate"}, - {"value": "VALID", "display_name": "All valid ballots"}, - {"value": "CAST", "display_name": "All casted ballots"}, - {"value": "DISABLED", "display_name": "Disabled (no percents)"}, + choices=tuple( + {"value": base[0], "display_name": base[1]} + for base in AssignmentPoll.PERCENT_BASES ), - help_text=( - "For Yes/No/Abstain per candidate and Yes/No per candidate the 100-%-base " - "depends on the election method: If there is only one option per candidate, " - "the sum of all votes of all candidates is 100 %. Otherwise for each " - "candidate the sum of all votes is 100 %." - ), - weight=420, - group="Elections", - subgroup="Ballot and ballot papers", + weight=400, + group="Polls", + subgroup="Elections", ) - # TODO: Add server side validation of the choices. yield ConfigVariable( - name="assignments_poll_default_majority_method", - default_value=majorityMethods[0]["value"], + name="assignment_poll_default_majority_method", + default_value="simple", input_type="choice", - choices=majorityMethods, + choices=tuple( + {"value": method[0], "display_name": method[1]} + for method in AssignmentPoll.MAJORITY_METHODS + ), label="Required majority", help_text="Default method to check whether a candidate has reached the required majority.", - weight=425, - group="Elections", - subgroup="Ballot and ballot papers", + weight=405, + group="Polls", + subgroup="Elections", ) yield ConfigVariable( - name="assignments_pdf_ballot_papers_selection", - default_value="CUSTOM_NUMBER", - input_type="choice", - label="Number of ballot papers (selection)", - choices=( - {"value": "NUMBER_OF_DELEGATES", "display_name": "Number of all delegates"}, - { - "value": "NUMBER_OF_ALL_PARTICIPANTS", - "display_name": "Number of all participants", - }, - { - "value": "CUSTOM_NUMBER", - "display_name": "Use the following custom number", - }, - ), - weight=430, - group="Elections", - subgroup="Ballot and ballot papers", - ) - - yield ConfigVariable( - name="assignments_pdf_ballot_papers_number", - default_value=8, - input_type="integer", - label="Custom number of ballot papers", - weight=435, - group="Elections", - subgroup="Ballot and ballot papers", - validators=(MinValueValidator(1),), - ) - - yield ConfigVariable( - name="assignments_add_candidates_to_list_of_speakers", + name="assignment_poll_add_candidates_to_list_of_speakers", default_value=True, input_type="boolean", label="Put all candidates on the list of speakers", - weight=440, - group="Elections", - subgroup="Ballot and ballot papers", + weight=410, + group="Polls", + subgroup="Elections", ) # PDF - yield ConfigVariable( name="assignments_pdf_title", default_value="Elections", diff --git a/openslides/assignments/migrations/0008_auto_20191017_1040.py b/openslides/assignments/migrations/0008_voting_1.py similarity index 72% rename from openslides/assignments/migrations/0008_auto_20191017_1040.py rename to openslides/assignments/migrations/0008_voting_1.py index 5a8b4aac2..e220a4748 100644 --- a/openslides/assignments/migrations/0008_auto_20191017_1040.py +++ b/openslides/assignments/migrations/0008_voting_1.py @@ -21,8 +21,6 @@ class Migration(migrations.Migration): migrations.RenameField( model_name="assignmentoption", old_name="candidate", new_name="user" ), - migrations.RemoveField(model_name="assignmentpoll", name="description"), - migrations.RemoveField(model_name="assignmentpoll", name="published"), migrations.AddField( model_name="assignmentpoll", name="global_abstain", @@ -99,6 +97,53 @@ class Migration(migrations.Migration): name="allow_multiple_votes_per_candidate", field=models.BooleanField(default=False), ), + migrations.AddField( + model_name="assignmentpoll", + name="majority_method", + field=models.CharField( + choices=[ + ("simple", "Simple majority"), + ("two_thirds", "Two-thirds majority"), + ("three_quarters", "Three-quarters majority"), + ("disabled", "Disabled"), + ], + default="", + max_length=14, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="assignmentpoll", + name="onehundred_percent_base", + field=models.CharField( + choices=[ + ("YN", "Yes/No per candidate"), + ("YNA", "Yes/No/Abstain per candidate"), + ("votes", "Sum of votes inclusive global ones"), + ("valid", "All valid ballots"), + ("cast", "All casted ballots"), + ("disabled", "Disabled (no percents)"), + ], + default="", + max_length=8, + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="assignment", + name="poll_description_default", + field=models.CharField(blank=True, max_length=255), + ), + migrations.RenameField( + model_name="assignment", + old_name="poll_description_default", + new_name="default_poll_description", + ), + migrations.AlterField( + model_name="assignmentpoll", + name="description", + field=models.CharField(blank=True, max_length=255), + ), migrations.AlterField( model_name="assignmentpoll", name="pollmethod", @@ -106,13 +151,6 @@ class Migration(migrations.Migration): choices=[("YN", "YN"), ("YNA", "YNA"), ("votes", "votes")], max_length=5 ), ), - migrations.AlterField( - model_name="assignmentvote", - name="value", - field=models.CharField( - choices=[("Y", "Y"), ("N", "N"), ("A", "A")], max_length=1 - ), - ), migrations.AlterField( model_name="assignmentvote", name="weight", @@ -134,6 +172,4 @@ class Migration(migrations.Migration): migrations.RenameField( model_name="assignmentpoll", old_name="votesvalid", new_name="db_votesvalid" ), - migrations.RemoveField(model_name="assignmentpoll", name="votesabstain"), - migrations.RemoveField(model_name="assignmentpoll", name="votesno"), ] diff --git a/openslides/assignments/migrations/0009_voting_2.py b/openslides/assignments/migrations/0009_voting_2.py new file mode 100644 index 000000000..da3437572 --- /dev/null +++ b/openslides/assignments/migrations/0009_voting_2.py @@ -0,0 +1,145 @@ +# Generated by Finn Stutzenstein on 2019-10-29 10:55 + +from decimal import Decimal + +from django.db import migrations, transaction + + +def change_pollmethods(apps, schema_editor): + """ yn->YN, yna->YNA """ + AssignmentPoll = apps.get_model("assignments", "AssignmentPoll") + pollmethod_map = { + "yn": "YN", + "yna": "YNA", + "votes": "votes", + } + for poll in AssignmentPoll.objects.all(): + poll.pollmethod = pollmethod_map.get(poll.pollmethod, "YNA") + poll.save(skip_autoupdate=True) + + +def set_poll_titles(apps, schema_editor): + """ + Sets titles to their indexes + """ + Assignment = apps.get_model("assignments", "Assignment") + for assignment in Assignment.objects.all(): + for i, poll in enumerate(assignment.polls.order_by("pk").all()): + poll.title = str(i + 1) + poll.save(skip_autoupdate=True) + + +def set_onehunderd_percent_bases(apps, schema_editor): + AssignmentPoll = apps.get_model("assignments", "AssignmentPoll") + ConfigStore = apps.get_model("core", "ConfigStore") + base_map = { + "YES_NO_ABSTAIN": "YNA", + "YES_NO": "YN", + "VALID": "valid", + "CAST": "cast", + "DISABLED": "disabled", + } + try: + config = ConfigStore.objects.get(key="assignments_poll_100_percent_base") + value = base_map[config.value] + except (ConfigStore.DoesNotExist, KeyError): + value = "YNA" + + for poll in AssignmentPoll.objects.all(): + if poll.pollmethod == "votes" and value in ("YN", "YNA"): + poll.onehundred_percent_base = "votes" + elif poll.pollmethod == "YN" and value == "YNA": + poll.onehundred_percent_base = "YN" + else: + poll.onehundred_percent_base = value + poll.save(skip_autoupdate=True) + + +def set_majority_methods(apps, schema_editor): + AssignmentPoll = apps.get_model("assignments", "AssignmentPoll") + ConfigStore = apps.get_model("core", "ConfigStore") + majority_map = { + "simple_majority": "simple", + "two-thirds_majority": "two_thirds", + "three-quarters_majority": "three_quarters", + "disabled": "disabled", + } + try: + config = ConfigStore.objects.get(key="assignments_poll_default_majority_method") + value = majority_map[config.value] + except (ConfigStore.DoesNotExist, KeyError): + value = "simple" + + for poll in AssignmentPoll.objects.all(): + poll.majority_method = value + poll.save(skip_autoupdate=True) + + +def convert_votes(apps, schema_editor): + AssignmentVote = apps.get_model("assignments", "AssignmentVote") + value_map = { + "Yes": "Y", + "No": "N", + "Abstain": "A", + "Votes": "Y", + } + for vote in AssignmentVote.objects.all(): + vote.value = value_map[vote.value] + vote.save(skip_autoupdate=True) + + +def convert_votesabstain(apps, schema_editor): + AssignmentPoll = apps.get_model("assignments", "AssignmentPoll") + AssignmentVote = apps.get_model("assignments", "AssignmentVote") + for poll in AssignmentPoll.objects.all(): + if poll.votesabstain is not None and poll.votesabstain > Decimal(0): + with transaction.atomic(): + option = poll.options.first() + vote = AssignmentVote( + option=option, value="A", weight=poll.votesabstain + ) + vote.save(skip_autoupdate=True) + + +def convert_votesno(apps, schema_editor): + AssignmentPoll = apps.get_model("assignments", "AssignmentPoll") + AssignmentVote = apps.get_model("assignments", "AssignmentVote") + for poll in AssignmentPoll.objects.all(): + if poll.votesno is not None and poll.votesno > Decimal(0): + with transaction.atomic(): + option = poll.options.first() + vote = AssignmentVote(option=option, value="N", weight=poll.votesno) + vote.save(skip_autoupdate=True) + + +def set_correct_state(apps, schema_editor): + """ if poll.published, set state to published """ + AssignmentPoll = apps.get_model("assignments", "AssignmentPoll") + AssignmentVote = apps.get_model("assignments", "AssignmentVote") + for poll in AssignmentPoll.objects.all(): + # Polls, that are published (old field) but have no votes, will be + # left at the created state... + if AssignmentVote.objects.filter(option__poll__pk=poll.pk).exists(): + if poll.published: + poll.state = 4 # published + else: + poll.state = 3 # finished + poll.save(skip_autoupdate=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ("assignments", "0008_voting_1"), + ] + + operations = [ + migrations.RunPython(change_pollmethods), + migrations.RunPython(set_poll_titles), + migrations.RunPython(set_onehunderd_percent_bases), + migrations.RunPython(set_majority_methods), + migrations.RunPython(convert_votes), + migrations.RunPython(convert_votesabstain), + migrations.RunPython(convert_votesno), + migrations.RunPython(set_correct_state), + ] diff --git a/openslides/assignments/migrations/0010_voting_3.py b/openslides/assignments/migrations/0010_voting_3.py new file mode 100644 index 000000000..5a3b2fc9a --- /dev/null +++ b/openslides/assignments/migrations/0010_voting_3.py @@ -0,0 +1,23 @@ +# Generated by Finn Stutzenstein on 2019-10-29 11:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("assignments", "0009_voting_2"), + ] + + operations = [ + migrations.AlterField( + model_name="assignmentvote", + name="value", + field=models.CharField( + choices=[("Y", "Y"), ("N", "N"), ("A", "A")], max_length=1 + ), + ), + migrations.RemoveField(model_name="assignmentpoll", name="votesabstain"), + migrations.RemoveField(model_name="assignmentpoll", name="votesno"), + migrations.RemoveField(model_name="assignmentpoll", name="published"), + ] diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index 008ecf2ca..4ee22bc9a 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -1,5 +1,4 @@ -from collections import OrderedDict -from typing import Any, Dict, List +from decimal import Decimal from django.conf import settings from django.core.validators import MinValueValidator @@ -120,7 +119,7 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model The number of members to be elected. """ - poll_description_default = models.CharField(max_length=79, blank=True) + default_poll_description = models.CharField(max_length=255, blank=True) """ Default text for the poll description. """ @@ -230,40 +229,6 @@ class Assignment(RESTModelMixin, AgendaItemWithListOfSpeakersMixin, models.Model self.phase = phase - def vote_results(self, only_published): - """ - Returns a table represented as a list with all candidates from all - related polls and their vote results. - """ - vote_results_dict: Dict[Any, List[AssignmentVote]] = OrderedDict() - - polls = self.polls.all() - if only_published: - polls = polls.filter(published=True) - - # All PollOption-Objects related to this assignment - options: List[AssignmentOption] = [] - for poll in polls: - options += poll.get_options() - - for option in options: - candidate = option.candidate - if candidate in vote_results_dict: - continue - vote_results_dict[candidate] = [] - for poll in polls: - votes: Any = {} - try: - # candidate related to this poll - poll_option = poll.get_options().get(candidate=candidate) - for vote in poll_option.get_votes(): - votes[vote.value] = vote.print_weight() - except AssignmentOption.DoesNotExist: - # candidate not in related to this poll - votes = None - vote_results_dict[candidate].append(votes) - return vote_results_dict - def get_title_information(self): return {"title": self.title} @@ -330,9 +295,6 @@ class AssignmentPollManager(models.Manager): ) -# 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() @@ -343,12 +305,32 @@ class AssignmentPoll(RESTModelMixin, BasePoll): Assignment, on_delete=models.CASCADE, related_name="polls" ) + description = models.CharField(max_length=255, blank=True) + POLLMETHOD_YN = "YN" POLLMETHOD_YNA = "YNA" POLLMETHOD_VOTES = "votes" POLLMETHODS = (("YN", "YN"), ("YNA", "YNA"), ("votes", "votes")) pollmethod = models.CharField(max_length=5, choices=POLLMETHODS) + PERCENT_BASE_YN = "YN" + PERCENT_BASE_YNA = "YNA" + PERCENT_BASE_VOTES = "votes" + PERCENT_BASE_VALID = "valid" + PERCENT_BASE_CAST = "cast" + PERCENT_BASE_DISABLED = "disabled" + PERCENT_BASES = ( + (PERCENT_BASE_YN, "Yes/No per candidate"), + (PERCENT_BASE_YNA, "Yes/No/Abstain per candidate"), + (PERCENT_BASE_VOTES, "Sum of votes inclusive global ones"), + (PERCENT_BASE_VALID, "All valid ballots"), + (PERCENT_BASE_CAST, "All casted ballots"), + (PERCENT_BASE_DISABLED, "Disabled (no percents)"), + ) + onehundred_percent_base = models.CharField( + max_length=8, blank=False, null=False, choices=PERCENT_BASES + ) + global_abstain = models.BooleanField(default=True) global_no = models.BooleanField(default=True) @@ -360,6 +342,27 @@ class AssignmentPoll(RESTModelMixin, BasePoll): class Meta: default_permissions = () + @property + def amount_global_no(self): + if self.pollmethod != AssignmentPoll.POLLMETHOD_VOTES or not self.global_no: + return None + no_sum = Decimal(0) + for option in self.options.all(): + no_sum += option.no + return no_sum + + @property + def amount_global_abstain(self): + if ( + self.pollmethod != AssignmentPoll.POLLMETHOD_VOTES + or not self.global_abstain + ): + return None + abstain_sum = Decimal(0) + for option in self.options.all(): + abstain_sum += option.abstain + return abstain_sum + def create_options(self): related_users = AssignmentRelatedUser.objects.filter( assignment__id=self.assignment.id @@ -374,7 +377,7 @@ class AssignmentPoll(RESTModelMixin, BasePoll): inform_changed_data(self) # Add all candidates to list of speakers of related agenda item - if config["assignments_add_candidates_to_list_of_speakers"]: + if config["assignment_poll_add_candidates_to_list_of_speakers"]: for related_user in related_users: try: Speaker.objects.add( diff --git a/openslides/assignments/serializers.py b/openslides/assignments/serializers.py index 9ef737cb8..13ed729d8 100644 --- a/openslides/assignments/serializers.py +++ b/openslides/assignments/serializers.py @@ -2,19 +2,19 @@ from openslides.poll.serializers import ( BASE_OPTION_FIELDS, BASE_POLL_FIELDS, BASE_VOTE_FIELDS, + BaseOptionSerializer, + BasePollSerializer, + BaseVoteSerializer, ) from openslides.utils.rest_api import ( BooleanField, - CharField, DecimalField, - IdPrimaryKeyRelatedField, IntegerField, ModelSerializer, - SerializerMethodField, ValidationError, ) -from ..utils.auth import get_group_model, has_perm +from ..utils.auth import has_perm from ..utils.autoupdate import inform_changed_data from ..utils.validate import validate_html from .models import ( @@ -47,40 +47,29 @@ class AssignmentRelatedUserSerializer(ModelSerializer): fields = ("id", "user", "elected", "weight") -class AssignmentVoteSerializer(ModelSerializer): +class AssignmentVoteSerializer(BaseVoteSerializer): """ Serializer for assignment.models.AssignmentVote objects. """ - pollstate = SerializerMethodField() - class Meta: model = AssignmentVote - fields = ("pollstate",) + BASE_VOTE_FIELDS + fields = BASE_VOTE_FIELDS read_only_fields = BASE_VOTE_FIELDS - def get_pollstate(self, vote): - return vote.option.poll.state - -class AssignmentOptionSerializer(ModelSerializer): +class AssignmentOptionSerializer(BaseOptionSerializer): """ Serializer for assignment.models.AssignmentOption objects. """ - yes = DecimalField(max_digits=15, decimal_places=6, min_value=-2, read_only=True) - no = DecimalField(max_digits=15, decimal_places=6, min_value=-2, read_only=True) - abstain = DecimalField( - max_digits=15, decimal_places=6, min_value=-2, read_only=True - ) - class Meta: model = AssignmentOption fields = ("user", "weight") + BASE_OPTION_FIELDS read_only_fields = ("user", "weight") + BASE_OPTION_FIELDS -class AssignmentPollSerializer(ModelSerializer): +class AssignmentPollSerializer(BasePollSerializer): """ Serializer for assignment.models.AssignmentPoll objects. @@ -88,20 +77,10 @@ class AssignmentPollSerializer(ModelSerializer): """ options = AssignmentOptionSerializer(many=True, read_only=True) - - title = CharField(allow_blank=False, required=True) - groups = IdPrimaryKeyRelatedField( - many=True, required=False, queryset=get_group_model().objects.all() - ) - voted = IdPrimaryKeyRelatedField(many=True, read_only=True) - - votesvalid = DecimalField( + amount_global_no = DecimalField( max_digits=15, decimal_places=6, min_value=-2, read_only=True ) - votesinvalid = DecimalField( - max_digits=15, decimal_places=6, min_value=-2, read_only=True - ) - votescast = DecimalField( + amount_global_abstain = DecimalField( max_digits=15, decimal_places=6, min_value=-2, read_only=True ) @@ -109,19 +88,55 @@ class AssignmentPollSerializer(ModelSerializer): model = AssignmentPoll fields = ( "assignment", + "description", "pollmethod", "votes_amount", "allow_multiple_votes_per_candidate", "global_no", + "amount_global_no", "global_abstain", + "amount_global_abstain", ) + BASE_POLL_FIELDS read_only_fields = ("state",) def update(self, instance, validated_data): - """ Prevent from updating the assignment """ + """ Prevent updating the assignment """ validated_data.pop("assignment", None) return super().update(instance, validated_data) + def norm_100_percent_base_to_pollmethod( + self, onehundred_percent_base, pollmethod, old_100_percent_base=None + ): + """ + Returns None, if the 100-%-base must not be changed, otherwise the correct 100-%-base. + """ + if pollmethod == AssignmentPoll.POLLMETHOD_YN and onehundred_percent_base in ( + AssignmentPoll.PERCENT_BASE_VOTES, + AssignmentPoll.PERCENT_BASE_YNA, + ): + return AssignmentPoll.PERCENT_BASE_YN + if ( + pollmethod == AssignmentPoll.POLLMETHOD_YNA + and onehundred_percent_base == AssignmentPoll.PERCENT_BASE_VOTES + ): + if old_100_percent_base is None: + return AssignmentPoll.PERCENT_BASE_YNA + else: + if old_100_percent_base in ( + AssignmentPoll.PERCENT_BASE_YN, + AssignmentPoll.PERCENT_BASE_YNA, + ): + return old_100_percent_base + else: + return pollmethod + if ( + pollmethod == AssignmentPoll.POLLMETHOD_VOTES + and onehundred_percent_base + in (AssignmentPoll.PERCENT_BASE_YN, AssignmentPoll.PERCENT_BASE_YNA) + ): + return AssignmentPoll.PERCENT_BASE_VOTES + return None + class AssignmentSerializer(ModelSerializer): """ @@ -146,7 +161,7 @@ class AssignmentSerializer(ModelSerializer): "open_posts", "phase", "assignment_related_users", - "poll_description_default", + "default_poll_description", "agenda_item_id", "list_of_speakers_id", "agenda_create", diff --git a/openslides/motions/access_permissions.py b/openslides/motions/access_permissions.py index 3f1a07903..797f81d25 100644 --- a/openslides/motions/access_permissions.py +++ b/openslides/motions/access_permissions.py @@ -1,8 +1,10 @@ import json from typing import Any, Dict, List -from ..poll.access_permissions import BaseVoteAccessPermissions -from ..poll.views import BasePoll +from ..poll.access_permissions import ( + BasePollAccessPermissions, + BaseVoteAccessPermissions, +) from ..utils.access_permissions import BaseAccessPermissions from ..utils.auth import async_has_perm, async_in_some_groups @@ -183,39 +185,9 @@ class StateAccessPermissions(BaseAccessPermissions): base_permission = "motions.can_see" -class MotionPollAccessPermissions(BaseAccessPermissions): +class MotionPollAccessPermissions(BasePollAccessPermissions): base_permission = "motions.can_see" - - 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. - 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, "motions.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 + manage_permission = "motions.can_manage_polls" class MotionVoteAccessPermissions(BaseVoteAccessPermissions): diff --git a/openslides/motions/config_variables.py b/openslides/motions/config_variables.py index abc052e74..c9251e71d 100644 --- a/openslides/motions/config_variables.py +++ b/openslides/motions/config_variables.py @@ -1,7 +1,7 @@ from django.core.validators import MinValueValidator from openslides.core.config import ConfigVariable -from openslides.poll.majority import majorityMethods +from openslides.motions.models import MotionPoll from .models import Workflow @@ -348,51 +348,6 @@ def get_config_variables(): subgroup="Voting and ballot papers", ) - # TODO: Add server side validation of the choices. - yield ConfigVariable( - name="motions_poll_default_majority_method", - default_value=majorityMethods[0]["value"], - input_type="choice", - choices=majorityMethods, - label="Required majority", - help_text="Default method to check whether a motion has reached the required majority.", - weight=372, - group="Motions", - subgroup="Voting and ballot papers", - ) - - yield ConfigVariable( - name="motions_pdf_ballot_papers_selection", - default_value="CUSTOM_NUMBER", - input_type="choice", - label="Number of ballot papers (selection)", - choices=( - {"value": "NUMBER_OF_DELEGATES", "display_name": "Number of all delegates"}, - { - "value": "NUMBER_OF_ALL_PARTICIPANTS", - "display_name": "Number of all participants", - }, - { - "value": "CUSTOM_NUMBER", - "display_name": "Use the following custom number", - }, - ), - weight=374, - group="Motions", - subgroup="Voting and ballot papers", - ) - - yield ConfigVariable( - name="motions_pdf_ballot_papers_number", - default_value=8, - input_type="integer", - label="Custom number of ballot papers", - weight=376, - group="Motions", - subgroup="Voting and ballot papers", - validators=(MinValueValidator(1),), - ) - # PDF export yield ConfigVariable( @@ -432,3 +387,33 @@ def get_config_variables(): group="Motions", subgroup="PDF export", ) + + # Polls + yield ConfigVariable( + name="motion_poll_default_100_percent_base", + default_value="YNA", + input_type="choice", + label="The 100-%-base of an election result consists of", + choices=tuple( + {"value": base[0], "display_name": base[1]} + for base in MotionPoll.PERCENT_BASES + ), + weight=420, + group="Polls", + subgroup="Motions", + ) + + yield ConfigVariable( + name="motion_poll_default_majority_method", + default_value="simple", + input_type="choice", + choices=tuple( + {"value": method[0], "display_name": method[1]} + for method in MotionPoll.MAJORITY_METHODS + ), + label="Required majority", + help_text="Default method to check whether a candidate has reached the required majority.", + weight=425, + group="Polls", + subgroup="Motions", + ) diff --git a/openslides/motions/migrations/0033_auto_20191017_1100.py b/openslides/motions/migrations/0033_voting_1.py similarity index 81% rename from openslides/motions/migrations/0033_auto_20191017_1100.py rename to openslides/motions/migrations/0033_voting_1.py index 2dac920de..0d0ba8554 100644 --- a/openslides/motions/migrations/0033_auto_20191017_1100.py +++ b/openslides/motions/migrations/0033_voting_1.py @@ -81,6 +81,37 @@ class Migration(migrations.Migration): name="voted", field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), ), + migrations.AddField( + model_name="motionpoll", + name="majority_method", + field=models.CharField( + choices=[ + ("simple", "Simple majority"), + ("two_thirds", "Two-thirds majority"), + ("three_quarters", "Three-quarters majority"), + ("disabled", "Disabled"), + ], + default="", + max_length=14, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="motionpoll", + name="onehundred_percent_base", + field=models.CharField( + choices=[ + ("YN", "Yes/No per candidate"), + ("YNA", "Yes/No/Abstain per candidate"), + ("valid", "All valid ballots"), + ("cast", "All casted ballots"), + ("disabled", "Disabled (no percents)"), + ], + default="", + max_length=8, + ), + preserve_default=False, + ), migrations.AlterField( model_name="motionvote", name="option", diff --git a/openslides/motions/migrations/0034_voting_2.py b/openslides/motions/migrations/0034_voting_2.py new file mode 100644 index 000000000..7d1f8fb56 --- /dev/null +++ b/openslides/motions/migrations/0034_voting_2.py @@ -0,0 +1,107 @@ +# Generated by Finn Stutzenstein on 2019-10-30 13:43 + +from django.db import migrations + + +def change_pollmethods(apps, schema_editor): + """ yn->YN, yna->YNA """ + MotionPoll = apps.get_model("motions", "MotionPoll") + pollmethod_map = { + "yn": "YN", + "yna": "YNA", + } + for poll in MotionPoll.objects.all(): + poll.pollmethod = pollmethod_map.get(poll.pollmethod, "YNA") + poll.save(skip_autoupdate=True) + + +def set_poll_titles(apps, schema_editor): + """ + Sets titles to their indexes + """ + Motion = apps.get_model("motions", "Motion") + for motion in Motion.objects.all(): + for i, poll in enumerate(motion.polls.order_by("pk").all()): + poll.title = str(i + 1) + poll.save(skip_autoupdate=True) + + +def set_onehunderd_percent_bases(apps, schema_editor): + MotionPoll = apps.get_model("motions", "MotionPoll") + ConfigStore = apps.get_model("core", "ConfigStore") + base_map = { + "YES_NO_ABSTAIN": "YNA", + "YES_NO": "YN", + "VALID": "valid", + "CAST": "cast", + "DISABLED": "disabled", + } + try: + config = ConfigStore.objects.get(key="motions_poll_100_percent_base") + value = base_map[config.value] + except (ConfigStore.DoesNotExist, KeyError): + value = "YNA" + + for poll in MotionPoll.objects.all(): + # The pollmethod is new (default is YNA), so we do not need + # to check, if the 100% base is valid. + poll.onehundred_percent_base = value + poll.save(skip_autoupdate=True) + + +def set_majority_methods(apps, schema_editor): + MotionPoll = apps.get_model("motions", "MotionPoll") + ConfigStore = apps.get_model("core", "ConfigStore") + majority_map = { + "simple_majority": "simple", + "two-thirds_majority": "two_thirds", + "three-quarters_majority": "three_quarters", + "disabled": "disabled", + } + try: + config = ConfigStore.objects.get(key="motions_poll_default_majority_method") + value = majority_map[config.value] + except (ConfigStore.DoesNotExist, KeyError): + value = "simple" + + for poll in MotionPoll.objects.all(): + poll.majority_method = value + poll.save(skip_autoupdate=True) + + +def convert_votes(apps, schema_editor): + MotionVote = apps.get_model("motions", "MotionVote") + value_map = { + "Yes": "Y", + "No": "N", + "Abstain": "A", + } + for vote in MotionVote.objects.all(): + vote.value = value_map[vote.value] + vote.save(skip_autoupdate=True) + + +def set_correct_state(apps, schema_editor): + """ If there are votes, set the state to finished """ + MotionPoll = apps.get_model("motions", "MotionPoll") + MotionVote = apps.get_model("motions", "MotionVote") + for poll in MotionPoll.objects.all(): + if MotionVote.objects.filter(option__poll__pk=poll.pk).exists(): + poll.state = 3 # finished + poll.save(skip_autoupdate=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ("motions", "0033_voting_1"), + ] + + operations = [ + migrations.RunPython(change_pollmethods), + migrations.RunPython(set_poll_titles), + migrations.RunPython(set_onehunderd_percent_bases), + migrations.RunPython(set_majority_methods), + migrations.RunPython(convert_votes), + migrations.RunPython(set_correct_state), + ] diff --git a/openslides/motions/models.py b/openslides/motions/models.py index 46dd0b9f7..dbea8468c 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -923,9 +923,6 @@ class MotionPollManager(models.Manager): ) -# 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 MotionPoll(RESTModelMixin, BasePoll): access_permissions = MotionPollAccessPermissions() option_class = MotionOption diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index dfcee8b25..2a526abd7 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -5,6 +5,9 @@ from openslides.poll.serializers import ( BASE_OPTION_FIELDS, BASE_POLL_FIELDS, BASE_VOTE_FIELDS, + BaseOptionSerializer, + BasePollSerializer, + BaseVoteSerializer, ) from ..core.config import config @@ -13,7 +16,6 @@ from ..utils.autoupdate import inform_changed_data from ..utils.rest_api import ( BooleanField, CharField, - DecimalField, Field, IdPrimaryKeyRelatedField, IntegerField, @@ -224,64 +226,47 @@ class AmendmentParagraphsJSONSerializerField(Field): return data -class MotionVoteSerializer(ModelSerializer): - pollstate = SerializerMethodField() - +class MotionVoteSerializer(BaseVoteSerializer): class Meta: model = MotionVote - fields = ("pollstate",) + BASE_VOTE_FIELDS + fields = BASE_VOTE_FIELDS read_only_fields = BASE_VOTE_FIELDS - def get_pollstate(self, vote): - return vote.option.poll.state - - -class MotionOptionSerializer(ModelSerializer): - yes = DecimalField(max_digits=15, decimal_places=6, min_value=-2, read_only=True) - no = DecimalField(max_digits=15, decimal_places=6, min_value=-2, read_only=True) - abstain = DecimalField( - max_digits=15, decimal_places=6, min_value=-2, read_only=True - ) +class MotionOptionSerializer(BaseOptionSerializer): class Meta: model = MotionOption fields = BASE_OPTION_FIELDS read_only_fields = BASE_OPTION_FIELDS -class MotionPollSerializer(ModelSerializer): +class MotionPollSerializer(BasePollSerializer): """ Serializer for motion.models.MotionPoll objects. """ options = MotionOptionSerializer(many=True, read_only=True) - title = CharField(allow_blank=False, required=True) - groups = IdPrimaryKeyRelatedField( - many=True, required=False, queryset=get_group_model().objects.all() - ) - voted = IdPrimaryKeyRelatedField(many=True, read_only=True) - - votesvalid = DecimalField( - max_digits=15, decimal_places=6, min_value=-2, read_only=True - ) - votesinvalid = DecimalField( - max_digits=15, decimal_places=6, min_value=-2, read_only=True - ) - votescast = DecimalField( - max_digits=15, decimal_places=6, min_value=-2, read_only=True - ) - class Meta: model = MotionPoll fields = ("motion", "pollmethod") + BASE_POLL_FIELDS read_only_fields = ("state",) def update(self, instance, validated_data): - """ Prevent from updating the motion """ + """ Prevent updating the motion """ validated_data.pop("motion", None) return super().update(instance, validated_data) + def norm_100_percent_base_to_pollmethod( + self, onehundred_percent_base, pollmethod, old_100_percent_base=None + ): + if ( + pollmethod == MotionPoll.POLLMETHOD_YN + and onehundred_percent_base == MotionPoll.PERCENT_BASE_YNA + ): + return MotionPoll.PERCENT_BASE_YN + return None + class MotionChangeRecommendationSerializer(ModelSerializer): """ diff --git a/openslides/poll/access_permissions.py b/openslides/poll/access_permissions.py index ba3d3aa1b..86d1820e2 100644 --- a/openslides/poll/access_permissions.py +++ b/openslides/poll/access_permissions.py @@ -1,3 +1,4 @@ +import json from typing import Any, Dict, List from ..poll.views import BasePoll @@ -5,6 +6,47 @@ from ..utils.access_permissions import BaseAccessPermissions from ..utils.auth import async_has_perm +class BasePollAccessPermissions(BaseAccessPermissions): + manage_permission = "" # set by subclass + + additional_fields: List[str] = [] + """ Add fields to be removed from each unpublished poll """ + + 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. + 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 + - Remove fields given in self.assitional_fields from the poll + """ + + if await async_has_perm(user_id, self.manage_permission): + 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 field in self.additional_fields: + del poll[field] + for option in poll["options"]: + del option["yes"] + del option["no"] + del option["abstain"] + data.append(poll) + return data + + class BaseVoteAccessPermissions(BaseAccessPermissions): manage_permission = "" # set by subclass diff --git a/openslides/poll/majority.py b/openslides/poll/majority.py deleted file mode 100644 index 7f498542b..000000000 --- a/openslides/poll/majority.py +++ /dev/null @@ -1,7 +0,0 @@ -# Common majority methods for all apps using polls. The first one should be the default. -majorityMethods = ( - {"value": "simple_majority", "display_name": "Simple majority"}, - {"value": "two-thirds_majority", "display_name": "Two-thirds majority"}, - {"value": "three-quarters_majority", "display_name": "Three-quarters majority"}, - {"value": "disabled", "display_name": "Disabled"}, -) diff --git a/openslides/poll/models.py b/openslides/poll/models.py index 29c48770e..6415583a5 100644 --- a/openslides/poll/models.py +++ b/openslides/poll/models.py @@ -1,5 +1,5 @@ from decimal import Decimal -from typing import Optional, Type +from typing import Iterable, Optional, Tuple, Type from django.conf import settings from django.core.validators import MinValueValidator @@ -131,6 +131,36 @@ class BasePoll(models.Model): decimal_places=6, ) + PERCENT_BASE_YN = "YN" + PERCENT_BASE_YNA = "YNA" + PERCENT_BASE_VALID = "valid" + PERCENT_BASE_CAST = "cast" + PERCENT_BASE_DISABLED = "disabled" + PERCENT_BASES: Iterable[Tuple[str, str]] = ( + (PERCENT_BASE_YN, "Yes/No per candidate"), + (PERCENT_BASE_YNA, "Yes/No/Abstain per candidate"), + (PERCENT_BASE_VALID, "All valid ballots"), + (PERCENT_BASE_CAST, "All casted ballots"), + (PERCENT_BASE_DISABLED, "Disabled (no percents)"), + ) # type: ignore + onehundred_percent_base = models.CharField( + max_length=8, blank=False, null=False, choices=PERCENT_BASES + ) + + MAJORITY_SIMPLE = "simple" + MAJORITY_TWO_THIRDS = "two_thirds" + MAJORITY_THREE_QUARTERS = "three_quarters" + MAJORITY_DISABLED = "disabled" + MAJORITY_METHODS = ( + (MAJORITY_SIMPLE, "Simple majority"), + (MAJORITY_TWO_THIRDS, "Two-thirds majority"), + (MAJORITY_THREE_QUARTERS, "Three-quarters majority"), + (MAJORITY_DISABLED, "Disabled"), + ) + majority_method = models.CharField( + max_length=14, blank=False, null=False, choices=MAJORITY_METHODS + ) + class Meta: abstract = True @@ -201,8 +231,6 @@ class BasePoll(models.Model): def get_votes(self): """ Return a QuerySet with all vote objects related to this poll. - - TODO: This might be a performance issue when used in properties that are serialized. """ return self.get_vote_class().objects.filter(option__poll__id=self.id) diff --git a/openslides/poll/serializers.py b/openslides/poll/serializers.py index 9bc312623..74af68396 100644 --- a/openslides/poll/serializers.py +++ b/openslides/poll/serializers.py @@ -1,3 +1,34 @@ +from ..utils.auth import get_group_model +from ..utils.rest_api import ( + CharField, + DecimalField, + IdPrimaryKeyRelatedField, + ModelSerializer, + SerializerMethodField, +) + + +BASE_VOTE_FIELDS = ("id", "weight", "value", "user", "option", "pollstate") + + +class BaseVoteSerializer(ModelSerializer): + pollstate = SerializerMethodField() + + def get_pollstate(self, vote): + return vote.option.poll.state + + +BASE_OPTION_FIELDS = ("id", "yes", "no", "abstain") + + +class BaseOptionSerializer(ModelSerializer): + yes = DecimalField(max_digits=15, decimal_places=6, min_value=-2, read_only=True) + no = DecimalField(max_digits=15, decimal_places=6, min_value=-2, read_only=True) + abstain = DecimalField( + max_digits=15, decimal_places=6, min_value=-2, read_only=True + ) + + BASE_POLL_FIELDS = ( "state", "type", @@ -9,8 +40,62 @@ BASE_POLL_FIELDS = ( "options", "voted", "id", + "onehundred_percent_base", + "majority_method", ) -BASE_OPTION_FIELDS = ("id", "yes", "no", "abstain") -BASE_VOTE_FIELDS = ("id", "weight", "value", "user", "option") +class BasePollSerializer(ModelSerializer): + title = CharField(allow_blank=False, required=True) + groups = IdPrimaryKeyRelatedField( + many=True, required=False, queryset=get_group_model().objects.all() + ) + voted = IdPrimaryKeyRelatedField(many=True, read_only=True) + + votesvalid = DecimalField( + max_digits=15, decimal_places=6, min_value=-2, read_only=True + ) + votesinvalid = DecimalField( + max_digits=15, decimal_places=6, min_value=-2, read_only=True + ) + votescast = DecimalField( + max_digits=15, decimal_places=6, min_value=-2, read_only=True + ) + + def create(self, validated_data): + """ + Match the 100 percent base to the pollmethod. Change the base, if it does not + fit to the pollmethod + """ + new_100_percent_base = self.norm_100_percent_base_to_pollmethod( + validated_data["onehundred_percent_base"], validated_data["pollmethod"] + ) + if new_100_percent_base is not None: + validated_data["onehundred_percent_base"] = new_100_percent_base + return super().create(validated_data) + + def update(self, instance, validated_data): + """ + Adjusts the 100%-base to the pollmethod. This might be needed, + if at least one of them was changed. Wrong comobinations should be + also handled by the client, but here we make it sure aswell! + + E.g. the pollmethod is YN, but the 100%-base is YNA, this micht noght be + possible (see implementing serializers to see forbidden combinations) + """ + old_100_percent_base = instance.onehundred_percent_base + instance = super().update(instance, validated_data) + + new_100_percent_base = self.norm_100_percent_base_to_pollmethod( + instance.onehundred_percent_base, instance.pollmethod, old_100_percent_base + ) + if new_100_percent_base is not None: + instance.onehundred_percent_base = new_100_percent_base + instance.save() + + return instance + + def norm_100_percent_base_to_pollmethod( + self, onehundred_percent_base, pollmethod, old_100_percent_base=None + ): + raise NotImplementedError() diff --git a/tests/integration/assignments/test_polls.py b/tests/integration/assignments/test_polls.py index ef05cb594..acdc1d7ca 100644 --- a/tests/integration/assignments/test_polls.py +++ b/tests/integration/assignments/test_polls.py @@ -91,7 +91,7 @@ def create_assignment_polls(): class CreateAssignmentPoll(TestCase): def advancedSetUp(self): self.assignment = Assignment.objects.create( - title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1 + title="test_assignment_ohneivoh9caiB8Yiungo", open_posts=1, ) self.assignment.add_candidate(self.admin) @@ -100,24 +100,28 @@ class CreateAssignmentPoll(TestCase): reverse("assignmentpoll-list"), { "title": "test_title_ailai4toogh3eefaa2Vo", - "pollmethod": "YNA", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "type": "named", "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertTrue(AssignmentPoll.objects.exists()) poll = AssignmentPoll.objects.get() self.assertEqual(poll.title, "test_title_ailai4toogh3eefaa2Vo") - self.assertEqual(poll.pollmethod, "YNA") + self.assertEqual(poll.pollmethod, AssignmentPoll.POLLMETHOD_YNA) self.assertEqual(poll.type, "named") # Check defaults self.assertTrue(poll.global_no) self.assertTrue(poll.global_abstain) + 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.assignment.id, self.assignment.id) + self.assertEqual(poll.description, "") self.assertTrue(poll.options.exists()) option = AssignmentOption.objects.get() self.assertTrue(option.user.id, self.admin.id) @@ -127,26 +131,29 @@ class CreateAssignmentPoll(TestCase): reverse("assignmentpoll-list"), { "title": "test_title_ahThai4pae1pi4xoogoo", - "pollmethod": "YN", + "pollmethod": AssignmentPoll.POLLMETHOD_YN, "type": "pseudoanonymous", "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YNA, + "majority_method": AssignmentPoll.MAJORITY_THREE_QUARTERS, "global_no": False, "global_abstain": False, "allow_multiple_votes_per_candidate": True, "votes_amount": 5, + "description": "test_description_ieM8ThuasoSh8aecai8p", }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertTrue(AssignmentPoll.objects.exists()) poll = AssignmentPoll.objects.get() self.assertEqual(poll.title, "test_title_ahThai4pae1pi4xoogoo") - self.assertEqual(poll.pollmethod, "YN") + self.assertEqual(poll.pollmethod, AssignmentPoll.POLLMETHOD_YN) self.assertEqual(poll.type, "pseudoanonymous") 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.description, "test_description_ieM8ThuasoSh8aecai8p") def test_no_candidates(self): self.assignment.remove_candidate(self.admin) @@ -154,62 +161,34 @@ class CreateAssignmentPoll(TestCase): reverse("assignmentpoll-list"), { "title": "test_title_eing5eipue5cha2Iefai", - "pollmethod": "YNA", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "type": "named", "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.exists()) - def test_missing_title(self): - response = self.client.post( - reverse("assignmentpoll-list"), - {"pollmethod": "YNA", "type": "named", "assignment_id": self.assignment.id}, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(AssignmentPoll.objects.exists()) - - def test_missing_pollmethod(self): - response = self.client.post( - reverse("assignmentpoll-list"), - { - "title": "test_title_OoCh9aitaeyaeth8nom1", - "type": "named", - "assignment_id": self.assignment.id, - }, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(AssignmentPoll.objects.exists()) - - def test_missing_type(self): - response = self.client.post( - reverse("assignmentpoll-list"), - { - "title": "test_title_Ail9Eizohshim0fora6o", - "pollmethod": "YNA", - "assignment_id": self.assignment.id, - }, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(AssignmentPoll.objects.exists()) - - def test_missing_assignment_id(self): - response = self.client.post( - reverse("assignmentpoll-list"), - { - "title": "test_title_eic7ooxaht5mee3quohK", - "pollmethod": "YNA", - "type": "named", - }, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(AssignmentPoll.objects.exists()) + def test_missing_keys(self): + complete_request_data = { + "title": "test_title_keugh8Iu9ciyooGaevoh", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + } + for key in complete_request_data.keys(): + request_data = { + _key: value + for _key, value in complete_request_data.items() + if _key != key + } + response = self.client.post(reverse("assignmentpoll-list"), request_data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) def test_with_groups(self): group1 = get_group_model().objects.get(pk=1) @@ -218,12 +197,13 @@ class CreateAssignmentPoll(TestCase): reverse("assignmentpoll-list"), { "title": "test_title_Thoo2eiphohhi1eeXoow", - "pollmethod": "YNA", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "type": "named", "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, "groups_id": [1, 2], }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) poll = AssignmentPoll.objects.get() @@ -235,12 +215,13 @@ class CreateAssignmentPoll(TestCase): reverse("assignmentpoll-list"), { "title": "test_title_Thoo2eiphohhi1eeXoow", - "pollmethod": "YNA", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "type": "named", "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, "groups_id": [], }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) poll = AssignmentPoll.objects.get() @@ -251,11 +232,12 @@ class CreateAssignmentPoll(TestCase): reverse("assignmentpoll-list"), { "title": "test_title_yaiyeighoh0Iraet3Ahc", - "pollmethod": "YNA", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "type": "not_existing", "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.exists()) @@ -268,12 +250,93 @@ class CreateAssignmentPoll(TestCase): "pollmethod": "not_existing", "type": "named", "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.exists()) + def test_not_supported_onehundred_percent_base(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_Thoo2eiphohhi1eeXoow", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": "invalid base", + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + + def test_not_supported_majority_method(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_Thoo2eiphohhi1eeXoow", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YN, + "majority_method": "invalid majority method", + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(AssignmentPoll.objects.exists()) + + def test_wrong_pollmethod_onehundred_percent_base_combination_1(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_Thoo2eiphohhi1eeXoow", + "pollmethod": AssignmentPoll.POLLMETHOD_YNA, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_VOTES, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, AssignmentPoll.PERCENT_BASE_YNA) + + def test_wrong_pollmethod_onehundred_percent_base_combination_2(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_Thoo2eiphohhi1eeXoow", + "pollmethod": AssignmentPoll.POLLMETHOD_YN, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_VOTES, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, AssignmentPoll.PERCENT_BASE_YN) + + def test_wrong_pollmethod_onehundred_percent_base_combination_3(self): + response = self.client.post( + reverse("assignmentpoll-list"), + { + "title": "test_title_Thoo2eiphohhi1eeXoow", + "pollmethod": AssignmentPoll.POLLMETHOD_VOTES, + "type": "named", + "assignment_id": self.assignment.id, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_YNA, + "majority_method": AssignmentPoll.MAJORITY_SIMPLE, + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + poll = AssignmentPoll.objects.get() + self.assertEqual( + poll.onehundred_percent_base, AssignmentPoll.PERCENT_BASE_VOTES + ) + class UpdateAssignmentPoll(TestCase): """ @@ -289,8 +352,10 @@ class UpdateAssignmentPoll(TestCase): self.poll = AssignmentPoll.objects.create( assignment=self.assignment, title="test_title_beeFaihuNae1vej2ai8m", - pollmethod="votes", + pollmethod=AssignmentPoll.POLLMETHOD_VOTES, type=BasePoll.TYPE_NAMED, + onehundred_percent_base=AssignmentPoll.PERCENT_BASE_VOTES, + majority_method=AssignmentPoll.MAJORITY_SIMPLE, ) self.poll.create_options() self.poll.groups.add(self.group) @@ -317,11 +382,13 @@ class UpdateAssignmentPoll(TestCase): def test_patch_pollmethod(self): response = self.client.patch( - reverse("assignmentpoll-detail", args=[self.poll.pk]), {"pollmethod": "YNA"} + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"pollmethod": AssignmentPoll.POLLMETHOD_YNA}, ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() - self.assertEqual(poll.pollmethod, "YNA") + self.assertEqual(poll.pollmethod, AssignmentPoll.POLLMETHOD_YNA) + self.assertEqual(poll.onehundred_percent_base, AssignmentPoll.PERCENT_BASE_YNA) def test_patch_invalid_pollmethod(self): response = self.client.patch( @@ -330,7 +397,7 @@ class UpdateAssignmentPoll(TestCase): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) poll = AssignmentPoll.objects.get() - self.assertEqual(poll.pollmethod, "votes") + self.assertEqual(poll.pollmethod, AssignmentPoll.POLLMETHOD_VOTES) def test_patch_type(self): response = self.client.patch( @@ -350,9 +417,7 @@ class UpdateAssignmentPoll(TestCase): def test_patch_groups_to_empty(self): response = self.client.patch( - reverse("assignmentpoll-detail", args=[self.poll.pk]), - {"groups_id": []}, - format="json", + reverse("assignmentpoll-detail", args=[self.poll.pk]), {"groups_id": []}, ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() @@ -363,7 +428,6 @@ class UpdateAssignmentPoll(TestCase): response = self.client.patch( reverse("assignmentpoll-detail", args=[self.poll.pk]), {"groups_id": [group2.id]}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() @@ -381,12 +445,50 @@ class UpdateAssignmentPoll(TestCase): poll = AssignmentPoll.objects.get() self.assertEqual(poll.title, "test_title_beeFaihuNae1vej2ai8m") + def test_patch_100_percent_base(self): + 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(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": "invalid"}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + self.assertEqual( + poll.onehundred_percent_base, AssignmentPoll.PERCENT_BASE_VOTES + ) + + def test_patch_majority_method(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.majority_method, AssignmentPoll.MAJORITY_TWO_THIRDS) + + def test_patch_wrong_majority_method(self): + response = self.client.patch( + reverse("assignmentpoll-detail", args=[self.poll.pk]), + {"majority_method": "invalid majority method"}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + poll = AssignmentPoll.objects.get() + self.assertEqual(poll.majority_method, AssignmentPoll.MAJORITY_SIMPLE) + def test_patch_multiple_fields(self): response = self.client.patch( reverse("assignmentpoll-detail", args=[self.poll.pk]), { "title": "test_title_ees6Tho8ahheen4cieja", - "pollmethod": "votes", + "pollmethod": AssignmentPoll.POLLMETHOD_VOTES, "global_no": True, "global_abstain": False, "allow_multiple_votes_per_candidate": True, @@ -396,9 +498,11 @@ class UpdateAssignmentPoll(TestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() self.assertEqual(poll.title, "test_title_ees6Tho8ahheen4cieja") - self.assertEqual(poll.pollmethod, "votes") + self.assertEqual(poll.pollmethod, AssignmentPoll.POLLMETHOD_VOTES) self.assertTrue(poll.global_no) self.assertFalse(poll.global_abstain) + 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) @@ -462,7 +566,6 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): "votesvalid": "4.64", "votesinvalid": "-2", }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(AssignmentVote.objects.count(), 6) @@ -490,7 +593,6 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): "2": {"Y": "1", "N": "2.35", "A": "-1"}, } }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) @@ -501,7 +603,6 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"options": {"1": {"Y": "1", "N": "2.35", "A": "-1"}}}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) @@ -517,7 +618,6 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): "3": {"Y": "1", "N": "2.35", "A": "-1"}, } }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) @@ -543,9 +643,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): def test_wrong_data_format(self): self.start_poll() response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), - [1, 2, 5], - format="json", + reverse("assignmentpoll-vote", args=[self.poll.pk]), [1, 2, 5], ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -555,7 +653,6 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"options": [1, "string"]}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) @@ -565,7 +662,6 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"options": {"string": "some_other_string"}}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -575,7 +671,6 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), {"options": {"1": [None]}}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -586,7 +681,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): data = {"options": {"1": {"Y": "1", "N": "3", "A": "-1"}}} del data["options"]["1"][value] response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), data, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), data ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -861,6 +956,8 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.assertEqual(option.yes, Decimal("0")) self.assertEqual(option.no, Decimal("2")) self.assertEqual(option.abstain, Decimal("0")) + self.assertEqual(poll.amount_global_no, Decimal("2")) + self.assertEqual(poll.amount_global_abstain, Decimal("0")) def test_global_no_forbidden(self): self.poll.global_no = False @@ -871,6 +968,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + self.assertEqual(AssignmentPoll.objects.get().amount_global_no, None) def test_global_abstain(self): self.poll.votes_amount = 2 @@ -885,6 +983,8 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.assertEqual(option.yes, Decimal("0")) self.assertEqual(option.no, Decimal("0")) self.assertEqual(option.abstain, Decimal("2")) + self.assertEqual(poll.amount_global_no, Decimal("0")) + self.assertEqual(poll.amount_global_abstain, Decimal("2")) def test_global_abstain_forbidden(self): self.poll.global_abstain = False @@ -895,6 +995,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) + self.assertEqual(AssignmentPoll.objects.get().amount_global_abstain, None) def test_negative_vote(self): self.start_poll() @@ -1463,12 +1564,16 @@ class VoteAssignmentPollAutoupdatesBaseClass(TestCase): title="test_assignment_" + self._get_random_string(), open_posts=1 ) self.assignment.add_candidate(self.admin) + self.description = "test_description_paiquei5ahpie1wu8ohW" self.poll = AssignmentPoll.objects.create( assignment=self.assignment, title="test_title_" + self._get_random_string(), pollmethod=AssignmentPoll.POLLMETHOD_YNA, type=self.poll_type, state=AssignmentPoll.STATE_STARTED, + onehundred_percent_base=AssignmentPoll.PERCENT_BASE_CAST, + majority_method=AssignmentPoll.MAJORITY_TWO_THIRDS, + description=self.description, ) self.poll.create_options() self.poll.groups.add(self.delegate_group) @@ -1495,6 +1600,8 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "assignment_id": 1, "global_abstain": True, "global_no": True, + "amount_global_abstain": None, + "amount_global_no": None, "groups_id": [GROUP_DELEGATE_PK], "id": 1, "options": [ @@ -1510,7 +1617,10 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "state": AssignmentPoll.STATE_STARTED, "title": self.poll.title, + "description": self.description, "type": AssignmentPoll.TYPE_NAMED, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, + "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, "voted_id": [self.user.id], "votes_amount": 1, "votescast": "1.000000", @@ -1558,7 +1668,10 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "state": AssignmentPoll.STATE_STARTED, "type": AssignmentPoll.TYPE_NAMED, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, + "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, "title": self.poll.title, + "description": self.description, "groups_id": [GROUP_DELEGATE_PK], "options": [{"id": 1, "user_id": self.admin.id, "weight": 1}], "id": 1, @@ -1593,6 +1706,8 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "assignment_id": 1, "global_abstain": True, "global_no": True, + "amount_global_abstain": None, + "amount_global_no": None, "groups_id": [GROUP_DELEGATE_PK], "id": 1, "options": [ @@ -1608,7 +1723,10 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "state": AssignmentPoll.STATE_STARTED, "title": self.poll.title, + "description": self.description, "type": AssignmentPoll.TYPE_PSEUDOANONYMOUS, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, + "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, "voted_id": [self.user.id], "votes_amount": 1, "votescast": "1.000000", @@ -1642,7 +1760,10 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "pollmethod": AssignmentPoll.POLLMETHOD_YNA, "state": AssignmentPoll.STATE_STARTED, "type": AssignmentPoll.TYPE_PSEUDOANONYMOUS, + "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, + "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, "title": self.poll.title, + "description": self.description, "groups_id": [GROUP_DELEGATE_PK], "options": [{"id": 1, "user_id": self.admin.id, "weight": 1}], "id": 1, diff --git a/tests/integration/motions/test_polls.py b/tests/integration/motions/test_polls.py index ae4b3f977..66c6a320d 100644 --- a/tests/integration/motions/test_polls.py +++ b/tests/integration/motions/test_polls.py @@ -95,8 +95,9 @@ class CreateMotionPoll(TestCase): "pollmethod": "YNA", "type": "named", "motion_id": self.motion.id, + "onehundred_percent_base": "YN", + "majority_method": "simple", }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertTrue(MotionPoll.objects.exists()) @@ -107,53 +108,24 @@ class CreateMotionPoll(TestCase): self.assertEqual(poll.motion.id, self.motion.id) self.assertTrue(poll.options.exists()) - def test_missing_title(self): - response = self.client.post( - reverse("motionpoll-list"), - {"pollmethod": "YNA", "type": "named", "motion_id": self.motion.id}, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(MotionPoll.objects.exists()) - - def test_missing_pollmethod(self): - response = self.client.post( - reverse("motionpoll-list"), - { - "title": "test_title_OoCh9aitaeyaeth8nom1", - "type": "named", - "motion_id": self.motion.id, - }, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(MotionPoll.objects.exists()) - - def test_missing_type(self): - response = self.client.post( - reverse("motionpoll-list"), - { - "title": "test_title_Ail9Eizohshim0fora6o", - "pollmethod": "YNA", - "motion_id": self.motion.id, - }, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(MotionPoll.objects.exists()) - - def test_missing_assignment_id(self): - response = self.client.post( - reverse("motionpoll-list"), - { - "title": "test_title_eic7ooxaht5mee3quohK", - "pollmethod": "YNA", - "type": "named", - }, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertFalse(MotionPoll.objects.exists()) + def test_missing_keys(self): + complete_request_data = { + "title": "test_title_OoCh9aitaeyaeth8nom1", + "type": "named", + "pollmethod": "YNA", + "motion_id": self.motion.id, + "onehundred_percent_base": "YN", + "majority_method": "simple", + } + for key in complete_request_data.keys(): + request_data = { + _key: value + for _key, value in complete_request_data.items() + if _key != key + } + response = self.client.post(reverse("motionpoll-list"), request_data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.exists()) def test_with_groups(self): group1 = get_group_model().objects.get(pk=1) @@ -165,9 +137,10 @@ class CreateMotionPoll(TestCase): "pollmethod": "YNA", "type": "named", "motion_id": self.motion.id, + "onehundred_percent_base": "YN", + "majority_method": "simple", "groups_id": [1, 2], }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) poll = MotionPoll.objects.get() @@ -182,9 +155,10 @@ class CreateMotionPoll(TestCase): "pollmethod": "YNA", "type": "named", "motion_id": self.motion.id, + "onehundred_percent_base": "YN", + "majority_method": "simple", "groups_id": [], }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) poll = MotionPoll.objects.get() @@ -199,7 +173,6 @@ class CreateMotionPoll(TestCase): "type": "not_existing", "motion_id": self.motion.id, }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.exists()) @@ -213,7 +186,6 @@ class CreateMotionPoll(TestCase): "type": "named", "motion_id": self.motion.id, }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.exists()) @@ -236,8 +208,10 @@ class UpdateMotionPoll(TestCase): self.poll = MotionPoll.objects.create( motion=self.motion, title="test_title_beeFaihuNae1vej2ai8m", - pollmethod="YN", + pollmethod="YNA", type="named", + onehundred_percent_base="YN", + majority_method="simple", ) self.poll.create_options() self.poll.groups.add(self.group) @@ -266,11 +240,12 @@ class UpdateMotionPoll(TestCase): def test_patch_pollmethod(self): response = self.client.patch( - reverse("motionpoll-detail", args=[self.poll.pk]), {"pollmethod": "YNA"} + reverse("motionpoll-detail", args=[self.poll.pk]), {"pollmethod": "YN"} ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = MotionPoll.objects.get() - self.assertEqual(poll.pollmethod, "YNA") + self.assertEqual(poll.pollmethod, "YN") + self.assertEqual(poll.onehundred_percent_base, "YN") def test_patch_invalid_pollmethod(self): response = self.client.patch( @@ -278,7 +253,7 @@ class UpdateMotionPoll(TestCase): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) poll = MotionPoll.objects.get() - self.assertEqual(poll.pollmethod, "YN") + self.assertEqual(poll.pollmethod, "YNA") def test_patch_type(self): response = self.client.patch( @@ -296,11 +271,45 @@ class UpdateMotionPoll(TestCase): poll = MotionPoll.objects.get() self.assertEqual(poll.type, "named") - def test_patch_groups_to_empty(self): + def test_patch_100_percent_base(self): response = self.client.patch( reverse("motionpoll-detail", args=[self.poll.pk]), - {"groups_id": []}, - format="json", + {"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(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"onehundred_percent_base": "invalid"}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + poll = MotionPoll.objects.get() + self.assertEqual(poll.onehundred_percent_base, "YN") + + def test_patch_majority_method(self): + 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_wrong_majority_method(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), + {"majority_method": "invalid majority method"}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + poll = MotionPoll.objects.get() + self.assertEqual(poll.majority_method, "simple") + + def test_patch_groups_to_empty(self): + response = self.client.patch( + reverse("motionpoll-detail", args=[self.poll.pk]), {"groups_id": []}, ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -311,7 +320,6 @@ class UpdateMotionPoll(TestCase): response = self.client.patch( reverse("motionpoll-detail", args=[self.poll.pk]), {"groups_id": [group2.id]}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -378,7 +386,6 @@ class VoteMotionPollAnalog(TestCase): "votesvalid": "4.64", "votesinvalid": "-2", }, - format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -403,9 +410,7 @@ class VoteMotionPollAnalog(TestCase): def test_vote_missing_data(self): self.start_poll() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), - {"Y": "4", "N": "22.6"}, - format="json", + reverse("motionpoll-vote", args=[self.poll.pk]), {"Y": "4", "N": "22.6"}, ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -413,7 +418,7 @@ class VoteMotionPollAnalog(TestCase): def test_vote_wrong_data_format(self): self.start_poll() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5], format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5] ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -423,7 +428,6 @@ class VoteMotionPollAnalog(TestCase): response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), {"Y": "some string", "N": "-2", "A": "3"}, - format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -477,7 +481,7 @@ class VoteMotionPollNamed(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "N", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "N" ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -498,11 +502,11 @@ class VoteMotionPollNamed(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "N", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "N" ) self.assertEqual(response.status_code, status.HTTP_200_OK) response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "A", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "A" ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -524,7 +528,7 @@ class VoteMotionPollNamed(TestCase): config["general_system_enable_anonymous"] = True guest_client = APIClient() response = guest_client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "Y", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "Y" ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -572,7 +576,7 @@ class VoteMotionPollNamed(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5], format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5] ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -608,6 +612,8 @@ class VoteMotionPollNamedAutoupdates(TestCase): pollmethod="YNA", type=BasePoll.TYPE_NAMED, state=MotionPoll.STATE_STARTED, + onehundred_percent_base="YN", + majority_method="simple", ) self.poll.create_options() self.poll.groups.add(self.delegate_group) @@ -631,6 +637,8 @@ class VoteMotionPollNamedAutoupdates(TestCase): "state": 2, "type": "named", "title": "test_title_tho8PhiePh8upaex6phi", + "onehundred_percent_base": "YN", + "majority_method": "simple", "groups_id": [GROUP_DELEGATE_PK], "votesvalid": "1.000000", "votesinvalid": "0.000000", @@ -685,6 +693,8 @@ class VoteMotionPollNamedAutoupdates(TestCase): "state": 2, "type": "named", "title": "test_title_tho8PhiePh8upaex6phi", + "onehundred_percent_base": "YN", + "majority_method": "simple", "groups_id": [GROUP_DELEGATE_PK], "options": [{"id": 1}], "id": 1, @@ -726,6 +736,8 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): pollmethod="YNA", type=BasePoll.TYPE_PSEUDOANONYMOUS, state=MotionPoll.STATE_STARTED, + onehundred_percent_base="YN", + majority_method="simple", ) self.poll.create_options() self.poll.groups.add(self.delegate_group) @@ -749,6 +761,8 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): "state": 2, "type": "pseudoanonymous", "title": "test_title_cahP1umooteehah2jeey", + "onehundred_percent_base": "YN", + "majority_method": "simple", "groups_id": [GROUP_DELEGATE_PK], "votesvalid": "1.000000", "votesinvalid": "0.000000", @@ -789,6 +803,8 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): "state": 2, "type": "pseudoanonymous", "title": "test_title_cahP1umooteehah2jeey", + "onehundred_percent_base": "YN", + "majority_method": "simple", "groups_id": [GROUP_DELEGATE_PK], "options": [{"id": 1}], "id": 1, @@ -847,7 +863,7 @@ class VoteMotionPollPseudoanonymous(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "N", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "N" ) self.assertEqual(response.status_code, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -869,11 +885,11 @@ class VoteMotionPollPseudoanonymous(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "N", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "N" ) self.assertEqual(response.status_code, status.HTTP_200_OK) response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "A", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "A" ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) option = MotionPoll.objects.get().options.get() @@ -889,7 +905,7 @@ class VoteMotionPollPseudoanonymous(TestCase): config["general_system_enable_anonymous"] = True guest_client = APIClient() response = guest_client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "Y", format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), "Y" ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -928,7 +944,7 @@ class VoteMotionPollPseudoanonymous(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5], format="json" + reverse("motionpoll-vote", args=[self.poll.pk]), [1, 2, 5] ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -976,6 +992,8 @@ class PublishMotionPoll(TestCase): title="test_title_Nufae0iew7Iorox2thoo", pollmethod="YNA", type=BasePoll.TYPE_PSEUDOANONYMOUS, + onehundred_percent_base="YN", + majority_method="simple", ) self.poll.create_options() option = self.poll.options.get() @@ -1003,6 +1021,8 @@ class PublishMotionPoll(TestCase): "state": 4, "type": "pseudoanonymous", "title": "test_title_Nufae0iew7Iorox2thoo", + "onehundred_percent_base": "YN", + "majority_method": "simple", "groups_id": [], "votesvalid": "0.000000", "votesinvalid": "0.000000",