Merge branch 'thubrecht/authens' into 'master'

Authens

See merge request klub-dev-ens/kadenios!3
This commit is contained in:
Tom Hubrecht 2021-03-15 16:05:11 +01:00
commit ad790b4676
43 changed files with 1544 additions and 227 deletions

View file

@ -4,6 +4,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Election, Option, Question from .models import Election, Option, Question
from .utils import check_csv
class ElectionForm(forms.ModelForm): class ElectionForm(forms.ModelForm):
@ -21,19 +22,43 @@ class ElectionForm(forms.ModelForm):
class Meta: class Meta:
model = Election model = Election
fields = ["name", "description", "start_date", "end_date"] fields = ["name", "description", "restricted", "start_date", "end_date"]
class UploadVotersForm(forms.Form):
csv_file = forms.FileField(label=_("Sélectionnez un fichier .csv"))
def clean_csv_file(self):
csv_file = self.cleaned_data["csv_file"]
if csv_file.name.lower().endswith(".csv"):
for error in check_csv(csv_file):
self.add_error(None, error)
else:
self.add_error(
None,
_("Extension de fichier invalide, il faut un fichier au format CSV."),
)
csv_file.seek(0)
return csv_file
class VoterMailForm(forms.Form):
objet = forms.CharField()
message = forms.CharField(widget=forms.Textarea)
class QuestionForm(forms.ModelForm): class QuestionForm(forms.ModelForm):
class Meta: class Meta:
model = Question model = Question
fields = ["text"] fields = ["text"]
widgets = {"text": forms.TextInput}
class OptionForm(forms.ModelForm): class OptionForm(forms.ModelForm):
class Meta: class Meta:
model = Option model = Option
fields = ["text"] fields = ["text"]
widgets = {"text": forms.TextInput}
class VoteForm(forms.ModelForm): class VoteForm(forms.ModelForm):

View file

@ -1,8 +1,11 @@
# Generated by Django 2.2.17 on 2020-11-20 15:31 # Generated by Django 2.2.17 on 2020-12-20 16:11
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -10,40 +13,243 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("auth", "0011_update_proxy_permissions"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Election', name="User",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=255, verbose_name='nom')), "id",
('short_name', models.SlugField(unique=True, verbose_name='nom bref')), models.AutoField(
('description', models.TextField(blank=True, verbose_name='description')), auto_created=True,
('start_time', models.DateTimeField(verbose_name='date et heure de début')), primary_key=True,
('end_time', models.DateTimeField(verbose_name='date et heure de fin')), serialize=False,
('results_public', models.BooleanField(default=False, verbose_name='résultats publics')), verbose_name="ID",
('tallied', models.BooleanField(default=False, verbose_name='dépouillée')), ),
('archived', models.BooleanField(default=False, verbose_name='archivée')), ),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), ("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=30, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.Group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.Permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Question', name="Election",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('text', models.TextField(verbose_name='question')), "id",
('election', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='elections.Election')), models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255, verbose_name="nom")),
("short_name", models.SlugField(unique=True, verbose_name="nom bref")),
(
"description",
models.TextField(blank=True, verbose_name="description"),
),
(
"start_date",
models.DateTimeField(verbose_name="date et heure de début"),
),
("end_date", models.DateTimeField(verbose_name="date et heure de fin")),
(
"results_public",
models.BooleanField(
default=False, verbose_name="résultats publics"
),
),
(
"tallied",
models.BooleanField(default=False, verbose_name="dépouillée"),
),
(
"archived",
models.BooleanField(default=False, verbose_name="archivée"),
),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
], ],
options={
"ordering": ["-start_date", "-end_date"],
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='Option', name="Question",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('text', models.TextField(verbose_name='texte')), "id",
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='elections.Question')), models.AutoField(
('voters', models.ManyToManyField(related_name='votes', to=settings.AUTH_USER_MODEL)), auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("text", models.TextField(verbose_name="question")),
(
"max_votes",
models.PositiveSmallIntegerField(
default=0, verbose_name="nombre maximal de votes reçus"
),
),
(
"election",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="questions",
to="elections.Election",
),
),
], ],
options={
"ordering": ["id"],
},
),
migrations.CreateModel(
name="Option",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("text", models.TextField(verbose_name="texte")),
(
"nb_votes",
models.PositiveSmallIntegerField(
default=0, verbose_name="nombre de votes reçus"
),
),
(
"question",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="options",
to="elections.Question",
),
),
(
"voters",
models.ManyToManyField(
related_name="votes", to=settings.AUTH_USER_MODEL
),
),
],
options={
"ordering": ["id"],
},
), ),
] ]

View file

@ -1,23 +0,0 @@
# Generated by Django 2.2.17 on 2020-12-18 14:52
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("elections", "0001_initial"),
]
operations = [
migrations.RenameField(
model_name="election",
old_name="end_time",
new_name="end",
),
migrations.RenameField(
model_name="election",
old_name="start_time",
new_name="start",
),
]

View file

@ -0,0 +1,36 @@
# Generated by Django 2.2.17 on 2020-12-20 16:35
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("elections", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="user",
name="election",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="registered_voters",
to="elections.Election",
),
),
migrations.AlterField(
model_name="election",
name="created_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="elections_created",
to=settings.AUTH_USER_MODEL,
),
),
]

View file

@ -1,23 +0,0 @@
# Generated by Django 2.2.17 on 2020-12-18 19:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("elections", "0002_auto_20201218_1452"),
]
operations = [
migrations.RenameField(
model_name="election",
old_name="end",
new_name="end_date",
),
migrations.RenameField(
model_name="election",
old_name="start",
new_name="start_date",
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 2.2.17 on 2020-12-20 17:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("elections", "0002_auto_20201220_1735"),
]
operations = [
migrations.AddField(
model_name="election",
name="restricted",
field=models.BooleanField(
default=True, verbose_name="restreint le vote à une liste de personnes"
),
),
]

View file

@ -0,0 +1,25 @@
# Generated by Django 2.2.17 on 2020-12-20 17:47
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("elections", "0003_election_restricted"),
]
operations = [
migrations.AlterField(
model_name="user",
name="election",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="registered_voters",
to="elections.Election",
),
),
]

View file

@ -1,20 +0,0 @@
# Generated by Django 2.2.17 on 2020-12-19 16:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("elections", "0003_auto_20201218_1954"),
]
operations = [
migrations.AddField(
model_name="option",
name="nb_votes",
field=models.PositiveSmallIntegerField(
default=0, verbose_name="nombre de votes reçus"
),
),
]

