Merge pull request #5300 from FinnStutzenstein/fixVoting

Added vote weight and fixed named voting
This commit is contained in:
Emanuel Schütze 2020-04-07 07:47:12 +02:00 committed by GitHub
commit cc372cfba5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 121 additions and 30 deletions

View File

@ -1,6 +1,6 @@
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { BaseModel } from '../base/base-model';
import { BaseDecimalModel } from '../base/base-decimal-model';
/**
* Iterable pre selection of genders (sexes)
@ -14,7 +14,7 @@ export type UserAuthType = 'default' | 'saml';
* Representation of a user in contrast to the operator.
* @ignore
*/
export class User extends BaseModel<User> {
export class User extends BaseDecimalModel<User> {
public static COLLECTIONSTRING = 'users/user';
public id: number;
@ -35,8 +35,13 @@ export class User extends BaseModel<User> {
public is_active?: boolean;
public default_password?: string;
public auth_type?: UserAuthType;
public vote_weight: number;
public constructor(input?: Partial<User>) {
super(User.COLLECTIONSTRING, input);
}
protected getDecimalFields(): string[] {
return ['vote_weight'];
}
}

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.12 on 2020-04-06 11:26
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("assignments", "0011_voting_4"),
]
operations = [
migrations.AlterUniqueTogether(
name="assignmentvote", unique_together={("user", "option")},
),
]

View File

@ -245,6 +245,7 @@ class AssignmentVote(RESTModelMixin, BaseVote):
class Meta:
default_permissions = ()
unique_together = ("user", "option")
class AssignmentOptionManager(BaseManager):

View File

@ -507,22 +507,29 @@ class AssignmentPollViewSet(BasePollViewSet):
def create_votes_type_named_pseudoanonymous(
self, data, poll, check_user, vote_user
):
""" check_user is used for the voted-array, vote_user is the one put into the vote """
"""
check_user is used for the voted-array and weight of the vote,
vote_user is the one put into the vote
"""
options = poll.get_options()
for option_id, result in data.items():
option = options.get(pk=option_id)
vote = AssignmentVote.objects.create(
option=option, user=vote_user, value=result
option=option,
user=vote_user,
value=result,
weight=check_user.vote_weight,
)
inform_changed_data(vote, no_delete_on_restriction=True)
inform_changed_data(option, no_delete_on_restriction=True)
poll.voted.add(check_user)
def handle_named_vote(self, data, poll, user):
if user in poll.voted.all():
raise ValidationError({"detail": "You have already voted"})
def add_user_to_voted_array(self, user, poll):
VotedModel = AssignmentPoll.voted.through
VotedModel.objects.create(assignmentpoll=poll, user=user)
def handle_named_vote(self, data, poll, user):
if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES:
self.create_votes_type_votes(data, poll, user)
elif poll.pollmethod in (
@ -532,9 +539,6 @@ class AssignmentPollViewSet(BasePollViewSet):
self.create_votes_type_named_pseudoanonymous(data, poll, user, user)
def handle_pseudoanonymous_vote(self, data, poll, user):
if user in poll.voted.all():
raise ValidationError({"detail": "You have already voted"})
if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES:
self.create_votes_type_votes(data, poll, user)

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.12 on 2020-04-06 11:26
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("motions", "0034_voting_2"),
]
operations = [
migrations.AlterUniqueTogether(
name="motionvote", unique_together={("user", "option")},
),
]

View File

@ -880,6 +880,7 @@ class MotionVote(RESTModelMixin, BaseVote):
class Meta:
default_permissions = ()
unique_together = ("user", "option")
class MotionOptionManager(BaseManager):

View File

@ -1,4 +1,3 @@
from decimal import Decimal
from typing import List, Set
import jsonschema
@ -1177,6 +1176,10 @@ class MotionPollViewSet(BasePollViewSet):
return result
def add_user_to_voted_array(self, user, poll):
VotedModel = MotionPoll.voted.through
VotedModel.objects.create(motionpoll=poll, user=user)
def handle_analog_vote(self, data, poll, user):
option = poll.options.get()
vote, _ = MotionVote.objects.get_or_create(option=option, value="Y")
@ -1223,13 +1226,9 @@ class MotionPollViewSet(BasePollViewSet):
elif poll.pollmethod == MotionPoll.POLLMETHOD_YN and data not in ("Y", "N"):
raise ValidationError("Data must be Y or N")
if poll.type == MotionPoll.TYPE_PSEUDOANONYMOUS:
if user in poll.voted.all():
raise ValidationError("You already voted on this poll")
def handle_named_vote(self, data, poll, user):
option = poll.options.get()
vote, _ = MotionVote.objects.get_or_create(user=user, option=option)
vote = MotionVote.objects.create(user=user, option=option)
self.handle_named_and_pseudoanonymous_vote(data, user, poll, option, vote)
def handle_pseudoanonymous_vote(self, data, poll, user):
@ -1239,13 +1238,10 @@ class MotionPollViewSet(BasePollViewSet):
def handle_named_and_pseudoanonymous_vote(self, data, user, poll, option, vote):
vote.value = data
vote.weight = Decimal("1")
vote.weight = user.vote_weight
vote.save(no_delete_on_restriction=True)
inform_changed_data(option)
poll.voted.add(user)
poll.save()
class MotionOptionViewSet(BaseOptionViewSet):
queryset = MotionOption.objects.all()

View File

@ -2,6 +2,7 @@ from textwrap import dedent
from django.contrib.auth.models import AnonymousUser
from django.db import transaction
from django.db.utils import IntegrityError
from rest_framework import status
from openslides.utils.auth import in_some_groups
@ -115,6 +116,7 @@ class BasePollViewSet(ModelViewSet):
poll.save()
@detail_route(methods=["POST"])
@transaction.atomic
def start(self, request, pk):
poll = self.get_object()
if poll.state != BasePoll.STATE_CREATED:
@ -126,6 +128,7 @@ class BasePollViewSet(ModelViewSet):
return Response()
@detail_route(methods=["POST"])
@transaction.atomic
def stop(self, request, pk):
poll = self.get_object()
# Analog polls could not be stopped; they are stopped when
@ -145,6 +148,7 @@ class BasePollViewSet(ModelViewSet):
return Response()
@detail_route(methods=["POST"])
@transaction.atomic
def publish(self, request, pk):
poll = self.get_object()
if poll.state != BasePoll.STATE_FINISHED:
@ -157,6 +161,7 @@ class BasePollViewSet(ModelViewSet):
return Response()
@detail_route(methods=["POST"])
@transaction.atomic
def pseudoanonymize(self, request, pk):
poll = self.get_object()
@ -173,12 +178,14 @@ class BasePollViewSet(ModelViewSet):
return Response()
@detail_route(methods=["POST"])
@transaction.atomic
def reset(self, request, pk):
poll = self.get_object()
poll.reset()
return Response()
@detail_route(methods=["POST"])
@transaction.atomic
def vote(self, request, pk):
"""
For motion polls: Just "Y", "N" or "A" (if pollmethod is "YNA")
@ -214,10 +221,10 @@ class BasePollViewSet(ModelViewSet):
def assert_can_vote(self, poll, request):
"""
Raises a permission denied, if the user is not allowed to vote.
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!
Analog: has to have manage permissions
Named & Pseudoanonymous: has to be in a poll group and present
Note: For pseudoanonymous it is *not* tested, if the user has already voted!
"""
if poll.type == BasePoll.TYPE_ANALOG:
if not self.has_manage_permissions():
@ -232,6 +239,12 @@ class BasePollViewSet(ModelViewSet):
):
self.permission_denied(request)
try:
self.add_user_to_voted_array(request.user, poll)
inform_changed_data(poll)
except IntegrityError:
raise ValidationError({"detail": "You have already voted"})
def parse_vote_value(self, obj, key):
""" Raises a ValidationError on incorrect values, including None """
if key not in obj:
@ -252,6 +265,13 @@ class BasePollViewSet(ModelViewSet):
"""
pass
def add_user_to_voted_array(self, user, poll):
"""
To be implemented by subclass. Adds the given user to the voted array of the given poll.
Throws an IntegrityError if the user already exists in the array
"""
raise NotImplementedError()
def validate_vote_data(self, data, poll, user):
"""
To be implemented by subclass. Validates the data according to poll type and method and fields by validated versions.

View File

@ -0,0 +1,22 @@
# Generated by Django 2.2.12 on 2020-04-06 10:34
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0012_user_auth_type"),
]
operations = [
migrations.AddField(
model_name="user",
name="vote_weight",
field=models.DecimalField(
blank=True, decimal_places=6, default=Decimal("1"), max_digits=15
),
),
]

View File

@ -1,4 +1,5 @@
import smtplib
from decimal import Decimal
from django.conf import settings
from django.contrib.auth.hashers import make_password
@ -159,6 +160,10 @@ class User(RESTModelMixin, PermissionsMixin, AbstractBaseUser):
is_committee = models.BooleanField(default=False)
vote_weight = models.DecimalField(
default=Decimal("1"), max_digits=15, decimal_places=6, null=False, blank=True
)
objects = UserManager()
class Meta:

View File

@ -25,6 +25,7 @@ USERCANSEESERIALIZER_FIELDS = (
"groups",
"is_present",
"is_committee",
"vote_weight",
)
@ -38,7 +39,7 @@ USERCANSEEEXTRASERIALIZER_FIELDS = USERCANSEESERIALIZER_FIELDS + (
)
class UserFullSerializer(ModelSerializer):
class UserSerializer(ModelSerializer):
"""
Serializer for users.models.User objects.

View File

@ -775,7 +775,7 @@ class VoteMotionPollNamed(TestCase):
response = self.client.post(
reverse("motionpoll-vote", args=[self.poll.pk]), "A"
)
self.assertHttpStatusVerbose(response, status.HTTP_200_OK)
self.assertHttpStatusVerbose(response, status.HTTP_400_BAD_REQUEST)
poll = MotionPoll.objects.get()
self.assertEqual(poll.votesvalid, Decimal("1"))
self.assertEqual(poll.votesinvalid, Decimal("0"))
@ -783,8 +783,8 @@ class VoteMotionPollNamed(TestCase):
self.assertEqual(poll.get_votes().count(), 1)
option = poll.options.get()
self.assertEqual(option.yes, Decimal("0"))
self.assertEqual(option.no, Decimal("0"))
self.assertEqual(option.abstain, Decimal("1"))
self.assertEqual(option.no, Decimal("1"))
self.assertEqual(option.abstain, Decimal("0"))
vote = option.votes.get()
self.assertEqual(vote.user, self.admin)

View File

@ -1,7 +1,7 @@
from unittest import TestCase
from unittest.mock import MagicMock, patch
from openslides.users.serializers import UserFullSerializer
from openslides.users.serializers import UserSerializer
from openslides.utils.rest_api import ValidationError
@ -10,7 +10,7 @@ class UserCreateUpdateSerializerTest(TestCase):
"""
Tests, that the validator raises a ValidationError, if not data is given.
"""
serializer = UserFullSerializer()
serializer = UserSerializer()
data: object = {}
with self.assertRaises(ValidationError):
@ -22,7 +22,7 @@ class UserCreateUpdateSerializerTest(TestCase):
Tests, that an empty username is generated.
"""
generate_username.return_value = "test_value"
serializer = UserFullSerializer()
serializer = UserSerializer()
data = {"first_name": "TestName"}
new_data = serializer.validate(data)
@ -34,7 +34,7 @@ class UserCreateUpdateSerializerTest(TestCase):
Tests, that an empty username is not set in a patch request context.
"""
view = MagicMock(action="partial_update")
serializer = UserFullSerializer(context={"view": view})
serializer = UserSerializer(context={"view": view})
data = {"first_name": "TestName"}
new_data = serializer.validate(data)