kadenios/elections/views.py

562 lines
18 KiB
Python

import csv
from typing import TYPE_CHECKING
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.core.mail import EmailMessage
from django.db import transaction
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django.views.generic import (
CreateView,
DetailView,
FormView,
ListView,
UpdateView,
View,
)
from elections.typing import AuthenticatedRequest
from shared.json import JsonCreateView, JsonDeleteView, JsonUpdateView
from shared.views import BackgroundUpdateView, TimeMixin
from .forms import (
DeleteVoteForm,
ElectionForm,
OptionForm,
QuestionForm,
UploadVotersForm,
VoterMailForm,
)
from .mixins import (
AdminOnlyMixin,
ClosedElectionMixin,
CreatorOnlyEditMixin,
CreatorOnlyMixin,
NotArchivedMixin,
OpenElectionOnlyMixin,
)
from .models import Election, Option, Question, Vote
from .staticdefs import MAIL_VOTE_DELETED, MAIL_VOTERS, QUESTION_TYPES, VOTE_RULES
from .tasks import pseudonimize_election, send_election_mail
from .utils import create_users
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*
# #############################################################################
# Administration Views
# #############################################################################
class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
object: Election
model = Election
form_class = ElectionForm
success_message = _("Élection créée avec succès !")
template_name = "elections/election_create.html"
def get_success_url(self):
return reverse("election.admin", args=[self.object.pk])
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
)[:50]
# TODO: Change this if we modify the user model
form.instance.created_by = self.request.user
return super().form_valid(form)
class ElectionDeleteView(CreatorOnlyMixin, BackgroundUpdateView):
model = Election
pattern_name = "election.list"
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.sent_mail:
raise Http404
return obj
def get(self, request, *args, **kwargs):
self.get_object().delete()
return super().get(request, *args, **kwargs)
class ElectionAdminView(CreatorOnlyMixin, TimeMixin, DetailView):
object: Election
model = Election
template_name = "elections/election_admin.html"
def get_next_url(self):
return reverse("election.view", args=[self.object.pk])
def get_context_data(self, **kwargs):
kwargs.update(
{
"question_types": QUESTION_TYPES,
"o_form": OptionForm,
"q_form": QuestionForm,
}
)
return super().get_context_data(**kwargs)
def get_queryset(self):
return super().get_queryset().prefetch_related("questions__options")
class ElectionSetVisibleView(CreatorOnlyMixin, BackgroundUpdateView):
model = Election
pattern_name = "election.admin"
success_message = _("Élection visible !")
def get(self, request, *args, **kwargs):
self.election: Election = self.get_object()
self.election.visible = True
self.election.save()
return super().get(request, *args, **kwargs)
class ExportVotersView(CreatorOnlyMixin, View):
model = Election
def get(self, request, *args, **kwargs):
response = HttpResponse(content_type="text/csv")
writer = csv.writer(response)
response["Content-Disposition"] = "attachment; filename=voters.csv"
writer.writerow(["Nom", "login"])
for v in self.get_object().voters.all():
writer.writerow([v.full_name, v.base_username])
return response
class ElectionUploadVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormView):
model = Election
form_class = UploadVotersForm
success_message = _("Liste de votant·e·s importée avec succès !")
template_name = "elections/election_upload_voters.html"
def get_queryset(self):
# On ne peut ajouter une liste d'électeurs que sur une élection restreinte
return super().get_queryset().filter(restricted=True)
def get_success_url(self):
return reverse("election.upload-voters", args=[self.object.pk])
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["voters"] = self.object.registered_voters.all()
return context
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return super().post(request, *args, **kwargs)
def form_valid(self, form):
# On crée les comptes nécessaires à l'élection, en supprimant ceux
# existant déjà pour ne pas avoir de doublons
self.object.registered_voters.all().delete()
create_users(self.object, form.cleaned_data["csv_file"])
return super().form_valid(form)
class ElectionMailVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormView):
model = Election
form_class = VoterMailForm
success_message = _("Mail d'annonce en cours d'envoi !")
template_name = "elections/election_mail_voters.html"
def get_queryset(self):
# On ne peut envoyer un mail que sur une élection restreinte qui n'a pas
# déjà vu son mail envoyé
return super().get_queryset().filter(restricted=True, sent_mail=False)
def get_success_url(self):
return reverse("election.upload-voters", args=[self.object.pk])
def get_initial(self):
return {"objet": f"Vote : {self.object.name}", "message": MAIL_VOTERS}
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return super().post(request, *args, **kwargs)
def form_valid(self, form):
self.object.sent_mail = None
self.object.save()
send_election_mail(
election_pk=self.object.pk,
subject=form.cleaned_data["objet"],
body=form.cleaned_data["message"],
reply_to=self.request.user.email,
)
return super().form_valid(form)
class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView):
model = Election
form_class = ElectionForm
success_message = _("Élection modifiée avec succès !")
template_name = "elections/election_update.html"
def get_form(self, form_class=None):
form = super().get_form(form_class)
if self.object.sent_mail:
form.fields["restricted"].disabled = True
return form
def get_success_url(self):
return reverse("election.admin", args=[self.object.pk])
def form_valid(self, form):
# Si on ouvre l'élection à tout le monde, on supprime les votant·e·s
# pré-enregistré·e·s
if not form.cleaned_data["restricted"]:
self.object.registered_voters.all().delete()
return super().form_valid(form)
class DeleteVoteView(ClosedElectionMixin, JsonDeleteView):
voter: User
model = Election
def get_message(self):
return {
"content": _("Vote de {} supprimé !").format(self.voter.full_name),
"class": "success",
}
@transaction.atomic
def get(self, request, *args, **kwargs):
election = self.get_object()
self.voter = User.objects.get(pk=self.kwargs["user_pk"])
# On envoie un mail à la personne lui indiquant que le vote est supprimé
EmailMessage(
subject="Vote removed",
body=MAIL_VOTE_DELETED.format(
full_name=self.voter.full_name,
election_name=election.name,
),
to=[self.voter.email],
).send()
# On supprime les votes
Vote.objects.filter(
user=self.voter,
option__question__election=election,
).delete()
# On marque les questions comme non votées
self.voter.cast_elections.remove(election)
self.voter.cast_questions.remove(*list(election.questions.all()))
return self.render_to_json(action="delete")
class ElectionTallyView(ClosedElectionMixin, BackgroundUpdateView):
model = Election
pattern_name = "election.admin"
success_message = _("Élection dépouillée avec succès !")
def get_queryset(self):
return (
super()
.get_queryset()
.filter(tallied=False)
.prefetch_related("questions__options")
)
def get(self, request, *args, **kwargs):
election = self.get_object()
for q in election.questions.all():
q.tally()
election.tallied = True
election.time_tallied = timezone.now()
election.save()
pseudonimize_election(election.pk)
return super().get(request, *args, **kwargs)
class ElectionChangePublicationView(ClosedElectionMixin, BackgroundUpdateView):
model = Election
pattern_name = "election.admin"
def get_success_message(self):
if self.election.results_public:
return _("Élection publiée avec succès !")
return _("Élection dépubliée avec succès !")
def get(self, request, *args, **kwargs):
self.election = self.get_object()
self.election.results_public = not self.election.results_public
self.election.time_published = (
timezone.now() if self.election.results_public else None
)
self.election.save()
return super().get(request, *args, **kwargs)
class DownloadResultsView(CreatorOnlyMixin, View):
model = Election
def get_queryset(self):
return super().get_queryset().filter(tallied=True)
def get(self, request, *args, **kwargs):
content = "\n".join([q.results for q in self.get_object().questions.all()])
response = HttpResponse(content, content_type="text/plain")
response["Content-Disposition"] = "attachment; filename=results.txt"
return response
class ElectionArchiveView(ClosedElectionMixin, BackgroundUpdateView):
model = Election
pattern_name = "election.admin"
success_message = _("Élection archivée avec succès !")
def get(self, request, *args, **kwargs):
election = self.get_object()
election.archived = True
election.save()
return super().get(request, *args, **kwargs)
# #############################################################################
# Question Views
# #############################################################################
class CreateQuestionView(CreatorOnlyEditMixin, TimeMixin, JsonCreateView):
model = Election
form_class = QuestionForm
context_object_name = "q"
template_name = "elections/admin/question.html"
def form_valid(self, form):
form.instance.election = self.get_object()
return super().form_valid(form)
class UpdateQuestionView(CreatorOnlyEditMixin, TimeMixin, JsonUpdateView):
model = Question
form_class = QuestionForm
context_object_name = "q"
template_name = "elections/admin/question.html"
class DeleteQuestionView(CreatorOnlyEditMixin, JsonDeleteView):
model = Question
message = _("Question supprimée !")
# #############################################################################
# Option Views
# #############################################################################
class CreateOptionView(CreatorOnlyEditMixin, TimeMixin, JsonCreateView):
model = Question
form_class = OptionForm
context_object_name = "o"
template_name = "elections/admin/option.html"
def form_valid(self, form):
form.instance.question = self.get_object()
return super().form_valid(form)
class UpdateOptionView(CreatorOnlyEditMixin, TimeMixin, JsonUpdateView):
model = Option
form_class = OptionForm
context_object_name = "o"
template_name = "elections/admin/option.html"
class DeleteOptionView(CreatorOnlyEditMixin, JsonDeleteView):
model = Option
message = _("Option supprimée !")
# #############################################################################
# Public Views
# #############################################################################
class ElectionListView(NotArchivedMixin, ListView):
model = Election
template_name = "elections/election_list.html"
class ElectionView(NotArchivedMixin, DetailView):
model = Election
template_name = "elections/election.html"
def get_next_url(self):
return self.request.path
def get_context_data(self, **kwargs):
user = self.request.user
context = super().get_context_data(**kwargs)
context["current_time"] = timezone.now()
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(
pk=context["election"].pk
).exists()
return context
def get_queryset(self):
return (
super()
.get_queryset()
.select_related("created_by")
.prefetch_related("questions__options", "questions__duels")
)
class ElectionVotersView(NotArchivedMixin, DetailView):
model = Election
template_name = "elections/election_voters.html"
def get_context_data(self, **kwargs):
user = self.request.user
context = super().get_context_data(**kwargs)
election = context["election"]
voters = list(election.voters.all())
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 = (
election.created_by == user
and election.end_date < timezone.now()
and not election.tallied
)
if can_delete:
context["d_form"] = DeleteVoteForm()
context["can_delete"] = can_delete
context["from_admin"] = self.request.GET.get("prev") == "admin"
context["voters"] = voters
return context
class ElectionBallotsView(NotArchivedMixin, DetailView):
model = Election
template_name = "elections/election_ballots.html"
def get_queryset(self):
return (
super()
.get_queryset()
.filter(results_public=True, tallied=True)
.prefetch_related("questions__options")
)
class VoteView(OpenElectionOnlyMixin, DetailView):
request: AuthenticatedRequest
model = Question
def dispatch(self, request, *args, **kwargs):
# Si l'utilisateur n'est pas connecté on renvoie sur la vue de l'élection
if not request.user.is_authenticated:
return HttpResponseRedirect(
reverse("election.view", args=[super().get_object().election.pk])
)
return super().dispatch(request, *args, **kwargs)
def get_template_names(self):
return [f"elections/vote/{self.object.vote_type}.html"]
def get_next_url(self):
return reverse("election.view", args=[self.object.election.pk])
def get_success_url(self):
questions = list(self.object.election.questions.all())
q_index = questions.index(self.object)
if q_index + 1 == len(questions):
# On était à la dernière question
# On enregistre le vote pour l'élection
self.object.election.voters.add(self.request.user)
return reverse("election.view", args=[self.object.election.pk])
# On récupère l'id de la prochaine question
q_next = questions[q_index + 1].pk
return reverse("election.vote", args=[q_next])
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
questions = list(self.object.election.questions.all())
context["q_index"] = questions.index(self.object) + 1
context["q_total"] = len(questions)
context["nb_options"] = self.object.options.count()
context["range_options"] = range(1, context["nb_options"] + 1)
context["vote_rule"] = VOTE_RULES[self.object.type].format(**context)
return context
def get_object(self):
question = super().get_object()
# Seulement les utilisateur·ice·s ayant le droit de voter dans l'élection
# peuvent voir la page
if not self.request.user.can_vote(self.request, question.election):
raise Http404
return question
def get(self, request, *args, **kwargs):
self.object = self.get_object()
vote_form = self.object.get_formset()(instance=self.object)
return self.render_to_response(self.get_context_data(formset=vote_form))
def post(self, request, *args, **kwargs):
self.object = self.get_object()
vote_form = self.object.get_formset()(self.request.POST, instance=self.object)
if self.object.is_form_valid(vote_form):
# On enregistre le vote
self.object.cast_ballot(self.request.user, vote_form)
self.object.voters.add(self.request.user)
messages.success(self.request, _("Votre choix a bien été enregistré !"))
return HttpResponseRedirect(self.get_success_url())
else:
return self.render_to_response(self.get_context_data(formset=vote_form))