diff --git a/default.nix b/default.nix index eed74c2..f8cac0d 100644 --- a/default.nix +++ b/default.nix @@ -31,6 +31,7 @@ in (python3.withPackages (ps: [ ps.django ps.ipython + ps.django-stubs ps.markdown ps.numpy diff --git a/elections/forms.py b/elections/forms.py index df19573..5073819 100644 --- a/elections/forms.py +++ b/elections/forms.py @@ -14,6 +14,9 @@ class ElectionForm(forms.ModelForm): def clean(self): cleaned_data = super().clean() + + assert cleaned_data is not None + if cleaned_data["start_date"] < timezone.now(): self.add_error( "start_date", _("Impossible de faire débuter l'élection dans le passé") diff --git a/elections/mixins.py b/elections/mixins.py index 2b243bb..82bc25e 100644 --- a/elections/mixins.py +++ b/elections/mixins.py @@ -1,23 +1,32 @@ +from typing import Any + 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.utils import timezone from django.views.generic.detail import SingleObjectMixin +from elections.typing import AuthenticatedRequest + from .models import Election, Option, Question class AdminOnlyMixin(PermissionRequiredMixin): """Restreint l'accès aux admins""" + request: AuthenticatedRequest + permission_required = "elections.election_admin" class SelectElectionMixin: """Sélectionne automatiquement les foreignkeys voulues""" - def get_queryset(self): - qs = super().get_queryset() + model: type + + def get_queryset(self) -> QuerySet: + qs = super().get_queryset() # pyright: ignore if self.model is Question: return qs.select_related("election") elif self.model is Option: @@ -28,15 +37,19 @@ class SelectElectionMixin: class RestrictAccessMixin(SelectElectionMixin): """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): - return self.f_prefixes.get(self.model, None) + def get_f_prefix(self) -> str: + return self.f_prefixes.get(self.model, "") - def get_filters(self): + def get_filters(self) -> dict[str, Any]: return {} - def get_queryset(self): + def get_queryset(self) -> QuerySet: qs = super().get_queryset() if self.model in self.f_prefixes: return qs.filter(**self.get_filters()) @@ -47,7 +60,7 @@ class RestrictAccessMixin(SelectElectionMixin): class OpenElectionOnlyMixin(RestrictAccessMixin): """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() # On ne peut modifier que les élections qui n'ont pas commencé, et # 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): return reverse("kadenios") - def get_filters(self): + def get_filters(self) -> dict[str, Any]: filters = super().get_filters() # TODO: change the way we collect the user according to the model used filters[self.get_f_prefix() + "created_by"] = self.request.user @@ -77,7 +90,7 @@ class CreatorOnlyMixin(AdminOnlyMixin, RestrictAccessMixin, SingleObjectMixin): class CreatorOnlyEditMixin(CreatorOnlyMixin): """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é filters = super().get_filters() filters[self.get_f_prefix() + "start_date__gt"] = timezone.now() @@ -87,7 +100,7 @@ class CreatorOnlyEditMixin(CreatorOnlyMixin): class ClosedElectionMixin(CreatorOnlyMixin): """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() # L'élection doit être terminée et non archivée filters = super().get_filters() @@ -102,9 +115,11 @@ class NotArchivedMixin: ou dont on est l'admin """ - def get_queryset(self): + request: HttpRequest + + def get_queryset(self) -> QuerySet: user = self.request.user - qs = super().get_queryset() + qs = super().get_queryset() # pyright: ignore if user.is_authenticated: return qs.filter(Q(archived=False, visible=True) | Q(created_by=user)) diff --git a/elections/models.py b/elections/models.py index 4d1bab8..7722717 100644 --- a/elections/models.py +++ b/elections/models.py @@ -1,8 +1,11 @@ +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 _ @@ -25,12 +28,20 @@ from .utils import ( 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( @@ -88,6 +99,9 @@ class Election(models.Model): class Question(Serializer, models.Model): + options: models.Manager["Option"] + duels: models.Manager["Duel"] + election = models.ForeignKey( Election, related_name="questions", on_delete=models.CASCADE ) @@ -113,22 +127,22 @@ class Question(Serializer, models.Model): 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]) return vote_form.is_valid() and validate_function(vote_form) @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(user, vote_form) @transaction.atomic - def tally(self): + def tally(self) -> None: tally_function = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type]) tally_function(self) @property - def results(self): + def results(self) -> str: return render_to_string( f"elections/results/{self.vote_type}_export.txt", {"question": self} ) @@ -150,8 +164,8 @@ class Question(Serializer, models.Model): def vote_type(self): return BALLOT_TYPE[self.type] - def __str__(self): - return self.text + def __str__(self) -> str: + return str(self.text) class Meta: ordering = ["id"] @@ -168,7 +182,7 @@ class Option(Serializer, models.Model): voters = models.ManyToManyField( settings.AUTH_USER_MODEL, related_name="votes", - through="Vote", + through="elections.Vote", blank=True, ) # 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) - def get_abbr(self, default): + def get_abbr(self, default: str) -> str: return self.abbreviation or default - def __str__(self): + def __str__(self) -> str: if self.abbreviation: - return self.abbreviation + " - " + self.text - return self.text + return f"{self.abbreviation} - {self.text}" + return str(self.text) class Meta: ordering = ["id"] @@ -202,6 +216,10 @@ class Vote(models.Model): ordering = ["option"] +class RankedVote(Vote): + rank: "Rank" + + class Rank(models.Model): vote = models.OneToOneField(Vote, on_delete=models.CASCADE) rank = models.PositiveSmallIntegerField(_("rang de l'option")) @@ -229,6 +247,10 @@ class Duel(models.Model): class User(AbstractUser): + cast_elections: "ManyRelatedManager[Election]" + cast_questions: "ManyRelatedManager[Question]" + votes: "ManyRelatedManager[Vote]" + election = models.ForeignKey( Election, related_name="registered_voters", @@ -240,28 +262,30 @@ class User(AbstractUser): has_valid_email = models.BooleanField(_("email valide"), null=True, default=None) @property - def base_username(self): + def base_username(self) -> str: 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 # 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") + 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): + def is_admin(self, election: Election) -> bool: return election.created_by == self or self.is_staff - def get_prefix(self): + def get_prefix(self) -> str: return self.username.split("__")[0] @property - def connection_method(self): + def connection_method(self) -> "_StrPromise": method = self.username.split("__")[0] return CONNECTION_METHODS.get(method, _("identifiants spécifiques")) diff --git a/elections/tasks.py b/elections/tasks.py index 21f6b80..d6b94bf 100644 --- a/elections/tasks.py +++ b/elections/tasks.py @@ -5,7 +5,7 @@ from .utils import send_mail @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) send_mail(election, subject, body, reply_to) election.sent_mail = True diff --git a/elections/tests/test_models.py b/elections/tests/test_models.py index c75ca26..321ae0f 100644 --- a/elections/tests/test_models.py +++ b/elections/tests/test_models.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + from django.conf import settings from django.contrib.auth import get_user_model from django.test import TestCase @@ -5,7 +7,10 @@ from django.utils.translation import gettext_lazy as _ 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): @@ -40,8 +45,11 @@ class UserTests(TestCase): session["CASCONNECTED"] = True session.save() + assert session.session_key is not None + # On sauvegarde le cookie de session session_cookie_name = settings.SESSION_COOKIE_NAME + self.client.cookies[session_cookie_name] = session.session_key self.assertFalse(self.cas_user.can_vote(self.client, self.election_1)) diff --git a/elections/tests/test_views.py b/elections/tests/test_views.py index cc1bedb..6a05bc5 100644 --- a/elections/tests/test_views.py +++ b/elections/tests/test_views.py @@ -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.test import TestCase from django.urls import reverse 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): diff --git a/elections/typing.py b/elections/typing.py new file mode 100644 index 0000000..1e25117 --- /dev/null +++ b/elections/typing.py @@ -0,0 +1,7 @@ +from django.http.request import HttpRequest + +from elections.models import User + + +class AuthenticatedRequest(HttpRequest): + user: User diff --git a/elections/utils.py b/elections/utils.py index 346060c..722372a 100644 --- a/elections/utils.py +++ b/elections/utils.py @@ -1,31 +1,46 @@ import csv import io import smtplib +from typing import TYPE_CHECKING, TypeGuard import networkx as nx import numpy as np from networkx.algorithms.dag import ancestors, descendants +from numpy._typing import NDArray from django.contrib.auth import get_user_model from django.contrib.auth.hashers import make_password from django.core.exceptions import ValidationError +from django.core.files.base import File from django.core.mail import EmailMessage from django.core.validators import validate_email +from django.forms import BaseFormSet from django.template.loader import render_to_string from django.urls import reverse from django.utils.translation import gettext_lazy as _ 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 # ############################################################################# +def has_rank(v: "Vote") -> TypeGuard["RankedVote"]: + return hasattr(v, "rank") + + class CastFunctions: """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""" selected, n_selected = [], [] for v in vote_form: @@ -37,7 +52,8 @@ class CastFunctions: user.votes.add(*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""" from .models import Rank, Vote @@ -53,7 +69,8 @@ class CastFunctions: for v in vote_form: vote = votes[v.instance] - if hasattr(vote, "rank"): + + if has_rank(vote): vote.rank.rank = v.cleaned_data["rank"] ranks_update.append(vote.rank) else: @@ -66,7 +83,8 @@ class CastFunctions: class TallyFunctions: """Classe pour gérer les dépouillements""" - def tally_select(question): + @staticmethod + def tally_select(question: "Question") -> None: """On dépouille un vote classique""" from .models import Option @@ -86,7 +104,8 @@ class TallyFunctions: 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""" from .models import Duel, Option, Rank @@ -102,12 +121,12 @@ class TallyFunctions: else: ranks_by_user[user] = [r] - ballots = [] + ballots: list[NDArray[np.int_]] = [] # Pour chaque votant·e, on regarde son classement for user in ranks_by_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 j in range(i): @@ -121,6 +140,9 @@ class TallyFunctions: # des duels duels = sum(ballots) + # As ballots is not empty, sum cannot be 0 + assert duels != 0 + # Configuration du graphe graph = nx.DiGraph() @@ -163,11 +185,11 @@ class TallyFunctions: # le plus faible min_weight = min(nx.get_edge_attributes(graph, "weight").values()) min_edges = [] - for (u, v) in graph.edges(): + for u, v in graph.edges(): if graph[u][v]["weight"] == min_weight: min_edges.append((u, v)) - for (u, v) in min_edges: + for u, v in min_edges: graph.remove_edge(u, v) # Les options gagnantes sont celles encore présentes dans le graphe @@ -181,29 +203,31 @@ class TallyFunctions: class ValidateFunctions: """Classe pour valider les formsets selon le type de question""" - def always_true(vote_form): - """Retourne True pour les votes sans validation particulière""" + @staticmethod + def always_true(_) -> bool: + """Renvoie True pour les votes sans validation particulière""" return True - def unique_selected(vote_form): + @staticmethod + def unique_selected(vote_form: "BaseFormSet[SelectVoteForm]") -> bool: """Vérifie qu'une seule option est choisie""" - nb_selected = 0 - for v in vote_form: - nb_selected += v.cleaned_data["selected"] + + nb_selected = sum(v.cleaned_data["selected"] for v in vote_form) 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.")) ) return False 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.")) ) return False return True - def limit_ranks(vote_form): + @staticmethod + def limit_ranks(vote_form: "BaseFormSet[RankVoteForm]"): """Limite le classement au nombre d'options""" nb_options = len(vote_form) valid = True @@ -229,11 +253,13 @@ class ValidateFunctions: class ResultsData: """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""" 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""" duels = question.duels.all() options = list(question.options.all()) @@ -270,7 +296,8 @@ class ResultsData: class BallotsData: """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""" from .models import Vote @@ -290,7 +317,8 @@ class BallotsData: {"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""" 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 `username`, `election` et `full_name`. """ @@ -331,7 +359,7 @@ def create_users(election, csv_file): users = [ User( election=election, - username=f"{election.id}__{username}", + username=f"{election.pk}__{username}", email=email, full_name=full_name, ) @@ -341,7 +369,7 @@ def create_users(election, csv_file): 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é""" try: dialect = csv.Sniffer().sniff(csv_file.readline().decode("utf-8")) @@ -394,15 +422,14 @@ def check_csv(csv_file): 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 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 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}" start = election.start_date.strftime("%d/%m/%Y %H:%M %Z") end = election.end_date.strftime("%d/%m/%Y %H:%M %Z") @@ -431,7 +458,7 @@ def send_mail(election, subject, body, reply_to): ) ) - for (m, v) in messages: + for m, v in messages: try: m.send() except smtplib.SMTPException: diff --git a/elections/views.py b/elections/views.py index e8f9635..6b7ebc8 100644 --- a/elections/views.py +++ b/elections/views.py @@ -1,7 +1,7 @@ import csv +from typing import TYPE_CHECKING from django.contrib import messages -from django.contrib.auth import get_user_model from django.contrib.messages.views import SuccessMessageMixin from django.core.mail import EmailMessage from django.db import transaction @@ -19,6 +19,7 @@ from django.views.generic import ( View, ) +from elections.typing import AuthenticatedRequest from shared.json import JsonCreateView, JsonDeleteView, JsonUpdateView 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 .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* @@ -53,6 +58,8 @@ User = get_user_model() class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView): + object: Election + model = Election form_class = ElectionForm success_message = _("Élection créée avec succès !") @@ -61,7 +68,7 @@ class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView): def get_success_url(self): 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 form.instance.short_name = slugify( form.instance.start_date.strftime("%Y-%m-%d") + "_" + form.instance.name @@ -75,11 +82,11 @@ class ElectionDeleteView(CreatorOnlyMixin, BackgroundUpdateView): model = Election pattern_name = "election.list" - def get_object(self, queryset=None): - obj = self.get_object() + def get_object(self): + obj: Election = super().get_object() # On ne peut supprimer que les élections n'ayant pas eu de vote et dont # 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 return obj @@ -89,6 +96,8 @@ class ElectionDeleteView(CreatorOnlyMixin, BackgroundUpdateView): class ElectionAdminView(CreatorOnlyMixin, TimeMixin, DetailView): + object: Election + model = Election template_name = "elections/election_admin.html" @@ -115,7 +124,7 @@ class ElectionSetVisibleView(CreatorOnlyMixin, BackgroundUpdateView): success_message = _("Élection visible !") def get(self, request, *args, **kwargs): - self.election = self.get_object() + self.election: Election = self.get_object() self.election.visible = True self.election.save() return super().get(request, *args, **kwargs) @@ -232,6 +241,8 @@ class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView): class DeleteVoteView(ClosedElectionMixin, JsonDeleteView): + voter: User + model = Election def get_message(self): @@ -416,7 +427,7 @@ class ElectionView(NotArchivedMixin, DetailView): context = super().get_context_data(**kwargs) 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["cast_questions"] = user.cast_questions.all() context["has_voted"] = user.cast_elections.filter( @@ -444,7 +455,7 @@ class ElectionVotersView(NotArchivedMixin, DetailView): election = context["election"] 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["is_admin"] = user.is_admin(election) can_delete = ( @@ -476,6 +487,8 @@ class ElectionBallotsView(NotArchivedMixin, DetailView): class VoteView(OpenElectionOnlyMixin, DetailView): + request: AuthenticatedRequest + model = Question def dispatch(self, request, *args, **kwargs): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dc1584a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pyright] +reportIncompatibleMethodOverride = false +reportIncompatibleVariableOverride = false diff --git a/shared/auth/__init__.py b/shared/auth/__init__.py index 074cc4f..75aee9e 100644 --- a/shared/auth/__init__.py +++ b/shared/auth/__init__.py @@ -1,3 +1,5 @@ from .staticdefs import CONNECTION_METHODS -__all__ = [CONNECTION_METHODS] +__all__ = [ + "CONNECTION_METHODS", +] diff --git a/shared/auth/backends.py b/shared/auth/backends.py index 8ad1c4d..16e5a59 100644 --- a/shared/auth/backends.py +++ b/shared/auth/backends.py @@ -1,10 +1,15 @@ +from typing import TYPE_CHECKING + from authens.backends import ENSCASBackend -from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend 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): diff --git a/shared/auth/forms.py b/shared/auth/forms.py index 00b5204..dda4e2e 100644 --- a/shared/auth/forms.py +++ b/shared/auth/forms.py @@ -1,11 +1,16 @@ +from typing import TYPE_CHECKING + from django import forms from django.contrib.auth import authenticate 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.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): diff --git a/shared/auth/utils.py b/shared/auth/utils.py index 77b3fd8..9531dc7 100644 --- a/shared/auth/utils.py +++ b/shared/auth/utils.py @@ -4,12 +4,10 @@ import random # Fonctions universelles # ############################################################################# +alphabet = "abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + def generate_password(size=15): 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)) diff --git a/shared/auth/views.py b/shared/auth/views.py index b63f001..7f2c5cd 100644 --- a/shared/auth/views.py +++ b/shared/auth/views.py @@ -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.hashers import make_password from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.models import Permission from django.contrib.messages.views import SuccessMessageMixin +from django.db.models import QuerySet from django.urls import reverse, reverse_lazy from django.utils.translation import gettext_lazy as _ from django.views.generic import CreateView, FormView, ListView, TemplateView +from elections.typing import AuthenticatedRequest + from .forms import ElectionAuthForm, PwdUserForm, UserAdminForm 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 """ + request: AuthenticatedRequest + def test_func(self): 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): 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["pwd_users"] = qs.filter(username__startswith="pwd__") @@ -143,16 +153,16 @@ class PermissionManagementView(StaffMemberMixin, SuccessMessageMixin, FormView): # Election admin election_perm = Permission.objects.get(codename="election_admin") if form.cleaned_data["election_admin"]: - election_perm.user_set.add(user) + election_perm.user_set.add(user) # pyright: ignore else: - election_perm.user_set.remove(user) + election_perm.user_set.remove(user) # pyright: ignore # FAQ admin faq_perm = Permission.objects.get(codename="faq_admin") if form.cleaned_data["faq_admin"]: - faq_perm.user_set.add(user) + faq_perm.user_set.add(user) # pyright: ignore else: - faq_perm.user_set.remove(user) + faq_perm.user_set.remove(user) # pyright: ignore user.save() return super().form_valid(form) diff --git a/shared/json/views.py b/shared/json/views.py index 5e5df85..c1d5824 100644 --- a/shared/json/views.py +++ b/shared/json/views.py @@ -1,4 +1,7 @@ +from typing import Any + from django.http import JsonResponse +from django.template.response import TemplateResponse from django.utils.decorators import method_decorator from django.views.decorators.http import require_POST from django.views.generic.base import TemplateResponseMixin, View @@ -48,10 +51,17 @@ class JsonMessageMixin: def get_data(self, **kwargs): 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): self.object = self.get_object() context = self.get_context_data(object=self.object) @@ -69,7 +79,7 @@ class JsonDeleteView(JsonMessageMixin, JsonDetailView): @method_decorator(require_POST, name="dispatch") class JsonCreateView( - JsonMessageMixin, JsonModelFormMixin, TemplateResponseMixin, ProcessFormView + JsonMessageMixin, JsonModelFormMixin, TypedResponseMixin, ProcessFormView ): def render_to_json(self, **kwargs): context = self.get_context_data(object=self.object) @@ -81,7 +91,7 @@ class JsonCreateView( @method_decorator(require_POST, name="dispatch") class JsonUpdateView( - JsonMessageMixin, JsonModelFormMixin, TemplateResponseMixin, ProcessFormView + JsonMessageMixin, JsonModelFormMixin, TypedResponseMixin, ProcessFormView ): def post(self, request, *args, **kwargs): self.object = self.get_object() diff --git a/shared/management/commands/createadmin.py b/shared/management/commands/createadmin.py index 037dcd8..7b2ba9a 100644 --- a/shared/management/commands/createadmin.py +++ b/shared/management/commands/createadmin.py @@ -1,9 +1,14 @@ +from typing import TYPE_CHECKING + from django.contrib.auth import get_user_model from django.contrib.auth.hashers import make_password from django.contrib.auth.models import Permission 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): @@ -38,5 +43,11 @@ class Command(BaseCommand): user.save() - Permission.objects.get(codename="election_admin").user_set.add(user) - Permission.objects.get(codename="faq_admin").user_set.add(user) + Permission.objects.get( + codename="election_admin" + ).user_set.add( # pyright: ignore + user + ) + Permission.objects.get(codename="faq_admin").user_set.add( # pyright: ignore + user + ) diff --git a/shared/views.py b/shared/views.py index 93aae87..0536c86 100644 --- a/shared/views.py +++ b/shared/views.py @@ -23,4 +23,4 @@ class BackgroundUpdateView(RedirectView): class TimeMixin: def get_context_data(self, **kwargs): kwargs.update(current_time=timezone.now()) - return super().get_context_data(**kwargs) + return super().get_context_data(**kwargs) # pyright: ignore