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 { 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) * Iterable pre selection of genders (sexes)
@ -14,7 +14,7 @@ export type UserAuthType = 'default' | 'saml';
* Representation of a user in contrast to the operator. * Representation of a user in contrast to the operator.
* @ignore * @ignore
*/ */
export class User extends BaseModel<User> { export class User extends BaseDecimalModel<User> {
public static COLLECTIONSTRING = 'users/user'; public static COLLECTIONSTRING = 'users/user';
public id: number; public id: number;
@ -35,8 +35,13 @@ export class User extends BaseModel<User> {
public is_active?: boolean; public is_active?: boolean;
public default_password?: string; public default_password?: string;
public auth_type?: UserAuthType; public auth_type?: UserAuthType;
public vote_weight: number;
public constructor(input?: Partial<User>) { public constructor(input?: Partial<User>) {
super(User.COLLECTIONSTRING, input); 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: class Meta:
default_permissions = () default_permissions = ()
unique_together = ("user", "option")
class AssignmentOptionManager(BaseManager): class AssignmentOptionManager(BaseManager):

View File

@ -507,22 +507,29 @@ class AssignmentPollViewSet(BasePollViewSet):
def create_votes_type_named_pseudoanonymous( def create_votes_type_named_pseudoanonymous(
self, data, poll, check_user, vote_user 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() options = poll.get_options()
for option_id, result in data.items(): for option_id, result in data.items():
option = options.get(pk=option_id) option = options.get(pk=option_id)
vote = AssignmentVote.objects.create( 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(vote, no_delete_on_restriction=True)
inform_changed_data(option, no_delete_on_restriction=True) inform_changed_data(option, no_delete_on_restriction=True)
poll.voted.add(check_user) poll.voted.add(check_user)
def handle_named_vote(self, data, poll, user): def add_user_to_voted_array(self, user, poll):
if user in poll.voted.all(): VotedModel = AssignmentPoll.voted.through
raise ValidationError({"detail": "You have already voted"}) VotedModel.objects.create(assignmentpoll=poll, user=user)
def handle_named_vote(self, data, poll, user):
if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES: if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES:
self.create_votes_type_votes(data, poll, user) self.create_votes_type_votes(data, poll, user)
elif poll.pollmethod in ( elif poll.pollmethod in (
@ -532,9 +539,6 @@ class AssignmentPollViewSet(BasePollViewSet):
self.create_votes_type_named_pseudoanonymous(data, poll, user, user) self.create_votes_type_named_pseudoanonymous(data, poll, user, user)
def handle_pseudoanonymous_vote(self, data, poll, 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: if poll.pollmethod == AssignmentPoll.POLLMETHOD_VOTES:
self.create_votes_type_votes(data, poll, user) 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: class Meta:
default_permissions = () default_permissions = ()
unique_together = ("user", "option")
class MotionOptionManager(BaseManager): class MotionOptionManager(BaseManager):

View File

@ -1,4 +1,3 @@
from decimal import Decimal
from typing import List, Set from typing import List, Set
import jsonschema import jsonschema
@ -1177,6 +1176,10 @@ class MotionPollViewSet(BasePollViewSet):
return result 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): def handle_analog_vote(self, data, poll, user):
option = poll.options.get() option = poll.options.get()
vote, _ = MotionVote.objects.get_or_create(option=option, value="Y") 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"): elif poll.pollmethod == MotionPoll.POLLMETHOD_YN and data not in ("Y", "N"):
raise ValidationError("Data must be Y or 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): def handle_named_vote(self, data, poll, user):
option = poll.options.get() 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) self.handle_named_and_pseudoanonymous_vote(data, user, poll, option, vote)
def handle_pseudoanonymous_vote(self, data, poll, user): 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): def handle_named_and_pseudoanonymous_vote(self, data, user, poll, option, vote):
vote.value = data vote.value = data
vote.weight = Decimal("1") vote.weight = user.vote_weight
vote.save(no_delete_on_restriction=True) vote.save(no_delete_on_restriction=True)
inform_changed_data(option) inform_changed_data(option)
poll.voted.add(user)
poll.save()
class MotionOptionViewSet(BaseOptionViewSet): class MotionOptionViewSet(BaseOptionViewSet):
queryset = MotionOption.objects.all() queryset = MotionOption.objects.all()

View File

@ -2,6 +2,7 @@ from textwrap import dedent
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.db import transaction from django.db import transaction
from django.db.utils import IntegrityError
from rest_framework import status from rest_framework import status
from openslides.utils.auth import in_some_groups from openslides.utils.auth import in_some_groups
@ -115,6 +116,7 @@ class BasePollViewSet(ModelViewSet):
poll.save() poll.save()
@detail_route(methods=["POST"]) @detail_route(methods=["POST"])
@transaction.atomic
def start(self, request, pk): def start(self, request, pk):
poll = self.get_object() poll = self.get_object()
if poll.state != BasePoll.STATE_CREATED: if poll.state != BasePoll.STATE_CREATED:
@ -126,6 +128,7 @@ class BasePollViewSet(ModelViewSet):
return Response() return Response()
@detail_route(methods=["POST"]) @detail_route(methods=["POST"])
@transaction.atomic
def stop(self, request, pk): def stop(self, request, pk):
poll = self.get_object() poll = self.get_object()
# Analog polls could not be stopped; they are stopped when # Analog polls could not be stopped; they are stopped when
@ -145,6 +148,7 @@ class BasePollViewSet(ModelViewSet):
return Response() return Response()
@detail_route(methods=["POST"]) @detail_route(methods=["POST"])
@transaction.atomic
def publish(self, request, pk): def publish(self, request, pk):
poll = self.get_object() poll = self.get_object()
if poll.state != BasePoll.STATE_FINISHED: if poll.state != BasePoll.STATE_FINISHED:
@ -157,6 +161,7 @@ class BasePollViewSet(ModelViewSet):
return Response() return Response()
@detail_route(methods=["POST"]) @detail_route(methods=["POST"])
@transaction.atomic
def pseudoanonymize(self, request, pk): def pseudoanonymize(self, request, pk):
poll = self.get_object() poll = self.get_object()
@ -173,12 +178,14 @@ class BasePollViewSet(ModelViewSet):
return Response() return Response()
@detail_route(methods=["POST"]) @detail_route(methods=["POST"])
@transaction.atomic
def reset(self, request, pk): def reset(self, request, pk):
poll = self.get_object() poll = self.get_object()
poll.reset() poll.reset()
return Response() return Response()
@detail_route(methods=["POST"]) @detail_route(methods=["POST"])
@transaction.atomic
def vote(self, request, pk): def vote(self, request, pk):
""" """
For motion polls: Just "Y", "N" or "A" (if pollmethod is "YNA") 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): 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 Analog: has to have manage permissions
Named & Pseudoanonymous: has to be in a poll group and present 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 poll.type == BasePoll.TYPE_ANALOG:
if not self.has_manage_permissions(): if not self.has_manage_permissions():
@ -232,6 +239,12 @@ class BasePollViewSet(ModelViewSet):
): ):
self.permission_denied(request) 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): def parse_vote_value(self, obj, key):
""" Raises a ValidationError on incorrect values, including None """ """ Raises a ValidationError on incorrect values, including None """
if key not in obj: if key not in obj:
@ -252,6 +265,13 @@ class BasePollViewSet(ModelViewSet):
""" """
pass 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): 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. 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 import smtplib
from decimal import Decimal
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
@ -159,6 +160,10 @@ class User(RESTModelMixin, PermissionsMixin, AbstractBaseUser):
is_committee = models.BooleanField(default=False) 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() objects = UserManager()
class Meta: class Meta:

View File

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

View File

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

View File

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