From fb0e5a8a3765caf5e0eb8f9b482f198f256e54d6 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Thu, 11 Jul 2024 14:54:56 +0200 Subject: [PATCH] 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: