feat(elections): Pseudonimize tallied elections

This commit is contained in:
Tom Hubrecht 2024-07-11 14:54:56 +02:00
parent 3a0d499ba1
commit fb0e5a8a37
Signed by: thubrecht
SSH key fingerprint: SHA256:r+nK/SIcWlJ0zFZJGHtlAoRwq1Rm+WcKAm5ADYMoQPc
4 changed files with 104 additions and 6 deletions

View file

@ -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),
),
]

View file

@ -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)]

View file

@ -10,6 +10,7 @@ from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from shared.auth import CONNECTION_METHODS from shared.auth import CONNECTION_METHODS
from shared.auth.utils import generate_password
from shared.json import Serializer from shared.json import Serializer
from shared.utils import choices_length 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 = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type])
tally_function(self) 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 @property
def results(self) -> str: def results(self) -> str:
return render_to_string( return render_to_string(
@ -172,6 +193,8 @@ class Question(Serializer, models.Model):
class Option(Serializer, models.Model): class Option(Serializer, models.Model):
vote_set: models.Manager["Vote"]
question = models.ForeignKey( question = models.ForeignKey(
Question, related_name="options", on_delete=models.CASCADE Question, related_name="options", on_delete=models.CASCADE
) )
@ -210,7 +233,10 @@ class Option(Serializer, models.Model):
class Vote(models.Model): class Vote(models.Model):
option = models.ForeignKey(Option, on_delete=models.CASCADE) 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: class Meta:
ordering = ["option"] ordering = ["option"]
@ -219,6 +245,9 @@ class Vote(models.Model):
class RankedVote(Vote): class RankedVote(Vote):
rank: "Rank" rank: "Rank"
class Meta:
abstract = True
class Rank(models.Model): class Rank(models.Model):
vote = models.OneToOneField(Vote, on_delete=models.CASCADE) vote = models.OneToOneField(Vote, on_delete=models.CASCADE)

View file

@ -301,16 +301,16 @@ class BallotsData:
"""Renvoie un tableau affichant les options sélectionnées pour chaque bulletin""" """Renvoie un tableau affichant les options sélectionnées pour chaque bulletin"""
from .models import Vote 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()) options = list(question.options.all())
ballots = {} ballots = {}
for v in votes: 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 ballot[options.index(v.option)] = True
ballots[v.user] = ballot ballots[v.pseudonymous_user] = ballot
return render_to_string( return render_to_string(
"elections/ballots/select.html", "elections/ballots/select.html",
@ -323,13 +323,13 @@ class BallotsData:
from .models import Rank from .models import Rank
options = list(question.options.all()) options = list(question.options.all())
ranks = Rank.objects.select_related("vote__user").filter( ranks = Rank.objects.select_related("vote").filter(
vote__option__in=options vote__option__in=options
) )
ranks_by_user = {} ranks_by_user = {}
for r in ranks: for r in ranks:
user = r.vote.user user = r.vote.pseudonymous_user
if user in ranks_by_user: if user in ranks_by_user:
ranks_by_user[user].append(r.rank) ranks_by_user[user].append(r.rank)
else: else: