From 3ac8569712d06bc123f0cab551691719d70bbc9c Mon Sep 17 00:00:00 2001 From: Joshua Sangmeister Date: Thu, 10 Sep 2020 12:09:05 +0200 Subject: [PATCH 1/2] Add vote delegation on server side Add user_has_voted_for_delegations. Add tests Prevent self delegation Make delegated_user visible --- .../0015_assignmentvote_delegated_user.py | 29 ++ server/openslides/assignments/views.py | 46 ++- .../0037_motionvote_delegated_user.py | 29 ++ server/openslides/motions/views.py | 24 +- server/openslides/poll/access_permissions.py | 24 +- server/openslides/poll/models.py | 8 + server/openslides/poll/serializers.py | 10 +- server/openslides/poll/views.py | 54 ++-- server/openslides/users/access_permissions.py | 2 + .../migrations/0015_user_vote_delegated_to.py | 27 ++ server/openslides/users/models.py | 17 +- server/openslides/users/serializers.py | 13 + server/openslides/users/views.py | 69 +++++ .../integration/assignments/test_polls.py | 276 ++++++++++------- .../tests/integration/motions/test_polls.py | 285 ++++++++++++++---- .../tests/integration/users/test_viewset.py | 187 +++++++++++- server/tests/unit/motions/test_models.py | 25 +- 17 files changed, 923 insertions(+), 202 deletions(-) create mode 100644 server/openslides/assignments/migrations/0015_assignmentvote_delegated_user.py create mode 100644 server/openslides/motions/migrations/0037_motionvote_delegated_user.py create mode 100644 server/openslides/users/migrations/0015_user_vote_delegated_to.py diff --git a/server/openslides/assignments/migrations/0015_assignmentvote_delegated_user.py b/server/openslides/assignments/migrations/0015_assignmentvote_delegated_user.py new file mode 100644 index 000000000..9a591b9e6 --- /dev/null +++ b/server/openslides/assignments/migrations/0015_assignmentvote_delegated_user.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.16 on 2020-09-10 11:02 + +from django.conf import settings +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("assignments", "0014_remove_deprecated_slides"), + ] + + operations = [ + migrations.AddField( + model_name="assignmentvote", + name="delegated_user", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + related_name="assignmentvote_delegated_votes", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/server/openslides/assignments/views.py b/server/openslides/assignments/views.py index 3cab3b452..9f00f055a 100644 --- a/server/openslides/assignments/views.py +++ b/server/openslides/assignments/views.py @@ -289,7 +289,7 @@ class AssignmentPollViewSet(BasePollViewSet): poll.db_amount_global_no = Decimal(0) poll.save() - def handle_analog_vote(self, data, poll, user): + def handle_analog_vote(self, data, poll): for field in ["votesvalid", "votesinvalid", "votescast"]: setattr(poll, field, data[field]) @@ -336,7 +336,7 @@ class AssignmentPollViewSet(BasePollViewSet): poll.save() - def validate_vote_data(self, data, poll, user): + def validate_vote_data(self, data, poll): """ Request data: analog: @@ -478,10 +478,12 @@ class AssignmentPollViewSet(BasePollViewSet): options_data = data - def create_votes_type_votes(self, data, poll, vote_weight, vote_user): + def create_votes_type_votes(self, data, poll, vote_weight, vote_user, request_user): """ Helper function for handle_(named|pseudoanonymous)_vote Assumes data is already validated + vote_user is the user whose vote is given + request_user is the user who gives the vote, may be a delegate """ options = poll.get_options() if isinstance(data, dict): @@ -495,30 +497,46 @@ class AssignmentPollViewSet(BasePollViewSet): if config["users_activate_vote_weight"]: weight *= vote_weight vote = AssignmentVote.objects.create( - option=option, user=vote_user, weight=weight, value="Y" + option=option, + user=vote_user, + delegated_user=request_user, + weight=weight, + value="Y", ) inform_changed_data(vote, no_delete_on_restriction=True) else: # global_no or global_abstain option = options[0] weight = vote_weight if config["users_activate_vote_weight"] else Decimal(1) vote = AssignmentVote.objects.create( - option=option, user=vote_user, weight=weight, value=data + option=option, + user=vote_user, + delegated_user=request_user, + weight=weight, + value=data, ) inform_changed_data(vote, no_delete_on_restriction=True) inform_changed_data(option) inform_changed_data(poll) - def create_votes_types_yn_yna(self, data, poll, vote_weight, vote_user): + def create_votes_types_yn_yna( + self, data, poll, vote_weight, vote_user, request_user + ): """ Helper function for handle_(named|pseudoanonymous)_vote Assumes data is already validated + vote_user is the user whose vote is given + request_user is the user who gives the vote, may be a delegate """ options = poll.get_options() weight = vote_weight if config["users_activate_vote_weight"] else Decimal(1) for option_id, result in data.items(): option = options.get(pk=option_id) vote = AssignmentVote.objects.create( - option=option, user=vote_user, value=result, weight=weight + option=option, + user=vote_user, + delegated_user=request_user, + value=result, + weight=weight, ) inform_changed_data(vote, no_delete_on_restriction=True) inform_changed_data(option, no_delete_on_restriction=True) @@ -527,24 +545,28 @@ class AssignmentPollViewSet(BasePollViewSet): VotedModel = AssignmentPoll.voted.through VotedModel.objects.create(assignmentpoll=poll, user=user) - def handle_named_vote(self, data, poll, user): + def handle_named_vote(self, data, poll, vote_user, request_user): if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES: - self.create_votes_type_votes(data, poll, user.vote_weight, user) + self.create_votes_type_votes( + data, poll, vote_user.vote_weight, vote_user, request_user + ) elif poll.pollmethod in ( AssignmentPoll.POLLMETHOD_YN, AssignmentPoll.POLLMETHOD_YNA, ): - self.create_votes_types_yn_yna(data, poll, user.vote_weight, user) + self.create_votes_types_yn_yna( + data, poll, vote_user.vote_weight, vote_user, request_user + ) def handle_pseudoanonymous_vote(self, data, poll, user): if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES: - self.create_votes_type_votes(data, poll, user.vote_weight, None) + self.create_votes_type_votes(data, poll, user.vote_weight, None, None) elif poll.pollmethod in ( AssignmentPoll.POLLMETHOD_YN, AssignmentPoll.POLLMETHOD_YNA, ): - self.create_votes_types_yn_yna(data, poll, user.vote_weight, None) + self.create_votes_types_yn_yna(data, poll, user.vote_weight, None, None) def convert_option_data(self, poll, data): poll_options = poll.get_options() diff --git a/server/openslides/motions/migrations/0037_motionvote_delegated_user.py b/server/openslides/motions/migrations/0037_motionvote_delegated_user.py new file mode 100644 index 000000000..04e13cd00 --- /dev/null +++ b/server/openslides/motions/migrations/0037_motionvote_delegated_user.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.16 on 2020-09-10 11:02 + +from django.conf import settings +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("motions", "0036_rename_verbose_poll_types"), + ] + + operations = [ + migrations.AddField( + model_name="motionvote", + name="delegated_user", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + related_name="motionvote_delegated_votes", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/server/openslides/motions/views.py b/server/openslides/motions/views.py index a9d7253dc..e1ba80b9a 100644 --- a/server/openslides/motions/views.py +++ b/server/openslides/motions/views.py @@ -1190,7 +1190,7 @@ class MotionPollViewSet(BasePollViewSet): return result - def handle_analog_vote(self, data, poll, user): + def handle_analog_vote(self, data, poll): option = poll.options.get() vote, _ = MotionVote.objects.get_or_create(option=option, value="Y") vote.weight = data["Y"] @@ -1209,7 +1209,7 @@ class MotionPollViewSet(BasePollViewSet): poll.save() - def validate_vote_data(self, data, poll, user): + def validate_vote_data(self, data, poll): """ Request data for analog: { "Y": , "N": , ["A": ], @@ -1240,15 +1240,25 @@ class MotionPollViewSet(BasePollViewSet): VotedModel = MotionPoll.voted.through VotedModel.objects.create(motionpoll=poll, user=user) - def handle_named_vote(self, data, poll, user): - self.handle_named_and_pseudoanonymous_vote(data, user, user, poll) + def handle_named_vote(self, data, poll, vote_user, request_user): + self.handle_named_and_pseudoanonymous_vote( + data, + poll, + weight_user=vote_user, + vote_user=vote_user, + request_user=request_user, + ) def handle_pseudoanonymous_vote(self, data, poll, user): - self.handle_named_and_pseudoanonymous_vote(data, user, None, poll) + self.handle_named_and_pseudoanonymous_vote(data, poll, user, None, None) - def handle_named_and_pseudoanonymous_vote(self, data, weight_user, vote_user, poll): + def handle_named_and_pseudoanonymous_vote( + self, data, poll, weight_user, vote_user, request_user + ): option = poll.options.get() - vote = MotionVote.objects.create(user=vote_user, option=option) + vote = MotionVote.objects.create( + user=vote_user, delegated_user=request_user, option=option + ) vote.value = data vote.weight = ( weight_user.vote_weight diff --git a/server/openslides/poll/access_permissions.py b/server/openslides/poll/access_permissions.py index 576ee0ace..f9b1f124e 100644 --- a/server/openslides/poll/access_permissions.py +++ b/server/openslides/poll/access_permissions.py @@ -2,8 +2,13 @@ import json from typing import Any, Dict, List from ..poll.views import BasePoll +from ..utils import logging from ..utils.access_permissions import BaseAccessPermissions -from ..utils.auth import async_has_perm +from ..utils.auth import async_has_perm, user_collection_string +from ..utils.cache import element_cache + + +logger = logging.getLogger(__name__) class BaseVoteAccessPermissions(BaseAccessPermissions): @@ -26,6 +31,7 @@ class BaseVoteAccessPermissions(BaseAccessPermissions): for vote in full_data if vote["pollstate"] == BasePoll.STATE_PUBLISHED or vote["user_id"] == user_id + or vote["delegated_user_id"] == user_id ] return data @@ -71,8 +77,24 @@ class BasePollAccessPermissions(BaseAccessPermissions): """ # add has_voted for all users to check whether op has voted + # also fill user_has_voted_for_delegations with all users for which he has + # already voted + user_data = await element_cache.get_element_data( + user_collection_string, user_id + ) + if user_data is None: + logger.error(f"Could not find userdata for {user_id}") + vote_delegated_from_ids = set() + else: + vote_delegated_from_ids = set(user_data["vote_delegated_from_users_id"]) + for poll in full_data: poll["user_has_voted"] = user_id in poll["voted_id"] + voted_ids = set(poll["voted_id"]) + voted_for_delegations = list( + vote_delegated_from_ids.intersection(voted_ids) + ) + poll["user_has_voted_for_delegations"] = voted_for_delegations if await async_has_perm(user_id, self.manage_permission): data = full_data diff --git a/server/openslides/poll/models.py b/server/openslides/poll/models.py index b3d47d4d8..e362efbe4 100644 --- a/server/openslides/poll/models.py +++ b/server/openslides/poll/models.py @@ -29,6 +29,14 @@ class BaseVote(models.Model): blank=True, on_delete=SET_NULL_AND_AUTOUPDATE, ) + delegated_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + default=None, + null=True, + blank=True, + on_delete=SET_NULL_AND_AUTOUPDATE, + related_name="%(class)s_delegated_votes", + ) class Meta: abstract = True diff --git a/server/openslides/poll/serializers.py b/server/openslides/poll/serializers.py index a2b5d8311..03b682851 100644 --- a/server/openslides/poll/serializers.py +++ b/server/openslides/poll/serializers.py @@ -12,7 +12,15 @@ from ..utils.rest_api import ( from .models import BasePoll -BASE_VOTE_FIELDS = ("id", "weight", "value", "user", "option", "pollstate") +BASE_VOTE_FIELDS = ( + "id", + "weight", + "value", + "user", + "delegated_user", + "option", + "pollstate", +) class BaseVoteSerializer(ModelSerializer): diff --git a/server/openslides/poll/views.py b/server/openslides/poll/views.py index b89cdda02..9a11416b6 100644 --- a/server/openslides/poll/views.py +++ b/server/openslides/poll/views.py @@ -1,5 +1,6 @@ from textwrap import dedent +from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.db import transaction from django.db.utils import IntegrityError @@ -104,8 +105,8 @@ class BasePollViewSet(ModelViewSet): # convert user ids to option ids self.convert_option_data(poll, vote_data) - self.validate_vote_data(vote_data, poll, request.user) - self.handle_analog_vote(vote_data, poll, request.user) + self.validate_vote_data(vote_data, poll) + self.handle_analog_vote(vote_data, poll) if request.data.get("publish_immediately"): poll.state = BasePoll.STATE_PUBLISHED @@ -198,25 +199,41 @@ class BasePollViewSet(ModelViewSet): if isinstance(request.user, AnonymousUser): self.permission_denied(request) - # check permissions based on poll type and handle requests - self.assert_can_vote(poll, request) - + # data format is: + # { data: , [user_id: int] } + # if user_id is given, the operator votes for this user instead of himself + # user_id is ignored for analog polls data = request.data - self.validate_vote_data(data, poll, request.user) + if "data" not in data: + raise ValidationError({"detail": "No data provided."}) + vote_data = data["data"] + if "user_id" in data and poll.type != BasePoll.TYPE_ANALOG: + try: + vote_user = get_user_model().objects.get(pk=data["user_id"]) + except get_user_model().DoesNotExist: + raise ValidationError({"detail": "The given user does not exist."}) + else: + vote_user = request.user + + # check permissions based on poll type and user + self.assert_can_vote(poll, request, vote_user) + + # validate the vote data + self.validate_vote_data(vote_data, poll) if poll.type == BasePoll.TYPE_ANALOG: - self.handle_analog_vote(data, poll, request.user) - if request.data.get("publish_immediately") == "1": + self.handle_analog_vote(vote_data, poll) + if vote_data.get("publish_immediately") == "1": poll.state = BasePoll.STATE_PUBLISHED else: poll.state = BasePoll.STATE_FINISHED poll.save() elif poll.type == BasePoll.TYPE_NAMED: - self.handle_named_vote(data, poll, request.user) + self.handle_named_vote(vote_data, poll, vote_user, request.user) elif poll.type == BasePoll.TYPE_PSEUDOANONYMOUS: - self.handle_pseudoanonymous_vote(data, poll, request.user) + self.handle_pseudoanonymous_vote(vote_data, poll, vote_user) inform_changed_data(poll) @@ -231,13 +248,16 @@ class BasePollViewSet(ModelViewSet): inform_changed_data(poll.get_votes(), final_data=True) return Response() - def assert_can_vote(self, poll, request): + def assert_can_vote(self, poll, request, vote_user): """ Raises a permission denied, if the user is not allowed to vote (or has already voted). - Adds the user to the voted array, so this needs to be reverted on error! + Adds the user to the voted array, so this needs to be reverted if a later error happens! Analog: has to have manage permissions Named & Pseudoanonymous: has to be in a poll group and present """ + if request.user != vote_user and request.user != vote_user.vote_delegated_to: + self.permission_denied(request) + if poll.type == BasePoll.TYPE_ANALOG: if not self.has_manage_permissions(): self.permission_denied(request) @@ -246,14 +266,14 @@ class BasePollViewSet(ModelViewSet): raise ValidationError("You can only vote on a started poll.") if not request.user.is_present or not in_some_groups( - request.user.id, + vote_user.id, list(poll.groups.values_list("pk", flat=True)), exact=True, ): self.permission_denied(request) try: - self.add_user_to_voted_array(request.user, poll) + self.add_user_to_voted_array(vote_user, poll) inform_changed_data(poll) except IntegrityError: raise ValidationError({"detail": "You have already voted"}) @@ -292,20 +312,20 @@ class BasePollViewSet(ModelViewSet): """ raise NotImplementedError() - def validate_vote_data(self, data, poll, user): + def validate_vote_data(self, data, poll): """ To be implemented by subclass. Validates the data according to poll type and method and fields by validated versions. Raises ValidationError on failure """ raise NotImplementedError() - def handle_analog_vote(self, data, poll, user): + def handle_analog_vote(self, data, poll): """ To be implemented by subclass. Handles the analog vote. Assumes data is validated """ raise NotImplementedError() - def handle_named_vote(self, data, poll, user): + def handle_named_vote(self, data, poll, vote_user, request_user): """ To be implemented by subclass. Handles the named vote. Assumes data is validated. Needs to manage the voted-array per option. diff --git a/server/openslides/users/access_permissions.py b/server/openslides/users/access_permissions.py index 4e4084f01..a299a2ab2 100644 --- a/server/openslides/users/access_permissions.py +++ b/server/openslides/users/access_permissions.py @@ -58,6 +58,8 @@ class UserAccessPermissions(BaseAccessPermissions): own_data_fields = set(little_data_fields) own_data_fields.add("email") own_data_fields.add("gender") + own_data_fields.add("vote_delegated_to_id") + own_data_fields.add("vote_delegated_from_users_id") # Check user permissions. if await async_has_perm(user_id, "users.can_see_name"): diff --git a/server/openslides/users/migrations/0015_user_vote_delegated_to.py b/server/openslides/users/migrations/0015_user_vote_delegated_to.py new file mode 100644 index 000000000..7d9c0499e --- /dev/null +++ b/server/openslides/users/migrations/0015_user_vote_delegated_to.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.16 on 2020-09-03 11:13 + +from django.conf import settings +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0014_user_rename_permission"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="vote_delegated_to", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + related_name="vote_delegated_from_users", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/server/openslides/users/models.py b/server/openslides/users/models.py index 44794fe61..ad3c45fe5 100644 --- a/server/openslides/users/models.py +++ b/server/openslides/users/models.py @@ -22,7 +22,11 @@ from openslides.utils.manager import BaseManager from ..core.config import config from ..utils.auth import GROUP_ADMIN_PK -from ..utils.models import CASCADE_AND_AUTOUPDATE, RESTModelMixin +from ..utils.models import ( + CASCADE_AND_AUTOUPDATE, + SET_NULL_AND_AUTOUPDATE, + RESTModelMixin, +) from .access_permissions import ( GroupAccessPermissions, PersonalNoteAccessPermissions, @@ -54,7 +58,8 @@ class UserManager(BaseUserManager): queryset=Permission.objects.select_related("content_type"), ) ), - ) + ), + "vote_delegated_from_users", ) def create_user(self, username, password, skip_autoupdate=False, **kwargs): @@ -164,6 +169,14 @@ class User(RESTModelMixin, PermissionsMixin, AbstractBaseUser): default=Decimal("1"), max_digits=15, decimal_places=6, null=False, blank=True ) + vote_delegated_to = models.ForeignKey( + "self", + on_delete=SET_NULL_AND_AUTOUPDATE, + null=True, + blank=True, + related_name="vote_delegated_from_users", + ) + objects = UserManager() class Meta: diff --git a/server/openslides/users/serializers.py b/server/openslides/users/serializers.py index 6c2f44949..6397e8a14 100644 --- a/server/openslides/users/serializers.py +++ b/server/openslides/users/serializers.py @@ -7,6 +7,7 @@ from ..utils.rest_api import ( JSONField, ModelSerializer, RelatedField, + SerializerMethodField, ValidationError, ) from ..utils.validate import validate_html_strict @@ -36,6 +37,8 @@ USERCANSEEEXTRASERIALIZER_FIELDS = USERCANSEESERIALIZER_FIELDS + ( "comment", "is_active", "auth_type", + "vote_delegated_to_id", + "vote_delegated_from_users_id", ) @@ -57,11 +60,14 @@ class UserSerializer(ModelSerializer): ), ) + vote_delegated_from_users_id = SerializerMethodField() + class Meta: model = User fields = USERCANSEEEXTRASERIALIZER_FIELDS + ( "default_password", "session_auth_hash", + "vote_delegated_to", ) read_only_fields = ("last_email_send", "auth_type") @@ -119,6 +125,13 @@ class UserSerializer(ModelSerializer): inform_changed_data(user) return user + def get_vote_delegated_from_users_id(self, user): + # check needed to prevent errors on import since we only give an OrderedDict there + if hasattr(user, "vote_delegated_from_users"): + return [delegator.id for delegator in user.vote_delegated_from_users.all()] + else: + return [] + class PermissionRelatedField(RelatedField): """ diff --git a/server/openslides/users/views.py b/server/openslides/users/views.py index 1361f8da5..5d795f05c 100644 --- a/server/openslides/users/views.py +++ b/server/openslides/users/views.py @@ -174,9 +174,77 @@ class UserViewSet(ModelViewSet): ): request.data["username"] = user.username + # check that no chains are created with vote delegation + delegate_id = request.data.get("vote_delegated_to_id") + if delegate_id: + try: + delegate = User.objects.get(id=delegate_id) + except User.DoesNotExist: + raise ValidationError( + { + "detail": f"Vote delegation: The user with id {delegate_id} does not exist" + } + ) + + self.assert_no_self_delegation(user, [delegate_id]) + self.assert_vote_not_delegated(delegate) + self.assert_has_no_delegated_votes(user) + + inform_changed_data(delegate) + if user.vote_delegated_to: + inform_changed_data(user.vote_delegated_to) + + # handle delegated_from field seperately since its a SerializerMethodField + new_delegation_ids = request.data.get("vote_delegated_from_users_id") + if "vote_delegated_from_users_id" in request.data: + del request.data["vote_delegated_from_users_id"] + response = super().update(request, *args, **kwargs) + + # after rest of the request succeeded, handle delegation changes + if new_delegation_ids: + self.assert_no_self_delegation(user, new_delegation_ids) + self.assert_vote_not_delegated(user) + + for id in new_delegation_ids: + delegation_user = User.objects.get(id=id) + self.assert_has_no_delegated_votes(delegation_user) + delegation_user.vote_delegated_to = user + delegation_user.save() + + delegations_to_remove = user.vote_delegated_from_users.exclude( + id__in=(new_delegation_ids or []) + ) + for old_delegation_user in delegations_to_remove: + old_delegation_user.vote_delegated_to = None + old_delegation_user.save() + + # if only delegated_from was changed, we need an autoupdate for the operator + if new_delegation_ids or delegations_to_remove: + inform_changed_data(user) + return response + def assert_vote_not_delegated(self, user): + if user.vote_delegated_to: + raise ValidationError( + { + "detail": "You cannot delegate a vote to a user who has already delegated his vote." + } + ) + + def assert_has_no_delegated_votes(self, user): + if user.vote_delegated_from_users and len(user.vote_delegated_from_users.all()): + raise ValidationError( + { + "detail": "You cannot delegate a vote of a user who is already a delegate of another user." + } + ) + + def assert_no_self_delegation(self, user, delegate_ids): + if user.id in delegate_ids: + raise ValidationError({"detail": "You cannot delegate a vote to yourself."}) + def destroy(self, request, *args, **kwargs): """ Customized view endpoint to delete an user. @@ -391,6 +459,7 @@ class UserViewSet(ModelViewSet): data = serializer.prepare_password(serializer.data) groups = data["groups_id"] del data["groups_id"] + del data["vote_delegated_from_users_id"] db_user = User(**data) try: diff --git a/server/tests/integration/assignments/test_polls.py b/server/tests/integration/assignments/test_polls.py index 275cbaf71..090f4ff2f 100644 --- a/server/tests/integration/assignments/test_polls.py +++ b/server/tests/integration/assignments/test_polls.py @@ -770,13 +770,15 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), { - "options": { - "1": {"Y": "1", "N": "2.35", "A": "-1"}, - "2": {"Y": "30", "N": "-2", "A": "8.93"}, + "data": { + "options": { + "1": {"Y": "1", "N": "2.35", "A": "-1"}, + "2": {"Y": "30", "N": "-2", "A": "8.93"}, + }, + "votesvalid": "4.64", + "votesinvalid": "-2", + "votescast": "-2", }, - "votesvalid": "4.64", - "votesinvalid": "-2", - "votescast": "-2", }, ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) @@ -800,10 +802,12 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), { - "options": {"1": {"Y": "1", "N": "1", "A": "1"}}, - "votesvalid": "-1.5", - "votesinvalid": "-2", - "votescast": "-2", + "data": { + "options": {"1": {"Y": "1", "N": "1", "A": "1"}}, + "votesvalid": "-1.5", + "votesinvalid": "-2", + "votescast": "-2", + }, }, ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -814,10 +818,12 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), { - "options": { - "1": {"Y": "1", "N": "2.35", "A": "-1"}, - "2": {"Y": "1", "N": "2.35", "A": "-1"}, - } + "data": { + "options": { + "1": {"Y": "1", "N": "2.35", "A": "-1"}, + "2": {"Y": "1", "N": "2.35", "A": "-1"}, + } + }, }, ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -828,7 +834,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"options": {"1": {"Y": "1", "N": "2.35", "A": "-1"}}}, + {"data": {"options": {"1": {"Y": "1", "N": "2.35", "A": "-1"}}}}, ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) @@ -839,9 +845,11 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), { - "options": { - "1": {"Y": "1", "N": "2.35", "A": "-1"}, - "3": {"Y": "1", "N": "2.35", "A": "-1"}, + "data": { + "options": { + "1": {"Y": "1", "N": "2.35", "A": "-1"}, + "3": {"Y": "1", "N": "2.35", "A": "-1"}, + } } }, ) @@ -851,25 +859,31 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): def test_no_permissions(self): self.start_poll() self.make_admin_delegate() - response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_state(self): - response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_missing_data(self): self.start_poll() - response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_wrong_data_format(self): self.start_poll() response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), [1, 2, 5] + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": [1, 2, 5]} ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -878,7 +892,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"options": [1, "string"]}, + {"data": {"options": [1, "string"]}}, ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) @@ -887,7 +901,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"options": {"string": "some_other_string"}}, + {"data": {"options": {"string": "some_other_string"}}}, ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -896,7 +910,7 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"options": {"1": [None]}}, + {"data": {"options": {"1": [None]}}}, ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -907,7 +921,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 + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": data} ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) @@ -917,10 +931,12 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), { - "options": {"1": {"Y": 5, "N": 0, "A": 1}}, - "votesvalid": "-2", - "votesinvalid": "1", - "votescast": "-1", + "data": { + "options": {"1": {"Y": 5, "N": 0, "A": 1}}, + "votesvalid": "-2", + "votesinvalid": "1", + "votescast": "-1", + } }, ) self.poll.state = 3 @@ -928,10 +944,12 @@ class VoteAssignmentPollAnalogYNA(VoteAssignmentPollBaseTestClass): response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), { - "options": {"1": {"Y": 2, "N": 2, "A": 2}}, - "votesvalid": "4.64", - "votesinvalid": "-2", - "votescast": "3", + "data": { + "options": {"1": {"Y": 2, "N": 2, "A": 2}}, + "votesvalid": "4.64", + "votesinvalid": "-2", + "votescast": "3", + } }, ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) @@ -973,7 +991,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y", "2": "N", "3": "A"}, + {"data": {"1": "Y", "2": "N", "3": "A"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) @@ -1007,7 +1025,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y", "2": "N", "3": "A"}, + {"data": {"1": "Y", "2": "N", "3": "A"}}, ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertEqual(AssignmentVote.objects.count(), 3) @@ -1039,12 +1057,12 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y"}, + {"data": {"1": "Y"}}, format="json", ) response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "N"}, + {"data": {"1": "N"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1064,7 +1082,8 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): option2 = self.poll2.options.get() # Do request to poll with option2 (which is wrong...) response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {str(option2.id): "Y"} + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"data": {str(option2.id): "Y"}}, ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertEqual(AssignmentVote.objects.count(), 0) @@ -1081,7 +1100,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y", "2": "N"}, + {"data": {"1": "Y", "2": "N"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1092,7 +1111,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y"}, + {"data": {"1": "Y"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) @@ -1103,7 +1122,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y", "3": "N"}, + {"data": {"1": "Y", "3": "N"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1114,7 +1133,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): self.make_admin_delegate() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y"}, + {"data": {"1": "Y"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) @@ -1125,7 +1144,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): gclient = self.create_guest_client() response = gclient.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y"}, + {"data": {"1": "Y"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) @@ -1137,20 +1156,24 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): self.admin.save() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y"}, + {"data": {"1": "Y"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_state(self): - response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_missing_data(self): self.start_poll() - response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose( response, status.HTTP_200_OK ) # new "feature" because of partial requests: empty requests work! @@ -1160,7 +1183,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - [1, 2, 5], + {"data": [1, 2, 5]}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1170,7 +1193,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "string"}, + {"data": {"1": "string"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1180,7 +1203,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"id": "Y"}, + {"data": {"id": "Y"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1190,7 +1213,7 @@ class VoteAssignmentPollNamedYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": [None]}, + {"data": {"1": [None]}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1229,7 +1252,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": 1, "2": 0}, + {"data": {"1": 1, "2": 0}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) @@ -1254,12 +1277,12 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": 1, "2": 0}, + {"data": {"1": 1, "2": 0}}, format="json", ) response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": 0, "2": 1}, + {"data": {"1": 0, "2": 1}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1278,7 +1301,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.poll.save() self.start_poll() response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), "N" + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": "N"} ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() @@ -1294,7 +1317,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.poll.save() self.start_poll() response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), "N" + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": "N"} ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) @@ -1305,7 +1328,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.poll.save() self.start_poll() response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), "A" + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": "A"} ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() @@ -1321,7 +1344,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.poll.save() self.start_poll() response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), "A" + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": "A"} ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) @@ -1331,7 +1354,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": -1}, + {"data": {"1": -1}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1342,7 +1365,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": 2, "2": 1}, + {"data": {"1": 2, "2": 1}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) @@ -1361,7 +1384,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": 2, "2": 2}, + {"data": {"1": 2, "2": 2}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1372,7 +1395,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": 1, "2": 1, "3": 1}, + {"data": {"1": 1, "2": 1, "3": 1}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1381,7 +1404,9 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): def test_wrong_options(self): self.start_poll() response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"2": 1}, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"data": {"2": 1}}, + format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) @@ -1390,7 +1415,9 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.start_poll() self.make_admin_delegate() response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"data": {"1": 1}}, + format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentVote.objects.exists()) @@ -1399,7 +1426,9 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.start_poll() gclient = self.create_guest_client() response = gclient.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"data": {"1": 1}}, + format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentVote.objects.exists()) @@ -1409,21 +1438,27 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.admin.is_present = False self.admin.save() response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"data": {"1": 1}}, + format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_state(self): response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"data": {"1": 1}}, + format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_missing_data(self): self.start_poll() - response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertFalse(AssignmentVote.objects.exists()) @@ -1431,7 +1466,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - [1, 2, 5], + {"data": [1, 2, 5]}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1441,7 +1476,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "string"}, + {"data": {"1": "string"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1451,7 +1486,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"id": 1}, + {"data": {"id": 1}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1461,7 +1496,7 @@ class VoteAssignmentPollNamedVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": [None]}, + {"data": {"1": [None]}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1495,7 +1530,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y", "2": "N", "3": "A"}, + {"data": {"1": "Y", "2": "N", "3": "A"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) @@ -1522,12 +1557,12 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y"}, + {"data": {"1": "Y"}}, format="json", ) response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "N"}, + {"data": {"1": "N"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1541,7 +1576,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y", "2": "N"}, + {"data": {"1": "Y", "2": "N"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1552,7 +1587,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y"}, + {"data": {"1": "Y"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) @@ -1563,7 +1598,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y", "3": "N"}, + {"data": {"1": "Y", "3": "N"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1574,7 +1609,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): self.make_admin_delegate() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y"}, + {"data": {"1": "Y"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) @@ -1585,7 +1620,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): gclient = self.create_guest_client() response = gclient.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y"}, + {"data": {"1": "Y"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) @@ -1597,20 +1632,24 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): self.admin.save() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "Y"}, + {"data": {"1": "Y"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_state(self): - response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_missing_data(self): self.start_poll() - response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertFalse(AssignmentVote.objects.exists()) @@ -1618,7 +1657,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - [1, 2, 5], + {"data": [1, 2, 5]}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1628,7 +1667,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "string"}, + {"data": {"1": "string"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1638,7 +1677,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"id": "Y"}, + {"data": {"id": "Y"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1648,7 +1687,7 @@ class VoteAssignmentPollPseudoanonymousYNA(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": [None]}, + {"data": {"1": [None]}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1687,7 +1726,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": 1, "2": 0}, + {"data": {"1": 1, "2": 0}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) @@ -1714,12 +1753,12 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": 1, "2": 0}, + {"data": {"1": 1, "2": 0}}, format="json", ) response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": 0, "2": 1}, + {"data": {"1": 0, "2": 1}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1737,7 +1776,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": -1}, + {"data": {"1": -1}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1748,7 +1787,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": 2, "2": 1}, + {"data": {"1": 2, "2": 1}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) @@ -1769,7 +1808,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": 2, "2": 2}, + {"data": {"1": 2, "2": 2}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1780,7 +1819,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": 1, "2": 1, "3": 1}, + {"data": {"1": 1, "2": 1, "3": 1}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1789,7 +1828,9 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): def test_wrong_options(self): self.start_poll() response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"2": 1}, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"data": {"2": 1}}, + format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) @@ -1798,7 +1839,9 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): self.start_poll() self.make_admin_delegate() response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"data": {"1": 1}}, + format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentVote.objects.exists()) @@ -1807,7 +1850,9 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): self.start_poll() gclient = self.create_guest_client() response = gclient.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"data": {"1": 1}}, + format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentVote.objects.exists()) @@ -1817,21 +1862,27 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): self.admin.is_present = False self.admin.save() response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"data": {"1": 1}}, + format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(AssignmentPoll.objects.get().get_votes().exists()) def test_wrong_state(self): response = self.client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": 1}, format="json" + reverse("assignmentpoll-vote", args=[self.poll.pk]), + {"data": {"1": 1}}, + format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(AssignmentVote.objects.exists()) def test_missing_data(self): self.start_poll() - response = self.client.post(reverse("assignmentpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) self.assertFalse(AssignmentVote.objects.exists()) @@ -1839,7 +1890,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - [1, 2, 5], + {"data": {"data": [1, 2, 5]}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1849,7 +1900,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": "string"}, + {"data": {"1": "string"}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1859,7 +1910,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"id": 1}, + {"data": {"id": 1}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1869,7 +1920,7 @@ class VoteAssignmentPollPseudoanonymousVotes(VoteAssignmentPollBaseTestClass): self.start_poll() response = self.client.post( reverse("assignmentpoll-vote", args=[self.poll.pk]), - {"1": [None]}, + {"data": {"1": [None]}}, format="json", ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) @@ -1923,7 +1974,7 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) def test_vote(self): response = self.user_client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": "A"} + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": {"1": "A"}} ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() @@ -1956,6 +2007,7 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "votesinvalid": "0.000000", "votesvalid": "1.000000", "user_has_voted": False, + "user_has_voted_for_delegations": [], "voted_id": [self.user.id], }, "assignments/assignment-option:1": { @@ -1973,6 +2025,7 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "option_id": 1, "pollstate": AssignmentPoll.STATE_STARTED, "user_id": self.user.id, + "delegated_user_id": self.user.id, "value": "A", "weight": "1.000000", }, @@ -1989,6 +2042,7 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "option_id": 1, "pollstate": AssignmentPoll.STATE_STARTED, "user_id": self.user.id, + "delegated_user_id": self.user.id, "value": "A", "weight": "1.000000", }, @@ -2018,6 +2072,7 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "id": 1, "votes_amount": 1, "user_has_voted": user == self.user, + "user_has_voted_for_delegations": [], }, ) @@ -2073,6 +2128,7 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "votesinvalid": "0.000000", "votesvalid": "1.000000", "user_has_voted": user == self.user, + "user_has_voted_for_delegations": [], "voted_id": [self.user.id], }, ) @@ -2084,6 +2140,7 @@ class VoteAssignmentPollNamedAutoupdates(VoteAssignmentPollAutoupdatesBaseClass) "weight": "1.000000", "value": "A", "user_id": 3, + "delegated_user_id": None, "option_id": 1, }, ) @@ -2108,9 +2165,9 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( ): poll_type = AssignmentPoll.TYPE_PSEUDOANONYMOUS - def test_vote(self): + def test_votex(self): response = self.user_client.post( - reverse("assignmentpoll-vote", args=[self.poll.pk]), {"1": "A"} + reverse("assignmentpoll-vote", args=[self.poll.pk]), {"data": {"1": "A"}} ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = AssignmentPoll.objects.get() @@ -2136,6 +2193,7 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "description": self.description, "type": AssignmentPoll.TYPE_PSEUDOANONYMOUS, "user_has_voted": False, + "user_has_voted_for_delegations": [], "voted_id": [self.user.id], "onehundred_percent_base": AssignmentPoll.PERCENT_BASE_CAST, "majority_method": AssignmentPoll.MAJORITY_TWO_THIRDS, @@ -2159,6 +2217,7 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "option_id": 1, "pollstate": AssignmentPoll.STATE_STARTED, "user_id": None, + "delegated_user_id": None, "value": "A", "weight": "1.000000", }, @@ -2190,6 +2249,7 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "id": 1, "votes_amount": 1, "user_has_voted": user == self.user, + "user_has_voted_for_delegations": [], }, ) @@ -2245,6 +2305,7 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "votesinvalid": "0.000000", "votesvalid": "1.000000", "user_has_voted": user == self.user, + "user_has_voted_for_delegations": [], "voted_id": [self.user.id], }, "assignments/assignment-vote:1": { @@ -2253,6 +2314,7 @@ class VoteAssignmentPollPseudoanonymousAutoupdates( "weight": "1.000000", "value": "A", "user_id": None, + "delegated_user_id": None, "option_id": 1, }, "assignments/assignment-option:1": { diff --git a/server/tests/integration/motions/test_polls.py b/server/tests/integration/motions/test_polls.py index 2b93759bd..e4bc1ed10 100644 --- a/server/tests/integration/motions/test_polls.py +++ b/server/tests/integration/motions/test_polls.py @@ -167,6 +167,7 @@ class CreateMotionPoll(TestCase): "id": 1, "voted_id": [], "user_has_voted": False, + "user_has_voted_for_delegations": [], }, ) self.assertEqual(autoupdate[1], []) @@ -610,12 +611,14 @@ class VoteMotionPollAnalog(TestCase): response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), { - "Y": "1", - "N": "2.35", - "A": "-1", - "votesvalid": "4.64", - "votesinvalid": "-2", - "votescast": "-2", + "data": { + "Y": "1", + "N": "2.35", + "A": "-1", + "votesvalid": "4.64", + "votesinvalid": "-2", + "votescast": "-2", + }, }, ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) @@ -634,14 +637,23 @@ class VoteMotionPollAnalog(TestCase): def test_vote_no_permissions(self): self.start_poll() self.make_admin_delegate() - response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + def test_vote_no_data(self): + self.start_poll() + response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk]), {}) + self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + 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"} + reverse("motionpoll-vote", args=[self.poll.pk]), + {"data": {"Y": "4", "N": "22.6"}}, ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -649,7 +661,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] + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": [1, 2, 5]} ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -658,7 +670,7 @@ class VoteMotionPollAnalog(TestCase): self.start_poll() response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), - {"Y": "some string", "N": "-2", "A": "3"}, + {"data": {"Y": "some string", "N": "-2", "A": "3"}}, ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -668,12 +680,14 @@ class VoteMotionPollAnalog(TestCase): self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), { - "Y": "3", - "N": "1", - "A": "5", - "votesvalid": "-2", - "votesinvalid": "1", - "votescast": "-1", + "data": { + "Y": "3", + "N": "1", + "A": "5", + "votesvalid": "-2", + "votesinvalid": "1", + "votescast": "-1", + }, }, ) self.poll.state = 3 @@ -681,12 +695,14 @@ class VoteMotionPollAnalog(TestCase): response = self.client.post( reverse("motionpoll-vote", args=[self.poll.pk]), { - "Y": "1", - "N": "2.35", - "A": "-1", - "votesvalid": "4.64", - "votesinvalid": "-2", - "votescast": "3", + "data": { + "Y": "1", + "N": "2.35", + "A": "-1", + "votesvalid": "4.64", + "votesinvalid": "-2", + "votescast": "3", + }, }, ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) @@ -749,7 +765,7 @@ class VoteMotionPollNamed(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "N" + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": "N"} ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -773,7 +789,7 @@ class VoteMotionPollNamed(TestCase): self.admin.vote_weight = weight = Decimal("3.5") self.admin.save() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "A" + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": "A"} ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -799,11 +815,11 @@ class VoteMotionPollNamed(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "N" + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": "N"} ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "A" + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": "A"} ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) poll = MotionPoll.objects.get() @@ -824,38 +840,35 @@ class VoteMotionPollNamed(TestCase): config["general_system_enable_anonymous"] = True guest_client = APIClient() response = guest_client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "Y" + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": "Y"} ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) - # TODO: Move to unit tests - def test_not_set_vote_values(self): - with self.assertRaises(ValueError): - self.poll.votesvalid = Decimal("1") - with self.assertRaises(ValueError): - self.poll.votesinvalid = Decimal("1") - with self.assertRaises(ValueError): - self.poll.votescast = Decimal("1") - def test_vote_wrong_state(self): self.make_admin_present() self.make_admin_delegate() - response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) def test_vote_wrong_group(self): self.start_poll() self.make_admin_present() - response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) def test_vote_not_present(self): self.start_poll() self.make_admin_delegate() - response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -863,7 +876,9 @@ class VoteMotionPollNamed(TestCase): self.start_poll() self.make_admin_delegate() self.make_admin_present() - response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -872,11 +887,118 @@ 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] + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": [1, 2, 5]} ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + def setup_vote_delegation(self, with_delegation=True): + """ user -> admin """ + self.start_poll() + self.make_admin_delegate() + self.make_admin_present() + user, _ = self.create_user() + user.groups.add(GROUP_DELEGATE_PK) + if with_delegation: + user.vote_delegated_to = self.admin + user.save() + inform_changed_data(self.admin) # put the admin into the cache to update + # its vote_delegated_to_id field + self.user = user + + def test_vote_delegation(self): + self.setup_vote_delegation() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), + {"data": "N", "user_id": self.user.pk}, # user not present + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("1")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("1")) + self.assertEqual(poll.get_votes().count(), 1) + option = poll.options.get() + self.assertEqual(option.yes, Decimal("0")) + self.assertEqual(option.no, Decimal("1")) + self.assertEqual(option.abstain, Decimal("0")) + vote = option.votes.get() + self.assertEqual(vote.user, self.user) + self.assertEqual(vote.delegated_user, self.admin) + + autoupdate = self.get_last_autoupdate(user=self.admin) + self.assertIn("motions/motion-poll:1", autoupdate[0]) + self.assertEqual( + autoupdate[0]["motions/motion-poll:1"]["user_has_voted_for_delegations"], + [self.user.pk], + ) + + def test_vote_delegation_and_self_vote(self): + self.test_vote_delegation() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": "Y"} + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.votesvalid, Decimal("2")) + self.assertEqual(poll.votesinvalid, Decimal("0")) + self.assertEqual(poll.votescast, Decimal("2")) + self.assertEqual(poll.get_votes().count(), 2) + option = poll.options.get() + self.assertEqual(option.yes, Decimal("1")) + self.assertEqual(option.no, Decimal("1")) + self.assertEqual(option.abstain, Decimal("0")) + vote = option.votes.get(user_id=self.admin.pk) + self.assertEqual(vote.user, self.admin) + self.assertEqual(vote.delegated_user, self.admin) + + def test_vote_delegation_forbidden(self): + self.setup_vote_delegation(False) + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), + {"data": "N", "user_id": self.user.pk}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_delegation_not_present(self): + self.setup_vote_delegation() + self.admin.is_present = False + self.admin.save() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), + {"data": "N", "user_id": self.user.pk}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + + def test_vote_delegation_delegatee_not_in_group(self): + self.setup_vote_delegation() + self.admin.groups.remove(GROUP_DELEGATE_PK) + self.admin.save() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), + {"data": "N", "user_id": self.user.pk}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + poll = MotionPoll.objects.get() + self.assertEqual(poll.get_votes().count(), 1) + vote = poll.get_votes()[0] + self.assertEqual(vote.value, "N") + self.assertEqual(vote.user, self.user) + self.assertEqual(vote.delegated_user, self.admin) + + def test_vote_delegation_delegator_not_in_group(self): + self.setup_vote_delegation() + self.user.groups.remove(GROUP_DELEGATE_PK) + self.user.save() + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), + {"data": "N", "user_id": self.user.pk}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) + self.assertFalse(MotionPoll.objects.get().get_votes().exists()) + class VoteMotionPollNamedAutoupdates(TestCase): """3 important users: @@ -916,7 +1038,7 @@ class VoteMotionPollNamedAutoupdates(TestCase): def test_vote(self): response = self.user_client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "A" + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": "A"} ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -942,6 +1064,7 @@ class VoteMotionPollNamedAutoupdates(TestCase): "options_id": [1], "id": 1, "user_has_voted": False, + "user_has_voted_for_delegations": [], "voted_id": [self.user.id], }, "motions/motion-vote:1": { @@ -950,6 +1073,7 @@ class VoteMotionPollNamedAutoupdates(TestCase): "weight": "1.000000", "value": "A", "user_id": self.user.id, + "delegated_user_id": self.user.id, "option_id": 1, }, "motions/motion-option:1": { @@ -975,6 +1099,7 @@ class VoteMotionPollNamedAutoupdates(TestCase): "weight": "1.000000", "value": "A", "user_id": self.user.id, + "delegated_user_id": self.user.id, }, ) self.assertEqual( @@ -1001,6 +1126,7 @@ class VoteMotionPollNamedAutoupdates(TestCase): "options_id": [1], "id": 1, "user_has_voted": user == self.user, + "user_has_voted_for_delegations": [], }, ) self.assertEqual( @@ -1055,7 +1181,7 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): def test_vote(self): response = self.user_client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "A" + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": "A"} ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -1081,6 +1207,7 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): "options_id": [1], "id": 1, "user_has_voted": False, + "user_has_voted_for_delegations": [], "voted_id": [self.user.id], }, "motions/motion-vote:1": { @@ -1090,6 +1217,7 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): "weight": "1.000000", "value": "A", "user_id": None, + "delegated_user_id": None, }, "motions/motion-option:1": { "abstain": "1.000000", @@ -1122,6 +1250,7 @@ class VoteMotionPollPseudoanonymousAutoupdates(TestCase): "options_id": [1], "id": 1, "user_has_voted": user == self.user, + "user_has_voted_for_delegations": [], }, ) @@ -1177,7 +1306,7 @@ class VoteMotionPollPseudoanonymous(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "N" + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": "N"} ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) poll = MotionPoll.objects.get() @@ -1199,11 +1328,11 @@ class VoteMotionPollPseudoanonymous(TestCase): self.make_admin_delegate() self.make_admin_present() response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "N" + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": "N"} ) self.assertHttpStatusVerbose(response, status.HTTP_200_OK) response = self.client.post( - reverse("motionpoll-vote", args=[self.poll.pk]), "A" + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": "A"} ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) option = MotionPoll.objects.get().options.get() @@ -1219,7 +1348,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" + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": "Y"} ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -1227,21 +1356,27 @@ class VoteMotionPollPseudoanonymous(TestCase): def test_vote_wrong_state(self): self.make_admin_present() self.make_admin_delegate() - response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) def test_vote_wrong_group(self): self.start_poll() self.make_admin_present() - response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) def test_vote_not_present(self): self.start_poll() self.make_admin_delegate() - response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_403_FORBIDDEN) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -1249,7 +1384,9 @@ class VoteMotionPollPseudoanonymous(TestCase): self.start_poll() self.make_admin_delegate() self.make_admin_present() - response = self.client.post(reverse("motionpoll-vote", args=[self.poll.pk])) + response = self.client.post( + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": {}} + ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -1258,7 +1395,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] + reverse("motionpoll-vote", args=[self.poll.pk]), {"data": [1, 2, 5]} ) self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST) self.assertFalse(MotionPoll.objects.get().get_votes().exists()) @@ -1344,6 +1481,7 @@ class PublishMotionPoll(TestCase): "options_id": [1], "id": 1, "user_has_voted": False, + "user_has_voted_for_delegations": [], "voted_id": [], }, "motions/motion-vote:1": { @@ -1353,6 +1491,7 @@ class PublishMotionPoll(TestCase): "weight": "2.000000", "value": "N", "user_id": None, + "delegated_user_id": None, }, "motions/motion-option:1": { "abstain": "0.000000", @@ -1495,3 +1634,45 @@ class ResetMotionPoll(TestCase): for user in (self.admin, self.user1, self.user2): self.assertDeletedAutoupdate(self.vote1, user=user) self.assertDeletedAutoupdate(self.vote2, user=user) + + +class TestMotionPollWithVoteDelegationAutoupdate(TestCase): + def advancedSetUp(self): + """ Set up user -> other_user delegation. """ + self.motion = Motion( + title="test_title_dL91JqhMTiQuQLSDRItZ", + text="test_text_R7nURdXKVEfEnnJBXJYa", + ) + self.motion.save() + + self.delegate_group = get_group_model().objects.get(pk=GROUP_DELEGATE_PK) + + self.other_user, _ = self.create_user() + self.user, user_password = self.create_user() + self.user.groups.add(self.delegate_group) + self.user.is_present = True + self.user.vote_delegated_to = self.other_user + self.user.save() + + self.user_client = APIClient() + self.user_client.login(username=self.user.username, password=user_password) + + self.poll = MotionPoll.objects.create( + motion=self.motion, + title="test_title_Q3EuRaALSCCPJuQ2tMqj", + pollmethod="YNA", + type=BasePoll.TYPE_NAMED, + onehundred_percent_base="YN", + majority_method="simple", + ) + self.poll.create_options() + self.poll.groups.add(self.delegate_group) + self.poll.save() + + def test_start_poll(self): + response = self.client.post(reverse("motionpoll-start", args=[self.poll.pk])) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + + # other_user has to receive an autoupdate because he was delegated + autoupdate = self.get_last_autoupdate(user=self.other_user) + assert "motions/motion-poll:1" in autoupdate[0] diff --git a/server/tests/integration/users/test_viewset.py b/server/tests/integration/users/test_viewset.py index 9f7dad1e7..45ff81983 100644 --- a/server/tests/integration/users/test_viewset.py +++ b/server/tests/integration/users/test_viewset.py @@ -24,12 +24,13 @@ def test_user_db_queries(): """ Tests that only the following db queries are done: * 2 requests to get the list of all users and - * 1 requests to get the list of all groups. + * 1 request to get all vote delegations + * 1 request to get the list of all groups. """ for index in range(10): User.objects.create(username=f"user{index}") - assert count_queries(User.get_elements)() == 3 + assert count_queries(User.get_elements)() == 4 @pytest.mark.django_db(transaction=False) @@ -232,6 +233,188 @@ class UserUpdate(TestCase): # The user is not allowed to change some other fields (like last_name). self.assertNotEqual(user.last_name, "New name fae1Bu1Eyeis9eRox4xu") + def test_update_vote_delegation(self): + user = User.objects.create_user( + username="non-admin Yd4ejrJXZi4Wn16ugHgY", + password="non-admin AQ4Dw2tN9byKpGD4f1gs", + ) + + response = self.client.patch( + reverse("user-detail", args=[user.pk]), + {"vote_delegated_to_id": self.admin.pk}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + user = User.objects.get(pk=user.pk) + self.assertEqual(user.vote_delegated_to_id, self.admin.pk) + admin = User.objects.get(username="admin") + self.assertEqual( + list(admin.vote_delegated_from_users.values_list("id", flat=True)), + [user.pk], + ) + + def test_update_vote_delegation_non_admin(self): + user = User.objects.create_user( + username="non-admin WpBQRSsCg6qNWNtN6bLP", + password="non-admin IzsDBt1uoqc2wo5BSUF1", + ) + client = APIClient() + client.login( + username="non-admin WpBQRSsCg6qNWNtN6bLP", + password="non-admin IzsDBt1uoqc2wo5BSUF1", + ) + + response = client.patch( + reverse("user-detail", args=[user.pk]), + {"vote_delegated_to_id": self.admin.pk}, + ) + + # self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_200_OK) + user = User.objects.get(pk=user.pk) + self.assertIsNone(user.vote_delegated_to_id) + + def test_update_vote_delegated_to_self(self): + response = self.client.patch( + reverse("user-detail", args=[self.admin.pk]), + {"vote_delegated_to_id": self.admin.pk}, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + admin = User.objects.get(pk=self.admin.pk) + self.assertIsNone(admin.vote_delegated_to_id) + + def test_update_vote_delegation_invalid_id(self): + response = self.client.patch( + reverse("user-detail", args=[self.admin.pk]), + {"vote_delegated_to_id": 42}, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + admin = User.objects.get(pk=self.admin.pk) + self.assertIsNone(admin.vote_delegated_to_id) + + def test_update_vote_delegated_from_self(self): + response = self.client.patch( + reverse("user-detail", args=[self.admin.pk]), + {"vote_delegated_from_users_id": [self.admin.pk]}, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + admin = User.objects.get(pk=self.admin.pk) + self.assertIsNone(admin.vote_delegated_to_id) + + def setup_vote_delegation(self): + """ login and setup user -> user2 delegation """ + self.user, _ = self.create_user() + self.user2, _ = self.create_user() + self.user.vote_delegated_to = self.user2 + self.user.save() + self.assertEqual(self.user.vote_delegated_to_id, self.user2.pk) + + def test_update_reset_vote_delegated_to(self): + self.setup_vote_delegation() + response = self.client.patch( + reverse("user-detail", args=[self.user.pk]), + {"vote_delegated_to_id": None}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + user = User.objects.get(pk=self.user.pk) + self.assertEqual(user.vote_delegated_to_id, None) + + def test_update_reset_vote_delegated_from(self): + self.setup_vote_delegation() + response = self.client.patch( + reverse("user-detail", args=[self.user2.pk]), + {"vote_delegated_from_users_id": None}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + user = User.objects.get(pk=self.user.pk) + self.assertEqual(user.vote_delegated_to_id, None) + + def test_update_nested_vote_delegation_1(self): + """ user -> user2 -> admin """ + self.setup_vote_delegation() + response = self.client.patch( + reverse("user-detail", args=[self.user2.pk]), + {"vote_delegated_to_id": self.admin.pk}, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + user2 = User.objects.get(pk=self.user2.pk) + self.assertIsNone(user2.vote_delegated_to_id) + + def test_update_nested_vote_delegation_2(self): + """ admin -> user -> user2 """ + self.setup_vote_delegation() + response = self.client.patch( + reverse("user-detail", args=[self.admin.pk]), + {"vote_delegated_to_id": self.user.pk}, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + admin = User.objects.get(pk=self.admin.pk) + self.assertIsNone(admin.vote_delegated_to_id) + + def test_update_vote_delegation_autoupdate(self): + self.setup_vote_delegation() + response = self.client.patch( + reverse("user-detail", args=[self.user.pk]), + {"vote_delegated_to_id": self.admin.pk}, + ) + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + + autoupdate = self.get_last_autoupdate(user=self.admin) + user_au = autoupdate[0].get(f"users/user:{self.user.pk}") + self.assertIsNotNone(user_au) + self.assertEqual(user_au["vote_delegated_to_id"], self.admin.pk) + user2_au = autoupdate[0].get(f"users/user:{self.user2.pk}") + self.assertIsNotNone(user2_au) + self.assertEqual(user2_au["vote_delegated_from_users_id"], []) + admin_au = autoupdate[0].get(f"users/user:{self.admin.pk}") + self.assertIsNotNone(admin_au) + self.assertEqual(admin_au["vote_delegated_from_users_id"], [self.user.pk]) + self.assertEqual(autoupdate[1], []) + + def test_update_vote_delegated_from(self): + self.setup_vote_delegation() + response = self.client.patch( + reverse("user-detail", args=[self.user2.pk]), + {"vote_delegated_from_users_id": [self.admin.pk]}, + ) + + self.assertHttpStatusVerbose(response, status.HTTP_200_OK) + admin = User.objects.get(pk=self.admin.pk) + self.assertEqual(admin.vote_delegated_to_id, self.user2.id) + user = User.objects.get(pk=self.user.pk) + self.assertIsNone(user.vote_delegated_to_id) + + def test_update_vote_delegated_from_nested_1(self): + """ admin -> user -> user2 """ + self.setup_vote_delegation() + response = self.client.patch( + reverse("user-detail", args=[self.user.pk]), + {"vote_delegated_from_users_id": [self.admin.pk]}, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + admin = User.objects.get(pk=self.admin.pk) + self.assertIsNone(admin.vote_delegated_to_id) + + def test_update_vote_delegated_from_nested_2(self): + """ user -> user2 -> admin """ + self.setup_vote_delegation() + response = self.client.patch( + reverse("user-detail", args=[self.admin.pk]), + {"vote_delegated_from_users_id": [self.user2.pk]}, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + user2 = User.objects.get(pk=self.user2.pk) + self.assertIsNone(user2.vote_delegated_to_id) + class UserDelete(TestCase): """ diff --git a/server/tests/unit/motions/test_models.py b/server/tests/unit/motions/test_models.py index dc3af8163..ede566292 100644 --- a/server/tests/unit/motions/test_models.py +++ b/server/tests/unit/motions/test_models.py @@ -1,6 +1,7 @@ +from decimal import Decimal from unittest import TestCase -from openslides.motions.models import Motion, MotionChangeRecommendation +from openslides.motions.models import Motion, MotionChangeRecommendation, MotionPoll # TODO: test for MotionPoll.set_options() @@ -50,3 +51,25 @@ class MotionChangeRecommendationTest(TestCase): other_recommendations ) self.assertFalse(collides) + + +class MotionPollAnalogFieldsTest(TestCase): + def setUp(self): + self.motion = Motion( + title="test_title_OoK9IeChe2Jeib9Deeji", + text="test_text_eichui1oobiSeit9aifo", + ) + self.poll = MotionPoll( + motion=self.motion, + title="test_title_tho8PhiePh8upaex6phi", + pollmethod="YNA", + type=MotionPoll.TYPE_NAMED, + ) + + def test_not_set_vote_values(self): + with self.assertRaises(ValueError): + self.poll.votesvalid = Decimal("1") + with self.assertRaises(ValueError): + self.poll.votesinvalid = Decimal("1") + with self.assertRaises(ValueError): + self.poll.votescast = Decimal("1") From 8c28b03ffc60bb32c3757f2cee96e35ba3599ca3 Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 22 Sep 2020 14:50:55 +0200 Subject: [PATCH 2/2] Vote delegations on client Add "your vote was delegated" error Observe operator alterations during vote Add "canVoteFor" getter Adjust poll progress bars --- .../assignment-poll-repository.service.ts | 17 +- .../motions/motion-poll-repository.service.ts | 13 +- .../users/user-repository.service.ts | 14 ++ .../app/core/ui-services/voting.service.ts | 37 +++-- .../search-value-selector.component.html | 2 +- .../src/app/shared/models/poll/base-poll.ts | 3 +- .../src/app/shared/models/poll/base-vote.ts | 7 + client/src/app/shared/models/users/user.ts | 6 + .../assignment-poll-vote.component.html | 88 +++++++---- .../assignment-poll-vote.component.scss | 8 + .../assignment-poll-vote.component.ts | 145 +++++++++++------- .../assignment-poll.component.ts | 2 + .../poll-collection.component.html | 4 +- .../motion-poll-vote.component.html | 58 +++++-- .../motion-poll-vote.component.scss | 12 ++ .../motion-poll-vote.component.ts | 73 +++++---- .../motion-poll/motion-poll.component.html | 2 +- .../components/base-poll-vote.component.ts | 68 +++++++- .../poll-progress/poll-progress.component.ts | 9 +- .../app/site/polls/models/view-base-poll.ts | 4 + .../services/base-poll-repository.service.ts | 4 +- .../user-detail/user-detail.component.html | 22 +++ .../user-detail/user-detail.component.ts | 4 + .../user-list/user-list.component.html | 19 ++- .../user-list/user-list.component.ts | 21 ++- client/src/app/site/users/models/view-user.ts | 6 + 26 files changed, 465 insertions(+), 183 deletions(-) diff --git a/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts b/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts index d05b9d2ee..acf234cea 100644 --- a/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts +++ b/client/src/app/core/repositories/assignments/assignment-poll-repository.service.ts @@ -7,8 +7,8 @@ import { HttpService } from 'app/core/core-services/http.service'; import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { RelationDefinition } from 'app/core/definitions/relations'; -import { VotingService } from 'app/core/ui-services/voting.service'; import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll'; +import { UserVote } from 'app/shared/models/poll/base-vote'; import { ViewAssignment } from 'app/site/assignments/models/view-assignment'; import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; import { AssignmentPollTitleInformation, ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; @@ -97,7 +97,6 @@ export class AssignmentPollRepositoryService extends BasePollRepositoryService< viewModelStoreService: ViewModelStoreService, translate: TranslateService, relationManager: RelationManagerService, - votingService: VotingService, http: HttpService ) { super( @@ -110,7 +109,6 @@ export class AssignmentPollRepositoryService extends BasePollRepositoryService< AssignmentPoll, AssignmentPollRelations, {}, - votingService, http ); } @@ -123,14 +121,11 @@ export class AssignmentPollRepositoryService extends BasePollRepositoryService< return this.translate.instant(plural ? 'Polls' : 'Poll'); }; - public vote(data: VotingData, poll_id: number): Promise { - let requestData; - if (data.global) { - requestData = `"${data.global}"`; - } else { - requestData = data.votes; - } - + public vote(data: VotingData, poll_id: number, userId?: number): Promise { + const requestData: UserVote = { + data: data.global ?? data.votes, + user_id: userId ?? undefined + }; return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, requestData); } } diff --git a/client/src/app/core/repositories/motions/motion-poll-repository.service.ts b/client/src/app/core/repositories/motions/motion-poll-repository.service.ts index 87de50df6..f939607bd 100644 --- a/client/src/app/core/repositories/motions/motion-poll-repository.service.ts +++ b/client/src/app/core/repositories/motions/motion-poll-repository.service.ts @@ -7,9 +7,8 @@ import { HttpService } from 'app/core/core-services/http.service'; import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { RelationDefinition } from 'app/core/definitions/relations'; -import { VotingService } from 'app/core/ui-services/voting.service'; import { MotionPoll } from 'app/shared/models/motions/motion-poll'; -import { VoteValue } from 'app/shared/models/poll/base-vote'; +import { UserVote, VoteValue } from 'app/shared/models/poll/base-vote'; import { ViewMotion } from 'app/site/motions/models/view-motion'; import { ViewMotionOption } from 'app/site/motions/models/view-motion-option'; import { MotionPollTitleInformation, ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; @@ -66,7 +65,6 @@ export class MotionPollRepositoryService extends BasePollRepositoryService< viewModelStoreService: ViewModelStoreService, translate: TranslateService, relationManager: RelationManagerService, - votingService: VotingService, http: HttpService ) { super( @@ -79,7 +77,6 @@ export class MotionPollRepositoryService extends BasePollRepositoryService< MotionPoll, MotionPollRelations, {}, - votingService, http ); } @@ -92,7 +89,11 @@ export class MotionPollRepositoryService extends BasePollRepositoryService< return this.translate.instant(plural ? 'Polls' : 'Poll'); }; - public vote(vote: VoteValue, poll_id: number): Promise { - return this.http.post(`/rest/motions/motion-poll/${poll_id}/vote/`, JSON.stringify(vote)); + public vote(vote: VoteValue, poll_id: number, userId?: number): Promise { + const requestData: UserVote = { + data: vote, + user_id: userId ?? undefined + }; + return this.http.post(`/rest/motions/motion-poll/${poll_id}/vote/`, requestData); } } diff --git a/client/src/app/core/repositories/users/user-repository.service.ts b/client/src/app/core/repositories/users/user-repository.service.ts index 00364c10e..acd8bde08 100644 --- a/client/src/app/core/repositories/users/user-repository.service.ts +++ b/client/src/app/core/repositories/users/user-repository.service.ts @@ -43,6 +43,18 @@ const UserRelations: RelationDefinition[] = [ ownIdKey: 'groups_id', ownKey: 'groups', foreignViewModel: ViewGroup + }, + { + type: 'M2O', + ownIdKey: 'vote_delegated_to_id', + ownKey: 'voteDelegatedTo', + foreignViewModel: ViewUser + }, + { + type: 'M2M', + ownIdKey: 'vote_delegated_from_users_id', + ownKey: 'voteDelegationsFrom', + foreignViewModel: ViewUser } ]; @@ -255,6 +267,8 @@ export class UserRepositoryService extends BaseRepository, viewModel: ViewUser): Promise { this.preventAlterationOnDemoUsers(viewModel); + console.log('update: ', update); + return super.update(update, viewModel); } diff --git a/client/src/app/core/ui-services/voting.service.ts b/client/src/app/core/ui-services/voting.service.ts index 0af871c12..e37931c09 100644 --- a/client/src/app/core/ui-services/voting.service.ts +++ b/client/src/app/core/ui-services/voting.service.ts @@ -1,7 +1,10 @@ import { Injectable } from '@angular/core'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; + import { PollState, PollType } from 'app/shared/models/poll/base-poll'; import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; +import { ViewUser } from 'app/site/users/models/view-user'; import { OperatorService } from '../core-services/operator.service'; export enum VotingError { @@ -9,19 +12,21 @@ export enum VotingError { POLL_WRONG_TYPE, USER_HAS_NO_PERMISSION, USER_IS_ANONYMOUS, - USER_NOT_PRESENT + USER_NOT_PRESENT, + USER_HAS_DELEGATED_RIGHT } /** * TODO: It appears that the only message that makes sense for the user to see it the last one. */ -export const VotingErrorVerbose = { - 1: "You can't vote on this poll right now because it's not in the 'Started' state.", - 2: "You can't vote on this poll because its type is set to analog voting.", - 3: "You don't have permission to vote on this poll.", - 4: 'You have to be logged in to be able to vote.', - 5: 'You have to be present to vote on a poll.', - 6: "You have already voted on this poll. You can't change your vote in a pseudoanonymous poll." +const VotingErrorVerbose = { + 1: _("You can not vote on this poll right now because it is not in the 'Started' state."), + 2: _('You can not vote on this poll because its type is set to analog voting.'), + 3: _('You do not not have the permission to vote on this poll.'), + 4: _('You have to be logged in to be able to vote.'), + 5: _('You have to be present to vote on a poll.'), + 6: _('Your right to vote was delegated to another user.'), + 7: _('You have already voted on this poll. You can not change your vote in a pseudoanonymous poll.') }; @Injectable({ @@ -33,8 +38,8 @@ export class VotingService { /** * checks whether the operator can vote on the given poll */ - public canVote(poll: ViewBasePoll): boolean { - const error = this.getVotePermissionError(poll); + public canVote(poll: ViewBasePoll, user?: ViewUser): boolean { + const error = this.getVotePermissionError(poll, user); return !error; } @@ -42,12 +47,11 @@ export class VotingService { * checks whether the operator can vote on the given poll * @returns null if no errors exist (= user can vote) or else a VotingError */ - public getVotePermissionError(poll: ViewBasePoll): VotingError | void { + public getVotePermissionError(poll: ViewBasePoll, user: ViewUser = this.operator.viewUser): VotingError | void { if (this.operator.isAnonymous) { return VotingError.USER_IS_ANONYMOUS; } - const user = this.operator.user; if (!poll.groups_id.intersect(user.groups_id).length) { return VotingError.USER_HAS_NO_PERMISSION; } @@ -57,13 +61,16 @@ export class VotingService { if (poll.state !== PollState.Started) { return VotingError.POLL_WRONG_STATE; } - if (!user.is_present) { + if (!user.is_present && !this.operator.viewUser.canVoteFor(user)) { return VotingError.USER_NOT_PRESENT; } + if (user.isVoteRightDelegated && this.operator.user.id === user.id) { + return VotingError.USER_HAS_DELEGATED_RIGHT; + } } - public getVotePermissionErrorVerbose(poll: ViewBasePoll): string | void { - const error = this.getVotePermissionError(poll); + public getVotePermissionErrorVerbose(poll: ViewBasePoll, user: ViewUser = this.operator.viewUser): string | void { + const error = this.getVotePermissionError(poll, user); if (error) { return VotingErrorVerbose[error]; } diff --git a/client/src/app/shared/components/search-value-selector/search-value-selector.component.html b/client/src/app/shared/components/search-value-selector/search-value-selector.component.html index af6d82e91..ca99f4013 100644 --- a/client/src/app/shared/components/search-value-selector/search-value-selector.component.html +++ b/client/src/app/shared/components/search-value-selector/search-value-selector.component.html @@ -29,7 +29,7 @@ - + {{ noneTitle | translate }} diff --git a/client/src/app/shared/models/poll/base-poll.ts b/client/src/app/shared/models/poll/base-poll.ts index b6334732f..103bfe4c0 100644 --- a/client/src/app/shared/models/poll/base-poll.ts +++ b/client/src/app/shared/models/poll/base-poll.ts @@ -56,8 +56,9 @@ export abstract class BasePoll< public votescast: number; public groups_id: number[]; public majority_method: MajorityMethod; + public voted_id: number[]; public user_has_voted: boolean; - + public user_has_voted_for_delegations: number[]; public pollmethod: PM; public onehundred_percent_base: PB; diff --git a/client/src/app/shared/models/poll/base-vote.ts b/client/src/app/shared/models/poll/base-vote.ts index b761984af..108576a55 100644 --- a/client/src/app/shared/models/poll/base-vote.ts +++ b/client/src/app/shared/models/poll/base-vote.ts @@ -16,6 +16,13 @@ export const GeneralValueVerbose = { votesabstain: 'Votes abstain' }; +export interface UserVote { + // the voting payload is hard to describe. + // Can be "VoteValue" or any userID-Number sequence in combination with any VoteValue + data: Object; + user_id?: number; +} + export abstract class BaseVote extends BaseDecimalModel { public weight: number; public value: VoteValue; diff --git a/client/src/app/shared/models/users/user.ts b/client/src/app/shared/models/users/user.ts index 174c5b5aa..c45f2ffbf 100644 --- a/client/src/app/shared/models/users/user.ts +++ b/client/src/app/shared/models/users/user.ts @@ -30,6 +30,8 @@ export class User extends BaseDecimalModel { public is_present: boolean; public is_committee: boolean; public email?: string; + public vote_delegated_to_id: number; + public vote_delegated_from_users_id: number[]; public last_email_send?: string; // ISO datetime string public comment?: string; public is_active?: boolean; @@ -41,6 +43,10 @@ export class User extends BaseDecimalModel { return this.vote_weight === 1; } + public get isVoteRightDelegated(): boolean { + return !!this.vote_delegated_to_id; + } + public constructor(input?: Partial) { super(User.COLLECTIONSTRING, input); } 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 2fd0afa15..9ffd633ee 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 @@ -1,5 +1,27 @@ - + + + + + +
+ + + +
+
+
+ + +

+ {{ 'Vote delegation for' | translate }} +  {{ delegation.getFullName() }} +

+ +

{{ pollHint }} @@ -9,7 +31,7 @@

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

@@ -36,14 +58,9 @@ @@ -64,9 +81,9 @@ @@ -79,8 +96,9 @@ @@ -92,39 +110,47 @@
- +
- - - {{ vmanager.getVotePermissionErrorVerbose(poll) | translate }} + - + - +
-
- + +
+
{{ 'Voting successful.' | translate }}
-
+ +

{{ 'Delivering vote... Please wait!' | translate }}
+ + +
+ {{ getVotingError(delegation) | translate }} +
- +
- @@ -14,21 +36,33 @@
-
+ + + + +

{{ 'Delivering vote... Please wait!' | translate }}
- + - -
+ + +
- +
{{ 'Voting successful.' | translate }}
+ + +
+ {{ getVotingError(delegation) | translate }} +
diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.scss b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.scss index 9f976137d..2e22748a3 100644 --- a/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.scss +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.scss @@ -8,6 +8,14 @@ grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); } +.motion-vote-delegation { + margin-top: 1em; + + .motion-delegation-title { + font-weight: 500; + } +} + .submit-vote-indicator { margin-top: 1em; text-align: center; @@ -26,6 +34,10 @@ } } +.mat-divider-horizontal { + position: initial; +} + .user-has-voted { display: flex; text-align: center; diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.ts index 2f3b9d090..314bdfd75 100644 --- a/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.ts +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Title } from '@angular/platform-browser'; @@ -10,14 +10,8 @@ import { PromptService } from 'app/core/ui-services/prompt.service'; import { VotingService } from 'app/core/ui-services/voting.service'; import { VoteValue } from 'app/shared/models/poll/base-vote'; import { ViewMotionPoll } from 'app/site/motions/models/view-motion-poll'; -import { BasePollVoteComponentDirective } from 'app/site/polls/components/base-poll-vote.component'; - -interface VoteOption { - vote?: VoteValue; - css?: string; - icon?: string; - label?: string; -} +import { BasePollVoteComponentDirective, VoteOption } from 'app/site/polls/components/base-poll-vote.component'; +import { ViewUser } from 'app/site/users/models/view-user'; @Component({ selector: 'os-motion-poll-vote', @@ -25,8 +19,7 @@ interface VoteOption { styleUrls: ['./motion-poll-vote.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class MotionPollVoteComponent extends BasePollVoteComponentDirective { - public currentVote: VoteOption = {}; +export class MotionPollVoteComponent extends BasePollVoteComponentDirective implements OnInit { public voteOptions: VoteOption[] = [ { vote: 'Y', @@ -53,30 +46,56 @@ export class MotionPollVoteComponent extends BasePollVoteComponentDirective { + this.cd.markForCheck(); + }) + ); } - public async saveVote(vote: VoteValue): Promise { - this.currentVote.vote = vote; - const title = this.translate.instant('Submit selection now?'); - const content = this.translate.instant('Your decision cannot be changed afterwards.'); - const confirmed = await this.promptService.open(title, content); + public ngOnInit(): void { + this.createVotingDataObjects(); + this.cd.markForCheck(); + } - if (confirmed) { - this.deliveringVote = true; - this.cd.markForCheck(); + public getActionButtonClass(voteOption: VoteOption, user: ViewUser = this.user): string { + if (this.voteRequestData[user.id]?.vote === voteOption.vote) { + return voteOption.css; + } + return ''; + } - this.pollRepo - .vote(vote, this.poll.id) - .catch(this.raiseError) - .finally(() => { - this.deliveringVote = false; - }); + public async saveVote(vote: VoteValue, user: ViewUser = this.user): Promise { + if (this.voteRequestData[user.id]) { + this.voteRequestData[user.id].vote = vote; + + const title = this.translate.instant('Submit selection now?'); + const content = this.translate.instant('Your decision cannot be changed afterwards.'); + const confirmed = await this.promptService.open(title, content); + + if (confirmed) { + this.deliveringVote[user.id] = true; + this.cd.markForCheck(); + + this.pollRepo + .vote(vote, this.poll.id, user.id) + .then(() => { + this.alreadyVoted[user.id] = true; + }) + .catch(this.raiseError) + .finally(() => { + this.deliveringVote[user.id] = false; + this.cd.markForCheck(); + }); + } } } } diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.html b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.html index 25a8fc426..2176ab904 100644 --- a/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.html +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.html @@ -60,7 +60,7 @@
- + diff --git a/client/src/app/site/polls/components/base-poll-vote.component.ts b/client/src/app/site/polls/components/base-poll-vote.component.ts index 44ad75134..d2bc71d01 100644 --- a/client/src/app/site/polls/components/base-poll-vote.component.ts +++ b/client/src/app/site/polls/components/base-poll-vote.component.ts @@ -5,11 +5,20 @@ import { Title } from '@angular/platform-browser'; import { TranslateService } from '@ngx-translate/core'; import { OperatorService } from 'app/core/core-services/operator.service'; -import { VotingError } from 'app/core/ui-services/voting.service'; +import { VotingData } from 'app/core/repositories/assignments/assignment-poll-repository.service'; +import { VotingError, VotingService } from 'app/core/ui-services/voting.service'; +import { VoteValue } from 'app/shared/models/poll/base-vote'; import { BaseViewComponentDirective } from 'app/site/base/base-view'; import { ViewUser } from 'app/site/users/models/view-user'; import { ViewBasePoll } from '../models/view-base-poll'; +export interface VoteOption { + vote?: VoteValue; + css?: string; + icon?: string; + label?: string; +} + @Directive() export abstract class BasePollVoteComponentDirective extends BaseViewComponentDirective { @Input() @@ -17,22 +26,71 @@ export abstract class BasePollVoteComponentDirective ext public votingErrors = VotingError; - public deliveringVote = false; + protected voteRequestData = {}; + + protected alreadyVoted = {}; + + protected deliveringVote = {}; protected user: ViewUser; + protected delegations: ViewUser[]; + public constructor( title: Title, translate: TranslateService, matSnackbar: MatSnackBar, - protected operator: OperatorService + operator: OperatorService, + protected votingService: VotingService ) { super(title, translate, matSnackbar); this.subscriptions.push( - this.operator.getViewUserObservable().subscribe(user => { - this.user = user; + operator.getViewUserObservable().subscribe(user => { + if (user) { + this.user = user; + this.delegations = user.voteDelegationsFrom; + } }) ); } + + protected createVotingDataObjects(): void { + if (this.user) { + this.voteRequestData[this.user.id] = { + votes: {} + } as VotingData; + this.alreadyVoted[this.user.id] = this.poll.user_has_voted; + this.deliveringVote[this.user.id] = false; + } + + if (this.delegations) { + for (const delegation of this.delegations) { + this.voteRequestData[delegation.id] = { + votes: {} + } as VotingData; + this.alreadyVoted[delegation.id] = this.poll.hasVotedId(delegation.id); + this.deliveringVote[delegation.id] = false; + } + } + } + + public isDeliveringVote(user: ViewUser = this.user): boolean { + return this.deliveringVote[user.id] === true; + } + + public hasAlreadyVoted(user: ViewUser = this.user): boolean { + return this.alreadyVoted[user.id] === true; + } + + public canVote(user: ViewUser = this.user): boolean { + return ( + this.votingService.canVote(this.poll, user) && !this.isDeliveringVote(user) && !this.hasAlreadyVoted(user) + ); + } + + public getVotingError(user: ViewUser = this.user): string | void { + console.log('error ', this.votingService.getVotePermissionErrorVerbose(this.poll, user)); + return this.votingService.getVotePermissionErrorVerbose(this.poll, user); + } } diff --git a/client/src/app/site/polls/components/poll-progress/poll-progress.component.ts b/client/src/app/site/polls/components/poll-progress/poll-progress.component.ts index 4900153cf..000370ab1 100644 --- a/client/src/app/site/polls/components/poll-progress/poll-progress.component.ts +++ b/client/src/app/site/polls/components/poll-progress/poll-progress.component.ts @@ -39,8 +39,15 @@ export class PollProgressComponent extends BaseViewComponentDirective implements .getViewModelListObservable() .pipe( map(users => + /** + * Filter the users who would be able to vote: + * They are present or have their right to vote delegated + * They are in one of the voting groups + */ users.filter( - user => user.is_present && this.poll.groups_id.intersect(user.groups_id).length + user => + (user.is_present || user.isVoteRightDelegated) && + this.poll.groups_id.intersect(user.groups_id).length ) ) ) 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 9154ab607..9652bc47b 100644 --- a/client/src/app/site/polls/models/view-base-poll.ts +++ b/client/src/app/site/polls/models/view-base-poll.ts @@ -114,6 +114,10 @@ export abstract class ViewBasePoll< public canBeVotedFor: () => boolean; + public hasVotedId(userId: number): boolean { + return this.user_has_voted_for_delegations?.includes(userId); + } + public abstract getSlide(): ProjectorElementBuildDeskriptor; public abstract getContentObject(): BaseViewModel; diff --git a/client/src/app/site/polls/services/base-poll-repository.service.ts b/client/src/app/site/polls/services/base-poll-repository.service.ts index fe766acee..1a010ca72 100644 --- a/client/src/app/site/polls/services/base-poll-repository.service.ts +++ b/client/src/app/site/polls/services/base-poll-repository.service.ts @@ -8,7 +8,6 @@ import { RelationManagerService } from 'app/core/core-services/relation-manager. import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; import { RelationDefinition } from 'app/core/definitions/relations'; import { BaseRepository, NestedModelDescriptors } from 'app/core/repositories/base-repository'; -import { VotingService } from 'app/core/ui-services/voting.service'; import { ModelConstructor } from 'app/shared/models/base/base-model'; import { BasePoll, PollState } from 'app/shared/models/poll/base-poll'; import { BaseViewModel, TitleInformation } from 'app/site/base/base-view-model'; @@ -30,7 +29,6 @@ export abstract class BasePollRepositoryService< protected baseModelCtor: ModelConstructor, protected relationDefinitions: RelationDefinition[] = [], protected nestedModelDescriptors: NestedModelDescriptors = {}, - private votingService: VotingService, protected http: HttpService ) { super( @@ -53,7 +51,7 @@ export abstract class BasePollRepositoryService< protected createViewModelWithTitles(model: M): V { const viewModel = super.createViewModelWithTitles(model); Object.defineProperty(viewModel, 'canBeVotedFor', { - value: () => this.votingService.canVote(viewModel) + value: () => viewModel.isStarted }); return viewModel; } diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.html b/client/src/app/site/users/components/user-detail/user-detail.component.html index a832b5f1c..4f89fa863 100644 --- a/client/src/app/site/users/components/user-detail/user-detail.component.html +++ b/client/src/app/site/users/components/user-detail/user-detail.component.html @@ -164,6 +164,16 @@ [inputListValues]="groups" > + + + + +
@@ -303,6 +313,18 @@
+ +
+

{{ 'Vote right delegated to:' | translate }}

+ {{ user.voteDelegatedTo }} +
+ + +
+

{{ 'Vote delegations from' | translate }}

+ {{ user.voteDelegationsFrom }} +
+

{{ 'Vote weight' | translate }}

diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.ts b/client/src/app/site/users/components/user-detail/user-detail.component.ts index 633ba5e2d..ff02a0b03 100644 --- a/client/src/app/site/users/components/user-detail/user-detail.component.ts +++ b/client/src/app/site/users/components/user-detail/user-detail.component.ts @@ -71,6 +71,8 @@ export class UserDetailComponent extends BaseViewComponentDirective implements O */ public readonly groups: BehaviorSubject = new BehaviorSubject([]); + public readonly users: BehaviorSubject = new BehaviorSubject([]); + /** * Hold the list of genders (sexes) publicly to dynamically iterate in the view */ @@ -124,6 +126,7 @@ export class UserDetailComponent extends BaseViewComponentDirective implements O .subscribe(active => (this.isVoteWeightActive = active)); this.groupRepo.getViewModelListObservableWithoutDefaultGroup().subscribe(this.groups); + this.users = this.repo.getViewModelListBehaviorSubject(); } /** @@ -173,6 +176,7 @@ export class UserDetailComponent extends BaseViewComponentDirective implements O vote_weight: [], about_me: [''], groups_id: [''], + vote_delegated_from_users_id: [''], is_present: [true], is_committee: [false], email: ['', Validators.email], diff --git a/client/src/app/site/users/components/user-list/user-list.component.html b/client/src/app/site/users/components/user-list/user-list.component.html index f243d7090..c9763c11f 100644 --- a/client/src/app/site/users/components/user-list/user-list.component.html +++ b/client/src/app/site/users/components/user-list/user-list.component.html @@ -62,7 +62,7 @@ >
- + {{ group.getTitle() | translate }}, @@ -74,6 +74,10 @@
{{ user.number }}
+ +
+ {{ user.voteDelegatedTo }} +
@@ -90,9 +94,7 @@ - - comment - + comment {{ 'Is SAML user' | translate }} + + + - @@ -300,6 +310,7 @@ color="accent" [mat-dialog-close]="{ groups_id: infoDialog.groups_id, + vote_delegated_from_users_id: infoDialog.vote_delegated_from_users_id, gender: infoDialog.gender, number: infoDialog.number, structure_level: infoDialog.structure_level diff --git a/client/src/app/site/users/components/user-list/user-list.component.ts b/client/src/app/site/users/components/user-list/user-list.component.ts index 52a3da394..014844e70 100644 --- a/client/src/app/site/users/components/user-list/user-list.component.ts +++ b/client/src/app/site/users/components/user-list/user-list.component.ts @@ -7,6 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { TranslateService } from '@ngx-translate/core'; import { PblColumnDefinition } from '@pebula/ngrid'; +import { BehaviorSubject } from 'rxjs'; import { OperatorService, Permission } from 'app/core/core-services/operator.service'; import { StorageService } from 'app/core/core-services/storage.service'; @@ -55,6 +56,11 @@ interface InfoDialog { * Structure level for one user. */ structure_level: string; + + /** + * Transfer voting rights + */ + vote_delegated_from_users_id: number[]; } /** @@ -82,6 +88,8 @@ export class UserListComponent extends BaseListViewComponent implement */ public groups: ViewGroup[]; + public readonly users: BehaviorSubject = new BehaviorSubject([]); + /** * The list of all genders. */ @@ -187,6 +195,7 @@ export class UserListComponent extends BaseListViewComponent implement // enable multiSelect for this listView this.canMultiSelect = true; + this.users = this.repo.getViewModelListBehaviorSubject(); config.get('users_enable_presence_view').subscribe(state => (this._presenceViewConfigured = state)); config.get('users_activate_vote_weight').subscribe(active => (this.isVoteWeightActive = active)); config.get(this.selfPresentConfStr).subscribe(allowed => (this.allowSelfSetPresent = allowed)); @@ -203,9 +212,12 @@ export class UserListComponent extends BaseListViewComponent implement // Initialize the groups this.groups = this.groupRepo.getViewModelList().filter(group => group.id !== 1); - this.groupRepo - .getViewModelListObservable() - .subscribe(groups => (this.groups = groups.filter(group => group.id !== 1))); + + this.subscriptions.push( + this.groupRepo + .getViewModelListObservable() + .subscribe(groups => (this.groups = groups.filter(group => group.id !== 1))) + ); } /** @@ -242,7 +254,8 @@ export class UserListComponent extends BaseListViewComponent implement groups_id: user.groups_id, gender: user.gender, structure_level: user.structure_level, - number: user.number + number: user.number, + vote_delegated_from_users_id: user.vote_delegated_from_users_id }; const dialogRef = this.dialog.open(this.userInfoDialog, infoDialogSettings); diff --git a/client/src/app/site/users/models/view-user.ts b/client/src/app/site/users/models/view-user.ts index f0411adee..7c42be6c3 100644 --- a/client/src/app/site/users/models/view-user.ts +++ b/client/src/app/site/users/models/view-user.ts @@ -82,9 +82,15 @@ export class ViewUser extends BaseProjectableViewModel implements UserTitl getDialogTitle: () => this.getTitle() }; } + + public canVoteFor(user: ViewUser): boolean { + return this.vote_delegated_from_users_id.includes(user.id); + } } interface IUserRelations { groups: ViewGroup[]; + voteDelegatedTo: ViewUser; + voteDelegationsFrom: ViewUser[]; } export interface ViewUser extends User, IUserRelations {}