Compare commits

...

3 commits

21 changed files with 267 additions and 126 deletions

View file

@ -31,6 +31,7 @@ in
(python3.withPackages (ps: [ (python3.withPackages (ps: [
ps.django ps.django
ps.ipython ps.ipython
ps.django-stubs
ps.markdown ps.markdown
ps.numpy ps.numpy

View file

@ -14,6 +14,9 @@ class ElectionForm(forms.ModelForm):
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
assert cleaned_data is not None
if cleaned_data["start_date"] < timezone.now(): if cleaned_data["start_date"] < timezone.now():
self.add_error( self.add_error(
"start_date", _("Impossible de faire débuter l'élection dans le passé") "start_date", _("Impossible de faire débuter l'élection dans le passé")

View file

@ -1,23 +1,32 @@
from typing import Any
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Q from django.db.models import Q, QuerySet
from django.http.request import HttpRequest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from elections.typing import AuthenticatedRequest
from .models import Election, Option, Question from .models import Election, Option, Question
class AdminOnlyMixin(PermissionRequiredMixin): class AdminOnlyMixin(PermissionRequiredMixin):
"""Restreint l'accès aux admins""" """Restreint l'accès aux admins"""
request: AuthenticatedRequest
permission_required = "elections.election_admin" permission_required = "elections.election_admin"
class SelectElectionMixin: class SelectElectionMixin:
"""Sélectionne automatiquement les foreignkeys voulues""" """Sélectionne automatiquement les foreignkeys voulues"""
def get_queryset(self): model: type
qs = super().get_queryset()
def get_queryset(self) -> QuerySet:
qs = super().get_queryset() # pyright: ignore
if self.model is Question: if self.model is Question:
return qs.select_related("election") return qs.select_related("election")
elif self.model is Option: elif self.model is Option:
@ -28,15 +37,19 @@ class SelectElectionMixin:
class RestrictAccessMixin(SelectElectionMixin): class RestrictAccessMixin(SelectElectionMixin):
"""Permet de restreindre l'accès à des élections/questions/options""" """Permet de restreindre l'accès à des élections/questions/options"""
f_prefixes = {Election: "", Question: "election__", Option: "question__election__"} f_prefixes = {
Election: "",
Question: "election__",
Option: "question__election__",
}
def get_f_prefix(self): def get_f_prefix(self) -> str:
return self.f_prefixes.get(self.model, None) return self.f_prefixes.get(self.model, "")
def get_filters(self): def get_filters(self) -> dict[str, Any]:
return {} return {}
def get_queryset(self): def get_queryset(self) -> QuerySet:
qs = super().get_queryset() qs = super().get_queryset()
if self.model in self.f_prefixes: if self.model in self.f_prefixes:
return qs.filter(**self.get_filters()) return qs.filter(**self.get_filters())
@ -47,7 +60,7 @@ class RestrictAccessMixin(SelectElectionMixin):
class OpenElectionOnlyMixin(RestrictAccessMixin): class OpenElectionOnlyMixin(RestrictAccessMixin):
"""N'autorise la vue que lorsque l'élection est ouverte""" """N'autorise la vue que lorsque l'élection est ouverte"""
def get_filters(self): def get_filters(self) -> dict[str, Any]:
f_prefix = self.get_f_prefix() f_prefix = self.get_f_prefix()
# On ne peut modifier que les élections qui n'ont pas commencé, et # On ne peut modifier que les élections qui n'ont pas commencé, et
# accessoirement qui ne sont pas dépouillées ou archivées # accessoirement qui ne sont pas dépouillées ou archivées
@ -67,7 +80,7 @@ class CreatorOnlyMixin(AdminOnlyMixin, RestrictAccessMixin, SingleObjectMixin):
def get_next_url(self): def get_next_url(self):
return reverse("kadenios") return reverse("kadenios")
def get_filters(self): def get_filters(self) -> dict[str, Any]:
filters = super().get_filters() filters = super().get_filters()
# TODO: change the way we collect the user according to the model used # TODO: change the way we collect the user according to the model used
filters[self.get_f_prefix() + "created_by"] = self.request.user filters[self.get_f_prefix() + "created_by"] = self.request.user
@ -77,7 +90,7 @@ class CreatorOnlyMixin(AdminOnlyMixin, RestrictAccessMixin, SingleObjectMixin):
class CreatorOnlyEditMixin(CreatorOnlyMixin): class CreatorOnlyEditMixin(CreatorOnlyMixin):
"""Permet au créateurice de modifier l'élection implicitement""" """Permet au créateurice de modifier l'élection implicitement"""
def get_filters(self): def get_filters(self) -> dict[str, Any]:
# On ne peut modifier que les élections qui n'ont pas commencé # On ne peut modifier que les élections qui n'ont pas commencé
filters = super().get_filters() filters = super().get_filters()
filters[self.get_f_prefix() + "start_date__gt"] = timezone.now() filters[self.get_f_prefix() + "start_date__gt"] = timezone.now()
@ -87,7 +100,7 @@ class CreatorOnlyEditMixin(CreatorOnlyMixin):
class ClosedElectionMixin(CreatorOnlyMixin): class ClosedElectionMixin(CreatorOnlyMixin):
"""Permet d'agir sur une élection terminée""" """Permet d'agir sur une élection terminée"""
def get_filters(self): def get_filters(self) -> dict[str, Any]:
f_prefix = self.get_f_prefix() f_prefix = self.get_f_prefix()
# L'élection doit être terminée et non archivée # L'élection doit être terminée et non archivée
filters = super().get_filters() filters = super().get_filters()
@ -102,9 +115,11 @@ class NotArchivedMixin:
ou dont on est l'admin ou dont on est l'admin
""" """
def get_queryset(self): request: HttpRequest
def get_queryset(self) -> QuerySet:
user = self.request.user user = self.request.user
qs = super().get_queryset() qs = super().get_queryset() # pyright: ignore
if user.is_authenticated: if user.is_authenticated:
return qs.filter(Q(archived=False, visible=True) | Q(created_by=user)) return qs.filter(Q(archived=False, visible=True) | Q(created_by=user))

View file

@ -1,8 +1,11 @@
from typing import TYPE_CHECKING
from translated_fields import TranslatedFieldWithFallback from translated_fields import TranslatedFieldWithFallback
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models, transaction from django.db import models, transaction
from django.http.request import HttpRequest
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -25,12 +28,20 @@ from .utils import (
ValidateFunctions, ValidateFunctions,
) )
if TYPE_CHECKING:
from django.db.models.fields.related_descriptors import ManyRelatedManager
from django.utils.functional import _StrPromise
# ############################################################################# # #############################################################################
# Models regarding an election # Models regarding an election
# ############################################################################# # #############################################################################
class Election(models.Model): class Election(models.Model):
registered_voters: models.Manager["User"]
questions: models.Manager["Question"]
name = TranslatedFieldWithFallback(models.CharField(_("nom"), max_length=255)) name = TranslatedFieldWithFallback(models.CharField(_("nom"), max_length=255))
short_name = models.SlugField(_("nom bref"), unique=True) short_name = models.SlugField(_("nom bref"), unique=True)
description = TranslatedFieldWithFallback( description = TranslatedFieldWithFallback(
@ -88,6 +99,9 @@ class Election(models.Model):
class Question(Serializer, models.Model): class Question(Serializer, models.Model):
options: models.Manager["Option"]
duels: models.Manager["Duel"]
election = models.ForeignKey( election = models.ForeignKey(
Election, related_name="questions", on_delete=models.CASCADE Election, related_name="questions", on_delete=models.CASCADE
) )
@ -113,22 +127,22 @@ class Question(Serializer, models.Model):
serializable_fields = ["text_en", "text_fr", "type"] serializable_fields = ["text_en", "text_fr", "type"]
def is_form_valid(self, vote_form): def is_form_valid(self, vote_form) -> bool:
validate_function = getattr(ValidateFunctions, VALIDATE_FUNCTIONS[self.type]) validate_function = getattr(ValidateFunctions, VALIDATE_FUNCTIONS[self.type])
return vote_form.is_valid() and validate_function(vote_form) return vote_form.is_valid() and validate_function(vote_form)
@transaction.atomic @transaction.atomic
def cast_ballot(self, user, vote_form): def cast_ballot(self, user: "User", vote_form) -> None:
cast_function = getattr(CastFunctions, CAST_FUNCTIONS[self.type]) cast_function = getattr(CastFunctions, CAST_FUNCTIONS[self.type])
cast_function(user, vote_form) cast_function(user, vote_form)
@transaction.atomic @transaction.atomic
def tally(self): def tally(self) -> None:
tally_function = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type]) tally_function = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type])
tally_function(self) tally_function(self)
@property @property
def results(self): def results(self) -> str:
return render_to_string( return render_to_string(
f"elections/results/{self.vote_type}_export.txt", {"question": self} f"elections/results/{self.vote_type}_export.txt", {"question": self}
) )
@ -150,8 +164,8 @@ class Question(Serializer, models.Model):
def vote_type(self): def vote_type(self):
return BALLOT_TYPE[self.type] return BALLOT_TYPE[self.type]
def __str__(self): def __str__(self) -> str:
return self.text return str(self.text)
class Meta: class Meta:
ordering = ["id"] ordering = ["id"]
@ -168,7 +182,7 @@ class Option(Serializer, models.Model):
voters = models.ManyToManyField( voters = models.ManyToManyField(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
related_name="votes", related_name="votes",
through="Vote", through="elections.Vote",
blank=True, blank=True,
) )
# For now, we store the amount of votes received after the election is tallied # For now, we store the amount of votes received after the election is tallied
@ -182,13 +196,13 @@ class Option(Serializer, models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_abbr(self, default): def get_abbr(self, default: str) -> str:
return self.abbreviation or default return self.abbreviation or default
def __str__(self): def __str__(self) -> str:
if self.abbreviation: if self.abbreviation:
return self.abbreviation + " - " + self.text return f"{self.abbreviation} - {self.text}"
return self.text return str(self.text)
class Meta: class Meta:
ordering = ["id"] ordering = ["id"]
@ -202,6 +216,10 @@ class Vote(models.Model):
ordering = ["option"] ordering = ["option"]
class RankedVote(Vote):
rank: "Rank"
class Rank(models.Model): class Rank(models.Model):
vote = models.OneToOneField(Vote, on_delete=models.CASCADE) vote = models.OneToOneField(Vote, on_delete=models.CASCADE)
rank = models.PositiveSmallIntegerField(_("rang de l'option")) rank = models.PositiveSmallIntegerField(_("rang de l'option"))
@ -229,6 +247,10 @@ class Duel(models.Model):
class User(AbstractUser): class User(AbstractUser):
cast_elections: "ManyRelatedManager[Election]"
cast_questions: "ManyRelatedManager[Question]"
votes: "ManyRelatedManager[Vote]"
election = models.ForeignKey( election = models.ForeignKey(
Election, Election,
related_name="registered_voters", related_name="registered_voters",
@ -240,28 +262,30 @@ class User(AbstractUser):
has_valid_email = models.BooleanField(_("email valide"), null=True, default=None) has_valid_email = models.BooleanField(_("email valide"), null=True, default=None)
@property @property
def base_username(self): def base_username(self) -> str:
return "__".join(self.username.split("__")[1:]) return "__".join(self.username.split("__")[1:])
def can_vote(self, request, election): def can_vote(self, request: HttpRequest, election: Election) -> bool:
# Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections # Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections
# ouvertes à tou·te·s # ouvertes à tou·te·s
if self.election is None: if self.election is None:
# If the user is connected via CAS, request.session["CASCONNECTED"] is set # If the user is connected via CAS, request.session["CASCONNECTED"] is set
# to True by authens # to True by authens
return not election.restricted and request.session.get("CASCONNECTED") return not election.restricted and request.session.get(
"CASCONNECTED", False
)
# Pour les élections restreintes, il faut y être associé # Pour les élections restreintes, il faut y être associé
return election.restricted and (self.election == election) return election.restricted and (self.election == election)
def is_admin(self, election): def is_admin(self, election: Election) -> bool:
return election.created_by == self or self.is_staff return election.created_by == self or self.is_staff
def get_prefix(self): def get_prefix(self) -> str:
return self.username.split("__")[0] return self.username.split("__")[0]
@property @property
def connection_method(self): def connection_method(self) -> "_StrPromise":
method = self.username.split("__")[0] method = self.username.split("__")[0]
return CONNECTION_METHODS.get(method, _("identifiants spécifiques")) return CONNECTION_METHODS.get(method, _("identifiants spécifiques"))

View file

@ -1,27 +1,24 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
MAIL_VOTERS = ( MAIL_VOTERS = """Dear {full_name},
"Dear {full_name},\n"
"\n"
"\n"
"Election URL: {election_url}\n"
"The election will take place from {start} to {end}.\n"
"\n"
"Your voter ID: {username}\n"
"Your password: {password}\n"
"\n"
"-- \n"
"Kadenios"
)
MAIL_VOTE_DELETED = ( Election URL: {election_url}
"Dear {full_name},\n" The election will take place from {start} to {end}.
"\n"
"Your vote for {election_name} has been removed." Your voter ID: {username}
"\n" Your password: {password}
"-- \n"
"Kadenios" --
) Kadenios
"""
MAIL_VOTE_DELETED = """Dear {full_name},
Your vote for {election_name} has been removed.
--
Kadenios
"""
QUESTION_TYPES = [ QUESTION_TYPES = [
("assentiment", _("Assentiment")), ("assentiment", _("Assentiment")),

View file

@ -5,7 +5,7 @@ from .utils import send_mail
@background @background
def send_election_mail(election_pk, subject, body, reply_to): def send_election_mail(election_pk: int, subject: str, body: str, reply_to: str):
election = Election.objects.get(pk=election_pk) election = Election.objects.get(pk=election_pk)
send_mail(election, subject, body, reply_to) send_mail(election, subject, body, reply_to)
election.sent_mail = True election.sent_mail = True

View file

@ -1,3 +1,5 @@
from typing import TYPE_CHECKING
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
@ -5,7 +7,10 @@ from django.utils.translation import gettext_lazy as _
from .test_utils import create_election from .test_utils import create_election
User = get_user_model() if TYPE_CHECKING:
from elections.typing import User
else:
User = get_user_model()
class UserTests(TestCase): class UserTests(TestCase):
@ -40,8 +45,11 @@ class UserTests(TestCase):
session["CASCONNECTED"] = True session["CASCONNECTED"] = True
session.save() session.save()
assert session.session_key is not None
# On sauvegarde le cookie de session # On sauvegarde le cookie de session
session_cookie_name = settings.SESSION_COOKIE_NAME session_cookie_name = settings.SESSION_COOKIE_NAME
self.client.cookies[session_cookie_name] = session.session_key self.client.cookies[session_cookie_name] = session.session_key
self.assertFalse(self.cas_user.can_vote(self.client, self.election_1)) self.assertFalse(self.cas_user.can_vote(self.client, self.election_1))

View file

@ -1,11 +1,16 @@
from django.contrib.auth import get_user_model from typing import TYPE_CHECKING
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from .test_utils import create_election from .test_utils import create_election
User = get_user_model() if TYPE_CHECKING:
from elections.typing import User
else:
from django.contrib.auth import get_user_model
User = get_user_model()
class AdminViewsTest(TestCase): class AdminViewsTest(TestCase):

7
elections/typing.py Normal file
View file

@ -0,0 +1,7 @@
from django.http.request import HttpRequest
from elections.models import User
class AuthenticatedRequest(HttpRequest):
user: User

View file

@ -1,31 +1,46 @@
import csv import csv
import io import io
import smtplib import smtplib
from typing import TYPE_CHECKING, TypeGuard
import networkx as nx import networkx as nx
import numpy as np import numpy as np
from networkx.algorithms.dag import ancestors, descendants from networkx.algorithms.dag import ancestors, descendants
from numpy._typing import NDArray
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.base import File
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.core.validators import validate_email from django.core.validators import validate_email
from django.forms import BaseFormSet
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from shared.auth.utils import generate_password from shared.auth.utils import generate_password
if TYPE_CHECKING:
from elections.forms import RankVoteForm, SelectVoteForm
from elections.models import Election, Question, RankedVote, Vote
from elections.typing import User
# ############################################################################# # #############################################################################
# Classes pour différencier les différents types de questions # Classes pour différencier les différents types de questions
# ############################################################################# # #############################################################################
def has_rank(v: "Vote") -> TypeGuard["RankedVote"]:
return hasattr(v, "rank")
class CastFunctions: class CastFunctions:
"""Classe pour enregistrer les votes""" """Classe pour enregistrer les votes"""
def cast_select(user, vote_form): @staticmethod
def cast_select(user: "User", vote_form: "BaseFormSet[SelectVoteForm]"):
"""On enregistre un vote classique""" """On enregistre un vote classique"""
selected, n_selected = [], [] selected, n_selected = [], []
for v in vote_form: for v in vote_form:
@ -37,7 +52,8 @@ class CastFunctions:
user.votes.add(*selected) user.votes.add(*selected)
user.votes.remove(*n_selected) user.votes.remove(*n_selected)
def cast_rank(user, vote_form): @staticmethod
def cast_rank(user: "User", vote_form: "BaseFormSet[RankVoteForm]"):
"""On enregistre un vote par classement""" """On enregistre un vote par classement"""
from .models import Rank, Vote from .models import Rank, Vote
@ -53,7 +69,8 @@ class CastFunctions:
for v in vote_form: for v in vote_form:
vote = votes[v.instance] vote = votes[v.instance]
if hasattr(vote, "rank"):
if has_rank(vote):
vote.rank.rank = v.cleaned_data["rank"] vote.rank.rank = v.cleaned_data["rank"]
ranks_update.append(vote.rank) ranks_update.append(vote.rank)
else: else:
@ -66,7 +83,8 @@ class CastFunctions:
class TallyFunctions: class TallyFunctions:
"""Classe pour gérer les dépouillements""" """Classe pour gérer les dépouillements"""
def tally_select(question): @staticmethod
def tally_select(question: "Question") -> None:
"""On dépouille un vote classique""" """On dépouille un vote classique"""
from .models import Option from .models import Option
@ -86,7 +104,8 @@ class TallyFunctions:
Option.objects.bulk_update(options, ["nb_votes", "winner"]) Option.objects.bulk_update(options, ["nb_votes", "winner"])
def tally_schultze(question): @staticmethod
def tally_schultze(question: "Question") -> None:
"""On dépouille un vote par classement et on crée la matrice des duels""" """On dépouille un vote par classement et on crée la matrice des duels"""
from .models import Duel, Option, Rank from .models import Duel, Option, Rank
@ -102,12 +121,12 @@ class TallyFunctions:
else: else:
ranks_by_user[user] = [r] ranks_by_user[user] = [r]
ballots = [] ballots: list[NDArray[np.int_]] = []
# Pour chaque votant·e, on regarde son classement # Pour chaque votant·e, on regarde son classement
for user in ranks_by_user: for user in ranks_by_user:
votes = ranks_by_user[user] votes = ranks_by_user[user]
ballot = np.zeros((nb_options, nb_options)) ballot = np.zeros((nb_options, nb_options), dtype=int)
for i in range(nb_options): for i in range(nb_options):
for j in range(i): for j in range(i):
@ -121,6 +140,9 @@ class TallyFunctions:
# des duels # des duels
duels = sum(ballots) duels = sum(ballots)
# As ballots is not empty, sum cannot be 0
assert duels != 0
# Configuration du graphe # Configuration du graphe
graph = nx.DiGraph() graph = nx.DiGraph()
@ -163,11 +185,11 @@ class TallyFunctions:
# le plus faible # le plus faible
min_weight = min(nx.get_edge_attributes(graph, "weight").values()) min_weight = min(nx.get_edge_attributes(graph, "weight").values())
min_edges = [] min_edges = []
for (u, v) in graph.edges(): for u, v in graph.edges():
if graph[u][v]["weight"] == min_weight: if graph[u][v]["weight"] == min_weight:
min_edges.append((u, v)) min_edges.append((u, v))
for (u, v) in min_edges: for u, v in min_edges:
graph.remove_edge(u, v) graph.remove_edge(u, v)
# Les options gagnantes sont celles encore présentes dans le graphe # Les options gagnantes sont celles encore présentes dans le graphe
@ -181,29 +203,31 @@ class TallyFunctions:
class ValidateFunctions: class ValidateFunctions:
"""Classe pour valider les formsets selon le type de question""" """Classe pour valider les formsets selon le type de question"""
def always_true(vote_form): @staticmethod
"""Retourne True pour les votes sans validation particulière""" def always_true(_) -> bool:
"""Renvoie True pour les votes sans validation particulière"""
return True return True
def unique_selected(vote_form): @staticmethod
def unique_selected(vote_form: "BaseFormSet[SelectVoteForm]") -> bool:
"""Vérifie qu'une seule option est choisie""" """Vérifie qu'une seule option est choisie"""
nb_selected = 0
for v in vote_form: nb_selected = sum(v.cleaned_data["selected"] for v in vote_form)
nb_selected += v.cleaned_data["selected"]
if nb_selected == 0: if nb_selected == 0:
vote_form._non_form_errors.append( vote_form._non_form_errors.append( # pyright: ignore
ValidationError(_("Vous devez sélectionnner une option.")) ValidationError(_("Vous devez sélectionnner une option."))
) )
return False return False
elif nb_selected > 1: elif nb_selected > 1:
vote_form._non_form_errors.append( vote_form._non_form_errors.append( # pyright: ignore
ValidationError(_("Vous ne pouvez pas sélectionner plus d'une option.")) ValidationError(_("Vous ne pouvez pas sélectionner plus d'une option."))
) )
return False return False
return True return True
def limit_ranks(vote_form): @staticmethod
def limit_ranks(vote_form: "BaseFormSet[RankVoteForm]"):
"""Limite le classement au nombre d'options""" """Limite le classement au nombre d'options"""
nb_options = len(vote_form) nb_options = len(vote_form)
valid = True valid = True
@ -229,11 +253,13 @@ class ValidateFunctions:
class ResultsData: class ResultsData:
"""Classe pour afficher des informations supplémentaires après la fin d'une élection""" """Classe pour afficher des informations supplémentaires après la fin d'une élection"""
def select(question): @staticmethod
def select(_: "Question") -> str:
"""On renvoie l'explication des couleurs""" """On renvoie l'explication des couleurs"""
return render_to_string("elections/results/select.html") return render_to_string("elections/results/select.html")
def rank(question): @staticmethod
def rank(question: "Question") -> str:
"""On récupère la matrice des résultats et on l'affiche""" """On récupère la matrice des résultats et on l'affiche"""
duels = question.duels.all() duels = question.duels.all()
options = list(question.options.all()) options = list(question.options.all())
@ -270,7 +296,8 @@ class ResultsData:
class BallotsData: class BallotsData:
"""Classe pour afficher les bulletins d'une question""" """Classe pour afficher les bulletins d'une question"""
def select(question): @staticmethod
def select(question: "Question") -> str:
"""Renvoie un tableau affichant les options sélectionnées pour chaque bulletin""" """Renvoie un tableau affichant les options sélectionnées pour chaque bulletin"""
from .models import Vote from .models import Vote
@ -290,7 +317,8 @@ class BallotsData:
{"options": options, "ballots": sorted(ballots.values(), reverse=True)}, {"options": options, "ballots": sorted(ballots.values(), reverse=True)},
) )
def rank(question): @staticmethod
def rank(question: "Question") -> str:
"""Renvoie un tableau contenant les classements des options par bulletin""" """Renvoie un tableau contenant les classements des options par bulletin"""
from .models import Rank from .models import Rank
@ -318,7 +346,7 @@ class BallotsData:
# ############################################################################# # #############################################################################
def create_users(election, csv_file): def create_users(election: "Election", csv_file: File):
"""Crée les votant·e·s pour l'élection donnée, en remplissant les champs """Crée les votant·e·s pour l'élection donnée, en remplissant les champs
`username`, `election` et `full_name`. `username`, `election` et `full_name`.
""" """
@ -331,7 +359,7 @@ def create_users(election, csv_file):
users = [ users = [
User( User(
election=election, election=election,
username=f"{election.id}__{username}", username=f"{election.pk}__{username}",
email=email, email=email,
full_name=full_name, full_name=full_name,
) )
@ -341,7 +369,7 @@ def create_users(election, csv_file):
User.objects.bulk_create(users) User.objects.bulk_create(users)
def check_csv(csv_file): def check_csv(csv_file: File):
"""Vérifie que le fichier donnant la liste de votant·e·s est bien formé""" """Vérifie que le fichier donnant la liste de votant·e·s est bien formé"""
try: try:
dialect = csv.Sniffer().sniff(csv_file.readline().decode("utf-8")) dialect = csv.Sniffer().sniff(csv_file.readline().decode("utf-8"))
@ -394,15 +422,14 @@ def check_csv(csv_file):
return errors return errors
def send_mail(election, subject, body, reply_to): def send_mail(election: "Election", subject: str, body: str, reply_to: str) -> None:
"""Envoie le mail d'annonce de l'élection avec identifiants et mot de passe """Envoie le mail d'annonce de l'élection avec identifiants et mot de passe
aux votant·e·s, le mdp est généré en même temps que le mail est envoyé. aux votant·e·s, le mdp est généré en même temps que le mail est envoyé.
""" """
User = get_user_model()
# On n'envoie le mail qu'aux personnes qui n'en n'ont pas déjà reçu un # On n'envoie le mail qu'aux personnes qui n'en n'ont pas déjà reçu un
voters = list(election.registered_voters.exclude(has_valid_email=True)) voters = list(election.registered_voters.exclude(has_valid_email=True))
e_url = reverse("election.view", args=[election.id]) e_url = reverse("election.view", args=[election.pk])
url = f"https://vote.eleves.ens.fr{e_url}" url = f"https://vote.eleves.ens.fr{e_url}"
start = election.start_date.strftime("%d/%m/%Y %H:%M %Z") start = election.start_date.strftime("%d/%m/%Y %H:%M %Z")
end = election.end_date.strftime("%d/%m/%Y %H:%M %Z") end = election.end_date.strftime("%d/%m/%Y %H:%M %Z")
@ -431,12 +458,11 @@ def send_mail(election, subject, body, reply_to):
) )
) )
for (m, v) in messages: for m, v in messages:
try: try:
m.send() m.send()
v.has_valid_email = True
except smtplib.SMTPException: except smtplib.SMTPException:
v.has_valid_email = False v.has_valid_email = False
else:
v.has_valid_email = True
User.objects.bulk_update(voters, ["password", "has_valid_email"]) v.save()

View file

@ -1,7 +1,7 @@
import csv import csv
from typing import TYPE_CHECKING
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.db import transaction from django.db import transaction
@ -19,6 +19,7 @@ from django.views.generic import (
View, View,
) )
from elections.typing import AuthenticatedRequest
from shared.json import JsonCreateView, JsonDeleteView, JsonUpdateView from shared.json import JsonCreateView, JsonDeleteView, JsonUpdateView
from shared.views import BackgroundUpdateView, TimeMixin from shared.views import BackgroundUpdateView, TimeMixin
@ -43,7 +44,11 @@ from .staticdefs import MAIL_VOTE_DELETED, MAIL_VOTERS, QUESTION_TYPES, VOTE_RUL
from .tasks import send_election_mail from .tasks import send_election_mail
from .utils import create_users from .utils import create_users
User = get_user_model() if TYPE_CHECKING:
from elections.typing import User
else:
from django.contrib.auth import get_user_model
User = get_user_model()
# TODO: access control *everywhere* # TODO: access control *everywhere*
@ -53,6 +58,8 @@ User = get_user_model()
class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView): class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
object: Election
model = Election model = Election
form_class = ElectionForm form_class = ElectionForm
success_message = _("Élection créée avec succès !") success_message = _("Élection créée avec succès !")
@ -61,7 +68,7 @@ class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
def get_success_url(self): def get_success_url(self):
return reverse("election.admin", args=[self.object.pk]) return reverse("election.admin", args=[self.object.pk])
def form_valid(self, form): def form_valid(self, form: ElectionForm):
# We need to add the short name and the creator od the election # We need to add the short name and the creator od the election
form.instance.short_name = slugify( form.instance.short_name = slugify(
form.instance.start_date.strftime("%Y-%m-%d") + "_" + form.instance.name form.instance.start_date.strftime("%Y-%m-%d") + "_" + form.instance.name
@ -75,11 +82,11 @@ class ElectionDeleteView(CreatorOnlyMixin, BackgroundUpdateView):
model = Election model = Election
pattern_name = "election.list" pattern_name = "election.list"
def get_object(self, queryset=None): def get_object(self):
obj = self.get_object() obj: Election = super().get_object()
# On ne peut supprimer que les élections n'ayant pas eu de vote et dont # On ne peut supprimer que les élections n'ayant pas eu de vote et dont
# le mail d'annonce n'a pas été fait # le mail d'annonce n'a pas été fait
if obj.voters.exists() or obj.send_election_mail: if obj.voters.exists() or obj.sent_mail:
raise Http404 raise Http404
return obj return obj
@ -89,6 +96,8 @@ class ElectionDeleteView(CreatorOnlyMixin, BackgroundUpdateView):
class ElectionAdminView(CreatorOnlyMixin, TimeMixin, DetailView): class ElectionAdminView(CreatorOnlyMixin, TimeMixin, DetailView):
object: Election
model = Election model = Election
template_name = "elections/election_admin.html" template_name = "elections/election_admin.html"
@ -115,7 +124,7 @@ class ElectionSetVisibleView(CreatorOnlyMixin, BackgroundUpdateView):
success_message = _("Élection visible !") success_message = _("Élection visible !")
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.election = self.get_object() self.election: Election = self.get_object()
self.election.visible = True self.election.visible = True
self.election.save() self.election.save()
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
@ -232,6 +241,8 @@ class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView):
class DeleteVoteView(ClosedElectionMixin, JsonDeleteView): class DeleteVoteView(ClosedElectionMixin, JsonDeleteView):
voter: User
model = Election model = Election
def get_message(self): def get_message(self):
@ -416,7 +427,7 @@ class ElectionView(NotArchivedMixin, DetailView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["current_time"] = timezone.now() context["current_time"] = timezone.now()
if user.is_authenticated: if user.is_authenticated and isinstance(user, User):
context["can_vote"] = user.can_vote(self.request, context["election"]) context["can_vote"] = user.can_vote(self.request, context["election"])
context["cast_questions"] = user.cast_questions.all() context["cast_questions"] = user.cast_questions.all()
context["has_voted"] = user.cast_elections.filter( context["has_voted"] = user.cast_elections.filter(
@ -444,7 +455,7 @@ class ElectionVotersView(NotArchivedMixin, DetailView):
election = context["election"] election = context["election"]
voters = list(election.voters.all()) voters = list(election.voters.all())
if user.is_authenticated: if user.is_authenticated and isinstance(user, User):
context["can_vote"] = user.can_vote(self.request, context["election"]) context["can_vote"] = user.can_vote(self.request, context["election"])
context["is_admin"] = user.is_admin(election) context["is_admin"] = user.is_admin(election)
can_delete = ( can_delete = (
@ -476,6 +487,8 @@ class ElectionBallotsView(NotArchivedMixin, DetailView):
class VoteView(OpenElectionOnlyMixin, DetailView): class VoteView(OpenElectionOnlyMixin, DetailView):
request: AuthenticatedRequest
model = Question model = Question
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):

3
pyproject.toml Normal file
View file

@ -0,0 +1,3 @@
[tool.pyright]
reportIncompatibleMethodOverride = false
reportIncompatibleVariableOverride = false

View file

@ -1,3 +1,5 @@
from .staticdefs import CONNECTION_METHODS from .staticdefs import CONNECTION_METHODS
__all__ = [CONNECTION_METHODS] __all__ = [
"CONNECTION_METHODS",
]

View file

@ -1,10 +1,15 @@
from typing import TYPE_CHECKING
from authens.backends import ENSCASBackend from authens.backends import ENSCASBackend
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
User = get_user_model() if TYPE_CHECKING:
from elections.typing import User
else:
from django.contrib.auth import get_user_model
User = get_user_model()
class CASBackend(ENSCASBackend): class CASBackend(ENSCASBackend):

View file

@ -1,11 +1,16 @@
from typing import TYPE_CHECKING
from django import forms from django import forms
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from django.contrib.auth import forms as auth_forms from django.contrib.auth import forms as auth_forms
from django.contrib.auth import get_user_model
from django.core.validators import validate_email from django.core.validators import validate_email
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
User = get_user_model() if TYPE_CHECKING:
from elections.typing import User
else:
from django.contrib.auth import get_user_model
User = get_user_model()
class ElectionAuthForm(forms.Form): class ElectionAuthForm(forms.Form):

View file

@ -4,12 +4,10 @@ import random
# Fonctions universelles # Fonctions universelles
# ############################################################################# # #############################################################################
alphabet = "abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
def generate_password(size=15): def generate_password(size=15):
random.seed() random.seed()
alphabet = "abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
password = ""
for i in range(size):
password += random.choice(alphabet)
return password return "".join(random.choice(alphabet) for _ in range(size))

View file

@ -1,17 +1,25 @@
from django.contrib.auth import get_user_model from typing import TYPE_CHECKING
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.db.models import QuerySet
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, FormView, ListView, TemplateView from django.views.generic import CreateView, FormView, ListView, TemplateView
from elections.typing import AuthenticatedRequest
from .forms import ElectionAuthForm, PwdUserForm, UserAdminForm from .forms import ElectionAuthForm, PwdUserForm, UserAdminForm
from .utils import generate_password from .utils import generate_password
User = get_user_model() if TYPE_CHECKING:
from elections.typing import User
else:
from django.contrib.auth import get_user_model
User = get_user_model()
# ############################################################################# # #############################################################################
@ -25,6 +33,8 @@ class StaffMemberMixin(UserPassesTestMixin):
n'est pas connectée, renvoie sur la page d'authentification n'est pas connectée, renvoie sur la page d'authentification
""" """
request: AuthenticatedRequest
def test_func(self): def test_func(self):
return self.request.user.is_active and self.request.user.is_staff return self.request.user.is_active and self.request.user.is_staff
@ -85,7 +95,7 @@ class AccountListView(StaffMemberMixin, ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
qs = self.get_queryset() qs: QuerySet = self.get_queryset() # pyright: ignore
ctx["cas_users"] = qs.filter(username__startswith="cas__") ctx["cas_users"] = qs.filter(username__startswith="cas__")
ctx["pwd_users"] = qs.filter(username__startswith="pwd__") ctx["pwd_users"] = qs.filter(username__startswith="pwd__")
@ -143,16 +153,16 @@ class PermissionManagementView(StaffMemberMixin, SuccessMessageMixin, FormView):
# Election admin # Election admin
election_perm = Permission.objects.get(codename="election_admin") election_perm = Permission.objects.get(codename="election_admin")
if form.cleaned_data["election_admin"]: if form.cleaned_data["election_admin"]:
election_perm.user_set.add(user) election_perm.user_set.add(user) # pyright: ignore
else: else:
election_perm.user_set.remove(user) election_perm.user_set.remove(user) # pyright: ignore
# FAQ admin # FAQ admin
faq_perm = Permission.objects.get(codename="faq_admin") faq_perm = Permission.objects.get(codename="faq_admin")
if form.cleaned_data["faq_admin"]: if form.cleaned_data["faq_admin"]:
faq_perm.user_set.add(user) faq_perm.user_set.add(user) # pyright: ignore
else: else:
faq_perm.user_set.remove(user) faq_perm.user_set.remove(user) # pyright: ignore
user.save() user.save()
return super().form_valid(form) return super().form_valid(form)

View file

@ -1,4 +1,7 @@
from typing import Any
from django.http import JsonResponse from django.http import JsonResponse
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.views.generic.base import TemplateResponseMixin, View from django.views.generic.base import TemplateResponseMixin, View
@ -48,10 +51,17 @@ class JsonMessageMixin:
def get_data(self, **kwargs): def get_data(self, **kwargs):
kwargs.update(message=self.get_message()) kwargs.update(message=self.get_message())
return super().get_data(**kwargs) return super().get_data(**kwargs) # pyright: ignore
class JsonDetailView(JsonMixin, SingleObjectMixin, TemplateResponseMixin, View): class TypedResponseMixin(TemplateResponseMixin):
def render_to_response(
self, context: dict[str, Any], **response_kwargs: Any
) -> TemplateResponse:
return super().render_to_response(context, **response_kwargs) # pyright: ignore
class JsonDetailView(JsonMixin, SingleObjectMixin, TypedResponseMixin, View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
context = self.get_context_data(object=self.object) context = self.get_context_data(object=self.object)
@ -69,7 +79,7 @@ class JsonDeleteView(JsonMessageMixin, JsonDetailView):
@method_decorator(require_POST, name="dispatch") @method_decorator(require_POST, name="dispatch")
class JsonCreateView( class JsonCreateView(
JsonMessageMixin, JsonModelFormMixin, TemplateResponseMixin, ProcessFormView JsonMessageMixin, JsonModelFormMixin, TypedResponseMixin, ProcessFormView
): ):
def render_to_json(self, **kwargs): def render_to_json(self, **kwargs):
context = self.get_context_data(object=self.object) context = self.get_context_data(object=self.object)
@ -81,7 +91,7 @@ class JsonCreateView(
@method_decorator(require_POST, name="dispatch") @method_decorator(require_POST, name="dispatch")
class JsonUpdateView( class JsonUpdateView(
JsonMessageMixin, JsonModelFormMixin, TemplateResponseMixin, ProcessFormView JsonMessageMixin, JsonModelFormMixin, TypedResponseMixin, ProcessFormView
): ):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()

View file

@ -1,9 +1,14 @@
from typing import TYPE_CHECKING
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
User = get_user_model() if TYPE_CHECKING:
from elections.typing import User
else:
User = get_user_model()
class Command(BaseCommand): class Command(BaseCommand):
@ -38,5 +43,11 @@ class Command(BaseCommand):
user.save() user.save()
Permission.objects.get(codename="election_admin").user_set.add(user) Permission.objects.get(
Permission.objects.get(codename="faq_admin").user_set.add(user) codename="election_admin"
).user_set.add( # pyright: ignore
user
)
Permission.objects.get(codename="faq_admin").user_set.add( # pyright: ignore
user
)

View file

@ -5,7 +5,5 @@
def choices_length(choices): def choices_length(choices):
"""Renvoie la longueur maximale des choix de choices""" """Renvoie la longueur maximale des choix de choices"""
m = 0
for c in choices: return max(len(c[0]) for c in choices)
m = max(m, len(c[0]))
return m

View file

@ -23,4 +23,4 @@ class BackgroundUpdateView(RedirectView):
class TimeMixin: class TimeMixin:
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs.update(current_time=timezone.now()) kwargs.update(current_time=timezone.now())
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs) # pyright: ignore