View file

@ -1,20 +0,0 @@
# Generated by Django 2.2.17 on 2020-12-19 17:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("elections", "0004_option_nb_votes"),
]
operations = [
migrations.AddField(
model_name="question",
name="max_votes",
field=models.PositiveSmallIntegerField(
default=0, verbose_name="nombre maximal de votes reçus"
),
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 2.2.17 on 2020-12-23 17:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("elections", "0004_auto_20201220_1847"),
]
operations = [
migrations.AddField(
model_name="user",
name="full_name",
field=models.CharField(
blank=True, max_length=150, verbose_name="Nom et Prénom"
),
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 2.2.17 on 2020-12-23 19:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("elections", "0005_user_full_name"),
]
operations = [
migrations.AddField(
model_name="election",
name="sent_mail",
field=models.BooleanField(
default=False, verbose_name="mail avec les identifiants envoyé"
),
),
]

View file

@ -0,0 +1,21 @@
# Generated by Django 2.2.17 on 2020-12-24 00:18
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("elections", "0006_election_sent_mail"),
]
operations = [
migrations.AddField(
model_name="election",
name="voters",
field=models.ManyToManyField(
related_name="cast_elections", to=settings.AUTH_USER_MODEL
),
),
]

View file

@ -0,0 +1,21 @@
# Generated by Django 2.2.17 on 2020-12-24 00:38
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("elections", "0007_election_voters"),
]
operations = [
migrations.AddField(
model_name="question",
name="voters",
field=models.ManyToManyField(
related_name="cast_questions", to=settings.AUTH_USER_MODEL
),
),
]

View file

@ -1,3 +1,5 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
@ -35,7 +37,7 @@ class RestrictAccessMixin(SelectElectionMixin):
return qs.none() return qs.none()
class OpenElectionOnly(RestrictAccessMixin): class OpenElectionOnlyMixin(RestrictAccessMixin):
"""N'autorise la vue que lorsque l'élection est ouverte""" """N'autorise la vue que lorsque l'élection est ouverte"""
def get_filters(self): def get_filters(self):
@ -51,9 +53,12 @@ class OpenElectionOnly(RestrictAccessMixin):
return filters return filters
class CreatorOnlyMixin(RestrictAccessMixin): class CreatorOnlyMixin(LoginRequiredMixin, RestrictAccessMixin):
"""Restreint l'accès au créateurice de l'élection""" """Restreint l'accès au créateurice de l'élection"""
def get_next_url(self):
return reverse("kadenios")
def get_filters(self): def get_filters(self):
filters = super().get_filters() filters = super().get_filters()
# TODO: change the way we collect the user according to the model used # TODO: change the way we collect the user according to the model used
@ -71,7 +76,7 @@ class CreatorOnlyEditMixin(CreatorOnlyMixin, SingleObjectMixin):
return filters return filters
class AdministratorOnlyMixin: class AdministratorOnlyMixin(LoginRequiredMixin):
"""Restreint l'accès aux admins""" """Restreint l'accès aux admins"""

View file

@ -1,8 +1,13 @@
from django.contrib.auth import get_user_model from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
User = get_user_model() from .staticdefs import CONNECTION_METHODS
# #############################################################################
# Models regarding an election
# #############################################################################
class Election(models.Model): class Election(models.Model):
@ -13,16 +18,38 @@ class Election(models.Model):
start_date = models.DateTimeField(_("date et heure de début")) start_date = models.DateTimeField(_("date et heure de début"))
end_date = models.DateTimeField(_("date et heure de fin")) end_date = models.DateTimeField(_("date et heure de fin"))
restricted = models.BooleanField(
_("restreint le vote à une liste de personnes"), default=True
)
sent_mail = models.BooleanField(
_("mail avec les identifiants envoyé"), default=False
)
created_by = models.ForeignKey( created_by = models.ForeignKey(
User, on_delete=models.SET_NULL, blank=True, null=True settings.AUTH_USER_MODEL,
related_name="elections_created",
on_delete=models.SET_NULL,
blank=True,
null=True,
)
voters = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name="cast_elections",
) )
results_public = models.BooleanField(_("résultats publics"), default=False) results_public = models.BooleanField(_("résultats publics"), default=False)
tallied = models.BooleanField(_("dépouillée"), default=False) tallied = models.BooleanField(_("dépouillée"), default=False)
# TODO : cache tally or recompute it each time ?
archived = models.BooleanField(_("archivée"), default=False) archived = models.BooleanField(_("archivée"), default=False)
@property
def preferred_method(self):
if self.restricted:
return "PWD"
return "CAS"
class Meta: class Meta:
ordering = ["-start_date", "-end_date"] ordering = ["-start_date", "-end_date"]
@ -37,6 +64,11 @@ class Question(models.Model):
_("nombre maximal de votes reçus"), default=0 _("nombre maximal de votes reçus"), default=0
) )
voters = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name="cast_questions",
)
class Meta: class Meta:
ordering = ["id"] ordering = ["id"]
@ -47,7 +79,7 @@ class Option(models.Model):
) )
text = models.TextField(_("texte"), blank=False) text = models.TextField(_("texte"), blank=False)
voters = models.ManyToManyField( voters = models.ManyToManyField(
User, settings.AUTH_USER_MODEL,
related_name="votes", related_name="votes",
) )
# For now, we store the amount of votes received after the election is tallied # For now, we store the amount of votes received after the election is tallied
@ -55,3 +87,40 @@ class Option(models.Model):
class Meta: class Meta:
ordering = ["id"] ordering = ["id"]
# #############################################################################
# Modification of the base User Model
# #############################################################################
class User(AbstractUser):
election = models.ForeignKey(
Election,
related_name="registered_voters",
null=True,
blank=True,
on_delete=models.CASCADE,
)
full_name = models.CharField(_("Nom et Prénom"), max_length=150, blank=True)
def get_username(self):
return "__".join(self.username.split("__")[1:])
def can_vote(self, request, election):
# 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")
# Pour les élections restreintes, il faut y être associé
return election.restricted and (self.election == election)
def get_prefix(self):
return self.username.split("__")[0]
def connection_method(self):
method = self.username.split("__")[0]
return CONNECTION_METHODS.get(method, _("identifiants spécifiques"))

19
elections/staticdefs.py Normal file
View file

