From 4b5a530f2b9a0d34d4e71a91faace2b6f9614e69 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Wed, 12 Jan 2022 11:30:46 +0100 Subject: [PATCH 01/18] Add journaling for the election and auth app --- elections/mixins.py | 4 +- elections/models.py | 16 ++++++- elections/views.py | 59 ++++++++++++++++++++++++-- shared/auth/urls.py | 1 + shared/auth/views.py | 22 +++++++++- shared/json/mixins.py | 13 ++++-- shared/migrations/0001_initial.py | 54 +++++++++++++++++++++++ shared/migrations/__init__.py | 0 shared/mixins.py | 23 ++++++++++ shared/models.py | 28 ++++++++++++ shared/templates/auth/admin-panel.html | 18 ++++++++ shared/templates/auth/journal.html | 53 +++++++++++++++++++++++ shared/templatetags/bulma.py | 5 +-- 13 files changed, 281 insertions(+), 15 deletions(-) create mode 100644 shared/migrations/0001_initial.py create mode 100644 shared/migrations/__init__.py create mode 100644 shared/mixins.py create mode 100644 shared/models.py create mode 100644 shared/templates/auth/journal.html diff --git a/elections/mixins.py b/elections/mixins.py index 2b243bb..fdd7dab 100644 --- a/elections/mixins.py +++ b/elections/mixins.py @@ -4,10 +4,12 @@ from django.urls import reverse from django.utils import timezone from django.views.generic.detail import SingleObjectMixin +from shared.mixins import LogMixin + from .models import Election, Option, Question -class AdminOnlyMixin(PermissionRequiredMixin): +class AdminOnlyMixin(LogMixin, PermissionRequiredMixin): """Restreint l'accès aux admins""" permission_required = "elections.election_admin" diff --git a/elections/models.py b/elections/models.py index 4d1bab8..565f821 100644 --- a/elections/models.py +++ b/elections/models.py @@ -30,7 +30,7 @@ from .utils import ( # ############################################################################# -class Election(models.Model): +class Election(Serializer, models.Model): name = TranslatedFieldWithFallback(models.CharField(_("nom"), max_length=255)) short_name = models.SlugField(_("nom bref"), unique=True) description = TranslatedFieldWithFallback( @@ -80,6 +80,16 @@ class Election(models.Model): _("date de publication"), null=True, default=None ) + serializable_fields = [ + "name_fr", + "name_en", + "description_fr", + "description_en", + "start_date", + "end_date", + "restricted", + ] + class Meta: permissions = [ ("election_admin", _("Peut administrer des élections")), @@ -228,7 +238,7 @@ class Duel(models.Model): # ############################################################################# -class User(AbstractUser): +class User(Serializer, AbstractUser): election = models.ForeignKey( Election, related_name="registered_voters", @@ -239,6 +249,8 @@ class User(AbstractUser): full_name = models.CharField(_("Nom et Prénom"), max_length=150, blank=True) has_valid_email = models.BooleanField(_("email valide"), null=True, default=None) + serializable_fields = ["username", "email", "is_staff"] + @property def base_username(self): return "__".join(self.username.split("__")[1:]) diff --git a/elections/views.py b/elections/views.py index e8f9635..8d921c3 100644 --- a/elections/views.py +++ b/elections/views.py @@ -68,6 +68,8 @@ class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView): )[: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) @@ -80,11 +82,16 @@ class ElectionDeleteView(CreatorOnlyMixin, BackgroundUpdateView): # 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): - self.get_object().delete() + obj = self.get_object() + + self.log_info("Election deleted", data={"election": obj.get_data()}) + + obj.delete() return super().get(request, *args, **kwargs) @@ -118,6 +125,11 @@ class ElectionSetVisibleView(CreatorOnlyMixin, BackgroundUpdateView): 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) @@ -131,9 +143,13 @@ class ExportVotersView(CreatorOnlyMixin, View): response["Content-Disposition"] = "attachment; filename=voters.csv" writer.writerow(["Nom", "login"]) - for v in self.get_object().voters.all(): + 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 @@ -168,6 +184,9 @@ class ElectionUploadVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormVi # 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) @@ -205,6 +224,11 @@ class ElectionMailVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormView 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) @@ -216,7 +240,7 @@ class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView): def get_form(self, form_class=None): form = super().get_form(form_class) - if self.object.sent_mail: + if self.object.sent_mail or self.object.sent_mail is None: form.fields["restricted"].disabled = True return form @@ -228,6 +252,9 @@ class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView): # 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) @@ -264,6 +291,12 @@ class DeleteVoteView(ClosedElectionMixin, JsonDeleteView): # 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") @@ -288,6 +321,9 @@ class ElectionTallyView(ClosedElectionMixin, BackgroundUpdateView): 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) @@ -308,6 +344,14 @@ class ElectionChangePublicationView(ClosedElectionMixin, BackgroundUpdateView): ) 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) @@ -318,11 +362,15 @@ class DownloadResultsView(CreatorOnlyMixin, View): 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()]) + 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 @@ -335,6 +383,9 @@ class ElectionArchiveView(ClosedElectionMixin, BackgroundUpdateView): 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) diff --git a/shared/auth/urls.py b/shared/auth/urls.py index 2cf428e..59d85a5 100644 --- a/shared/auth/urls.py +++ b/shared/auth/urls.py @@ -14,5 +14,6 @@ urlpatterns = [ "permissions", views.PermissionManagementView.as_view(), name="auth.permissions" ), path("accounts", views.AccountListView.as_view(), name="auth.accounts"), + path("journal", views.JournalView.as_view(), name="auth.journal"), path("admins", views.AdminAccountsView.as_view(), name="auth.admins"), ] diff --git a/shared/auth/views.py b/shared/auth/views.py index b63f001..09667a0 100644 --- a/shared/auth/views.py +++ b/shared/auth/views.py @@ -8,6 +8,9 @@ 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 shared.mixins import LogMixin +from shared.models import Event + from .forms import ElectionAuthForm, PwdUserForm, UserAdminForm from .utils import generate_password @@ -19,7 +22,7 @@ User = get_user_model() # ############################################################################# -class StaffMemberMixin(UserPassesTestMixin): +class StaffMemberMixin(LogMixin, UserPassesTestMixin): """ Mixin permettant de restreindre l'accès aux membres `staff`, si la personne n'est pas connectée, renvoie sur la page d'authentification @@ -71,6 +74,10 @@ class CreatePwdAccount(StaffMemberMixin, SuccessMessageMixin, CreateView): # On enregistre un mot de passe aléatoire form.instance.password = make_password(generate_password(32)) + self.log_info( + "Password account created", data={"user": form.instance.get_data()} + ) + return super().form_valid(form) @@ -155,9 +162,22 @@ class PermissionManagementView(StaffMemberMixin, SuccessMessageMixin, FormView): faq_perm.user_set.remove(user) user.save() + + self.log_info("Permissions changed", data={"user": user.get_data()}) + return super().form_valid(form) +# ############################################################################# +# Log history +# ############################################################################# + + +class JournalView(StaffMemberMixin, ListView): + model = Event + template_name = "auth/journal.html" + + # ############################################################################# # List of special accounts # ############################################################################# diff --git a/shared/json/mixins.py b/shared/json/mixins.py index 2b920c8..9413f72 100644 --- a/shared/json/mixins.py +++ b/shared/json/mixins.py @@ -1,3 +1,4 @@ +import datetime import json @@ -7,15 +8,21 @@ class Serializer: def get_serializable_fields(self): return self.serializable_fields - def to_json(self): + def get_data(self): data = {} for field in self.get_serializable_fields(): if hasattr(self, field): - data.update({field: getattr(self, field)}) + if isinstance(getattr(self, field), datetime.date): + data.update({field: getattr(self, field).isoformat()}) + else: + data.update({field: getattr(self, field)}) else: raise AttributeError( "This object does not have a field named '{}'".format(field) ) - return json.dumps(data) + return data + + def to_json(self): + return json.dumps(self.get_data()) diff --git a/shared/migrations/0001_initial.py b/shared/migrations/0001_initial.py new file mode 100644 index 0000000..35a960d --- /dev/null +++ b/shared/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 3.2.11 on 2022-01-12 01:10 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Event", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("message", models.TextField(default="")), + ( + "level", + models.CharField( + choices=[ + ("info", "INFO"), + ("warning", "WARNING"), + ("error", "ERROR"), + ], + max_length=7, + ), + ), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ("data", models.JSONField(default=dict)), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="events", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/shared/migrations/__init__.py b/shared/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shared/mixins.py b/shared/mixins.py new file mode 100644 index 0000000..ecc9429 --- /dev/null +++ b/shared/mixins.py @@ -0,0 +1,23 @@ +from .models import Event + +# ############################################################################# +# Fonctions pour la journalisation +# ############################################################################# + + +class LogMixin: + """Utility to log events related to the current user""" + + def _log(self, message, level, data={}): + Event.objects.create( + message=message, level=level, user=self.request.user, data=data + ) + + def log_info(self, message, data={}): + self._log(message, "info", data=data) + + def log_warn(self, message, data={}): + self._log(message, "warn", data=data) + + def log_error(self, message, data={}): + self._log(message, "error", data=data) diff --git a/shared/models.py b/shared/models.py new file mode 100644 index 0000000..de9ae5a --- /dev/null +++ b/shared/models.py @@ -0,0 +1,28 @@ +from django.contrib.auth import get_user_model +from django.db import models + +from .utils import choices_length + +User = get_user_model() + + +LOG_LEVELS = ( + ("info", "INFO"), + ("warning", "WARNING"), + ("error", "ERROR"), +) + + +class Event(models.Model): + message = models.TextField(default="") + level = models.CharField(choices=LOG_LEVELS, max_length=choices_length(LOG_LEVELS)) + timestamp = models.DateTimeField(auto_now_add=True) + + data = models.JSONField(default=dict) + + user = models.ForeignKey( + User, related_name="events", on_delete=models.SET_NULL, null=True + ) + + class Meta: + ordering = ["-timestamp"] diff --git a/shared/templates/auth/admin-panel.html b/shared/templates/auth/admin-panel.html index 1c92e68..f34613c 100644 --- a/shared/templates/auth/admin-panel.html +++ b/shared/templates/auth/admin-panel.html @@ -35,4 +35,22 @@ +
+ + + {# Placeholder #} +
+
+ {% endblock %} diff --git a/shared/templates/auth/journal.html b/shared/templates/auth/journal.html new file mode 100644 index 0000000..fe97036 --- /dev/null +++ b/shared/templates/auth/journal.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% load i18n bulma %} + + +{% block custom_js %} + +{% endblock %} + + +{% block content %} + +

{% trans "Journal d'évènements" %}

+
+ + + + + + + + + + + + + {% for e in object_list %} + + + + + + + + + + + {% endfor %} + +
{% trans "Niveau" %}{% trans "Message" %}{% trans "Origine" %}{% trans "Heure" %}
{{ e.get_level_display }}{{ e.message }}{% if e.user %}{{ e.user }}{% endif %}{{ e.timestamp }} + + + + +
+ + +{% endblock %} diff --git a/shared/templatetags/bulma.py b/shared/templatetags/bulma.py index e0e15e2..625bbbc 100644 --- a/shared/templatetags/bulma.py +++ b/shared/templatetags/bulma.py @@ -68,7 +68,4 @@ def bulmafy(field, css_class): @register.filter def bulma_message_tag(tag): - if tag == "error": - return "danger" - - return tag + return "danger" if tag == "error" else tag From c4cf8d2c380e1303d732ea519eb7d7ba8018abef Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Tue, 22 Feb 2022 15:06:32 +0100 Subject: [PATCH 02/18] Select related user, reduce db queries --- shared/auth/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/auth/views.py b/shared/auth/views.py index 09667a0..7d3f172 100644 --- a/shared/auth/views.py +++ b/shared/auth/views.py @@ -174,7 +174,7 @@ class PermissionManagementView(StaffMemberMixin, SuccessMessageMixin, FormView): class JournalView(StaffMemberMixin, ListView): - model = Event + queryset = Event.objects.select_related("user") template_name = "auth/journal.html" From 9d4902365f18fb8e3f5aaccb45bf75ae9a43fb5a Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Fri, 15 Jul 2022 18:34:26 +0200 Subject: [PATCH 03/18] Remove unused requirement --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a332359..bf4cd0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,4 @@ authens>=0.1b2 markdown numpy networkx -python-csv django-background-tasks From 4fa3f9618446c90292296a09c59ed895820a83f1 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Mon, 1 Aug 2022 13:33:30 +0200 Subject: [PATCH 04/18] Add shell.nix --- shell.nix | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 shell.nix diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..be49fa8 --- /dev/null +++ b/shell.nix @@ -0,0 +1,22 @@ +let + mach-nix = import + (builtins.fetchGit { + url = "https://github.com/DavHau/mach-nix"; + ref = "refs/tags/3.5.0"; + }) + { }; + + requirements = builtins.readFile ./requirements.txt; + + requirements-dev = '' + django-debug-toolbar + ipython + black + isort + flake8 + ''; +in + +mach-nix.mkPythonShell { + requirements = requirements + requirements-dev; +} From b38aadb0d8c504a410d901f81306fbd3904d9b67 Mon Sep 17 00:00:00 2001 From: Basile Clement Date: Wed, 7 Dec 2022 15:11:48 +0100 Subject: [PATCH 05/18] Add SERVER_EMAIL --- kadenios/settings/prod.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kadenios/settings/prod.py b/kadenios/settings/prod.py index db2f439..8c9e7e6 100644 --- a/kadenios/settings/prod.py +++ b/kadenios/settings/prod.py @@ -28,6 +28,8 @@ ALLOWED_HOSTS = ["vote.eleves.ens.fr"] STATIC_ROOT = os.path.join(os.path.dirname(BASE_DIR), "static") +SERVER_EMAIL = "kadenios@www.eleves.ens.fr" + # ############################################################################# # Paramètres du cache # ############################################################################# From b612664ae462ec21af0618af7788627c958ac356 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Thu, 15 Dec 2022 11:28:32 +0100 Subject: [PATCH 06/18] Change the return adress if the mail fails --- elections/utils.py | 2 +- kadenios/settings/common.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/elections/utils.py b/elections/utils.py index af33669..4c0e7f1 100644 --- a/elections/utils.py +++ b/elections/utils.py @@ -426,7 +426,7 @@ def send_mail(election, subject, body, reply_to): to=[v.email], reply_to=[reply_to], # On modifie l'adresse de retour d'erreur - headers={"Return-Path": "kadenios@www.eleves.ens.fr"}, + headers={"From": "Kadenios "}, ), v, ) diff --git a/kadenios/settings/common.py b/kadenios/settings/common.py index e15dd35..4ab9846 100644 --- a/kadenios/settings/common.py +++ b/kadenios/settings/common.py @@ -93,7 +93,7 @@ WSGI_APPLICATION = "kadenios.wsgi.application" DEFAULT_AUTO_FIELD = "django.db.models.AutoField" -DEFAULT_FROM_EMAIL = "Kadenios " +DEFAULT_FROM_EMAIL = "Kadenios " # ############################################################################# # Paramètres d'authentification From 78c385110f508ab992aa1bab6e51240451116713 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Thu, 9 Feb 2023 16:51:39 +0100 Subject: [PATCH 07/18] Add LICENSE --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e8be941 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Klub Dev ENS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 1383dc30eb45f7b4088d1e081164662cf4bce8e2 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Wed, 20 Dec 2023 23:21:20 +0100 Subject: [PATCH 08/18] fix(elections): Create active users --- elections/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/elections/utils.py b/elections/utils.py index 4c0e7f1..346060c 100644 --- a/elections/utils.py +++ b/elections/utils.py @@ -334,7 +334,6 @@ def create_users(election, csv_file): username=f"{election.id}__{username}", email=email, full_name=full_name, - is_active=False, ) for (username, full_name, email) in reader ] From 3c81dea1c9275480cbc7c3a303a8dc432af23359 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Sun, 7 Jul 2024 13:09:05 +0200 Subject: [PATCH 09/18] feat(nix): Update tooling --- .envrc | 1 + .gitignore | 1 + 01-authens.patch | 11 +++++++ default.nix | 64 +++++++++++++++++++++++++++++++++++++ npins/default.nix | 80 ++++++++++++++++++++++++++++++++++++++++++++++ npins/sources.json | 22 +++++++++++++ shell.nix | 23 +------------ 7 files changed, 180 insertions(+), 22 deletions(-) create mode 100644 .envrc create mode 100644 01-authens.patch create mode 100644 default.nix create mode 100644 npins/default.nix create mode 100644 npins/sources.json diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..1d953f4 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use nix diff --git a/.gitignore b/.gitignore index d433e86..98737dc 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ pyrightconfig.json *.sqlite3 .vscode +.direnv diff --git a/01-authens.patch b/01-authens.patch new file mode 100644 index 0000000..e161a1d --- /dev/null +++ b/01-authens.patch @@ -0,0 +1,11 @@ +diff --git a/authens/utils.py b/authens/utils.py +index 7306506..36063b6 100644 +--- a/authens/utils.py ++++ b/authens/utils.py +@@ -16,7 +16,7 @@ def get_cas_client(request): + service_url=urlunparse( + (request.scheme, request.get_host(), request.path, "", "", "") + ), +- server_url="https://cas.eleves.ens.fr/", ++ server_url="https://cas-eleves.dgnum.eu/", + ) diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..1a4b7fe --- /dev/null +++ b/default.nix @@ -0,0 +1,64 @@ +{ + sources ? import ./npins, + pkgs ? import sources.nixpkgs { }, +}: + +let + nix-pkgs = import sources.nix-pkgs { inherit pkgs; }; + + python3 = pkgs.python3.override { + packageOverrides = _: _: { + inherit (nix-pkgs) + django-background-tasks + django-browser-reload + django-bulma-forms + django-translated-fields + loadcredential + ; + + authens = nix-pkgs.authens.overridePythonAttrs (old: { + patches = [ ./01-authens.patch ]; + }); + }; + }; +in + +{ + devShell = pkgs.mkShell { + name = "cas-eleves.dev"; + + packages = [ + (python3.withPackages (ps: [ + ps.django + ps.ipython + + ps.markdown + ps.numpy + ps.networkx + + ps.authens + ps.django-background-tasks + ps.django-browser-reload + ps.django-bulma-forms + ps.django-debug-toolbar + ps.django-translated-fields + ps.loadcredential + ])) + + pkgs.gettext + pkgs.gtranslator + ]; + + env = { + CREDENTIALS_DIRECTORY = builtins.toString ./.credentials; + CE_DEBUG = true; + CE_STATIC_ROOT = builtins.toString ./.static; + }; + + shellHook = '' + if [ ! -d .static ]; then + mkdir .static + fi + ''; + }; +} diff --git a/npins/default.nix b/npins/default.nix new file mode 100644 index 0000000..fb04b70 --- /dev/null +++ b/npins/default.nix @@ -0,0 +1,80 @@ +# Generated by npins. Do not modify; will be overwritten regularly +let + data = builtins.fromJSON (builtins.readFile ./sources.json); + version = data.version; + + mkSource = + spec: + assert spec ? type; + let + path = + if spec.type == "Git" then + mkGitSource spec + else if spec.type == "GitRelease" then + mkGitSource spec + else if spec.type == "PyPi" then + mkPyPiSource spec + else if spec.type == "Channel" then + mkChannelSource spec + else + builtins.throw "Unknown source type ${spec.type}"; + in + spec // { outPath = path; }; + + mkGitSource = + { + repository, + revision, + url ? null, + hash, + branch ? null, + ... + }: + assert repository ? type; + # At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository + # In the latter case, there we will always be an url to the tarball + if url != null then + (builtins.fetchTarball { + inherit url; + sha256 = hash; + }) + else + assert repository.type == "Git"; + let + urlToName = + url: rev: + let + matched = builtins.match "^.*/([^/]*)(\\.git)?$" repository.url; + + short = builtins.substring 0 7 rev; + + appendShort = if (builtins.match "[a-f0-9]*" rev) != null then "-${short}" else ""; + in + "${if matched == null then "source" else builtins.head matched}${appendShort}"; + name = urlToName repository.url revision; + in + builtins.fetchGit { + url = repository.url; + rev = revision; + inherit name; + narHash = hash; + }; + + mkPyPiSource = + { url, hash, ... }: + builtins.fetchurl { + inherit url; + sha256 = hash; + }; + + mkChannelSource = + { url, hash, ... }: + builtins.fetchTarball { + inherit url; + sha256 = hash; + }; +in +if version == 4 then + builtins.mapAttrs (_: mkSource) data.pins +else + throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`" diff --git a/npins/sources.json b/npins/sources.json new file mode 100644 index 0000000..fdb8ee4 --- /dev/null +++ b/npins/sources.json @@ -0,0 +1,22 @@ +{ + "pins": { + "nix-pkgs": { + "type": "Git", + "repository": { + "type": "Git", + "url": "https://git.hubrecht.ovh/hubrecht/nix-pkgs.git" + }, + "branch": "main", + "revision": "46879d052e4a694ceb3027dbcff641c44e0ae1bd", + "url": null, + "hash": "sha256-/Yn3NDYA76bv8x06jahLAJ2z54L0vFeAtQKzyW3MfGA=" + }, + "nixpkgs": { + "type": "Channel", + "name": "nixpkgs-unstable", + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-24.11pre646460.0aeab749216e/nixexprs.tar.xz", + "hash": "0xa73bs0n28x731hf6ipqrlji0p3qf2a42vfm6g8snnhaab9mfwj" + } + }, + "version": 4 +} \ No newline at end of file diff --git a/shell.nix b/shell.nix index be49fa8..d6d21cf 100644 --- a/shell.nix +++ b/shell.nix @@ -1,22 +1 @@ -let - mach-nix = import - (builtins.fetchGit { - url = "https://github.com/DavHau/mach-nix"; - ref = "refs/tags/3.5.0"; - }) - { }; - - requirements = builtins.readFile ./requirements.txt; - - requirements-dev = '' - django-debug-toolbar - ipython - black - isort - flake8 - ''; -in - -mach-nix.mkPythonShell { - requirements = requirements + requirements-dev; -} +(import ./. { }).devShell From 1d7bace777f10516cb41801917e79ba765a3f166 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Sun, 7 Jul 2024 17:31:39 +0200 Subject: [PATCH 10/18] chore(kadenios): Rename kadenios -> app --- {kadenios => app}/__init__.py | 0 {kadenios => app}/apps.py | 0 {kadenios => app}/settings/.gitignore | 0 {kadenios => app}/settings/__init__.py | 0 {kadenios => app}/settings/common.py | 0 {kadenios => app}/settings/local.py | 0 {kadenios => app}/settings/prod.py | 0 {kadenios => app}/settings/secret_example.py | 0 {kadenios => app}/urls.py | 0 {kadenios => app}/views.py | 0 {kadenios => app}/wsgi.py | 0 11 files changed, 0 insertions(+), 0 deletions(-) rename {kadenios => app}/__init__.py (100%) rename {kadenios => app}/apps.py (100%) rename {kadenios => app}/settings/.gitignore (100%) rename {kadenios => app}/settings/__init__.py (100%) rename {kadenios => app}/settings/common.py (100%) rename {kadenios => app}/settings/local.py (100%) rename {kadenios => app}/settings/prod.py (100%) rename {kadenios => app}/settings/secret_example.py (100%) rename {kadenios => app}/urls.py (100%) rename {kadenios => app}/views.py (100%) rename {kadenios => app}/wsgi.py (100%) diff --git a/kadenios/__init__.py b/app/__init__.py similarity index 100% rename from kadenios/__init__.py rename to app/__init__.py diff --git a/kadenios/apps.py b/app/apps.py similarity index 100% rename from kadenios/apps.py rename to app/apps.py diff --git a/kadenios/settings/.gitignore b/app/settings/.gitignore similarity index 100% rename from kadenios/settings/.gitignore rename to app/settings/.gitignore diff --git a/kadenios/settings/__init__.py b/app/settings/__init__.py similarity index 100% rename from kadenios/settings/__init__.py rename to app/settings/__init__.py diff --git a/kadenios/settings/common.py b/app/settings/common.py similarity index 100% rename from kadenios/settings/common.py rename to app/settings/common.py diff --git a/kadenios/settings/local.py b/app/settings/local.py similarity index 100% rename from kadenios/settings/local.py rename to app/settings/local.py diff --git a/kadenios/settings/prod.py b/app/settings/prod.py similarity index 100% rename from kadenios/settings/prod.py rename to app/settings/prod.py diff --git a/kadenios/settings/secret_example.py b/app/settings/secret_example.py similarity index 100% rename from kadenios/settings/secret_example.py rename to app/settings/secret_example.py diff --git a/kadenios/urls.py b/app/urls.py similarity index 100% rename from kadenios/urls.py rename to app/urls.py diff --git a/kadenios/views.py b/app/views.py similarity index 100% rename from kadenios/views.py rename to app/views.py diff --git a/kadenios/wsgi.py b/app/wsgi.py similarity index 100% rename from kadenios/wsgi.py rename to app/wsgi.py From 7ae43d4d7ecbf879ddb0d46be30ba0c48178c7ed Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Tue, 9 Jul 2024 08:46:06 +0200 Subject: [PATCH 11/18] feat(kadenios): Update project --- .credentials/EMAIL_HOST | 1 + .credentials/FROM_EMAIL | 1 + .credentials/SECRET_KEY | 1 + .credentials/SERVER_EMAIL | 1 + app/apps.py | 5 - app/settings.py | 208 +++++++++++++++++++++++++++++++++ app/settings/.gitignore | 1 - app/settings/__init__.py | 0 app/settings/common.py | 151 ------------------------ app/settings/local.py | 55 --------- app/settings/prod.py | 70 ----------- app/settings/secret_example.py | 14 --- app/urls.py | 4 +- app/wsgi.py | 2 +- default.nix | 4 +- manage.py | 2 +- shared/__init__.py | 5 + shared/admin.py | 3 +- 18 files changed, 226 insertions(+), 302 deletions(-) create mode 100644 .credentials/EMAIL_HOST create mode 100644 .credentials/FROM_EMAIL create mode 100644 .credentials/SECRET_KEY create mode 100644 .credentials/SERVER_EMAIL delete mode 100644 app/apps.py create mode 100644 app/settings.py delete mode 100644 app/settings/.gitignore delete mode 100644 app/settings/__init__.py delete mode 100644 app/settings/common.py delete mode 100644 app/settings/local.py delete mode 100644 app/settings/prod.py delete mode 100644 app/settings/secret_example.py diff --git a/.credentials/EMAIL_HOST b/.credentials/EMAIL_HOST new file mode 100644 index 0000000..2fbb50c --- /dev/null +++ b/.credentials/EMAIL_HOST @@ -0,0 +1 @@ +localhost diff --git a/.credentials/FROM_EMAIL b/.credentials/FROM_EMAIL new file mode 100644 index 0000000..b5815bc --- /dev/null +++ b/.credentials/FROM_EMAIL @@ -0,0 +1 @@ +Kadenios diff --git a/.credentials/SECRET_KEY b/.credentials/SECRET_KEY new file mode 100644 index 0000000..545a6ec --- /dev/null +++ b/.credentials/SECRET_KEY @@ -0,0 +1 @@ +insecure-secret-key diff --git a/.credentials/SERVER_EMAIL b/.credentials/SERVER_EMAIL new file mode 100644 index 0000000..21c3954 --- /dev/null +++ b/.credentials/SERVER_EMAIL @@ -0,0 +1 @@ +kadenios@localhost diff --git a/app/apps.py b/app/apps.py deleted file mode 100644 index 2beb01c..0000000 --- a/app/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.contrib.staticfiles.apps import StaticFilesConfig - - -class IgnoreSrcStaticFilesConfig(StaticFilesConfig): - ignore_patterns = StaticFilesConfig.ignore_patterns + ["src/**"] diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..39f82ca --- /dev/null +++ b/app/settings.py @@ -0,0 +1,208 @@ +""" +Django settings for the kadenios project +""" + +from pathlib import Path + +from loadcredential import Credentials + +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ + +credentials = Credentials(env_prefix="KADENIOS_") + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# WARNING: keep the secret key used in production secret! +SECRET_KEY = credentials["SECRET_KEY"] + +# WARNING: don't run with debug turned on in production! +DEBUG = credentials.get_json("DEBUG", False) + +ALLOWED_HOSTS = credentials.get_json("ALLOWED_HOSTS", []) + +ADMINS = credentials.get_json("ADMINS", []) + + +### +# List the installed applications + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "shared.IgnoreSrcStaticFilesConfig", + "background_task", + "shared", + "elections", + "faqs", + "authens", +] + + +### +# List the installed middlewares + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + + +### +# The main url configuration + +ROOT_URLCONF = "app.urls" + + +### +# Template configuration: +# - Django Templating Language is used +# - Application directories can be used + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + + +### +# WSGI application configuration + +WSGI_APPLICATION = "app.wsgi.application" + + +### +# E-Mail configuration + +DEFAULT_FROM_EMAIL = credentials["FROM_EMAIL"] +EMAIL_HOST = credentials["EMAIL_HOST"] +SERVER_EMAIL = credentials["SERVER_EMAIL"] + +if DEBUG: + # Otherwise, use the default smtp backend + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + + +### +# Default primary key field type +# -> https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + + +### +# Database configuration +# -> https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = credentials.get_json( + "DATABASES", + { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } + }, +) + + +### +# Authentication configuration + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +AUTH_USER_MODEL = "elections.User" +AUTHENTICATION_BACKENDS = [ + "shared.auth.backends.PwdBackend", + "shared.auth.backends.CASBackend", + "shared.auth.backends.ElectionBackend", +] + +LOGIN_URL = reverse_lazy("authens:login") +LOGIN_REDIRECT_URL = "/" + +AUTHENS_USE_OLDCAS = False + + +### +# Internationalization configuration +# -> https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = "fr-fr" +TIME_ZONE = "Europe/Paris" + +USE_I18N = True +USE_L10N = True +USE_TZ = True + +LANGUAGES = [ + ("fr", _("Français")), + ("en", _("Anglais")), +] + +LOCALE_PATHS = [BASE_DIR / "shared" / "locale"] + + +### +# Static files (CSS, JavaScript, Images) configuration +# -> https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = "/static/" +STATIC_ROOT = credentials["STATIC_ROOT"] + + +### +# Background tasks configuration +# -> https://django4-background-tasks.readthedocs.io/en/latest/#settings + +BACKGROUND_TASK_RUN_ASYNC = True +BACKGROUND_TASK_ASYNC_THREADS = 4 + + +if DEBUG: + INSTALLED_APPS += [ + "debug_toolbar", + "django_browser_reload", + ] + + MIDDLEWARE += [ + "debug_toolbar.middleware.DebugToolbarMiddleware", + "django_browser_reload.middleware.BrowserReloadMiddleware", + ] + + INTERNAL_IPS = ["127.0.0.1"] + + DEBUG_TOOLBAR_CONFIG = {"INSERT_BEFORE": ""} diff --git a/app/settings/.gitignore b/app/settings/.gitignore deleted file mode 100644 index 2142506..0000000 --- a/app/settings/.gitignore +++ /dev/null @@ -1 +0,0 @@ -secret.py diff --git a/app/settings/__init__.py b/app/settings/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/settings/common.py b/app/settings/common.py deleted file mode 100644 index 4ab9846..0000000 --- a/app/settings/common.py +++ /dev/null @@ -1,151 +0,0 @@ -""" -Paramètres communs entre dev et prod -""" - -import os -import sys - -from django.urls import reverse_lazy -from django.utils.translation import gettext_lazy as _ - -# ############################################################################# -# Secrets -# ############################################################################# - -try: - from . import secret -except ImportError: - raise ImportError( - "The secret.py file is missing.\n" - "For a development environment, simply copy secret_example.py" - ) - - -def import_secret(name): - """ - Shorthand for importing a value from the secret module and raising an - informative exception if a secret is missing. - """ - try: - return getattr(secret, name) - except AttributeError: - raise RuntimeError("Secret missing: {}".format(name)) - - -SECRET_KEY = import_secret("SECRET_KEY") -ADMINS = import_secret("ADMINS") -SERVER_EMAIL = import_secret("SERVER_EMAIL") -EMAIL_HOST = import_secret("EMAIL_HOST") - - -# ############################################################################# -# Paramètres par défaut pour Django -# ############################################################################# - -DEBUG = False -TESTING = len(sys.argv) > 1 and sys.argv[1] == "test" -BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "kadenios.apps.IgnoreSrcStaticFilesConfig", - "background_task", - "shared", - "elections", - "faqs", - "authens", -] - -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.locale.LocaleMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", -] - -ROOT_URLCONF = "kadenios.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ], - }, - }, -] - -WSGI_APPLICATION = "kadenios.wsgi.application" - -DEFAULT_AUTO_FIELD = "django.db.models.AutoField" - -DEFAULT_FROM_EMAIL = "Kadenios " - -# ############################################################################# -# Paramètres d'authentification -# ############################################################################# - -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, -] - -AUTH_USER_MODEL = "elections.User" -AUTHENTICATION_BACKENDS = [ - "shared.auth.backends.PwdBackend", - "shared.auth.backends.CASBackend", - "shared.auth.backends.ElectionBackend", -] - -LOGIN_URL = reverse_lazy("authens:login") -LOGIN_REDIRECT_URL = "/" - -AUTHENS_USE_OLDCAS = False - -# ############################################################################# -# Paramètres de langage -# ############################################################################# - -LANGUAGE_CODE = "fr-fr" -TIME_ZONE = "Europe/Paris" - -USE_I18N = True -USE_L10N = True -USE_TZ = True - -LANGUAGES = [ - ("fr", _("Français")), - ("en", _("Anglais")), -] - -LOCALE_PATHS = [os.path.join(BASE_DIR, "shared", "locale")] - -# ############################################################################# -# Paramètres des fichiers statiques -# ############################################################################# - -STATIC_URL = "/static/" diff --git a/app/settings/local.py b/app/settings/local.py deleted file mode 100644 index 54ac597..0000000 --- a/app/settings/local.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Paramètre pour le développement local -""" - -import os - -from .common import * # noqa -from .common import BASE_DIR, INSTALLED_APPS, MIDDLEWARE, TESTING - -# ############################################################################# -# Paramètres Django -# ############################################################################# - -ALLOWED_HOSTS = [] - -DEBUG = True -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" - -STATIC_URL = "/static/" - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(BASE_DIR, "db.sqlite3"), - } -} - -# Use the default cache backend for local development -CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} - -# Pas besoin de sécurité en local -AUTH_PASSWORD_VALIDATORS = [] -PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] - -# ############################################################################# -# Paramètres pour la Django Debug Toolbar -# ############################################################################# - - -def show_toolbar(request): - """ - On active la debug-toolbar en mode développement local sauf : - - dans l'admin où ça ne sert pas à grand chose; - - si la variable d'environnement DJANGO_NO_DDT est à 1 → ça permet de la désactiver - sans modifier ce fichier en exécutant `export DJANGO_NO_DDT=1` dans le terminal - qui lance `./manage.py runserver`. - """ - env_no_ddt = bool(os.environ.get("DJANGO_NO_DDT", None)) - return DEBUG and not env_no_ddt and not request.path.startswith("/admin/") - - -if not TESTING: - INSTALLED_APPS = INSTALLED_APPS + ["debug_toolbar"] - MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE - DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": show_toolbar} diff --git a/app/settings/prod.py b/app/settings/prod.py deleted file mode 100644 index 8c9e7e6..0000000 --- a/app/settings/prod.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -Paramètres pour la mise en production -""" - -import os - -from .common import * # noqa -from .common import BASE_DIR, import_secret - -# ############################################################################# -# Secrets de production -# ############################################################################# - -REDIS_PASSWD = import_secret("REDIS_PASSWD") -REDIS_DB = import_secret("REDIS_DB") -REDIS_HOST = import_secret("REDIS_HOST") -REDIS_PORT = import_secret("REDIS_PORT") - -DBNAME = import_secret("DBNAME") -DBUSER = import_secret("DBUSER") -DBPASSWD = import_secret("DBPASSWD") - -# ############################################################################# -# À modifier possiblement lors de la mise en production -# ############################################################################# - -ALLOWED_HOSTS = ["vote.eleves.ens.fr"] - -STATIC_ROOT = os.path.join(os.path.dirname(BASE_DIR), "static") - -SERVER_EMAIL = "kadenios@www.eleves.ens.fr" - -# ############################################################################# -# Paramètres du cache -# ############################################################################# - -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://:{passwd}@{host}:{port}/{db}".format( - passwd=REDIS_PASSWD, host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB - ), - } -} - -# ############################################################################# -# Paramètres de la base de données -# ############################################################################# - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql_psycopg2", - "NAME": DBNAME, - "USER": DBUSER, - "PASSWORD": DBPASSWD, - "HOST": os.environ.get("DBHOST", ""), - } -} - -# ############################################################################# -# Paramètres Https -# ############################################################################# - -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True -SECURE_SSL_REDIRECT = True - -SECURE_HSTS_SECONDS = 31536000 -SECURE_HSTS_PRELOAD = True -SECURE_HSTS_INCLUDE_SUBDOMAINS = True diff --git a/app/settings/secret_example.py b/app/settings/secret_example.py deleted file mode 100644 index e61fe2a..0000000 --- a/app/settings/secret_example.py +++ /dev/null @@ -1,14 +0,0 @@ -SECRET_KEY = "f*!6tw8c74)&k_&4$toiw@e=8m00xv_(tmjf9_#wq30wg_7n^8" -ADMINS = None -SERVER_EMAIL = "root@localhost" -EMAIL_HOST = None - - -DBUSER = "kadenios" -DBNAME = "kadenios" -DBPASSWD = "O1LxCADDA6Px5SiKvifjvdp3DSjfbp" - -REDIS_PASSWD = "dummy" -REDIS_PORT = 6379 -REDIS_DB = 0 -REDIS_HOST = "127.0.0.1" diff --git a/app/urls.py b/app/urls.py index e1f0f26..4d53586 100644 --- a/app/urls.py +++ b/app/urls.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path @@ -16,7 +17,8 @@ urlpatterns = [ if settings.DEBUG: urlpatterns += [ path("admin/", admin.site.urls), - ] + path("__reload__/", include("django_browser_reload.urls")), + ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) if "debug_toolbar" in settings.INSTALLED_APPS: from debug_toolbar import urls as djdt_urls diff --git a/app/wsgi.py b/app/wsgi.py index b08712f..0fdc29c 100644 --- a/app/wsgi.py +++ b/app/wsgi.py @@ -11,6 +11,6 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'kadenios.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') application = get_wsgi_application() diff --git a/default.nix b/default.nix index 1a4b7fe..eed74c2 100644 --- a/default.nix +++ b/default.nix @@ -51,8 +51,8 @@ in env = { CREDENTIALS_DIRECTORY = builtins.toString ./.credentials; - CE_DEBUG = true; - CE_STATIC_ROOT = builtins.toString ./.static; + KADENIOS_DEBUG = "true"; + KADENIOS_STATIC_ROOT = builtins.toString ./.static; }; shellHook = '' diff --git a/manage.py b/manage.py index cc4c0ec..52ea74e 100755 --- a/manage.py +++ b/manage.py @@ -5,7 +5,7 @@ import sys def main(): - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "kadenios.settings.local") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/shared/__init__.py b/shared/__init__.py index e69de29..2beb01c 100644 --- a/shared/__init__.py +++ b/shared/__init__.py @@ -0,0 +1,5 @@ +from django.contrib.staticfiles.apps import StaticFilesConfig + + +class IgnoreSrcStaticFilesConfig(StaticFilesConfig): + ignore_patterns = StaticFilesConfig.ignore_patterns + ["src/**"] diff --git a/shared/admin.py b/shared/admin.py index 4b58b72..b1cbe06 100644 --- a/shared/admin.py +++ b/shared/admin.py @@ -1,6 +1,7 @@ from django.apps import apps from django.conf import settings from django.contrib import admin +from django.contrib.admin.sites import AlreadyRegistered if settings.DEBUG: models = apps.get_models() @@ -8,5 +9,5 @@ if settings.DEBUG: for model in models: try: admin.site.register(model) - except admin.sites.AlreadyRegistered: + except AlreadyRegistered: pass From c6aa72e843607621da88da9526bd457d9b097a56 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Tue, 9 Jul 2024 10:15:35 +0200 Subject: [PATCH 12/18] feat(settings): Add more email configuration --- app/settings.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/settings.py b/app/settings.py index 39f82ca..3e8ce17 100644 --- a/app/settings.py +++ b/app/settings.py @@ -96,13 +96,12 @@ WSGI_APPLICATION = "app.wsgi.application" # E-Mail configuration DEFAULT_FROM_EMAIL = credentials["FROM_EMAIL"] -EMAIL_HOST = credentials["EMAIL_HOST"] +EMAIL_HOST = credentials.get("EMAIL_HOST", "localhost") +EMAIL_HOST_PASSWORD = credentials.get("EMAIL_HOST_PASSWORD", "") +EMAIL_HOST_USER = credentials.get("EMAIL_HOST_USER", "") +EMAIL_USE_SSL = credentials.get("EMAIL_USE_SSL", False) SERVER_EMAIL = credentials["SERVER_EMAIL"] -if DEBUG: - # Otherwise, use the default smtp backend - EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" - ### # Default primary key field type @@ -193,6 +192,9 @@ BACKGROUND_TASK_ASYNC_THREADS = 4 if DEBUG: + # Print the e-mails in the console + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + INSTALLED_APPS += [ "debug_toolbar", "django_browser_reload", From 8f88eef5c7bf200272d1880dc204cc3357e44131 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Wed, 10 Jul 2024 13:51:24 +0200 Subject: [PATCH 13/18] feat(kadenios): Add typing --- default.nix | 1 + elections/forms.py | 3 + elections/mixins.py | 43 ++++++++---- elections/models.py | 58 +++++++++++----- elections/tasks.py | 2 +- elections/tests/test_models.py | 10 ++- elections/tests/test_views.py | 9 ++- elections/typing.py | 7 ++ elections/utils.py | 85 +++++++++++++++-------- elections/views.py | 31 ++++++--- pyproject.toml | 3 + shared/auth/__init__.py | 4 +- shared/auth/backends.py | 9 ++- shared/auth/forms.py | 9 ++- shared/auth/utils.py | 8 +-- shared/auth/views.py | 24 +++++-- shared/json/views.py | 18 +++-- shared/management/commands/createadmin.py | 17 ++++- shared/views.py | 2 +- 19 files changed, 245 insertions(+), 98 deletions(-) create mode 100644 elections/typing.py create mode 100644 pyproject.toml 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 From fd65aa36adf792f1a563334ca454ee3f66fae83d Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Wed, 10 Jul 2024 13:52:03 +0200 Subject: [PATCH 14/18] fix(send_mail): Save after each sent mail, as the bottleneck is in the SMTP --- elections/utils.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/elections/utils.py b/elections/utils.py index 722372a..556f7a0 100644 --- a/elections/utils.py +++ b/elections/utils.py @@ -461,9 +461,8 @@ def send_mail(election: "Election", subject: str, body: str, reply_to: str) -> N for m, v in messages: try: m.send() + v.has_valid_email = True except smtplib.SMTPException: v.has_valid_email = False - else: - v.has_valid_email = True - User.objects.bulk_update(voters, ["password", "has_valid_email"]) + v.save() From 1ca85957d210bbd18b45877baa2e9a3c75f636d8 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Wed, 10 Jul 2024 13:52:24 +0200 Subject: [PATCH 15/18] chore(misc): More idiomatic --- elections/staticdefs.py | 39 ++++++++++++++++++--------------------- shared/utils.py | 6 ++---- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/elections/staticdefs.py b/elections/staticdefs.py index 4f9d27a..eae7612 100644 --- a/elections/staticdefs.py +++ b/elections/staticdefs.py @@ -1,27 +1,24 @@ from django.utils.translation import gettext_lazy as _ -MAIL_VOTERS = ( - "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_VOTERS = """Dear {full_name}, -MAIL_VOTE_DELETED = ( - "Dear {full_name},\n" - "\n" - "Your vote for {election_name} has been removed." - "\n" - "-- \n" - "Kadenios" -) +Election URL: {election_url} +The election will take place from {start} to {end}. + +Your voter ID: {username} +Your password: {password} + +-- +Kadenios +""" + +MAIL_VOTE_DELETED = """Dear {full_name}, + +Your vote for {election_name} has been removed. + +-- +Kadenios +""" QUESTION_TYPES = [ ("assentiment", _("Assentiment")), diff --git a/shared/utils.py b/shared/utils.py index cda8450..b8365ad 100644 --- a/shared/utils.py +++ b/shared/utils.py @@ -5,7 +5,5 @@ def choices_length(choices): """Renvoie la longueur maximale des choix de choices""" - m = 0 - for c in choices: - m = max(m, len(c[0])) - return m + + return max(len(c[0]) for c in choices) From 3a0d499ba1ab9935595d2f0e850f826b198f7c5e Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Wed, 10 Jul 2024 15:33:42 +0200 Subject: [PATCH 16/18] chore(npins): Update and install pyrage --- 01-authens.patch | 6 +++--- default.nix | 2 ++ npins/sources.json | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/01-authens.patch b/01-authens.patch index e161a1d..4917c63 100644 --- a/01-authens.patch +++ b/01-authens.patch @@ -1,7 +1,7 @@ -diff --git a/authens/utils.py b/authens/utils.py +diff --git a/src/authens/utils.py b/src/authens/utils.py index 7306506..36063b6 100644 ---- a/authens/utils.py -+++ b/authens/utils.py +--- a/src/authens/utils.py ++++ b/src/authens/utils.py @@ -16,7 +16,7 @@ def get_cas_client(request): service_url=urlunparse( (request.scheme, request.get_host(), request.path, "", "", "") diff --git a/default.nix b/default.nix index f8cac0d..ded1719 100644 --- a/default.nix +++ b/default.nix @@ -14,6 +14,7 @@ let django-bulma-forms django-translated-fields loadcredential + pyrage ; authens = nix-pkgs.authens.overridePythonAttrs (old: { @@ -44,6 +45,7 @@ in ps.django-debug-toolbar ps.django-translated-fields ps.loadcredential + ps.pyrage ])) pkgs.gettext diff --git a/npins/sources.json b/npins/sources.json index fdb8ee4..b1f44d0 100644 --- a/npins/sources.json +++ b/npins/sources.json @@ -7,9 +7,9 @@ "url": "https://git.hubrecht.ovh/hubrecht/nix-pkgs.git" }, "branch": "main", - "revision": "46879d052e4a694ceb3027dbcff641c44e0ae1bd", + "revision": "22e90684e355bdd1e257c661b6275c7490f8c50b", "url": null, - "hash": "sha256-/Yn3NDYA76bv8x06jahLAJ2z54L0vFeAtQKzyW3MfGA=" + "hash": "sha256-yEZAv3bK7+gxNM8/31ONwdPIXlyQ5QnNnPDnWl3bXZo=" }, "nixpkgs": { "type": "Channel", From fb0e5a8a3765caf5e0eb8f9b482f198f256e54d6 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Thu, 11 Jul 2024 14:54:56 +0200 Subject: [PATCH 17/18] feat(elections): Pseudonimize tallied elections --- ..._vote_pseudonymous_user_alter_vote_user.py | 25 +++++++++++ .../migrations/0035_auto_20240711_1424.py | 44 +++++++++++++++++++ elections/models.py | 31 ++++++++++++- elections/utils.py | 10 ++--- 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 elections/migrations/0034_vote_pseudonymous_user_alter_vote_user.py create mode 100644 elections/migrations/0035_auto_20240711_1424.py diff --git a/elections/migrations/0034_vote_pseudonymous_user_alter_vote_user.py b/elections/migrations/0034_vote_pseudonymous_user_alter_vote_user.py new file mode 100644 index 0000000..3104a7c --- /dev/null +++ b/elections/migrations/0034_vote_pseudonymous_user_alter_vote_user.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.12 on 2024-07-11 12:01 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('elections', '0033_inactive_users'), + ] + + operations = [ + migrations.AddField( + model_name='vote', + name='pseudonymous_user', + field=models.CharField(blank=True, max_length=16), + ), + migrations.AlterField( + model_name='vote', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/elections/migrations/0035_auto_20240711_1424.py b/elections/migrations/0035_auto_20240711_1424.py new file mode 100644 index 0000000..6e5881b --- /dev/null +++ b/elections/migrations/0035_auto_20240711_1424.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.12 on 2024-07-11 12:24 + +import random + +from django.db import migrations + +alphabet = "abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + +def generate_password(size): + random.seed() + + return "".join(random.choice(alphabet) for _ in range(size)) + + +def pseudonymize_users(apps, _): + Question = apps.get_model("elections", "Question") + Vote = apps.get_model("elections", "Vote") + + votes = set() + + for q in Question.objects.filter(election__tallied=True).prefetch_related( + "options__vote_set" + ): + + for v in q.voters.all(): + pseudonym = generate_password(16) + + for opt in q.options.all(): + for vote in opt.vote_set.filter(user=v): + vote.pseudonymous_user = pseudonym + vote.user = None + votes.add(vote) + + Vote.objects.bulk_update(votes, ["pseudonymous_user", "user"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("elections", "0034_vote_pseudonymous_user_alter_vote_user"), + ] + + operations = [migrations.RunPython(pseudonymize_users)] diff --git a/elections/models.py b/elections/models.py index 7722717..70b772a 100644 --- a/elections/models.py +++ b/elections/models.py @@ -10,6 +10,7 @@ from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from shared.auth import CONNECTION_METHODS +from shared.auth.utils import generate_password from shared.json import Serializer from shared.utils import choices_length @@ -141,6 +142,26 @@ class Question(Serializer, models.Model): tally_function = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type]) tally_function(self) + @transaction.atomic + def pseudonymize(self): + """ + Generates a random id for each voter + """ + + options = list(self.options.prefetch_related("vote_set")) + votes: set[Vote] = set() + + for v in self.voters.all(): + pseudonym = generate_password(16) + + for opt in options: + for vote in opt.vote_set.filter(user=v): + vote.pseudonymous_user = pseudonym + vote.user = None + votes.add(vote) + + Vote.objects.bulk_update(votes, ["pseudonymous_user", "user"]) + @property def results(self) -> str: return render_to_string( @@ -172,6 +193,8 @@ class Question(Serializer, models.Model): class Option(Serializer, models.Model): + vote_set: models.Manager["Vote"] + question = models.ForeignKey( Question, related_name="options", on_delete=models.CASCADE ) @@ -210,7 +233,10 @@ class Option(Serializer, models.Model): class Vote(models.Model): option = models.ForeignKey(Option, on_delete=models.CASCADE) - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True + ) + pseudonymous_user = models.CharField(max_length=16, blank=True) class Meta: ordering = ["option"] @@ -219,6 +245,9 @@ class Vote(models.Model): class RankedVote(Vote): rank: "Rank" + class Meta: + abstract = True + class Rank(models.Model): vote = models.OneToOneField(Vote, on_delete=models.CASCADE) diff --git a/elections/utils.py b/elections/utils.py index 556f7a0..a62c700 100644 --- a/elections/utils.py +++ b/elections/utils.py @@ -301,16 +301,16 @@ class BallotsData: """Renvoie un tableau affichant les options sélectionnées pour chaque bulletin""" from .models import Vote - votes = Vote.objects.filter(option__question=question).select_related("user") + votes = Vote.objects.filter(option__question=question) options = list(question.options.all()) ballots = {} for v in votes: - ballot = ballots.get(v.user, [False] * len(options)) + ballot = ballots.get(v.pseudonymous_user, [False] * len(options)) ballot[options.index(v.option)] = True - ballots[v.user] = ballot + ballots[v.pseudonymous_user] = ballot return render_to_string( "elections/ballots/select.html", @@ -323,13 +323,13 @@ class BallotsData: from .models import Rank options = list(question.options.all()) - ranks = Rank.objects.select_related("vote__user").filter( + ranks = Rank.objects.select_related("vote").filter( vote__option__in=options ) ranks_by_user = {} for r in ranks: - user = r.vote.user + user = r.vote.pseudonymous_user if user in ranks_by_user: ranks_by_user[user].append(r.rank) else: From 4fd9e3a2117f54c4184b02fd3aef31626fcad149 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Thu, 11 Jul 2024 15:02:47 +0200 Subject: [PATCH 18/18] feat(elections): Pseudonimize votes after tallying --- elections/tasks.py | 8 ++++++++ elections/views.py | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/elections/tasks.py b/elections/tasks.py index d6b94bf..b5f564e 100644 --- a/elections/tasks.py +++ b/elections/tasks.py @@ -10,3 +10,11 @@ def send_election_mail(election_pk: int, subject: str, body: str, reply_to: str) send_mail(election, subject, body, reply_to) election.sent_mail = True election.save(update_fields=["sent_mail"]) + + +@background +def pseudonimize_election(election_pk: int): + election = Election.objects.get(pk=election_pk) + + for q in election.questions.all(): + q.pseudonymize() diff --git a/elections/views.py b/elections/views.py index 6b7ebc8..9bc0ace 100644 --- a/elections/views.py +++ b/elections/views.py @@ -41,13 +41,14 @@ from .mixins import ( ) 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 .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* @@ -299,6 +300,9 @@ class ElectionTallyView(ClosedElectionMixin, BackgroundUpdateView): election.tallied = True election.time_tallied = timezone.now() election.save() + + pseudonimize_election(election.pk) + return super().get(request, *args, **kwargs)