2018-08-22 17:34:16 +02:00
|
|
|
from decimal import Decimal
|
2020-03-11 10:22:03 +01:00
|
|
|
from typing import Iterable, Optional, Tuple, Type
|
2014-04-10 20:18:22 +02:00
|
|
|
|
2019-10-18 14:18:49 +02:00
|
|
|
from django.conf import settings
|
2018-08-22 17:34:16 +02:00
|
|
|
from django.core.validators import MinValueValidator
|
2011-07-31 10:46:29 +02:00
|
|
|
from django.db import models
|
2021-03-25 13:13:49 +01:00
|
|
|
from django.utils.crypto import get_random_string
|
|
|
|
from jsonfield import JSONField
|
|
|
|
|
|
|
|
from openslides.utils.manager import BaseManager
|
2011-07-31 10:46:29 +02:00
|
|
|
|
2020-04-22 16:54:50 +02:00
|
|
|
from ..core.config import config
|
2019-11-12 18:30:26 +01:00
|
|
|
from ..utils.autoupdate import inform_changed_data, inform_deleted_data
|
2019-10-18 14:18:49 +02:00
|
|
|
from ..utils.models import SET_NULL_AND_AUTOUPDATE
|
2012-07-03 00:05:48 +02:00
|
|
|
|
2012-07-13 11:16:06 +02:00
|
|
|
|
2021-03-25 13:13:49 +01:00
|
|
|
def generate_user_token():
|
2021-04-26 08:31:22 +02:00
|
|
|
"""Generates a 16 character alphanumeric token."""
|
2021-03-25 13:13:49 +01:00
|
|
|
return get_random_string(16)
|
|
|
|
|
|
|
|
|
2019-10-18 14:18:49 +02:00
|
|
|
class BaseVote(models.Model):
|
|
|
|
"""
|
|
|
|
All subclasses must have option attribute with the related name "votes"
|
2012-07-13 11:16:06 +02:00
|
|
|
"""
|
2019-01-06 16:22:33 +01:00
|
|
|
|
2019-10-18 14:18:49 +02:00
|
|
|
weight = models.DecimalField(
|
|
|
|
default=Decimal("1"),
|
|
|
|
validators=[MinValueValidator(Decimal("-2"))],
|
|
|
|
max_digits=15,
|
|
|
|
decimal_places=6,
|
|
|
|
)
|
|
|
|
value = models.CharField(max_length=1, choices=(("Y", "Y"), ("N", "N"), ("A", "A")))
|
|
|
|
user = models.ForeignKey(
|
|
|
|
settings.AUTH_USER_MODEL,
|
|
|
|
default=None,
|
|
|
|
null=True,
|
|
|
|
blank=True,
|
|
|
|
on_delete=SET_NULL_AND_AUTOUPDATE,
|
|
|
|
)
|
2020-09-10 12:09:05 +02:00
|
|
|
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",
|
|
|
|
)
|
2021-03-25 13:13:49 +01:00
|
|
|
user_token = models.CharField(default=generate_user_token, max_length=16)
|
2013-12-02 22:29:11 +01:00
|
|
|
|
|
|
|
class Meta:
|
|
|
|
abstract = True
|
2011-07-31 10:46:29 +02:00
|
|
|
|
2011-09-08 12:30:34 +02:00
|
|
|
|
2021-03-25 13:13:49 +01:00
|
|
|
class BaseVoteManager(BaseManager):
|
|
|
|
"""
|
|
|
|
Base vote manager that supplies the generate_user_token method.
|
|
|
|
"""
|
|
|
|
|
|
|
|
def generate_user_token(self):
|
|
|
|
return generate_user_token()
|
|
|
|
|
|
|
|
|
2019-10-18 14:18:49 +02:00
|
|
|
class BaseOption(models.Model):
|
2012-07-13 11:16:06 +02:00
|
|
|
"""
|
2020-03-11 10:22:03 +01:00
|
|
|
All subclasses must have poll attribute with the related name "options"
|
2012-07-13 11:16:06 +02:00
|
|
|
"""
|
2019-01-06 16:22:33 +01:00
|
|
|
|
2019-10-18 14:18:49 +02:00
|
|
|
vote_class: Optional[Type["BaseVote"]] = None
|
2011-07-31 10:46:29 +02:00
|
|
|
|
2013-12-02 22:29:11 +01:00
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
2019-10-18 14:18:49 +02:00
|
|
|
@property
|
|
|
|
def yes(self) -> Decimal:
|
|
|
|
return self.sum_weight("Y")
|
|
|
|
|
|
|
|
@property
|
|
|
|
def no(self) -> Decimal:
|
|
|
|
return self.sum_weight("N")
|
|
|
|
|
|
|
|
@property
|
|
|
|
def abstain(self) -> Decimal:
|
|
|
|
return self.sum_weight("A")
|
|
|
|
|
|
|
|
def sum_weight(self, value):
|
|
|
|
# We could do this in a nice .aggregate(Sum...) querystatement,
|
|
|
|
# but these might be expensive DB queries, because they are not preloaded.
|
|
|
|
# With this in-logic-counting, we operate inmemory.
|
|
|
|
weight_sum = Decimal(0)
|
|
|
|
for vote in self.votes.all():
|
|
|
|
if vote.value == value:
|
|
|
|
weight_sum += vote.weight
|
|
|
|
return weight_sum
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def get_vote_class(cls):
|
|
|
|
if cls.vote_class is None:
|
|
|
|
raise NotImplementedError(
|
|
|
|
f"The option class {cls} has to have an attribute vote_class."
|
|
|
|
)
|
|
|
|
return cls.vote_class
|
2013-12-02 22:29:11 +01:00
|
|
|
|
2020-02-12 17:18:01 +01:00
|
|
|
def get_votes(self):
|
|
|
|
"""
|
|
|
|
Return a QuerySet with all vote objects related to this option.
|
|
|
|
"""
|
|
|
|
return self.get_vote_class().objects.filter(option=self)
|
|
|
|
|
|
|
|
def pseudoanonymize(self):
|
|
|
|
for vote in self.get_votes():
|
|
|
|
vote.user = None
|
|
|
|
vote.save()
|
|
|
|
|
|
|
|
def reset(self):
|
|
|
|
# Delete votes
|
|
|
|
votes = self.get_votes()
|
|
|
|
votes_id = [vote.id for vote in votes]
|
|
|
|
votes.delete()
|
|
|
|
collection = self.get_vote_class().get_collection_string()
|
|
|
|
inform_deleted_data((collection, id) for id in votes_id)
|
|
|
|
|
|
|
|
# update self because the changed voted relation
|
|
|
|
inform_changed_data(self)
|
|
|
|
|
2012-02-19 19:27:00 +01:00
|
|
|
|
2019-10-18 14:18:49 +02:00
|
|
|
class BasePoll(models.Model):
|
|
|
|
option_class: Optional[Type["BaseOption"]] = None
|
|
|
|
|
|
|
|
STATE_CREATED = 1
|
|
|
|
STATE_STARTED = 2
|
|
|
|
STATE_FINISHED = 3
|
|
|
|
STATE_PUBLISHED = 4
|
|
|
|
STATES = (
|
|
|
|
(STATE_CREATED, "Created"),
|
|
|
|
(STATE_STARTED, "Started"),
|
|
|
|
(STATE_FINISHED, "Finished"),
|
|
|
|
(STATE_PUBLISHED, "Published"),
|
|
|
|
)
|
|
|
|
state = models.IntegerField(choices=STATES, default=STATE_CREATED)
|
|
|
|
|
|
|
|
TYPE_ANALOG = "analog"
|
|
|
|
TYPE_NAMED = "named"
|
|
|
|
TYPE_PSEUDOANONYMOUS = "pseudoanonymous"
|
|
|
|
TYPES = (
|
2020-05-13 11:27:45 +02:00
|
|
|
(TYPE_ANALOG, "analog"),
|
|
|
|
(TYPE_NAMED, "nominal"),
|
|
|
|
(TYPE_PSEUDOANONYMOUS, "non-nominal"),
|
2019-10-18 14:18:49 +02:00
|
|
|
)
|
|
|
|
type = models.CharField(max_length=64, blank=False, null=False, choices=TYPES)
|
2012-02-19 17:31:17 +01:00
|
|
|
|
2019-10-18 14:18:49 +02:00
|
|
|
title = models.CharField(max_length=255, blank=True, null=False)
|
|
|
|
groups = models.ManyToManyField(settings.AUTH_GROUP_MODEL, blank=True)
|
2020-03-11 10:22:03 +01:00
|
|
|
voted = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True)
|
2019-01-06 16:22:33 +01:00
|
|
|
|
2021-03-25 13:13:49 +01:00
|
|
|
votesvalid = models.DecimalField(
|
2019-01-06 16:22:33 +01:00
|
|
|
null=True,
|
|
|
|
blank=True,
|
|
|
|
validators=[MinValueValidator(Decimal("-2"))],
|
|
|
|
max_digits=15,
|
|
|
|
decimal_places=6,
|
|
|
|
)
|
2021-03-25 13:13:49 +01:00
|
|
|
votesinvalid = models.DecimalField(
|
2019-01-06 16:22:33 +01:00
|
|
|
null=True,
|
|
|
|
blank=True,
|
|
|
|
validators=[MinValueValidator(Decimal("-2"))],
|
|
|
|
max_digits=15,
|
|
|
|
decimal_places=6,
|
|
|
|
)
|
2021-03-25 13:13:49 +01:00
|
|
|
votescast = models.DecimalField(
|
2019-01-06 16:22:33 +01:00
|
|
|
null=True,
|
|
|
|
blank=True,
|
|
|
|
validators=[MinValueValidator(Decimal("-2"))],
|
|
|
|
max_digits=15,
|
|
|
|
decimal_places=6,
|
|
|
|
)
|
2013-12-02 22:29:11 +01:00
|
|
|
|
2019-10-29 09:44:19 +01:00
|
|
|
PERCENT_BASE_YN = "YN"
|
|
|
|
PERCENT_BASE_YNA = "YNA"
|
|
|
|
PERCENT_BASE_VALID = "valid"
|
|
|
|
PERCENT_BASE_CAST = "cast"
|
2021-03-25 13:13:49 +01:00
|
|
|
PERCENT_BASE_ENTITLED = "entitled"
|
2019-10-29 09:44:19 +01:00
|
|
|
PERCENT_BASE_DISABLED = "disabled"
|
|
|
|
PERCENT_BASES: Iterable[Tuple[str, str]] = (
|
2020-02-25 10:44:39 +01:00
|
|
|
(PERCENT_BASE_YN, "Yes/No"),
|
|
|
|
(PERCENT_BASE_YNA, "Yes/No/Abstain"),
|
2019-10-29 09:44:19 +01:00
|
|
|
(PERCENT_BASE_VALID, "All valid ballots"),
|
|
|
|
(PERCENT_BASE_CAST, "All casted ballots"),
|
2021-03-25 13:13:49 +01:00
|
|
|
(PERCENT_BASE_ENTITLED, "All entitled users"),
|
2019-10-29 09:44:19 +01:00
|
|
|
(PERCENT_BASE_DISABLED, "Disabled (no percents)"),
|
|
|
|
) # type: ignore
|
|
|
|
onehundred_percent_base = models.CharField(
|
|
|
|
max_length=8, blank=False, null=False, choices=PERCENT_BASES
|
|
|
|
)
|
|
|
|
|
|
|
|
MAJORITY_SIMPLE = "simple"
|
|
|
|
MAJORITY_TWO_THIRDS = "two_thirds"
|
|
|
|
MAJORITY_THREE_QUARTERS = "three_quarters"
|
|
|
|
MAJORITY_DISABLED = "disabled"
|
|
|
|
MAJORITY_METHODS = (
|
|
|
|
(MAJORITY_SIMPLE, "Simple majority"),
|
|
|
|
(MAJORITY_TWO_THIRDS, "Two-thirds majority"),
|
|
|
|
(MAJORITY_THREE_QUARTERS, "Three-quarters majority"),
|
|
|
|
(MAJORITY_DISABLED, "Disabled"),
|
|
|
|
)
|
|
|
|
majority_method = models.CharField(
|
|
|
|
max_length=14, blank=False, null=False, choices=MAJORITY_METHODS
|
|
|
|
)
|
|
|
|
|
2021-03-25 13:13:49 +01:00
|
|
|
is_pseudoanonymized = models.BooleanField(default=False)
|
2012-03-03 11:16:10 +01:00
|
|
|
|
2021-03-25 13:13:49 +01:00
|
|
|
entitled_users_at_stop = JSONField(null=True)
|
2012-03-03 11:16:10 +01:00
|
|
|
|
2021-03-25 13:13:49 +01:00
|
|
|
class Meta:
|
|
|
|
abstract = True
|
2012-02-14 16:31:21 +01:00
|
|
|
|
2019-10-18 14:18:49 +02:00
|
|
|
def create_options(self):
|
2021-04-26 08:31:22 +02:00
|
|
|
"""Should be called after creation of this model."""
|
2019-10-18 14:18:49 +02:00
|
|
|
raise NotImplementedError()
|
2012-02-14 16:31:21 +01:00
|
|
|
|
2019-10-18 14:18:49 +02:00
|
|
|
@classmethod
|
|
|
|
def get_option_class(cls):
|
|
|
|
if cls.option_class is None:
|
|
|
|
raise NotImplementedError(
|
|
|
|
f"The poll class {cls} has to have an attribute option_class."
|
|
|
|
)
|
|
|
|
return cls.option_class
|
2012-02-14 16:31:21 +01:00
|
|
|
|
2020-02-12 17:18:01 +01:00
|
|
|
def get_options(self):
|
|
|
|
"""
|
|
|
|
Returns the option objects for the poll.
|
|
|
|
"""
|
2020-02-13 18:24:51 +01:00
|
|
|
return self.options.all()
|
2020-02-12 17:18:01 +01:00
|
|
|
|
2019-10-18 14:18:49 +02:00
|
|
|
@classmethod
|
|
|
|
def get_vote_class(cls):
|
|
|
|
return cls.get_option_class().get_vote_class()
|
2012-07-13 11:16:06 +02:00
|
|
|
|
|
|
|
def get_votes(self):
|
|
|
|
"""
|
2013-12-02 22:29:11 +01:00
|
|
|
Return a QuerySet with all vote objects related to this poll.
|
2012-07-13 11:16:06 +02:00
|
|
|
"""
|
2012-07-16 14:29:30 +02:00
|
|
|
return self.get_vote_class().objects.filter(option__poll__id=self.id)
|
2012-07-13 11:16:06 +02:00
|
|
|
|
2019-10-18 14:18:49 +02:00
|
|
|
def pseudoanonymize(self):
|
2020-02-12 17:18:01 +01:00
|
|
|
for option in self.get_options():
|
|
|
|
option.pseudoanonymize()
|
2021-03-25 13:13:49 +01:00
|
|
|
self.is_pseudoanonymized = True
|
|
|
|
self.save()
|
2019-10-18 14:18:49 +02:00
|
|
|
|
|
|
|
def reset(self):
|
2020-02-12 17:18:01 +01:00
|
|
|
for option in self.get_options():
|
|
|
|
option.reset()
|
2019-11-12 18:30:26 +01:00
|
|
|
|
2020-03-11 10:22:03 +01:00
|
|
|
self.voted.clear()
|
2021-06-10 08:46:14 +02:00
|
|
|
self.entitled_users_at_stop = None
|
2020-03-11 10:22:03 +01:00
|
|
|
|
2019-10-18 14:18:49 +02:00
|
|
|
# Reset state
|
|
|
|
self.state = BasePoll.STATE_CREATED
|
|
|
|
if self.type == self.TYPE_ANALOG:
|
|
|
|
self.votesvalid = None
|
|
|
|
self.votesinvalid = None
|
|
|
|
self.votescast = None
|
2021-03-25 13:13:49 +01:00
|
|
|
if self.type != self.TYPE_PSEUDOANONYMOUS:
|
|
|
|
self.is_pseudoanonymized = False
|
|
|
|
self.save()
|
|
|
|
|
2021-05-06 16:06:52 +02:00
|
|
|
def calculate_votes(self, all_voted_users=None):
|
|
|
|
if self.type == BasePoll.TYPE_ANALOG:
|
|
|
|
return
|
|
|
|
|
|
|
|
if all_voted_users is None:
|
|
|
|
all_voted_users = self.voted.all()
|
|
|
|
|
|
|
|
self.votescast = len(all_voted_users)
|
|
|
|
if config["users_activate_vote_weight"]:
|
|
|
|
self.votesvalid = sum(user.vote_weight for user in all_voted_users)
|
|
|
|
else:
|
|
|
|
self.votesvalid = self.votescast
|
|
|
|
self.votesinvalid = Decimal(0)
|
|
|
|
|
|
|
|
def calculate_entitled_users(self, all_voted_users=None):
|
2021-03-25 13:13:49 +01:00
|
|
|
entitled_users = []
|
2021-04-08 10:47:26 +02:00
|
|
|
entitled_users_ids = set()
|
2021-05-06 16:06:52 +02:00
|
|
|
if all_voted_users is None:
|
|
|
|
all_voted_users = self.voted.all()
|
2021-03-25 13:13:49 +01:00
|
|
|
for group in self.groups.all():
|
|
|
|
for user in group.user_set.all():
|
2021-04-26 15:44:42 +02:00
|
|
|
if (
|
|
|
|
user.is_present
|
|
|
|
or (user.vote_delegated_to and user.vote_delegated_to.is_present)
|
|
|
|
) and user.id not in entitled_users_ids:
|
2021-04-08 10:47:26 +02:00
|
|
|
entitled_users_ids.add(user.id)
|
2021-03-25 13:13:49 +01:00
|
|
|
entitled_users.append(
|
|
|
|
{
|
|
|
|
"user_id": user.id,
|
2021-05-06 16:06:52 +02:00
|
|
|
"voted": user in all_voted_users,
|
2021-03-25 13:13:49 +01:00
|
|
|
"vote_delegated_to_id": user.vote_delegated_to_id,
|
|
|
|
}
|
|
|
|
)
|
|
|
|
self.entitled_users_at_stop = entitled_users
|
|
|
|
|
|
|
|
def stop(self):
|
|
|
|
"""
|
|
|
|
Saves a snapshot of the current voted users into the relevant fields and stops the poll.
|
|
|
|
"""
|
2021-05-06 16:06:52 +02:00
|
|
|
all_voted_users = self.voted.all() # just fetch this once from the database now
|
|
|
|
self.calculate_votes(all_voted_users)
|
|
|
|
self.calculate_entitled_users(all_voted_users)
|
2021-03-25 13:13:49 +01:00
|
|
|
self.state = self.STATE_FINISHED
|
2019-10-18 14:18:49 +02:00
|
|
|
self.save()
|