Merge pull request #5300 from FinnStutzenstein/fixVoting
Added vote weight and fixed named voting
This commit is contained in:
commit
cc372cfba5
@ -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'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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")},
|
||||||
|
),
|
||||||
|
]
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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")},
|
||||||
|
),
|
||||||
|
]
|
@ -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):
|
||||||
|
@ -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()
|
||||||
|
@ -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.
|
||||||
|
22
openslides/users/migrations/0013_user_vote_weight.py
Normal file
22
openslides/users/migrations/0013_user_vote_weight.py
Normal 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
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -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:
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user