import csv 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 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 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 send_election_mail from .utils import create_users User = get_user_model() # TODO: access control *everywhere* # ############################################################################# # Administration Views # ############################################################################# class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView): 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): # 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 self.log_info("Election created", data={"election": form.instance.get_data()}) return super().form_valid(form) class ElectionDeleteView(CreatorOnlyMixin, BackgroundUpdateView): model = Election pattern_name = "election.list" def get_object(self, queryset=None): obj = self.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: self.log_warn("Cannot delete election") raise Http404 return obj def get(self, request, *args, **kwargs): obj = self.get_object() self.log_info("Election deleted", data={"election": obj.get_data()}) obj.delete() return super().get(request, *args, **kwargs) class ElectionAdminView(CreatorOnlyMixin, TimeMixin, DetailView): 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 = self.get_object() self.election.visible = True self.election.save() self.log_info( "Election set to visible", data={"election": self.election.get_data()} ) 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"]) obj = self.get_object() for v in obj.voters.all(): writer.writerow([v.full_name, v.base_username]) self.log_info("Voters exported", data={"election": obj.get_data()}) 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"]) self.log_info("Voters imported", data={"election": self.object.get_data()}) 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, ) self.log_info( "Started sending e-mails", data={"election": self.object.get_data()} ) 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 or self.object.sent_mail is None: 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() self.log_info("Updated election", data={"election": self.object.get_data()}) return super().form_valid(form) class DeleteVoteView(ClosedElectionMixin, JsonDeleteView): 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())) self.log_warn( "Vote deleted", data={"election": election.get_data(), "voter": self.voter.get_data()}, ) 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() self.log_info("Election tallied", data={"election": election.get_data()}) 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() self.log_info( "Election published" if self.election.results_public else "Election unpublished", data={"election": self.election.get_data()}, ) 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): obj = self.get_object() content = "\n".join([q.results for q in obj.questions.all()]) response = HttpResponse(content, content_type="text/plain") response["Content-Disposition"] = "attachment; filename=results.txt" self.log_info("Results downloaded", data={"election": obj.get_data()}) 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() self.log_info("Election archived", data={"election": election.get_data()}) 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: 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: 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): 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))