97c2299aec
Implements vote weight in client The user detail page has a new property change deserialize to parse floats change "yes"-voting to send "Y" and "0" instead of "1" and "0" add vote weight to user list, filter, sort add vote weight to single voting result votesvalid and votescast respect the individual vote weight fix parse-poll pipe and null in pdf
274 lines
8.1 KiB
Python
274 lines
8.1 KiB
Python
from decimal import Decimal
|
|
from typing import Iterable, Optional, Tuple, Type
|
|
|
|
from django.conf import settings
|
|
from django.core.validators import MinValueValidator
|
|
from django.db import models
|
|
|
|
from ..core.config import config
|
|
from ..utils.autoupdate import inform_changed_data, inform_deleted_data
|
|
from ..utils.models import SET_NULL_AND_AUTOUPDATE
|
|
|
|
|
|
class BaseVote(models.Model):
|
|
"""
|
|
All subclasses must have option attribute with the related name "votes"
|
|
"""
|
|
|
|
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,
|
|
)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
class BaseOption(models.Model):
|
|
"""
|
|
All subclasses must have poll attribute with the related name "options"
|
|
"""
|
|
|
|
vote_class: Optional[Type["BaseVote"]] = None
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
@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
|
|
|
|
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)
|
|
|
|
|
|
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 = (
|
|
(TYPE_ANALOG, "Analog"),
|
|
(TYPE_NAMED, "Named"),
|
|
(TYPE_PSEUDOANONYMOUS, "Pseudoanonymous"),
|
|
)
|
|
type = models.CharField(max_length=64, blank=False, null=False, choices=TYPES)
|
|
|
|
title = models.CharField(max_length=255, blank=True, null=False)
|
|
groups = models.ManyToManyField(settings.AUTH_GROUP_MODEL, blank=True)
|
|
voted = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True)
|
|
|
|
db_votesvalid = models.DecimalField(
|
|
null=True,
|
|
blank=True,
|
|
validators=[MinValueValidator(Decimal("-2"))],
|
|
max_digits=15,
|
|
decimal_places=6,
|
|
)
|
|
db_votesinvalid = models.DecimalField(
|
|
null=True,
|
|
blank=True,
|
|
validators=[MinValueValidator(Decimal("-2"))],
|
|
max_digits=15,
|
|
decimal_places=6,
|
|
)
|
|
db_votescast = models.DecimalField(
|
|
null=True,
|
|
blank=True,
|
|
validators=[MinValueValidator(Decimal("-2"))],
|
|
max_digits=15,
|
|
decimal_places=6,
|
|
)
|
|
|
|
PERCENT_BASE_YN = "YN"
|
|
PERCENT_BASE_YNA = "YNA"
|
|
PERCENT_BASE_VALID = "valid"
|
|
PERCENT_BASE_CAST = "cast"
|
|
PERCENT_BASE_DISABLED = "disabled"
|
|
PERCENT_BASES: Iterable[Tuple[str, str]] = (
|
|
(PERCENT_BASE_YN, "Yes/No"),
|
|
(PERCENT_BASE_YNA, "Yes/No/Abstain"),
|
|
(PERCENT_BASE_VALID, "All valid ballots"),
|
|
(PERCENT_BASE_CAST, "All casted ballots"),
|
|
(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
|
|
)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
def get_votesvalid(self):
|
|
if self.type == self.TYPE_ANALOG:
|
|
return self.db_votesvalid
|
|
else:
|
|
return Decimal(self.amount_users_voted_with_individual_weight())
|
|
|
|
def set_votesvalid(self, value):
|
|
if self.type != self.TYPE_ANALOG:
|
|
raise ValueError("Do not set votesvalid for non analog polls")
|
|
self.db_votesvalid = value
|
|
|
|
votesvalid = property(get_votesvalid, set_votesvalid)
|
|
|
|
def get_votesinvalid(self):
|
|
if self.type == self.TYPE_ANALOG:
|
|
return self.db_votesinvalid
|
|
else:
|
|
return Decimal(0)
|
|
|
|
def set_votesinvalid(self, value):
|
|
if self.type != self.TYPE_ANALOG:
|
|
raise ValueError("Do not set votesinvalid for non analog polls")
|
|
self.db_votesinvalid = value
|
|
|
|
votesinvalid = property(get_votesinvalid, set_votesinvalid)
|
|
|
|
def get_votescast(self):
|
|
if self.type == self.TYPE_ANALOG:
|
|
return self.db_votescast
|
|
else:
|
|
return Decimal(self.amount_users_voted_with_individual_weight())
|
|
|
|
def set_votescast(self, value):
|
|
if self.type != self.TYPE_ANALOG:
|
|
raise ValueError("Do not set votescast for non analog polls")
|
|
self.db_votescast = value
|
|
|
|
votescast = property(get_votescast, set_votescast)
|
|
|
|
def amount_users_voted_with_individual_weight(self):
|
|
if config["users_activate_vote_weight"]:
|
|
return sum(user.vote_weight for user in self.voted.all())
|
|
else:
|
|
return len(self.voted.all())
|
|
|
|
def create_options(self):
|
|
""" Should be called after creation of this model. """
|
|
raise NotImplementedError()
|
|
|
|
@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
|
|
|
|
def get_options(self):
|
|
"""
|
|
Returns the option objects for the poll.
|
|
"""
|
|
return self.options.all()
|
|
|
|
@classmethod
|
|
def get_vote_class(cls):
|
|
return cls.get_option_class().get_vote_class()
|
|
|
|
def get_votes(self):
|
|
"""
|
|
Return a QuerySet with all vote objects related to this poll.
|
|
"""
|
|
return self.get_vote_class().objects.filter(option__poll__id=self.id)
|
|
|
|
def pseudoanonymize(self):
|
|
for option in self.get_options():
|
|
option.pseudoanonymize()
|
|
|
|
def reset(self):
|
|
for option in self.get_options():
|
|
option.reset()
|
|
|
|
self.voted.clear()
|
|
|
|
# Reset state
|
|
self.state = BasePoll.STATE_CREATED
|
|
if self.type == self.TYPE_ANALOG:
|
|
self.votesvalid = None
|
|
self.votesinvalid = None
|
|
self.votescast = None
|
|
self.save()
|