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.decorators import method_decorator from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_POST from django.views.generic import ( CreateView, DetailView, FormView, ListView, UpdateView, View, ) from shared.views import BackgroundUpdateView 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 return super().form_valid(form) class ElectionAdminView(CreatorOnlyMixin, 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( { "current_time": timezone.now(), "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() 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/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/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.apply_async( countdown=5, kwargs={ "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, FormView): model = Election template_name = "elections/delete_vote.html" form_class = DeleteVoteForm def get_success_url(self): return reverse("election.voters", args=[self.object.pk]) + "#v_{anchor}".format( **self.kwargs ) def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["voter"] = self.voter return kwargs def get_queryset(self): # On n'affiche la page que pour les élections ouvertes à toustes return super().get_queryset().filter(restricted=False) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["anchor"] = self.kwargs["anchor"] return context def get(self, request, *args, **kwargs): self.object = super().get_object() self.voter = User.objects.get(pk=self.kwargs["user_pk"]) return super().get(request, *args, **kwargs) def post(self, request, *args, **kwargs): self.object = super().get_object() self.voter = User.objects.get(pk=self.kwargs["user_pk"]) return super().post(request, *args, **kwargs) @transaction.atomic def form_valid(self, form): if form.cleaned_data["delete"] == "oui": # 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=self.object.name, ), to=[self.voter.email], ).send() # On supprime les votes Vote.objects.filter( user=self.voter, option__question__election=self.object, ).delete() # On marque les questions comme non votées self.voter.cast_elections.remove(self.object) self.voter.cast_questions.remove(*list(self.object.questions.all())) return super().form_valid(form) 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.save() 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.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 # ############################################################################# @method_decorator(require_POST, name="dispatch") class AddQuestionView(CreatorOnlyEditMixin, CreateView): model = Election form_class = QuestionForm def get_success_url(self): return reverse("election.admin", args=[self.election.pk]) + "#q_add" def form_valid(self, form): self.election = self.get_object() # On ajoute l'élection voulue à la question créée form.instance.election = self.election return super().form_valid(form) class ModQuestionView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView): model = Question form_class = QuestionForm success_message = _("Question modifiée avec succès !") template_name = "elections/question_update.html" def get_success_url(self): return ( reverse("election.admin", args=[self.object.election.pk]) + f"#q_{self.object.pk}" ) class DelQuestionView(CreatorOnlyEditMixin, BackgroundUpdateView): model = Question success_message = _("Question supprimée !") def get_redirect_url(self, *args, **kwargs): return reverse("election.admin", args=[self.election.pk]) + "#q_add" def get(self, request, *args, **kwargs): question = self.get_object() self.election = question.election question.delete() return super().get(request, *args, **kwargs) # ############################################################################# # Option Views # ############################################################################# @method_decorator(require_POST, name="dispatch") class AddOptionView(CreatorOnlyEditMixin, CreateView): model = Question form_class = OptionForm def get_success_url(self): return ( reverse("election.admin", args=[self.question.election.pk]) + f"#q_{self.question.pk}" ) def form_valid(self, form): self.question = self.get_object() # On ajoute l'élection voulue à la question créée form.instance.question = self.question return super().form_valid(form) class ModOptionView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView): model = Option form_class = OptionForm success_message = _("Option modifiée avec succès !") template_name = "elections/option_update.html" def get_success_url(self): return ( reverse("election.admin", args=[self.object.question.election.pk]) + f"#o_{self.object.pk}" ) class DelOptionView(CreatorOnlyEditMixin, BackgroundUpdateView): model = Option success_message = _("Option supprimée !") def get_redirect_url(self, *args, **kwargs): return reverse("election.admin", args=[self.election.pk]) + "#q_add" def get(self, request, *args, **kwargs): option = self.get_object() self.election = option.question.election option.delete() return super().get(request, *args, **kwargs) # ############################################################################# # 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: can_delete = ( not election.restricted and 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["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(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))