OpenSlides/server/openslides/poll/views.py
Sean 6dc5c3bfa9 stop-voting shows prompt
Stop voting shows options to either simply stop, publish directly or
abort. Was done using choice service.
Alter vote repo to simply choose with voting state to adress rather than
calculate the next state

Add server-side option to publish a poll in the started state
2021-05-26 17:53:46 +02:00

391 lines
14 KiB
Python

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
from rest_framework import status
from openslides.utils.auth import in_some_groups
from openslides.utils.autoupdate import disable_history, inform_changed_data
from openslides.utils.rest_api import (
DecimalField,
GenericViewSet,
ModelViewSet,
Response,
ValidationError,
action,
)
from .models import BasePoll
class BasePollViewSet(ModelViewSet):
valid_update_keys = [
"majority_method",
"onehundred_percent_base",
"title",
"description",
]
def check_view_permissions(self):
"""
the vote view is checked seperately. For all other views manage permissions
are required.
"""
if self.action == "vote":
return True
else:
return self.has_manage_permissions()
def get_locked_object(self):
"""
Enhance get_object to make sure to lock the underlying object to prevent
race conditions.
"""
poll = self.get_object()
return self.queryset.select_for_update().get(pk=poll.pk)
@transaction.atomic
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# for analog polls, votes can be given directly when the poll is created
# for assignment polls, the options do not exist yet, so the AssignmentRelatedUser ids are needed
if "votes" in request.data:
poll = serializer.save()
poll.create_options()
self.handle_request_with_votes(request, poll)
else:
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
def perform_create(self, serializer):
poll = serializer.save()
poll.create_options()
@transaction.atomic
def update(self, request, *args, **kwargs):
"""
Customized view endpoint to update a poll.
"""
poll = self.get_locked_object()
partial = kwargs.get("partial", False)
serializer = self.get_serializer(poll, data=request.data, partial=partial)
serializer.is_valid(raise_exception=False)
if poll.state != BasePoll.STATE_CREATED:
invalid_keys = set(serializer.validated_data.keys()) - set(
self.valid_update_keys
)
if len(invalid_keys):
raise ValidationError(
{
"detail": dedent(
f"""
The poll is not in the created state.
You can only edit: {', '.join(self.valid_update_keys)}.
You provided the invalid keys: {', '.join(invalid_keys)}.
"""
)
}
)
if "votes" in request.data:
self.handle_request_with_votes(request, poll)
return super().update(request, *args, **kwargs)
def handle_request_with_votes(self, request, poll):
if poll.type != BasePoll.TYPE_ANALOG:
raise ValidationError(
{"detail": "You cannot enter votes for a non-analog poll."}
)
vote_data = request.data["votes"]
# convert user ids to option ids
self.convert_option_data(poll, vote_data)
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
elif (
poll.state != BasePoll.STATE_PUBLISHED
): # only set to finished if not already published
poll.state = BasePoll.STATE_FINISHED
poll.save()
def extend_history_information(self, information):
# Use this method to add history information when a poll is started and stopped.
# The implementation can be found in concret view set e. g. MotionPollViewSet.
pass
@action(detail=True, methods=["POST"])
@transaction.atomic
def start(self, request, pk):
poll = self.get_locked_object()
if poll.state != BasePoll.STATE_CREATED:
raise ValidationError({"detail": "Wrong poll state"})
poll.state = BasePoll.STATE_STARTED
poll.save()
inform_changed_data(poll.get_votes())
self.extend_history_information(["Voting started"])
return Response()
@action(detail=True, methods=["POST"])
@transaction.atomic
def stop(self, request, pk):
poll = self.get_locked_object()
# Analog polls cannot be stopped; they are stopped when
# the results are entered.
if poll.type == BasePoll.TYPE_ANALOG:
raise ValidationError(
{"detail": "Analog polls can not be stopped. Please enter votes."}
)
if poll.state != BasePoll.STATE_STARTED:
raise ValidationError({"detail": "Wrong poll state"})
poll.stop()
inform_changed_data(poll.get_votes())
inform_changed_data(poll.get_options())
self.extend_history_information(["Voting stopped"])
return Response()
@action(detail=True, methods=["POST"])
@transaction.atomic
def publish(self, request, pk):
poll = self.get_locked_object()
if poll.state not in (BasePoll.STATE_STARTED, BasePoll.STATE_FINISHED):
raise ValidationError({"detail": "Wrong poll state"})
# stop poll if needed
if poll.state == BasePoll.STATE_STARTED:
poll.stop()
poll.state = BasePoll.STATE_PUBLISHED
poll.save()
inform_changed_data(
(
vote.user
for vote in poll.get_votes().prefetch_related("user").all()
if vote.user
)
)
inform_changed_data(poll.get_votes())
inform_changed_data(poll.get_options())
return Response()
@action(detail=True, methods=["POST"])
@transaction.atomic
def pseudoanonymize(self, request, pk):
poll = self.get_locked_object()
if poll.state not in (BasePoll.STATE_FINISHED, BasePoll.STATE_PUBLISHED):
raise ValidationError(
{"detail": "Anonymizing can only be done after finishing a poll."}
)
if poll.type != BasePoll.TYPE_NAMED:
raise ValidationError({"detail": "You can just anonymize named polls."})
poll.pseudoanonymize()
self.extend_history_information(["Voting anonymized"])
return Response()
@action(detail=True, methods=["POST"])
@transaction.atomic
def reset(self, request, pk):
poll = self.get_locked_object()
poll.reset()
self.extend_history_information(["Voting reset"])
return Response()
@action(detail=True, methods=["POST"])
@transaction.atomic
def vote(self, request, pk):
"""
For motion polls: Just "Y", "N" or "A" (if pollmethod is "YNA")
"""
poll = self.get_locked_object()
# Disable history for these requests
disable_history()
if isinstance(request.user, AnonymousUser):
self.permission_denied(request)
# data format is:
# { data: <vote_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
if "data" not in data:
raise ValidationError({"detail": "No data provided."})
vote_data = data["data"]
if (
"user_id" in data
and data["user_id"] != request.user.id
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(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(vote_data, poll, vote_user, request.user)
elif poll.type == BasePoll.TYPE_PSEUDOANONYMOUS:
self.handle_pseudoanonymous_vote(vote_data, poll, vote_user)
inform_changed_data(poll)
return Response()
@action(detail=True, methods=["POST"])
@transaction.atomic
def refresh(self, request, pk):
poll = self.get_locked_object()
inform_changed_data(poll)
inform_changed_data(poll.get_options())
inform_changed_data(poll.get_votes())
return Response()
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 if a later error happens!
Analog: has to have manage permissions
Named & Pseudoanonymous: has to be in a poll group and present
"""
# if the request user is not the vote user, the delegation must be right
if request.user != vote_user and request.user != vote_user.vote_delegated_to:
raise ValidationError(
{
"detail": f"You cannot vote for {vote_user.id} since the vote right was not delegated to you."
}
)
# If the request user is the vote user, this user must not have any delegation.
# It is not allowed to vote for oneself, if the vote is delegated
if request.user == vote_user and request.user.vote_delegated_to is not None:
raise ValidationError(
{"detail": "You cannot vote since your vote right is delegated."}
)
if poll.type == BasePoll.TYPE_ANALOG:
if not self.has_manage_permissions():
self.permission_denied(request)
else:
if poll.state != BasePoll.STATE_STARTED:
raise ValidationError(
{"detail": "You can only vote on a started poll."}
)
if not request.user.is_present or not in_some_groups(
vote_user.id,
list(poll.groups.values_list("pk", flat=True)),
exact=True,
):
self.permission_denied(request)
try:
self.add_user_to_voted_array(vote_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:
raise ValidationError({"detail": f"The field {key} is required"})
field = DecimalField(min_value=-2, max_digits=15, decimal_places=6)
value = field.to_internal_value(obj[key])
if value < 0 and value != -1 and value != -2:
raise ValidationError(
{
"detail": "No fractional negative values allowed, only the special values -1 and -2"
}
)
return value
def has_manage_permissions(self):
"""
Returns true, if the request user has manage perms.
"""
raise NotImplementedError()
def convert_option_data(self, poll, data):
"""
May be overwritten by subclass. Adjusts the option data based on the now existing poll
"""
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.
This operation should be atomic: If the user is already in the array, an IntegrityError must
be thrown, otherwise the user must be added.
"""
raise NotImplementedError()
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):
"""
To be implemented by subclass. Handles the analog vote. Assumes data is validated
"""
raise NotImplementedError()
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.
"""
raise NotImplementedError()
def handle_pseudoanonymous_vote(self, data, poll, user):
"""
To be implemented by subclass. Handles the pseudoanonymous vote. Assumes data
is validated. Needs to check, if the vote is allowed by the voted-array per poll.
Needs to add the user to the voted-array.
"""
raise NotImplementedError()
class BaseVoteViewSet(GenericViewSet):
pass
class BaseOptionViewSet(GenericViewSet):
pass