mirror of https://github.com/MieuxVoter/mvapi
parent
d9dce84767
commit
c8044acd75
@ -0,0 +1,29 @@
|
||||
name: test-driven development
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
- name: Static typing with mypy
|
||||
run: |
|
||||
# stop the build if there are MyPy errors
|
||||
mypy app
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
SQLITE=True pytest
|
@ -1,22 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Election, Vote, Token
|
||||
|
||||
admin.site.register(Vote)
|
||||
admin.site.register(Token)
|
||||
|
||||
|
||||
class ElectionAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"title",
|
||||
"candidates",
|
||||
"on_invitation_only",
|
||||
"num_grades",
|
||||
"start_at",
|
||||
"finish_at",
|
||||
"select_language",
|
||||
"restrict_results",
|
||||
)
|
||||
|
||||
|
||||
admin.site.register(Election, ElectionAdmin)
|
@ -1,5 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ElectionConfig(AppConfig):
|
||||
name = "election"
|
@ -1,53 +0,0 @@
|
||||
# Generated by Django 3.0.3 on 2020-04-16 14:45
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Election',
|
||||
fields=[
|
||||
('id', models.CharField(db_index=True, max_length=20, primary_key=True, serialize=False)),
|
||||
('title', models.CharField(max_length=255, verbose_name='Title')),
|
||||
('candidates', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255, verbose_name='Name'), size=None)),
|
||||
('on_invitation_only', models.BooleanField(default=False)),
|
||||
('num_grades', models.PositiveSmallIntegerField(verbose_name='Num. grades')),
|
||||
('start_at', models.IntegerField(default=1587048322, verbose_name='Start date')),
|
||||
('finish_at', models.IntegerField(default=1587048323, verbose_name='End date')),
|
||||
('select_language', models.CharField(default='en', max_length=2, verbose_name='Language')),
|
||||
('restrict_results', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Vote',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('grades_by_candidate', django.contrib.postgres.fields.ArrayField(base_field=models.SmallIntegerField(verbose_name='Note'), size=None)),
|
||||
('election', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='election.Election')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Token',
|
||||
fields=[
|
||||
('id', models.CharField(db_index=True, max_length=20, primary_key=True, serialize=False)),
|
||||
('email', models.EmailField(max_length=254)),
|
||||
('used', models.BooleanField(default=False, verbose_name='Used')),
|
||||
('election', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='election.Election')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
@ -1,29 +0,0 @@
|
||||
# Generated by Django 3.0.3 on 2020-04-28 10:52
|
||||
|
||||
from django.db import migrations, models
|
||||
import time
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('election', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='election',
|
||||
name='finish_at',
|
||||
field=models.IntegerField(default=time.time, verbose_name='End date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='election',
|
||||
name='restrict_results',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='election',
|
||||
name='start_at',
|
||||
field=models.IntegerField(default=time.time, verbose_name='Start date'),
|
||||
),
|
||||
]
|
@ -1,17 +0,0 @@
|
||||
# Generated by Django 3.0.3 on 2020-05-10 09:31
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('election', '0002_auto_20200428_1052'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='token',
|
||||
name='email',
|
||||
),
|
||||
]
|
@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.1.1 on 2021-04-10 15:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('election', '0003_remove_token_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='election',
|
||||
name='send_mail',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
@ -1,85 +0,0 @@
|
||||
import logging
|
||||
from time import time
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import IntegrityError, models
|
||||
from django.conf import settings
|
||||
from libs.django_randomprimary import RandomPrimaryIdModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Election(RandomPrimaryIdModel):
|
||||
|
||||
title = models.CharField("Title", max_length=255)
|
||||
candidates = ArrayField(models.CharField("Name", max_length=255))
|
||||
on_invitation_only = models.BooleanField(default=False)
|
||||
num_grades = models.PositiveSmallIntegerField("Num. grades", null=False)
|
||||
start_at = models.IntegerField("Start date", default=time)
|
||||
finish_at = models.IntegerField("End date", default=time)
|
||||
send_mail = models.BooleanField(default=True)
|
||||
# Language preference is used for emailing voters
|
||||
select_language = models.CharField("Language", max_length=2, default="en")
|
||||
# If results are restricted, one can see them only when the election is finished
|
||||
restrict_results = models.BooleanField(default=False)
|
||||
|
||||
# add some constraints before saving the database
|
||||
def save(self, *args, **kwargs):
|
||||
# make sure we don't ask for more grades than allowed in the database
|
||||
if self.num_grades is None:
|
||||
raise IntegrityError("Election requires a positive number of grades.")
|
||||
|
||||
if self.num_grades > settings.MAX_NUM_GRADES or self.num_grades <= 0:
|
||||
raise IntegrityError(
|
||||
"Max number of grades is %d. Asked for %d grades"
|
||||
% (self.num_grades, settings.MAX_NUM_GRADES)
|
||||
)
|
||||
|
||||
# check that the title is not empty
|
||||
if self.title is None or self.title == "":
|
||||
raise IntegrityError("Election requires a proper title")
|
||||
|
||||
# check that the language is known
|
||||
if not self.select_language in settings.LANGUAGE_AVAILABLE:
|
||||
raise IntegrityError(
|
||||
"Election is only available in " + settings.LANGUAGE_AVAILABLE
|
||||
)
|
||||
|
||||
# check if the end date is not in the past
|
||||
if self.finish_at <= round(time()):
|
||||
raise IntegrityError("The election cannot be over in the past")
|
||||
|
||||
# check if the end date is not before the begin date
|
||||
if self.start_at > self.finish_at:
|
||||
raise IntegrityError("The election can't end until it has started")
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Vote(models.Model):
|
||||
election = models.ForeignKey(Election, on_delete=models.CASCADE)
|
||||
grades_by_candidate = ArrayField(models.SmallIntegerField("Note"))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
logging.debug(self.grades_by_candidate, self.election.candidates)
|
||||
|
||||
if len(self.grades_by_candidate) != len(self.election.candidates):
|
||||
raise IntegrityError(
|
||||
"number of grades (%d) differs from number of candidates (%d)"
|
||||
% (len(self.grades_by_candidate), len(self.election.candidates))
|
||||
)
|
||||
|
||||
if not all(
|
||||
0 <= mention < self.election.num_grades
|
||||
for mention in self.grades_by_candidate
|
||||
):
|
||||
raise IntegrityError(
|
||||
"grades have to be between 0 and %d" % (self.election.num_grades - 1)
|
||||
)
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Token(RandomPrimaryIdModel):
|
||||
election = models.ForeignKey(Election, on_delete=models.CASCADE)
|
||||
used = models.BooleanField("Used", default=False)
|
@ -1,112 +0,0 @@
|
||||
from django.utils.text import slugify
|
||||
from rest_framework import serializers
|
||||
|
||||
from election.models import Election, Vote, Token
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class ElectionCreateSerializer(serializers.ModelSerializer):
|
||||
|
||||
elector_emails = serializers.ListField(
|
||||
child=serializers.EmailField(),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
def create(self, data):
|
||||
# Copy the validated_data
|
||||
validated_data = dict(data)
|
||||
validated_data["on_invitation_only"] = False
|
||||
if "elector_emails" in validated_data:
|
||||
if validated_data["elector_emails"] != []:
|
||||
validated_data["on_invitation_only"] = True
|
||||
validated_data.pop("elector_emails")
|
||||
return Election.objects.create(**validated_data)
|
||||
|
||||
class Meta:
|
||||
model = Election
|
||||
fields = (
|
||||
"title",
|
||||
"candidates",
|
||||
"on_invitation_only",
|
||||
"num_grades",
|
||||
"elector_emails",
|
||||
"start_at",
|
||||
"finish_at",
|
||||
"select_language",
|
||||
"restrict_results",
|
||||
"send_mail",
|
||||
)
|
||||
|
||||
def to_representation(self, instance):
|
||||
ret = super().to_representation(instance)
|
||||
ret["slug"] = slugify(instance.title)
|
||||
ret["id"] = instance.id
|
||||
ret["tokens"] = [token.id for token in Token.objects.filter(election=instance)]
|
||||
return ret
|
||||
|
||||
|
||||
class ElectionViewSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Election
|
||||
fields = "__all__"
|
||||
|
||||
def to_representation(self, instance):
|
||||
ret = super().to_representation(instance)
|
||||
ret["slug"] = slugify(instance.title)
|
||||
ret["id"] = instance.id
|
||||
return ret
|
||||
|
||||
|
||||
class VoteSerializer(serializers.ModelSerializer):
|
||||
|
||||
grades_by_candidate = serializers.ListField(
|
||||
child=serializers.IntegerField(
|
||||
min_value=0,
|
||||
max_value=settings.MAX_NUM_GRADES - 1,
|
||||
)
|
||||
)
|
||||
|
||||
token = serializers.CharField(write_only=True, required=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
# Copy the validated_data
|
||||
validated_data = dict(validated_data)
|
||||
try:
|
||||
validated_data.pop("token")
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return Vote.objects.create(**validated_data)
|
||||
|
||||
class Meta:
|
||||
model = Vote
|
||||
fields = (
|
||||
"grades_by_candidate",
|
||||
"election",
|
||||
"token",
|
||||
)
|
||||
|
||||
|
||||
# See https://github.com/MieuxVoter/mvapi/pull/5#discussion_r291891403 for explanations
|
||||
class Candidate:
|
||||
def __init__(self, name, idx, profile, grade):
|
||||
self.name = name
|
||||
self.id = idx
|
||||
self.profile = profile
|
||||
self.grade = grade
|
||||
|
||||
|
||||
class CandidateSerializer(serializers.Serializer):
|
||||
name = serializers.CharField()
|
||||
id = serializers.IntegerField(min_value=0)
|
||||
profile = serializers.DictField(child=serializers.IntegerField())
|
||||
grade = serializers.IntegerField(min_value=0, max_value=settings.MAX_NUM_GRADES)
|
||||
|
||||
|
||||
class LinkSerializer(serializers.Serializer):
|
||||
election_id = serializers.CharField()
|
||||
select_language = serializers.CharField(max_length=2, required=False)
|
||||
emails = serializers.ListField(
|
||||
child=serializers.EmailField(), write_only=True, required=True
|
||||
)
|
@ -1,215 +0,0 @@
|
||||
import logging
|
||||
from django.core import mail
|
||||
from django.db import IntegrityError
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
import election.urls as urls
|
||||
from election.models import MAX_NUM_GRADES, Election, Token, Vote
|
||||
from libs.majority_judgment import majority_judgment, compute_votes
|
||||
|
||||
|
||||
# To avoid undesirable logging messages due to 400 Error.
|
||||
logger = logging.getLogger("django.request")
|
||||
logger.setLevel(logging.ERROR)
|
||||
|
||||
|
||||
class ElectionCreateAPIViewTestCase(APITestCase):
|
||||
def test_create_election(self):
|
||||
title = "Super élection - utf-8 chars: 🤨 😐 😑 😶 🙄 😏 😣 😥 😮 🤐 😯 😪 😫 😴 😌 😛 😜 😝 🤤 😒 😓 😔 😕 🙃 🤑 😲 ☹️ 🙁 😖 😞 😟 😤 😢 😭 😦 😧 😨 😩 🤯 !"
|
||||
|
||||
candidates = [
|
||||
"Seb",
|
||||
"Pierre-Louis",
|
||||
]
|
||||
|
||||
response_post = self.client.post(
|
||||
urls.new_election(),
|
||||
{
|
||||
"title": title,
|
||||
"candidates": candidates,
|
||||
"on_invitation_only": False,
|
||||
"num_grades": 5,
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, response_post.status_code)
|
||||
|
||||
election_pk = response_post.data["id"]
|
||||
response_get = self.client.get(urls.election_details(election_pk))
|
||||
self.assertEqual(200, response_get.status_code)
|
||||
self.assertEqual(title, response_get.data["title"])
|
||||
self.assertEqual(candidates, response_get.data["candidates"])
|
||||
|
||||
def test_mandatory_fields(self):
|
||||
|
||||
# Missing num_grades
|
||||
self.assertRaises(
|
||||
IntegrityError,
|
||||
Election.objects.create,
|
||||
candidates=["Seb", "PL"],
|
||||
title="My election",
|
||||
)
|
||||
|
||||
# Missing candidates
|
||||
self.assertRaises(
|
||||
IntegrityError, Election.objects.create, num_grades=5, title="My election"
|
||||
)
|
||||
|
||||
# Missing title
|
||||
self.assertRaises(
|
||||
IntegrityError,
|
||||
Election.objects.create,
|
||||
candidates=["Seb", "PL"],
|
||||
num_grades=5,
|
||||
)
|
||||
|
||||
|
||||
class VoteOnInvitationViewTestCase(APITestCase):
|
||||
def setUp(self):
|
||||
self.election = Election.objects.create(
|
||||
title="Test election",
|
||||
candidates=[
|
||||
"Seb",
|
||||
"Pierre-Louis",
|
||||
],
|
||||
on_invitation_only=True,
|
||||
num_grades=5,
|
||||
)
|
||||
|
||||
self.token = Token.objects.create(
|
||||
election=self.election,
|
||||
email="joe@example.com",
|
||||
)
|
||||
|
||||
def test_valid_vote(self):
|
||||
response = self.client.post(
|
||||
urls.vote(),
|
||||
{
|
||||
"election": self.election.id,
|
||||
"grades_by_candidate": [0, 0],
|
||||
"token": self.token.id,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(201, response.status_code)
|
||||
|
||||
def test_vote_without_token(self):
|
||||
response = self.client.post(
|
||||
urls.vote(),
|
||||
{
|
||||
"election": self.election.id,
|
||||
"grades_by_candidate": [0, 0],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(400, response.status_code)
|
||||
|
||||
def test_vote_wrong_token(self):
|
||||
response = self.client.post(
|
||||
urls.vote(),
|
||||
{
|
||||
"election": self.election.id,
|
||||
"grades_by_candidate": [0, 0],
|
||||
# make sure the token is not the good one
|
||||
"token": self.token.id + "#abc",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(400, response.status_code)
|
||||
|
||||
def test_vote_already_used_token(self):
|
||||
for _ in range(2):
|
||||
response = self.client.post(
|
||||
urls.vote(),
|
||||
{
|
||||
"election": self.election.id,
|
||||
"grades_by_candidate": [0, 0],
|
||||
"token": self.token.id,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(400, response.status_code)
|
||||
|
||||
|
||||
class MailForCreationTestCase(TestCase):
|
||||
def test_send_mail(self):
|
||||
|
||||
response_post = self.client.post(
|
||||
urls.new_election(),
|
||||
{
|
||||
"title": "Test",
|
||||
"candidates": ["A", "B"],
|
||||
"on_invitation_only": False,
|
||||
"num_grades": 5,
|
||||
"elector_emails": ["name@example.com"],
|
||||
},
|
||||
)
|
||||
|
||||
election_pk = response_post.data["id"]
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertIn("/vote/" + election_pk, mail.outbox[0].body)
|
||||
|
||||
|
||||
class ResutsTestCase(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
self.election = Election.objects.create(
|
||||
title="Test election",
|
||||
candidates=[
|
||||
"Seb",
|
||||
"Pierre-Louis",
|
||||
],
|
||||
num_grades=5,
|
||||
on_invitation_only=True,
|
||||
)
|
||||
|
||||
self.votes = [
|
||||
Vote.objects.create(election=self.election, grades_by_candidate=[1, 2]),
|
||||
Vote.objects.create(election=self.election, grades_by_candidate=[1, 2]),
|
||||
Vote.objects.create(election=self.election, grades_by_candidate=[1, 3]),
|
||||
Vote.objects.create(election=self.election, grades_by_candidate=[2, 1]),
|
||||
]
|
||||
|
||||
self.election_no_vote = Election.objects.create(
|
||||
title="Election without votes", candidates=["Clement", "Seb"], num_grades=7
|
||||
)
|
||||
|
||||
def test_results_with_majority_judgment(self):
|
||||
profiles, scores, grades = compute_votes(
|
||||
[v.grades_by_candidate for v in self.votes], self.election.num_grades
|
||||
)
|
||||
sorted_indexes = majority_judgment(profiles)
|
||||
assert sorted_indexes == [1, 0]
|
||||
|
||||
def test_num_grades(self):
|
||||
self.assertRaises(
|
||||
IntegrityError,
|
||||
Vote.objects.create,
|
||||
election=self.election,
|
||||
grades_by_candidate=[1, 6],
|
||||
)
|
||||
|
||||
def test_view_existing_election(self):
|
||||
response = self.client.get(urls.results(self.election.id))
|
||||
self.assertEqual(200, response.status_code)
|
||||
|
||||
"""def test_ongoing_election(self):
|
||||
self.election.is_finished = False
|
||||
self.election.restrict_results = False
|
||||
self.election.save()
|
||||
response = self.client.get(
|
||||
urls.results(self.election.id)
|
||||
)
|
||||
self.assertEqual(400, response.status_code)
|
||||
self.election.is_finished = True
|
||||
self.election.restrict_results = True
|
||||
self.election.save()"""
|
||||
|
||||
def test_opened_election_without_vote(self):
|
||||
response = self.client.get(urls.results(self.election_no_vote.id))
|
||||
self.assertEqual(400, response.status_code)
|
||||
|
||||
def test_opened_election_with_vote(self):
|
||||
response = self.client.get(urls.results(self.election.id))
|
||||
self.assertEqual(200, response.status_code)
|
@ -1,33 +0,0 @@
|
||||
from django.urls import path, reverse
|
||||
|
||||
from election.views import *
|
||||
|
||||
app_name = "election"
|
||||
|
||||
urlpatterns = [
|
||||
path(r"", ElectionCreateAPIView.as_view(), name="create"),
|
||||
path(r"get/<str:pk>/", ElectionDetailsAPIView.as_view(), name="details"),
|
||||
path(r"vote/", VoteAPIView.as_view(), name="vote"),
|
||||
path(r"results/<str:pk>/", ResultAPIView.as_view(), name="results"),
|
||||
path(r"links/", LinkAPIView.as_view(), name="links"),
|
||||
]
|
||||
|
||||
|
||||
def new_election():
|
||||
return reverse("election:create")
|
||||
|
||||
|
||||
def election_details(election_pk):
|
||||
return reverse("election:details", args=(election_pk,))
|
||||
|
||||
|
||||
def vote():
|
||||
return reverse("election:vote")
|
||||
|
||||
|
||||
def results(election_pk):
|
||||
return reverse("election:results", args=(election_pk,))
|
||||
|
||||
|
||||
def links():
|
||||
return reverse("election:links")
|
@ -1,361 +0,0 @@
|
||||
import os
|
||||
import urllib
|
||||
import base64
|
||||
|
||||
from typing import Optional, Dict, Tuple, List
|
||||
from time import time
|
||||
from django.db import IntegrityError
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import activate, gettext
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.generics import CreateAPIView, RetrieveAPIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
import election.serializers as serializers
|
||||
from election.models import Election, Token, Vote
|
||||
from libs import majority_judgment as mj
|
||||
|
||||
# Error codes:
|
||||
UNKNOWN_ELECTION_ERROR = "E1: Unknown election"
|
||||
ONGOING_ELECTION_ERROR = "E2: Ongoing election"
|
||||
NO_VOTE_ERROR = "E3: No recorded vote"
|
||||
ELECTION_NOT_STARTED_ERROR = "E4: Election not started"
|
||||
ELECTION_FINISHED_ERROR = "E5: Election finished"
|
||||
INVITATION_ONLY_ERROR = "E6: Election on invitation only, please provide token"
|
||||
UNKNOWN_TOKEN_ERROR = "E7: Wrong token"
|
||||
USED_TOKEN_ERROR = "E8: Token already used"
|
||||
WRONG_ELECTION_ERROR = "E9: Parameters for the election are incorrect"
|
||||
SEND_MAIL_ERROR = "E10: Error sending email"
|
||||
|
||||
# A Grade is always given a int
|
||||
Grade = int
|
||||
|
||||
|
||||
def send_mails_invitation_api(list_email_token: list, election: str):
|
||||
"""
|
||||
Def to send the election invitation by API
|
||||
"""
|
||||
|
||||
for couple in list_email_token:
|
||||
token_get: str = f"?token={couple[1]}"
|
||||
merge_data: Dict[str, str] = {
|
||||
"invitation_url": f"{settings.SITE_URL}/vote/{election.id}{token_get}",
|
||||
"result_url": f"{settings.SITE_URL}/result/{election.id}",
|
||||
"title": election.title,
|
||||
}
|
||||
|
||||
if election.select_language not in settings.LANGUAGE_AVAILABLE:
|
||||
activate(settings.DEFAULT_LANGUAGE)
|
||||
else:
|
||||
activate(election.select_language)
|
||||
|
||||
text_body = render_to_string("election/mail_invitation.txt", merge_data)
|
||||
html_body = render_to_string("election/mail_invitation.html", merge_data)
|
||||
|
||||
data = urllib.parse.urlencode(
|
||||
{
|
||||
"from": "Mieux Voter <" + settings.DEFAULT_FROM_EMAIL + ">",
|
||||
"to": couple[0],
|
||||
"subject": f"[{gettext('Mieux Voter')}] {election.title}",
|
||||
"text": text_body,
|
||||
"html": html_body,
|
||||
"o:tracking": False,
|
||||
"o:tag": "Invitation",
|
||||
"o:require-tls": settings.EMAIL_USE_TLS,
|
||||
"o:skip-verification": settings.EMAIL_SKIP_VERIFICATION,
|
||||
},
|
||||
doseq=True,
|
||||
).encode()
|
||||
|
||||
send_api(data)
|
||||
|
||||
|
||||
def send_mail_api(email: str, text_body, html_body, title):
|
||||
"""
|
||||
Def to send mails by API
|
||||
"""
|
||||
data = urllib.parse.urlencode(
|
||||
{
|
||||
"from": "Mieux Voter <" + settings.DEFAULT_FROM_EMAIL + ">",
|
||||
"to": email,
|
||||
"subject": f"[{gettext('Mieux Voter')}] {title}",
|
||||
"text": text_body,
|
||||
"html": html_body,
|
||||
"o:tracking": False,
|
||||
"o:tag": "Invitation",
|
||||
"o:require-tls": settings.EMAIL_USE_TLS,
|
||||
"o:skip-verification": settings.EMAIL_SKIP_VERIFICATION,
|
||||
},
|
||||
doseq=True,
|
||||
).encode()
|
||||
send_api(data)
|
||||
|
||||
|
||||
def send_api(data):
|
||||
"""
|
||||
def to do api request
|
||||
"""
|
||||
request = urllib.request.Request(settings.EMAIL_API_DOMAIN, data=data)
|
||||
encoded_token = base64.b64encode(
|
||||
("api:" + settings.EMAIL_API_KEY).encode("ascii")
|
||||
).decode("ascii")
|
||||
request.add_header("Authorization", "Basic {}".format(encoded_token))
|
||||
try:
|
||||
urllib.request.urlopen(request)
|
||||
except Exception as err:
|
||||
return err
|
||||
|
||||
|
||||
def send_mails_invitation_smtp(list_email_token: list, election: str):
|
||||
"""
|
||||
Def to send the election invitation by SMTP
|
||||
"""
|
||||
for couple in list_email_token:
|
||||
token_get: str = f"?token={couple[1]}"
|
||||
merge_data: Dict[str, str] = {
|
||||
"invitation_url": f"{settings.SITE_URL}/vote/{election.id}{token_get}",
|
||||
"result_url": f"{settings.SITE_URL}/result/{election.id}",
|
||||
"title": election.title,
|
||||
}
|
||||
|
||||
if election.select_language not in settings.LANGUAGE_AVAILABLE:
|
||||
activate(settings.DEFAULT_LANGUAGE)
|
||||
else:
|
||||
activate(election.select_language)
|
||||
|
||||
text_body = render_to_string("election/mail_invitation.txt", merge_data)
|
||||
html_body = render_to_string("election/mail_invitation.html", merge_data)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
f"[{gettext('Mieux Voter')}] {election.title}",
|
||||
text_body,
|
||||
settings.EMAIL_HOST_USER,
|
||||
[couple[0]],
|
||||
)
|
||||
msg.attach_alternative(html_body, "text/html")
|
||||
msg.send()
|
||||
|
||||
|
||||
class ElectionCreateAPIView(CreateAPIView):
|
||||
serializer_class = serializers.ElectionCreateSerializer
|
||||
|
||||
def create(self, request: Request, *args, **kwargs) -> Response:
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
election = serializer.save()
|
||||
electors_emails = serializer.validated_data.get("elector_emails", [])
|
||||
|
||||
list_email_token = []
|
||||
for email in electors_emails:
|
||||
token = Token.objects.create(
|
||||
election=election,
|
||||
)
|
||||
list_email_token.append([email, token.id])
|
||||
|
||||
if election.send_mail:
|
||||
if settings.EMAIL_TYPE == "API":
|
||||
send_mails_invitation_api(list_email_token, election)
|
||||
else:
|
||||
send_mails_invitation_smtp(list_email_token, election)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
|
||||
|
||||
class ElectionDetailsAPIView(RetrieveAPIView):
|
||||
serializer_class = serializers.ElectionViewSerializer
|
||||
|
||||
def get(self, request: Request, pk: str, **kwargs) -> Response:
|
||||
|
||||
try:
|
||||
election = Election.objects.get(id=pk)
|
||||
except Election.DoesNotExist:
|
||||
return Response(
|
||||
UNKNOWN_ELECTION_ERROR,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if round(time()) < election.start_at:
|
||||
return Response(
|
||||
ELECTION_NOT_STARTED_ERROR,
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
serializer = serializers.ElectionViewSerializer(election)
|
||||
return Response(
|
||||
serializer.data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class VoteAPIView(CreateAPIView):
|
||||
"""
|
||||
View to vote in an election
|
||||
"""
|
||||
|
||||
serializer_class = serializers.VoteSerializer
|
||||
|
||||
def create(self, request: Request, *args, **kwargs) -> Response:
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
election = serializer.validated_data["election"]
|
||||
|
||||
if round(time()) >= election.finish_at:
|
||||
return Response(
|
||||
ELECTION_FINISHED_ERROR,
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if election.on_invitation_only:
|
||||
try:
|
||||
token = serializer.validated_data["token"]
|
||||
except KeyError:
|
||||
return Response(
|
||||
INVITATION_ONLY_ERROR,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
token_object = Token.objects.get(
|
||||
election=election,
|
||||
id=token,
|
||||
)
|
||||
except Token.DoesNotExist:
|
||||
return Response(
|
||||
UNKNOWN_TOKEN_ERROR,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if token_object.used:
|
||||
return Response(
|
||||
USED_TOKEN_ERROR,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
token_object.used = True
|
||||
token_object.save()
|
||||
|
||||
# Dealing with potential errors like the number of mentions
|
||||
# differs from the number of candidates.
|
||||
try:
|
||||
self.perform_create(serializer)
|
||||
except IntegrityError:
|
||||
return Response(WRONG_ELECTION_ERROR, status=status.HTTP_400_BAD_REQUEST)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
|
||||
class ResultAPIView(APIView):
|
||||
"""
|
||||
View to list the result of an election using majority judgment.
|
||||
"""
|
||||
|
||||
def get(self, request, pk, **kwargs):
|
||||
|
||||
try:
|
||||
election = Election.objects.get(id=pk)
|
||||
except Election.DoesNotExist:
|
||||
return Response(
|
||||
UNKNOWN_ELECTION_ERROR,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
WRONG_ELECTION_ERROR,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if election.restrict_results and round(time()) < election.finish_at:
|
||||
return Response(
|
||||
ONGOING_ELECTION_ERROR,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
votes = Vote.objects.filter(election=election)
|
||||
|
||||
if len(votes) == 0:
|
||||
return Response(
|
||||
NO_VOTE_ERROR,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
votes: List[List[Grade]] = [v.grades_by_candidate for v in votes]
|
||||
|
||||
merit_profiles: List[Dict[Grade, int]] = mj.votes_to_merit_profiles(
|
||||
votes, range(election.num_grades)
|
||||
)
|
||||
indexed_values: List[
|
||||
Tuple[int, mj.MajorityValue]
|
||||
] = mj.sort_by_value_with_index(
|
||||
[mj.MajorityValue(profil) for profil in merit_profiles]
|
||||
)
|
||||
print(len(indexed_values))
|
||||
|
||||
candidates = [
|
||||
serializers.Candidate(
|
||||
election.candidates[idx],
|
||||
idx,
|
||||
merit_profiles[idx],
|
||||
value.grade,
|
||||
)
|
||||
for idx, value in indexed_values
|
||||
]
|
||||
serializer = serializers.CandidateSerializer(candidates, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class LinkAPIView(CreateAPIView):
|
||||
"""
|
||||
View to send the result and vote links if it is an open election
|
||||
"""
|
||||
|
||||
serializer_class = serializers.LinkSerializer
|
||||
|
||||
def create(self, request: Request, *args, **kwargs) -> Response:
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
election_id = serializer.validated_data["election_id"]
|
||||
select_language = serializer.validated_data["select_language"]
|
||||
|
||||
try:
|
||||
election = Election.objects.get(id=election_id)
|
||||
except Election.DoesNotExist:
|
||||
return Response(
|
||||
WRONG_ELECTION_ERROR,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
emails = serializer.validated_data.get("emails", [])
|
||||
|
||||
merge_data: Dict[str, str] = {
|
||||
"result_url": f"{settings.SITE_URL}/result/{election.id}",
|
||||
"title": election.title,
|
||||
}
|
||||
|
||||
if (
|
||||
select_language == None
|
||||
or select_language not in settings.LANGUAGE_AVAILABLE
|
||||
):
|
||||
select_language = election.select_language
|
||||
|
||||
activate(select_language)
|
||||
|
||||
if election.on_invitation_only:
|
||||
text_body = render_to_string("election/mail_one_link.txt", merge_data)
|
||||
html_body = render_to_string("election/mail_one_link.html", merge_data)
|
||||
|
||||
else:
|
||||
merge_data["vote_url"] = f"{settings.SITE_URL}/vote/{election.id}"
|
||||
text_body = render_to_string("election/mail_two_links.txt", merge_data)
|
||||
html_body = render_to_string("election/mail_two_links.html", merge_data)
|
||||
|
||||
send_status = send_mail_api(emails, text_body, html_body, election.title)
|
||||
|
||||
return Response(status=send_status)
|
@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mvapi.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
@ -1,160 +0,0 @@
|
||||
"""
|
||||
Django settings for mvapi project.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/2.1/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/2.1/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.http.request import RAISE_ERROR
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
SITE_URL = os.environ["SITE_URL"]
|
||||
DEBUG = os.environ["DJANGO_DEBUG"] == "True"
|
||||
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
|
||||
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
|
||||
MAX_NUM_GRADES = int(os.environ["MAX_NUM_GRADES"])
|
||||
DEFAULT_LANGUAGE = "en"
|
||||
LANGUAGE_AVAILABLE = os.environ.get("LANGUAGE_AVAILABLE", DEFAULT_LANGUAGE)
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django_extensions",
|
||||
"dbbackup", # django-dbbackup
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"election",
|
||||
]
|
||||
|
||||
DBBACKUP_STORAGE = "django.core.files.storage.FileSystemStorage"
|
||||
DBBACKUP_STORAGE_OPTIONS = {"location": "backup"}
|
||||
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
]
|
||||
|
||||
CORS_ORIGIN_ALLOW_ALL = True
|
||||
|
||||
ROOT_URLCONF = "mvapi.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(BASE_DIR, "templates")],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.i18n",
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "mvapi.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/2.1/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "postgres",
|
||||
"USER": "postgres",
|
||||
"HOST": "db",
|
||||
"PORT": 5432,
|
||||
}
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators
|
||||
|
||||
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",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/2.1/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/2.1/howto/static-files/
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
|
||||
################################################################################
|
||||
# #
|
||||
# MAIL SETTINGS #
|
||||
# #
|
||||
################################################################################
|
||||
if os.environ["EMAIL_USE_TLS"] in ("True", "true", "on", "1"):
|
||||
EMAIL_USE_TLS = True
|
||||
else:
|
||||
EMAIL_USE_TLS = False
|
||||
|
||||
EMAIL_TYPE = os.environ["EMAIL_TYPE"]
|
||||
if EMAIL_TYPE == "API":
|
||||
# To use the Mailgun's API
|
||||
EMAIL_API_KEY = os.environ["EMAIL_API_KEY"]
|
||||
EMAIL_API_DOMAIN = os.environ["EMAIL_API_DOMAIN"]
|
||||
DEFAULT_FROM_EMAIL = os.environ["DEFAULT_FROM_EMAIL"]
|
||||
EMAIL_SKIP_VERIFICATION = os.environ["EMAIL_SKIP_VERIFICATION"]
|
||||
|
||||
elif EMAIL_TYPE == "SMTP":
|
||||
# To use with a SMTP service
|
||||
EMAIL_BACKEND = os.environ["EMAIL_BACKEND"]
|
||||
EMAIL_HOST_USER = os.environ["EMAIL_HOST_USER"]
|
||||
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
|
||||
EMAIL_PORT = os.environ["EMAIL_PORT"]
|
||||
EMAIL_HOST = os.environ["EMAIL_HOST"]
|
||||
|
||||
else:
|
||||
raise ValueError("API and SMTP are only available")
|
@ -1,26 +0,0 @@
|
||||
"""mvapi URL Configuration
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/2.1/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
api_urls = [
|
||||
path("election/", include("election.urls")),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/", include(api_urls)),
|
||||
]
|
@ -1,16 +0,0 @@
|
||||
"""
|
||||
WSGI config for mvapi project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mvapi.settings")
|
||||
|
||||
application = get_wsgi_application()
|
@ -1,52 +0,0 @@
|
||||
"""
|
||||
Add voters to a started election.
|
||||
"""
|
||||
from typing import List, Dict
|
||||
import os
|
||||
import pathlib
|
||||
import argparse
|
||||
import django
|
||||
|
||||
|
||||
def load_mvapi():
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.append("../")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mvapi.settings")
|
||||
django.setup()
|
||||
|
||||
|
||||
load_mvapi()
|
||||
from election.models import Election, Vote, Token
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--election_id", type=str)
|
||||
parser.add_argument("--num_tokens", type=int)
|
||||
parser.add_argument("--output", type=str, required=True)
|
||||
args = parser.parse_args()
|
||||
print(args)
|
||||
|
||||
try:
|
||||
election = Election.objects.get(id=args.election_id)
|
||||
except Election.DoesNotExist:
|
||||
raise ValueError(f"The election {election} does not exist")
|
||||
|
||||
tokens = []
|
||||
for email in range(args.num_tokens):
|
||||
token = Token.objects.create(election=election)
|
||||
tokens.append(token.id)
|
||||
# print(token)
|
||||
# send_mail_invitation(email, election, token.id)
|
||||
|
||||
with open(args.output, "w") as fid:
|
||||
fid.write(
|
||||
"\n".join(
|
||||
[
|
||||
f"https://app.mieuxvoter.fr/vote/{args.election_id}/?token={t}"
|
||||
for t in tokens
|
||||
]
|
||||
)
|
||||
)
|
Loading…
Reference in new issue