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 .models import Election, Option, Question
from .utils import check_csv
class ElectionForm(forms.ModelForm):
@ -21,19 +22,43 @@ class ElectionForm(forms.ModelForm):
class Meta:
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 Meta:
model = Question
fields = ["text"]
widgets = {"text": forms.TextInput}
class OptionForm(forms.ModelForm):
class Meta:
model = Option
fields = ["text"]
widgets = {"text": forms.TextInput}
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.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
@ -10,40 +13,243 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("auth", "0011_update_proxy_permissions"),
]
operations = [
migrations.CreateModel(
name='Election',
name="User",
fields=[
('id', 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_time', models.DateTimeField(verbose_name='date et heure de début')),
('end_time', 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)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("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(
name='Question',
name="Election",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.TextField(verbose_name='question')),
('election', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='elections.Election')),
(
"id",
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(
name='Option',
name="Question",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.TextField(verbose_name='texte')),
('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)),
(
"id",
models.AutoField(
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.views.generic.detail import SingleObjectMixin
@ -35,7 +37,7 @@ class RestrictAccessMixin(SelectElectionMixin):
return qs.none()
class OpenElectionOnly(RestrictAccessMixin):
class OpenElectionOnlyMixin(RestrictAccessMixin):
"""N'autorise la vue que lorsque l'élection est ouverte"""
def get_filters(self):
@ -51,9 +53,12 @@ class OpenElectionOnly(RestrictAccessMixin):
return filters
class CreatorOnlyMixin(RestrictAccessMixin):
class CreatorOnlyMixin(LoginRequiredMixin, RestrictAccessMixin):
"""Restreint l'accès au créateurice de l'élection"""
def get_next_url(self):
return reverse("kadenios")
def get_filters(self):
filters = super().get_filters()
# TODO: change the way we collect the user according to the model used
@ -71,7 +76,7 @@ class CreatorOnlyEditMixin(CreatorOnlyMixin, SingleObjectMixin):
return filters
class AdministratorOnlyMixin:
class AdministratorOnlyMixin(LoginRequiredMixin):
"""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.utils.translation import gettext_lazy as _
User = get_user_model()
from .staticdefs import CONNECTION_METHODS
# #############################################################################
# Models regarding an election
# #############################################################################
class Election(models.Model):
@ -13,16 +18,38 @@ class Election(models.Model):
start_date = models.DateTimeField(_("date et heure de début"))
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(
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)
tallied = models.BooleanField(_("dépouillée"), default=False)
# TODO : cache tally or recompute it each time ?
archived = models.BooleanField(_("archivée"), default=False)
@property
def preferred_method(self):
if self.restricted:
return "PWD"
return "CAS"
class Meta:
ordering = ["-start_date", "-end_date"]
@ -37,6 +64,11 @@ class Question(models.Model):
_("nombre maximal de votes reçus"), default=0
)
voters = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name="cast_questions",
)
class Meta:
ordering = ["id"]
@ -47,7 +79,7 @@ class Option(models.Model):
)
text = models.TextField(_("texte"), blank=False)
voters = models.ManyToManyField(
User,
settings.AUTH_USER_MODEL,
related_name="votes",
)
# For now, we store the amount of votes received after the election is tallied
@ -55,3 +87,40 @@ class Option(models.Model):
class Meta:
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>
<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 #}
<div class="message is-primary">
<div class="message-body">{{ election.description|linebreaksbr }}</div>
@ -46,7 +87,7 @@
{% for q in election.questions.all %}
<div class="panel" id="q_{{ q.pk }}">
<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 %}">
<span class="icon">
<i class="fas fa-vote-yea"></i>

View file

@ -4,7 +4,7 @@
{% block content %}
<div class="level">
<div class="level is-block-tablet is-block-desktop is-flex-fullhd">
<div class="level-left">
{# Titre de l'élection #}
<div class="level-item">
@ -22,9 +22,9 @@
</div>
<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">
<a class="button is-light is-outlined is-primary" href="{% url 'election.update' election.pk %}">
<span class="icon">
@ -33,10 +33,22 @@
<span>{% trans "Modifier" %}</span>
</a>
</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 %}
{% if not election.tallied %}
{# Lien pour le dépouillement #}
{% if not election.tallied %}
<div class="level-item">
<a class="button is-light is-outlined is-primary" href="{% url 'election.tally' election.pk %}">
<span class="icon">
@ -45,9 +57,9 @@
<span>{% trans "Dépouiller" %}</span>
</a>
</div>
{% else %}
{# Lien pour la publication des résultats #}
{% else %}
<div class="level-item">
<a class="button is-outlined is-primary" href="{% url 'election.publish' election.pk %}">
<span class="icon">

View file

@ -31,6 +31,8 @@
<h1 class="title">{% trans "Modification d'une élection" %}</h1>
<hr>
<div class="columns is-centered">
<div class="column is-two-thirds">
<form action="" method="post">
{% csrf_token %}
@ -55,5 +57,7 @@
</div>
</div>
</form>
</div>
</div>
{% 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,6 +13,8 @@
<h1 class="title">{% trans "Modification d'une option" %}</h1>
<hr>
<div class="columns is-centered">
<div class="column is-two-thirds">
<form action="" method="post">
{% csrf_token %}
@ -37,5 +39,7 @@
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -13,6 +13,8 @@
<h1 class="title">{% trans "Modification d'une question" %}</h1>
<hr>
<div class="columns is-centered">
<div class="column is-two-thirds">
<form action="" method="post">
{% csrf_token %}
@ -37,5 +39,7 @@
</div>
</div>
</form>
</div>
</div>
{% 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("created/", views.ElectionListView.as_view(), name="election.created"),
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("tally/<int:pk>", views.ElectionTallyView.as_view(), name="election.tally"),
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.db.models import Count, Prefetch
from django.http import HttpResponseRedirect
from django.http import Http404, HttpResponseRedirect
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
@ -10,14 +10,24 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import (
CreateView,
DetailView,
FormView,
ListView,
RedirectView,
UpdateView,
)
from .forms import ElectionForm, OptionForm, OptionFormSet, QuestionForm
from .mixins import CreatorOnlyEditMixin, CreatorOnlyMixin, OpenElectionOnly
from .forms import (
ElectionForm,
OptionForm,
OptionFormSet,
QuestionForm,
UploadVotersForm,
VoterMailForm,
)
from .mixins import CreatorOnlyEditMixin, CreatorOnlyMixin, OpenElectionOnlyMixin
from .models import Election, Option, Question
from .staticdefs import MAIL_VOTERS
from .utils import create_users, send_mail
# TODO: access control *everywhere*
@ -63,11 +73,13 @@ class ElectionCreateView(SuccessMessageMixin, CreateView):
return super().form_valid(form)
# TODO : only the creator can edit the election and view the admin panel
class ElectionAdminView(CreatorOnlyMixin, DetailView):
model = Election
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):
kwargs.update({"current_time": timezone.now()})
return super().get_context_data(**kwargs)
@ -76,6 +88,72 @@ class ElectionAdminView(CreatorOnlyMixin, DetailView):
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):
model = Election
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
template_name = "elections/election.html"
def get_next_url(self):
return self.request.path
def get_context_data(self, **kwargs):
kwargs.update({"current_time": timezone.now()})
return super().get_context_data(**kwargs)
# context = super().get_context_data(**kwargs)
# if self.object.tallied:
# options_qs = Option.objects.annotate(nb_votes=Count("voters"))
# questions = self.election.question.prefetch_related(
# Prefetch("options", queryset=options_qs)
# )
# context["questions"] = questions
# return context
user = self.request.user
context = super().get_context_data(**kwargs)
context["current_time"] = timezone.now()
context["can_vote"] = user.is_authenticated and user.can_vote(
self.request, context["election"]
)
return context
def get_queryset(self):
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
template_name = "elections/vote.html"
def get_next_url(self):
return reverse("election.view", args=[self.object.election.pk])
def get_success_url(self):
questions = list(self.object.election.questions.all())
q_index = questions.index(self.object)
@ -302,6 +388,14 @@ class VoteView(OpenElectionOnly, DetailView):
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):
self.object = self.get_object()
vote_form = OptionFormSet(instance=self.object)
@ -316,6 +410,10 @@ class VoteView(OpenElectionOnly, DetailView):
for v in vote_form:
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é !"))
return HttpResponseRedirect(self.get_success_url())

View file

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

View file

@ -8,6 +8,8 @@ urlpatterns = [
path("", HomeView.as_view(), name="kadenios"),
path("admin/", admin.site.urls),
path("elections/", include("elections.urls")),
path("auth/", include("shared.auth.urls")),
path("authens/", include("authens.urls")),
]
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">
<div class="level-left pl-4">
<div class="level-item">
<a href="{% url "kadenios" %}">
<h1 class="has-text-primary-light is-size-1 is-family-secondary">Kadenios</h1>
</div>
</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>
</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>
{% block layout %}
<div class="main-content">
<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">
{% if messages %}

View file

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

View file

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

View file

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