@ -0,0 +1,19 @@
from django.utils.translation import gettext_lazy as _
MAIL_VOTERS = (
"Dear {full_name},\n"
"\n"
"\n"
"Election URL: {election_url}\n"
"\n"
"Your voter ID: {username}\n"
"Your password: {password}\n"
"\n"
"-- \n"
"Kadenios"
)
CONNECTION_METHODS = {
"pwd": _("mot de passe"),
"cas": _("CAS"),
}

View file

@ -37,6 +37,47 @@
</div> </div>
<hr> <hr>
{# Indications de connexion #}
{% if election.start_date < current_time and election.end_date > current_time and not can_vote %}
<div class="message is-warning">
<div class="message-body">
{% if election.restricted %}
{% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide des identifiants reçus par mail." %}
{% else %}
{% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide du CAS élève, d'autres restrictions peuvent s'appliquer et votre vote pourra être supprimé si vous n'avez pas le droit de vote." %}
{% endif %}
</div>
</div>
<div class="columns is-centered">
<div class="column is-half">
<div class="tile is-ancestor">
<div class="tile is-parent">
{% if election.restricted %}
<a class="tile is-child notification is-primary" href="{% url 'auth.election' election.pk %}?next={% url 'election.view' election.pk %}">
<div class="subtitle has-text-centered mb-2">
<span class="icon has-text-white">
<i class="fas fa-unlock"></i>
</span>
<span class="ml-3">{% trans "Connexion par identifiants" %}</span>
</div>
</a>
{% else %}
<a class="tile is-child notification is-primary" href="{% url 'authens:login.cas' %}">
<div class="subtitle has-text-centered mb-2">
<span class="icon has-text-white">
<i class="fas fa-school"></i>
</span>
<span class="ml-3">{% trans "Connexion via CAS" %}</span>
</div>
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
{# Description de l'élection #} {# Description de l'élection #}
<div class="message is-primary"> <div class="message is-primary">
<div class="message-body">{{ election.description|linebreaksbr }}</div> <div class="message-body">{{ election.description|linebreaksbr }}</div>
@ -46,7 +87,7 @@
{% for q in election.questions.all %} {% for q in election.questions.all %}
<div class="panel" id="q_{{ q.pk }}"> <div class="panel" id="q_{{ q.pk }}">
<div class="panel-heading is-size-6"> <div class="panel-heading is-size-6">
{% if election.start_date < current_time and election.end_date > current_time %} {% if can_vote and election.start_date < current_time and election.end_date > current_time %}
<a class="tag is-small is-outlined is-light is-danger" href="{% url 'election.vote' q.pk %}"> <a class="tag is-small is-outlined is-light is-danger" href="{% url 'election.vote' q.pk %}">
<span class="icon"> <span class="icon">
<i class="fas fa-vote-yea"></i> <i class="fas fa-vote-yea"></i>

View file

@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div class="level"> <div class="level is-block-tablet is-block-desktop is-flex-fullhd">
<div class="level-left"> <div class="level-left">
{# Titre de l'élection #} {# Titre de l'élection #}
<div class="level-item"> <div class="level-item">
@ -22,9 +22,9 @@
</div> </div>
<div class="level-right"> <div class="level-right">
{% if election.start_date > current_time %}
{# Lien pour la modification #} {# Lien pour la modification et l'upload des votant·e·s #}
{% if election.start_date > current_time %}
<div class="level-item"> <div class="level-item">
<a class="button is-light is-outlined is-primary" href="{% url 'election.update' election.pk %}"> <a class="button is-light is-outlined is-primary" href="{% url 'election.update' election.pk %}">
<span class="icon"> <span class="icon">
@ -33,10 +33,22 @@
<span>{% trans "Modifier" %}</span> <span>{% trans "Modifier" %}</span>
</a> </a>
</div> </div>
{% if election.restricted %}
<div class="level-item">
<a class="button is-light is-outlined is-primary" href="{% url 'election.upload-voters' election.pk %}">
<span class="icon">
<i class="fas fa-file-import"></i>
</span>
<span>{% trans "Gestion de la liste de votant·e·s" %}</span>
</a>
</div>
{% endif %}
{% elif election.end_date < current_time %} {% elif election.end_date < current_time %}
{% if not election.tallied %}
{# Lien pour le dépouillement #} {# Lien pour le dépouillement #}
{% if not election.tallied %}
<div class="level-item"> <div class="level-item">
<a class="button is-light is-outlined is-primary" href="{% url 'election.tally' election.pk %}"> <a class="button is-light is-outlined is-primary" href="{% url 'election.tally' election.pk %}">
<span class="icon"> <span class="icon">
@ -45,9 +57,9 @@
<span>{% trans "Dépouiller" %}</span> <span>{% trans "Dépouiller" %}</span>
</a> </a>
</div> </div>
{% else %}
{# Lien pour la publication des résultats #} {# Lien pour la publication des résultats #}
{% else %}
<div class="level-item"> <div class="level-item">
<a class="button is-outlined is-primary" href="{% url 'election.publish' election.pk %}"> <a class="button is-outlined is-primary" href="{% url 'election.publish' election.pk %}">
<span class="icon"> <span class="icon">

View file

@ -31,29 +31,33 @@
<h1 class="title">{% trans "Modification d'une élection" %}</h1> <h1 class="title">{% trans "Modification d'une élection" %}</h1>
<hr> <hr>
<form action="" method="post"> <div class="columns is-centered">
{% csrf_token %} <div class="column is-two-thirds">
<form action="" method="post">
{% csrf_token %}
{% include "forms/form.html" with errors=False %} {% include "forms/form.html" with errors=False %}
<div class="field is-grouped is-centered"> <div class="field is-grouped is-centered">
<div class="control is-expanded"> <div class="control is-expanded">
<button class="button is-fullwidth is-outlined is-primary is-light"> <button class="button is-fullwidth is-outlined is-primary is-light">
<span class="icon is-small"> <span class="icon is-small">
<i class="fas fa-check"></i> <i class="fas fa-check"></i>
</span> </span>
<span>{% trans "Enregistrer" %}</span> <span>{% trans "Enregistrer" %}</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<a class="button is-primary" href="{% url 'election.admin' election.pk %}"> <a class="button is-primary" href="{% url 'election.admin' election.pk %}">
<span class="icon is-small"> <span class="icon is-small">
<i class="fas fa-undo-alt"></i> <i class="fas fa-undo-alt"></i>
</span> </span>
<span>{% trans "Retour" %}</span> <span>{% trans "Retour" %}</span>
</a> </a>
</div> </div>
</div>
</form>
</div> </div>
</form> </div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,8 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% endblock %}

View file

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<h1 class="title">{% trans "Composition du mail aux votant·e·s" %}</h1>
<hr>
<div class="message is-warning">
<div class="message-body">
{% trans "Assurez-vous que la liste des votant·e·s est complète, en effet, une fois le mail envoyé, il ne sera plus possible de la modifier." %}
</div>
</div>
<div class="message is-primary">
<div class="message-body">
{% trans "Rajoutez le message à envoyer aux électeurs entre 'Dear {full_name}' et l'url de l'élection." %}
</div>
</div>
<div class="columns is-centered">
<div class="column is-two-thirds">
<form action="" method="post" enctype="multipart/form-data" id="import-voters">
{% csrf_token %}
{% include "forms/form.html" %}
<div class="field is-grouped is-centered">
<div class="control is-expanded">
<button class="button is-fullwidth is-outlined is-primary is-light">
<span class="icon is-small">
<i class="fas fa-check"></i>
</span>
<span>{% trans "Envoyer" %}</span>
</button>
</div>
<div class="control">
<a class="button is-primary" href="{% url 'election.upload-voters' election.pk %}">
<span class="icon is-small">
<i class="fas fa-undo-alt"></i>
</span>
<span>{% trans "Retour" %}</span>
</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -13,29 +13,33 @@
<h1 class="title">{% trans "Modification d'une option" %}</h1> <h1 class="title">{% trans "Modification d'une option" %}</h1>
<hr> <hr>
<form action="" method="post"> <div class="columns is-centered">
{% csrf_token %} <div class="column is-two-thirds">
<form action="" method="post">
{% csrf_token %}
{% include "forms/form.html" with errors=False %} {% include "forms/form.html" with errors=False %}
<div class="field is-grouped is-centered"> <div class="field is-grouped is-centered">
<div class="control is-expanded"> <div class="control is-expanded">
<button class="button is-fullwidth is-outlined is-primary is-light"> <button class="button is-fullwidth is-outlined is-primary is-light">
<span class="icon is-small"> <span class="icon is-small">
<i class="fas fa-check"></i> <i class="fas fa-check"></i>
</span> </span>
<span>{% trans "Enregistrer" %}</span> <span>{% trans "Enregistrer" %}</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<a class="button is-primary" href="{% url 'election.admin' option.question.election.pk %}#o_{{ option.pk }}"> <a class="button is-primary" href="{% url 'election.admin' option.question.election.pk %}#o_{{ option.pk }}">
<span class="icon is-small"> <span class="icon is-small">
<i class="fas fa-undo-alt"></i> <i class="fas fa-undo-alt"></i>
</span> </span>
<span>{% trans "Retour" %}</span> <span>{% trans "Retour" %}</span>
</a> </a>
</div> </div>
</div>
</form>
</div> </div>
</form> </div>
{% endblock %} {% endblock %}

View file

@ -13,29 +13,33 @@
<h1 class="title">{% trans "Modification d'une question" %}</h1> <h1 class="title">{% trans "Modification d'une question" %}</h1>
<hr> <hr>
<form action="" method="post"> <div class="columns is-centered">
{% csrf_token %} <div class="column is-two-thirds">
<form action="" method="post">
{% csrf_token %}
{% include "forms/form.html" with errors=False %} {% include "forms/form.html" with errors=False %}
<div class="field is-grouped is-centered"> <div class="field is-grouped is-centered">
<div class="control is-expanded"> <div class="control is-expanded">
<button class="button is-fullwidth is-outlined is-primary is-light"> <button class="button is-fullwidth is-outlined is-primary is-light">
<span class="icon is-small"> <span class="icon is-small">
<i class="fas fa-check"></i> <i class="fas fa-check"></i>
</span> </span>
<span>{% trans "Enregistrer" %}</span> <span>{% trans "Enregistrer" %}</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<a class="button is-primary" href="{% url 'election.admin' question.election.pk %}#q_{{ question.pk }}"> <a class="button is-primary" href="{% url 'election.admin' question.election.pk %}#q_{{ question.pk }}">
<span class="icon is-small"> <span class="icon is-small">
<i class="fas fa-undo-alt"></i> <i class="fas fa-undo-alt"></i>
</span> </span>
<span>{% trans "Retour" %}</span> <span>{% trans "Retour" %}</span>
</a> </a>
</div> </div>
</div>
</form>
</div> </div>
</form> </div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,125 @@
{% extends "base.html" %}
{% load i18n %}
{% block extra_head %}
<script>
$(document).ready(function($) {
const fileInput = document.querySelector('#import-voters input[type=file]');
fileInput.onchange = () => {
if (fileInput.files.length > 0) {
const fileName = document.querySelector('#import-voters .file-name');
fileName.textContent = fileInput.files[0].name;
}
};
});
</script>
{% endblock %}
{% block content %}
<div class="level is-flex-widescreen">
<div class="level-left">
<div class="item-level">
<h1 class="title">{% trans "Gestion de la liste de votant·e·s" %}</h1>
</div>
</div>
<div class="level-right">
{% if not election.sent_mail %}
<div class="level-item">
<a class="button is-light is-outlined is-primary" href="{% url 'election.mail-voters' election.pk %}">
<span class="icon">
<i class="fas fa-envelope-open"></i>
</span>
<span>{% trans "Envoyer le mail d'annonce" %}</span>
</a>
</div>
{% endif %}
<div class="level-item">
<a class="button is-primary" href="{% url 'election.admin' election.pk %}">
<span class="icon is-small">
<i class="fas fa-undo-alt"></i>
</span>
<span>{% trans "Retour" %}</span>
</a>
</div>
</div>
</div>
<hr>
{# Si on a déjà envoyé le mail avec les identifiants, on ne peut plus changer la liste #}
{% if not election.sent_mail %}
<div class="message is-warning">
<div class="message-body">
{% trans "Importez un fichier au format CSV, avec sur la première colonne le login, sur la deuxième, le nom et prénom et enfin l'adresse email sur la troisième. Soit :<br><br><pre>Login_1,Prénom/Nom_1,mail_1@machin.test<br>Login_2,Prénom/Nom_2,mail_2@bidule.test<br>...</pre>" %}
</div>
</div>
<div class="columns is-centered">
<div class="column is-two-thirds">
{% if form.non_field_errors %}
<div class="notification is-danger">
<button class="delete"></button>
{% for error in form.non_field_errors %}
{{ error }}<br>
{% endfor %}
</div>
{% endif %}
<form action="" method="post" enctype="multipart/form-data" id="import-voters">
{% csrf_token %}
{% include "forms/form.html" with errors=False %}
<div class="field is-grouped is-centered">
<div class="control is-expanded">
<button class="button is-fullwidth is-outlined is-primary is-light">
<span class="icon is-small">
<i class="fas fa-check"></i>
</span>
<span>{% trans "Importer" %}</span>
</button>
</div>
</div>
</form>
</div>
</div>
<br>
{% endif %}
{# Liste des votant·e·s #}
{% if voters %}
<h3 class="subtitle">{% trans "Liste des votant·e·s pour cette élection" %}</h3>
<hr>
<div class="columns is-centered">
<div class="column is-two-thirds">
<table class="table is-fullwidth is-bordered is-striped has-text-centered">
<thead>
<tr>
<th>{% trans "Login" %}</th>
<th>{% trans "Nom" %}</th>
<th>{% trans "Email" %}</th>
</tr>
</thead>
<tbody>
{% for v in voters %}
<tr>
<td>{{ v.get_username }}</td>
<td>{{ v.full_name }}</td>
<td>{{ v.email }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -7,6 +7,16 @@ urlpatterns = [
path("create/", views.ElectionCreateView.as_view(), name="election.create"), path("create/", views.ElectionCreateView.as_view(), name="election.create"),
path("created/", views.ElectionListView.as_view(), name="election.created"), path("created/", views.ElectionListView.as_view(), name="election.created"),
path("admin/<int:pk>", views.ElectionAdminView.as_view(), name="election.admin"), path("admin/<int:pk>", views.ElectionAdminView.as_view(), name="election.admin"),
path(
"mail-voters/<int:pk>",
views.ElectionMailVotersView.as_view(),
name="election.mail-voters",
),
path(
"upload-voters/<int:pk>",
views.ElectionUploadVotersView.as_view(),
name="election.upload-voters",
),
path("update/<int:pk>", views.ElectionUpdateView.as_view(), name="election.update"), path("update/<int:pk>", views.ElectionUpdateView.as_view(), name="election.update"),
path("tally/<int:pk>", views.ElectionTallyView.as_view(), name="election.tally"), path("tally/<int:pk>", views.ElectionTallyView.as_view(), name="election.tally"),
path( path(

113
elections/utils.py Normal file
View file

@ -0,0 +1,113 @@
import csv
import io
import random
from django.contrib.auth.hashers import make_password
from django.core.exceptions import ValidationError
from django.core.mail import EmailMessage, get_connection
from django.core.validators import validate_email
from django.utils.translation import gettext_lazy as _
from .models import User
def create_users(election, csv_file):
"""Crée les votant·e·s pour l'élection donnée, en remplissant les champs
`username`, `election` et `full_name`.
"""
dialect = csv.Sniffer().sniff(csv_file.readline().decode("utf-8"))
csv_file.seek(0)
reader = csv.reader(io.StringIO(csv_file.read().decode("utf-8")), dialect)
for (username, full_name, email) in reader:
election.registered_voters.create(
username=f"{election.id}__{username}", email=email, full_name=full_name
)
def check_csv(csv_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"))
except csv.Error:
return [
_(
"Format invalide. Vérifiez que le fichier est bien formé (i.e. "
"chaque ligne de la forme 'login,nom,email')."
)
]
csv_file.seek(0)
reader = csv.reader(io.StringIO(csv_file.read().decode("utf-8")), dialect)
errors = []
users = {}
line_nb = 0
for line in reader:
line_nb += 1
if len(line) != 3:
errors.append(
_("La ligne {} n'a pas le bon nombre d'éléments.").format(line_nb)
)
else:
if line[0] == "":
errors.append(
_("Valeur manquante dans la ligne {} : 'login'.").format(line_nb)
)
else:
if line[0] in users:
errors.append(
_("Doublon dans les logins : lignes {} et {}.").format(
line_nb, users[line[0]]
)
)
else:
users[line[0]] = line_nb
if line[1] == "":
errors.append(
_("Valeur manquante dans la ligne {} : 'nom'.").format(line_nb)
)
try:
validate_email(line[2])
except ValidationError:
errors.append(
_("Adresse mail invalide à la ligne {} : '{}'.").format(
line_nb, line[2]
)
)
return errors
def generate_password():
random.seed()
alphabet = "abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
password = ""
for i in range(15):
password += random.choice(alphabet)
return password
def send_mail(election, mail_form):
"""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é.
"""
voters = list(election.registered_voters.all())
url = f"https://kadenios.eleves.ens.fr/elections/view/{election.id}"
messages = []
for v in voters:
password = generate_password()
v.password = make_password(password)
messages.append(
EmailMessage(
subject=mail_form.cleaned_data["objet"],
body=mail_form.cleaned_data["message"].format(
full_name=v.full_name,
election_url=url,
username=v.get_username,
password=password,
),
to=[v.email],
)
)
get_connection(fail_silently=False).send_messages(messages)
User.objects.bulk_update(voters, ["password"])

View file

@ -2,7 +2,7 @@ from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
# from django.db.models import Count, Prefetch # from django.db.models import Count, Prefetch
from django.http import HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.text import slugify from django.utils.text import slugify
@ -10,14 +10,24 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import ( from django.views.generic import (
CreateView, CreateView,
DetailView, DetailView,
FormView,
ListView, ListView,
RedirectView, RedirectView,
UpdateView, UpdateView,
) )
from .forms import ElectionForm, OptionForm, OptionFormSet, QuestionForm from .forms import (
from .mixins import CreatorOnlyEditMixin, CreatorOnlyMixin, OpenElectionOnly ElectionForm,
OptionForm,
OptionFormSet,
QuestionForm,
UploadVotersForm,
VoterMailForm,
)
from .mixins import CreatorOnlyEditMixin, CreatorOnlyMixin, OpenElectionOnlyMixin
from .models import Election, Option, Question from .models import Election, Option, Question
from .staticdefs import MAIL_VOTERS
from .utils import create_users, send_mail
# TODO: access control *everywhere* # TODO: access control *everywhere*
@ -63,11 +73,13 @@ class ElectionCreateView(SuccessMessageMixin, CreateView):
return super().form_valid(form) return super().form_valid(form)
# TODO : only the creator can edit the election and view the admin panel
class ElectionAdminView(CreatorOnlyMixin, DetailView): class ElectionAdminView(CreatorOnlyMixin, DetailView):
model = Election model = Election
template_name = "elections/election_admin.html" template_name = "elections/election_admin.html"
def get_next_url(self):
return reverse("election.view", args=[self.object.pk])
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs.update({"current_time": timezone.now()}) kwargs.update({"current_time": timezone.now()})
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
@ -76,6 +88,72 @@ class ElectionAdminView(CreatorOnlyMixin, DetailView):
return super().get_queryset().prefetch_related("questions__options") return super().get_queryset().prefetch_related("questions__options")
class ElectionUploadVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormView):
model = Election
form_class = UploadVotersForm
success_message = _("Liste de votant·e·s importée avec succès !")
template_name = "elections/upload_voters.html"
def get_queryset(self):
# On ne peut ajouter une liste d'électeurs que sur une élection restreinte
return super().get_queryset().filter(restricted=True)
def get_success_url(self):
return reverse("election.upload-voters", args=[self.object.pk])
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["voters"] = self.object.registered_voters.all()
return context
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return super().post(request, *args, **kwargs)
def form_valid(self, form):
# On crée les comptes nécessaires à l'élection, en supprimant ceux
# existant déjà pour ne pas avoir de doublons
self.object.registered_voters.all().delete()
create_users(self.object, form.cleaned_data["csv_file"])
return super().form_valid(form)
class ElectionMailVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormView):
model = Election
form_class = VoterMailForm
success_message = _("Mail d'annonce envoyé avec succès !")
template_name = "elections/mail_voters.html"
def get_queryset(self):
# On ne peut envoyer un mail que sur une élection restreinte qui n'a pas
# déjà vu son mail envoyé
return super().get_queryset().filter(restricted=True, sent_mail=False)
def get_success_url(self):
return reverse("election.upload-voters", args=[self.object.pk])
def get_initial(self):
return {"objet": f"Vote : {self.object.name}", "message": MAIL_VOTERS}
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return super().post(request, *args, **kwargs)
def form_valid(self, form):
self.object.sent_mail = True
send_mail(self.object, form)
self.object.save()
return super().form_valid(form)
class ElectionListView(CreatorOnlyMixin, ListView): class ElectionListView(CreatorOnlyMixin, ListView):
model = Election model = Election
template_name = "elections/election_list.html" template_name = "elections/election_list.html"
@ -257,7 +335,7 @@ class DelOptionView(CreatorOnlyEditMixin, BackgroundUpdateView):
# ############################################################################# # #############################################################################
# Common Views # Public Views
# ############################################################################# # #############################################################################
@ -265,17 +343,17 @@ class ElectionView(DetailView):
model = Election model = Election
template_name = "elections/election.html" template_name = "elections/election.html"
def get_next_url(self):
return self.request.path
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs.update({"current_time": timezone.now()}) user = self.request.user
return super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
# context = super().get_context_data(**kwargs) context["current_time"] = timezone.now()
# if self.object.tallied: context["can_vote"] = user.is_authenticated and user.can_vote(
# options_qs = Option.objects.annotate(nb_votes=Count("voters")) self.request, context["election"]
# questions = self.election.question.prefetch_related( )
# Prefetch("options", queryset=options_qs) return context
# )
# context["questions"] = questions
# return context
def get_queryset(self): def get_queryset(self):
return ( return (
@ -286,10 +364,18 @@ class ElectionView(DetailView):
) )
class VoteView(OpenElectionOnly, DetailView): class ElectionVotersView(DetailView):
model = Election
template_name = "elections/election_voters.html"
class VoteView(OpenElectionOnlyMixin, DetailView):
model = Question model = Question
template_name = "elections/vote.html" template_name = "elections/vote.html"
def get_next_url(self):
return reverse("election.view", args=[self.object.election.pk])
def get_success_url(self): def get_success_url(self):
questions = list(self.object.election.questions.all()) questions = list(self.object.election.questions.all())
q_index = questions.index(self.object) q_index = questions.index(self.object)
@ -302,6 +388,14 @@ class VoteView(OpenElectionOnly, DetailView):
return reverse("election.vote", args=[q_next]) return reverse("election.vote", args=[q_next])
def get_object(self):
question = super().get_object()
# Seulement les utilisateur·ice·s ayant le droit de voter dans l'élection
# peuvent voir la page
if not self.request.user.can_vote(self.request, question.election):
raise Http404
return question
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
vote_form = OptionFormSet(instance=self.object) vote_form = OptionFormSet(instance=self.object)
@ -316,6 +410,10 @@ class VoteView(OpenElectionOnly, DetailView):
for v in vote_form: for v in vote_form:
v.record_vote(self.request.user) v.record_vote(self.request.user)
# On enregistre le vote pour la question et l'élection
self.object.voters.add(self.request.user)
self.object.election.voters.add(self.request.user)
messages.success(self.request, _("Votre choix a bien été enregistré !")) messages.success(self.request, _("Votre choix a bien été enregistré !"))
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())

View file

@ -12,6 +12,8 @@ https://docs.djangoproject.com/en/2.2/ref/settings/
import os import os
from django.urls import reverse_lazy
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -35,6 +37,7 @@ INSTALLED_APPS = [
"django.contrib.messages", "django.contrib.messages",
"kadenios.apps.IgnoreSrcStaticFilesConfig", "kadenios.apps.IgnoreSrcStaticFilesConfig",
"elections", "elections",
"authens",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -68,7 +71,7 @@ TEMPLATES = [
WSGI_APPLICATION = "kadenios.wsgi.application" WSGI_APPLICATION = "kadenios.wsgi.application"
# Password validation # Password validation and authentication
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
@ -86,6 +89,17 @@ AUTH_PASSWORD_VALIDATORS = [
}, },
] ]
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 # On n'utilise que le CAS normal pour l'instant
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/ # https://docs.djangoproject.com/en/2.2/topics/i18n/
@ -101,6 +115,11 @@ USE_L10N = True
USE_TZ = True USE_TZ = True
# Mail configuration
DEFAULT_FROM_EMAIL = "Kadenios <klub-dev@ens.fr>"
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/ # https://docs.djangoproject.com/en/2.2/howto/static-files/

View file

@ -8,6 +8,8 @@ urlpatterns = [
path("", HomeView.as_view(), name="kadenios"), path("", HomeView.as_view(), name="kadenios"),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("elections/", include("elections.urls")), path("elections/", include("elections.urls")),
path("auth/", include("shared.auth.urls")),
path("authens/", include("authens.urls")),
] ]
if "debug_toolbar" in settings.INSTALLED_APPS: if "debug_toolbar" in settings.INSTALLED_APPS:

56
shared/auth/backends.py Normal file
View file

@ -0,0 +1,56 @@
from authens.backends import ENSCASBackend
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
UserModel = get_user_model()
class CASBackend(ENSCASBackend):
"""ENS CAS authentication backend, customized to get the full name at connection."""
def clean_cas_login(self, cas_login):
return f"cas__{cas_login.strip().lower()}"
def create_user(self, username, attributes):
email = attributes.get("email")
name = attributes.get("name")
return UserModel.objects.create_user(
username=username, email=email, full_name=name
)
class PwdBackend(ModelBackend):
"""Password authentication"""
def authenticate(self, request, username=None, password=None):
if username is None or password is None:
return None
return super().authenticate(
request, username=f"pwd__{username}", password=password
)
class ElectionBackend(ModelBackend):
"""Authentication for a specific election.
Given a login and an election, we check if the user `{election.id}__{login}`
exists, and then if the password matches.
"""
def authenticate(self, request, login=None, password=None, election_id=None):
if login is None or password is None or election_id is None:
return None
try:
user = UserModel.objects.get(
username=f"{election_id}__{login}", election=election_id
)
except UserModel.DoesNotExist:
return None
if user.check_password(password):
return user
return None

63
shared/auth/forms.py Normal file
View file

@ -0,0 +1,63 @@
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.utils.translation import gettext_lazy as _
UserModel = get_user_model()
class ElectionAuthForm(forms.Form):
"""Adapts Django's AuthenticationForm to allow for an election specific login."""
login = auth_forms.UsernameField(label=_("Identifiant"), max_length=255)
password = forms.CharField(
label=_("Mot de passe"),
strip=False,
widget=forms.PasswordInput(attrs={"autocomplete": "current-password"}),
)
election_id = forms.IntegerField(widget=forms.HiddenInput())
def __init__(self, request=None, *args, **kwargs):
self.request = request
self.user_cache = None
super().__init__(*args, **kwargs)
def clean(self):
login = self.cleaned_data.get("login")
password = self.cleaned_data.get("password")
election_id = self.cleaned_data.get("election_id")
if login is not None and password:
self.user_cache = authenticate(
self.request,
login=login,
password=password,
election_id=election_id,
)
if self.user_cache is None:
raise self.get_invalid_login_error()
return self.cleaned_data
def get_user(self):
# Necessary API for LoginView
return self.user_cache
def get_invalid_login_error(self):
return forms.ValidationError(
_(
"Aucun·e électeur·ice avec cet identifiant et mot de passe n'existe "
"pour cette élection. Vérifiez que les informations rentrées sont "
"correctes, les champs sont sensibles à la casse."
),
code="invalid_login",
)
class PwdResetForm(auth_forms.PasswordResetForm):
"""Restricts the search for password users, i.e. whose username starts with pwd__."""
def get_users(self, email):
users = super().get_users(email)
return (u for u in users if u.username.split("__")[0] == "pwd")

11
shared/auth/urls.py Normal file
View file

@ -0,0 +1,11 @@
from django.urls import path
from . import views
urlpatterns = [
path(
"election/<int:election_id>/login",
views.ElectionLoginView.as_view(),
name="auth.election",
),
]

19
shared/auth/views.py Normal file
View file

@ -0,0 +1,19 @@
from django.contrib.auth import views as auth_views
from .forms import ElectionAuthForm
# #############################################################################
# Election Specific Login
# #############################################################################
class ElectionLoginView(auth_views.LoginView):
template_name = "auth/election_login.html"
authentication_form = ElectionAuthForm
def get_initial(self):
return {"election_id": self.kwargs.get("election_id")}
def get_context_data(self, **kwargs):
kwargs.update({"election_id": self.kwargs.get("election_id")})
return super().get_context_data(**kwargs)

View file

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% load i18n %}
{% block auth %}{% endblock %}
{% block content %}
<h1 class="title">{% trans "Connexion par mot de passe" %}</h1>
<hr>
<div class="columns is-centered">
<div class="column is-two-thirds">
<form action="" method="post">
{% csrf_token %}
{% include "forms/form.html" with errors=True %}
<div class="field is-grouped is-centered">
<div class="control is-expanded">
<button class="button is-fullwidth is-outlined is-primary is-light">
<span class="icon is-small">
<i class="fas fa-check"></i>
</span>
<span>{% trans "Enregistrer" %}</span>
</button>
</div>
<div class="control">
<a class="button is-primary" href="{% url 'election.view' election_id %}">
<span class="icon is-small">
<i class="fas fa-undo-alt"></i>
</span>
<span>{% trans "Retour" %}</span>
</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,37 @@
{% extends "base.html" %}
{% load i18n %}
{% block auth %}{% endblock %}
{% block content %}
<h1 class="title">{% trans "Choisissez la méthode de connexion" %}</h1>
<hr>
<div class="tile is-ancestor">
<div class="tile is-parent">
<a class="tile is-child notification is-primary" href="{% url "authens:login.cas" %}?next={{ next }}">
<div class="subtitle has-text-centered mb-2">
<span class="icon has-text-white">
<i class="fas fa-school"></i>
</span>
<span class="ml-3">{% trans "Connexion via CAS" %}</span>
</div>
</a>
</div>
<div class="tile is-parent">
<a class="tile is-child notification" href="{% url "authens:login.pwd" %}?next={{ next }}">
<div class="subtitle has-text-centered mb-2">
<span class="icon">
<i class="fas fa-key"></i>
</span>
<span class="ml-3">{% trans "Connexion par mot de passe" %}</span>
</div>
</a>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,56 @@
{% extends "base.html" %}
{% load i18n %}
{% block auth %}{% endblock %}
{% block content %}
<h1 class="title">{% trans "Connexion par mot de passe" %}</h1>
<hr>
<div class="columns is-centered">
<div class="column is-two-thirds">
<form action="" method="post">
{% csrf_token %}
{% include "forms/form.html" with errors=True %}
<div class="field is-grouped is-centered">
<div class="control is-expanded">
<button class="button is-fullwidth is-outlined is-primary is-light">
<span class="icon is-small">
<i class="fas fa-check"></i>
</span>
<span>{% trans "Enregistrer" %}</span>
</button>
</div>
<div class="control">
<a class="button is-primary" href="{% url 'authens:login' %}?next={{ next }}">
<span class="icon is-small">
<i class="fas fa-undo-alt"></i>
</span>
<span>{% trans "Retour" %}</span>
</a>
</div>
</div>
<div class="field is-centered">
<div class="control">
<div class="help has-text-centered">
<span>{% trans "Mot de passe oublié :" %}</span>
<a class="tag has-text-primary" href="{% url 'authens:reset.pwd' %}">
<span>{% trans "Réinitialiser mon mot de passe." %}</span>
<span class="icon is-small">
<i class="fas fa-lock-open"></i>
</span>
</a>
</div>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% load i18n %}
{% block auth %}{% endblock %}
{% block content %}
<h1 class="title">{% trans "Réinitialisation du mot de passe" %}</h1>
<hr>
<div class="columns is-centered">
<div class="column is-two-thirds">
<form action="" method="post">
{% csrf_token %}
{% include "forms/form.html" with errors=True %}
<div class="field is-grouped is-centered">
<div class="control is-expanded">
<button class="button is-fullwidth is-outlined is-primary is-light">
<span class="icon is-small">
<i class="fas fa-check"></i>
</span>
<span>{% trans "Envoyer un mail" %}</span>
</button>
</div>
<div class="control">
<a class="button is-primary" href="{% url 'authens:login.pwd' %}?next={{ next }}">
<span class="icon is-small">
<i class="fas fa-undo-alt"></i>
</span>
<span>{% trans "Retour" %}</span>
</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% load i18n %}
{% block auth %}{% endblock %}
{% block content %}
<h1 class="title">{% trans "Réinitialisation du mot de passe" %}</h1>
<hr>
<div class="columns is-centered">
<div class="column is-two-thirds">
<form action="" method="post">
{% csrf_token %}
{% include "forms/form.html" with errors=True %}
<div class="field is-grouped is-centered">
<div class="control is-expanded">
<button class="button is-fullwidth is-outlined is-primary is-light">
<span class="icon is-small">
<i class="fas fa-check"></i>
</span>
<span>{% trans "Enregistrer" %}</span>
</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -39,21 +39,44 @@
<nav class="level has-background-primary"> <nav class="level has-background-primary">
<div class="level-left pl-4"> <div class="level-left pl-4">
<div class="level-item"> <div class="level-item">
<h1 class="has-text-primary-light is-size-1 is-family-secondary">Kadenios</h1> <a href="{% url "kadenios" %}">
</div> <h1 class="has-text-primary-light is-size-1 is-family-secondary">Kadenios</h1>
</div>
<div class="level-right pr-5">
<div class="level-item">
<a class="icon is-size-1 has-text-white" href="">
<i class="fas fa-sign-out-alt"></i>
</a> </a>
</div> </div>
</div> </div>
{% block auth %}
<div class="level-right pr-5">
{% if user.is_authenticated %}
<div class="level-item mr-5">
<div class="tag">
{% blocktrans with name=user.get_username connection=user.connection_method %}Connecté·e en tant que {{ name }} par {{ connection }}{% endblocktrans %}
</div>
</div>
<div class="level-item ml-3">
<a class="icon is-size-1 has-text-white" href="{% url 'authens:logout' %}?next={% if view.get_next_url %}{{ view.get_next_url }}{% else %}/{% endif %}">
<i class="fas fa-sign-out-alt"></i>
</a>
</div>
{% else %}
<div class="level-item">
<a class="tag has-text-primary is-size-5" href="{% url 'authens:login' %}?next={{ request.path }}">
<span>{% trans "Se connecter" %}</span>
<span class="icon">
<i class="fas fa-sign-in-alt"></i>
</span>
</a>
</div>
{% endif %}
</div>
{% endblock %}
</nav> </nav>
{% block layout %} {% block layout %}
<div class="main-content"> <div class="main-content">
<div class="columns is-centered"> <div class="columns is-centered">
<div class="column is-two-thirds"> <div class="column is-two-thirds-fullhd is-12-desktop is-12-widescreen">
<section class="section pt-3"> <section class="section pt-3">
{% if messages %} {% if messages %}

View file

@ -1,22 +1,27 @@
{% load bulma_utils i18n %} {% load bulma_utils i18n %}
<label class="label {% if field.field.required %}{{ form.required_css_class }}{% endif %}"> <label class="label {% if field.field.required %}{{ form.required_css_class }}{% endif %}">
{{ field.label }} {{ field.label_tag }}
</label> </label>
<div class="control"> <div class="control">
<label class="file-label"> <div class="file has-name">
{{ field|bulmafy:'file-input' }} <label class="file-label">
<span class="file-cta"> {{ field|bulmafy:'file-input' }}
<span class="file-icon"> <span class="file-cta">
<i class="fas fa-upload"></i> <span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label">
{% trans "Choisissez un fichier..." %}
</span>
</span> </span>
<span class="file-label"> <span class="file-name">
{% trans "Choisissez un fichier..." %} {% trans "Aucun fichier sélectionné" %}
</span> </span>
</span> </label>
</label> </div>
{% for error in field.errors %} {% for error in field.errors %}
<span class="help is-danger {{ form.error_css_class }}">{{ error }}</span> <span class="help is-danger {{ form.error_css_class }}">{{ error }}</span>

View file

@ -1,9 +1,6 @@
{% if errors %} {% if errors %}
{% if form.non_field_errors %} {% if form.non_field_errors %}
<div class="message is-danger"> <div class="message is-danger">
<div class="message-header">
<button class="delete" aria-label="delete"></button>
</div>
<div class="message-body"> <div class="message-body">
{% for non_field_error in form.non_field_errors %} {% for non_field_error in form.non_field_errors %}
{{ non_field_error }} {{ non_field_error }}

View file

@ -12,8 +12,8 @@
{% endfor %} {% endfor %}
{% if field.help_text %} {% if field.help_text %}
<p class="help"> <div class="help">
{{ field.help_text|safe }} {{ field.help_text|safe }}
</p> </div>
{% endif %} {% endif %}
</div> </div>

View file

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% load i18n %}
{% block auth %}{% endblock %}
{% block content %}
<h1 class="title">{% trans "Déconnexion réussie" %}</h1>
<hr>
<div class="columns is-centered">
<div class="column is-two-thirds">
<form action="" method="post">
{% csrf_token %}
{% include "forms/form.html" with errors=True %}
<div class="field is-grouped is-centered">
<div class="control is-expanded">
<a class="button is-fullwidth is-outlined is-primary is-light" href="{% url 'authens:login' %}">
<span>{% trans "Se reconnecter" %}</span>
<span class="icon is-small">
<i class="fas fa-unlock"></i>
</span>
</a>
</div>
<div class="control">
<a class="button is-primary" href="{% url 'kadenios' %}">
<span>{% trans "Accueil" %}</span>
<span class="icon is-small">
<i class="fas fa-home"></i>
</span>
</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}