322 lines
9.8 KiB
Python
322 lines
9.8 KiB
Python
from typing import TYPE_CHECKING
|
|
|
|
from translated_fields import TranslatedFieldWithFallback
|
|
|
|
from django.conf import settings
|
|
from django.contrib.auth.models import AbstractUser
|
|
from django.db import models, transaction
|
|
from django.http.request import HttpRequest
|
|
from django.template.loader import render_to_string
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from shared.auth import CONNECTION_METHODS
|
|
from shared.auth.utils import generate_password
|
|
from shared.json import Serializer
|
|
from shared.utils import choices_length
|
|
|
|
from .staticdefs import (
|
|
BALLOT_TYPE,
|
|
CAST_FUNCTIONS,
|
|
QUESTION_TYPES,
|
|
TALLY_FUNCTIONS,
|
|
VALIDATE_FUNCTIONS,
|
|
)
|
|
from .utils import (
|
|
BallotsData,
|
|
CastFunctions,
|
|
ResultsData,
|
|
TallyFunctions,
|
|
ValidateFunctions,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from django.db.models.fields.related_descriptors import ManyRelatedManager
|
|
from django.utils.functional import _StrPromise
|
|
|
|
|
|
# #############################################################################
|
|
# Models regarding an election
|
|
# #############################################################################
|
|
|
|
|
|
class Election(models.Model):
|
|
registered_voters: models.Manager["User"]
|
|
questions: models.Manager["Question"]
|
|
|
|
name = TranslatedFieldWithFallback(models.CharField(_("nom"), max_length=255))
|
|
short_name = models.SlugField(_("nom bref"), unique=True)
|
|
description = TranslatedFieldWithFallback(
|
|
models.TextField(_("description"), blank=True)
|
|
)
|
|
|
|
start_date = models.DateTimeField(_("date et heure de début"))
|
|
end_date = models.DateTimeField(_("date et heure de fin"))
|
|
|
|
visible = models.BooleanField(_("visible au public"), default=False)
|
|
|
|
vote_restrictions = TranslatedFieldWithFallback(
|
|
models.TextField(_("conditions de vote"), blank=True)
|
|
)
|
|
|
|
restricted = models.BooleanField(
|
|
_("restreint le vote à une liste de personnes"), default=True
|
|
)
|
|
|
|
sent_mail = models.BooleanField(
|
|
_("mail avec les identifiants envoyé"), null=True, default=False
|
|
)
|
|
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
related_name="elections_created",
|
|
on_delete=models.SET_NULL,
|
|
blank=True,
|
|
null=True,
|
|
)
|
|
|
|
voters = models.ManyToManyField(
|
|
settings.AUTH_USER_MODEL,
|
|
related_name="cast_elections",
|
|
blank=True,
|
|
)
|
|
|
|
results_public = models.BooleanField(_("résultats publics"), default=False)
|
|
tallied = models.BooleanField(_("dépouillée"), default=False)
|
|
|
|
archived = models.BooleanField(_("archivée"), default=False)
|
|
|
|
time_tallied = models.DateTimeField(
|
|
_("date du dépouillement"), null=True, default=None
|
|
)
|
|
time_published = models.DateTimeField(
|
|
_("date de publication"), null=True, default=None
|
|
)
|
|
|
|
class Meta:
|
|
permissions = [
|
|
("election_admin", _("Peut administrer des élections")),
|
|
]
|
|
ordering = ["-start_date", "-end_date"]
|
|
|
|
|
|
class Question(Serializer, models.Model):
|
|
options: models.Manager["Option"]
|
|
duels: models.Manager["Duel"]
|
|
|
|
election = models.ForeignKey(
|
|
Election, related_name="questions", on_delete=models.CASCADE
|
|
)
|
|
text = TranslatedFieldWithFallback(
|
|
models.TextField(_("question"), blank=True, default="")
|
|
)
|
|
type = models.CharField(
|
|
_("type de question"),
|
|
choices=QUESTION_TYPES,
|
|
default="assentiment",
|
|
max_length=choices_length(QUESTION_TYPES),
|
|
)
|
|
# We cache the maximum number of votes for an option
|
|
max_votes = models.PositiveSmallIntegerField(
|
|
_("nombre maximal de votes reçus"), default=0
|
|
)
|
|
|
|
voters = models.ManyToManyField(
|
|
settings.AUTH_USER_MODEL,
|
|
related_name="cast_questions",
|
|
blank=True,
|
|
)
|
|
|
|
serializable_fields = ["text_en", "text_fr", "type"]
|
|
|
|
def is_form_valid(self, vote_form) -> bool:
|
|
validate_function = getattr(ValidateFunctions, VALIDATE_FUNCTIONS[self.type])
|
|
return vote_form.is_valid() and validate_function(vote_form)
|
|
|
|
@transaction.atomic
|
|
def cast_ballot(self, user: "User", vote_form) -> None:
|
|
cast_function = getattr(CastFunctions, CAST_FUNCTIONS[self.type])
|
|
cast_function(user, vote_form)
|
|
|
|
@transaction.atomic
|
|
def tally(self) -> None:
|
|
tally_function = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type])
|
|
tally_function(self)
|
|
|
|
@transaction.atomic
|
|
def pseudonymize(self):
|
|
"""
|
|
Generates a random id for each voter
|
|
"""
|
|
|
|
options = list(self.options.prefetch_related("vote_set"))
|
|
votes: set[Vote] = set()
|
|
|
|
for v in self.voters.all():
|
|
pseudonym = generate_password(16)
|
|
|
|
for opt in options:
|
|
for vote in opt.vote_set.filter(user=v):
|
|
vote.pseudonymous_user = pseudonym
|
|
vote.user = None
|
|
votes.add(vote)
|
|
|
|
Vote.objects.bulk_update(votes, ["pseudonymous_user", "user"])
|
|
|
|
@property
|
|
def results(self) -> str:
|
|
return render_to_string(
|
|
f"elections/results/{self.vote_type}_export.txt", {"question": self}
|
|
)
|
|
|
|
def get_formset(self):
|
|
from .forms import BallotFormset # Avoid circular imports
|
|
|
|
return getattr(BallotFormset, BALLOT_TYPE[self.type])
|
|
|
|
def get_results_data(self):
|
|
results_function = getattr(ResultsData, BALLOT_TYPE[self.type])
|
|
return results_function(self)
|
|
|
|
def display_ballots(self):
|
|
display_function = getattr(BallotsData, BALLOT_TYPE[self.type])
|
|
return display_function(self)
|
|
|
|
@property
|
|
def vote_type(self):
|
|
return BALLOT_TYPE[self.type]
|
|
|
|
def __str__(self) -> str:
|
|
return str(self.text)
|
|
|
|
class Meta:
|
|
ordering = ["id"]
|
|
|
|
|
|
class Option(Serializer, models.Model):
|
|
vote_set: models.Manager["Vote"]
|
|
|
|
question = models.ForeignKey(
|
|
Question, related_name="options", on_delete=models.CASCADE
|
|
)
|
|
text = TranslatedFieldWithFallback(models.TextField(_("texte"), blank=False))
|
|
abbreviation = models.CharField(_("abréviation"), max_length=3, blank=True)
|
|
|
|
winner = models.BooleanField(_("option gagnante"), default=False)
|
|
voters = models.ManyToManyField(
|
|
settings.AUTH_USER_MODEL,
|
|
related_name="votes",
|
|
through="elections.Vote",
|
|
blank=True,
|
|
)
|
|
# For now, we store the amount of votes received after the election is tallied
|
|
nb_votes = models.PositiveSmallIntegerField(_("nombre de votes reçus"), default=0)
|
|
|
|
serializable_fields = ["text_fr", "text_en", "abbreviation"]
|
|
|
|
def save(self, *args, **kwargs):
|
|
# On enlève les espaces et on passe tout en majuscules
|
|
self.abbreviation = "".join(self.abbreviation.upper().split())
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
def get_abbr(self, default: str) -> str:
|
|
return self.abbreviation or default
|
|
|
|
def __str__(self) -> str:
|
|
if self.abbreviation:
|
|
return f"{self.abbreviation} - {self.text}"
|
|
return str(self.text)
|
|
|
|
class Meta:
|
|
ordering = ["id"]
|
|
|
|
|
|
class Vote(models.Model):
|
|
option = models.ForeignKey(Option, on_delete=models.CASCADE)
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True
|
|
)
|
|
pseudonymous_user = models.CharField(max_length=16, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["option"]
|
|
|
|
|
|
class RankedVote(Vote):
|
|
rank: "Rank"
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
class Rank(models.Model):
|
|
vote = models.OneToOneField(Vote, on_delete=models.CASCADE)
|
|
rank = models.PositiveSmallIntegerField(_("rang de l'option"))
|
|
|
|
class Meta:
|
|
ordering = ["vote"]
|
|
|
|
|
|
class Duel(models.Model):
|
|
question = models.ForeignKey(
|
|
Question, related_name="duels", on_delete=models.CASCADE
|
|
)
|
|
winner = models.ForeignKey(
|
|
Option, related_name="contests_won", on_delete=models.CASCADE
|
|
)
|
|
loser = models.ForeignKey(
|
|
Option, related_name="contests_lost", on_delete=models.CASCADE
|
|
)
|
|
amount = models.PositiveSmallIntegerField(_("votes supplémentaires"))
|
|
|
|
|
|
# #############################################################################
|
|
# Modification of the base User Model
|
|
# #############################################################################
|
|
|
|
|
|
class User(AbstractUser):
|
|
cast_elections: "ManyRelatedManager[Election]"
|
|
cast_questions: "ManyRelatedManager[Question]"
|
|
votes: "ManyRelatedManager[Vote]"
|
|
|
|
election = models.ForeignKey(
|
|
Election,
|
|
related_name="registered_voters",
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.CASCADE,
|
|
)
|
|
full_name = models.CharField(_("Nom et Prénom"), max_length=150, blank=True)
|
|
has_valid_email = models.BooleanField(_("email valide"), null=True, default=None)
|
|
|
|
@property
|
|
def base_username(self) -> str:
|
|
return "__".join(self.username.split("__")[1:])
|
|
|
|
def can_vote(self, request: HttpRequest, election: Election) -> bool:
|
|
# Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections
|
|
# ouvertes à tou·te·s
|
|
if self.election is None:
|
|
# If the user is connected via CAS, request.session["CASCONNECTED"] is set
|
|
# to True by authens
|
|
return not election.restricted and request.session.get(
|
|
"CASCONNECTED", False
|
|
)
|
|
|
|
# Pour les élections restreintes, il faut y être associé
|
|
return election.restricted and (self.election == election)
|
|
|
|
def is_admin(self, election: Election) -> bool:
|
|
return election.created_by == self or self.is_staff
|
|
|
|
def get_prefix(self) -> str:
|
|
return self.username.split("__")[0]
|
|
|
|
@property
|
|
def connection_method(self) -> "_StrPromise":
|
|
method = self.username.split("__")[0]
|
|
return CONNECTION_METHODS.get(method, _("identifiants spécifiques"))
|
|
|
|
class Meta:
|
|
ordering = ["username"]
|