From 5865583ace0220d58c8b0742f2a5e3a3fdee6b7c Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Sun, 20 Dec 2020 17:15:37 +0100 Subject: [PATCH 01/23] On refait les migrations pour le nouvel user --- elections/migrations/0001_initial.py | 252 ++++++++++++++++-- .../migrations/0002_auto_20201218_1452.py | 23 -- .../migrations/0003_auto_20201218_1954.py | 23 -- elections/migrations/0004_option_nb_votes.py | 20 -- .../migrations/0005_question_max_votes.py | 20 -- elections/models.py | 15 +- kadenios/settings_base.py | 1 + 7 files changed, 243 insertions(+), 111 deletions(-) delete mode 100644 elections/migrations/0002_auto_20201218_1452.py delete mode 100644 elections/migrations/0003_auto_20201218_1954.py delete mode 100644 elections/migrations/0004_option_nb_votes.py delete mode 100644 elections/migrations/0005_question_max_votes.py diff --git a/elections/migrations/0001_initial.py b/elections/migrations/0001_initial.py index 0ee73e0..2c47028 100644 --- a/elections/migrations/0001_initial.py +++ b/elections/migrations/0001_initial.py @@ -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"], + }, ), ] diff --git a/elections/migrations/0002_auto_20201218_1452.py b/elections/migrations/0002_auto_20201218_1452.py deleted file mode 100644 index d58de76..0000000 --- a/elections/migrations/0002_auto_20201218_1452.py +++ /dev/null @@ -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", - ), - ] diff --git a/elections/migrations/0003_auto_20201218_1954.py b/elections/migrations/0003_auto_20201218_1954.py deleted file mode 100644 index f9a2b2b..0000000 --- a/elections/migrations/0003_auto_20201218_1954.py +++ /dev/null @@ -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", - ), - ] diff --git a/elections/migrations/0004_option_nb_votes.py b/elections/migrations/0004_option_nb_votes.py deleted file mode 100644 index 7835d0a..0000000 --- a/elections/migrations/0004_option_nb_votes.py +++ /dev/null @@ -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" - ), - ), - ] diff --git a/elections/migrations/0005_question_max_votes.py b/elections/migrations/0005_question_max_votes.py deleted file mode 100644 index dc70f09..0000000 --- a/elections/migrations/0005_question_max_votes.py +++ /dev/null @@ -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" - ), - ), - ] diff --git a/elections/models.py b/elections/models.py index abf23c7..c6a7d17 100644 --- a/elections/models.py +++ b/elections/models.py @@ -1,8 +1,19 @@ -from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser from django.db import models from django.utils.translation import gettext_lazy as _ -User = get_user_model() +# ############################################################################# +# Modification of the base User Model +# ############################################################################# + + +class User(AbstractUser): + pass + + +# ############################################################################# +# Models regarding an election +# ############################################################################# class Election(models.Model): diff --git a/kadenios/settings_base.py b/kadenios/settings_base.py index 9e60711..d1d201f 100644 --- a/kadenios/settings_base.py +++ b/kadenios/settings_base.py @@ -86,6 +86,7 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +AUTH_USER_MODEL = "elections.User" # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ From e704e2a155ffd3ccd0667f0a5d9da6c26f367af2 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Sun, 20 Dec 2020 18:50:38 +0100 Subject: [PATCH 02/23] =?UTF-8?q?On=20restreint=20l'acc=C3=A8s=20au=20vote?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- elections/forms.py | 2 +- .../migrations/0002_auto_20201220_1735.py | 36 +++++++++++++++ .../migrations/0003_election_restricted.py | 20 +++++++++ .../migrations/0004_auto_20201220_1847.py | 25 +++++++++++ elections/mixins.py | 2 +- elections/models.py | 45 ++++++++++++++----- elections/views.py | 22 ++++----- 7 files changed, 128 insertions(+), 24 deletions(-) create mode 100644 elections/migrations/0002_auto_20201220_1735.py create mode 100644 elections/migrations/0003_election_restricted.py create mode 100644 elections/migrations/0004_auto_20201220_1847.py diff --git a/elections/forms.py b/elections/forms.py index ed2093c..bb04956 100644 --- a/elections/forms.py +++ b/elections/forms.py @@ -21,7 +21,7 @@ class ElectionForm(forms.ModelForm): class Meta: model = Election - fields = ["name", "description", "start_date", "end_date"] + fields = ["name", "description", "restricted", "start_date", "end_date"] class QuestionForm(forms.ModelForm): diff --git a/elections/migrations/0002_auto_20201220_1735.py b/elections/migrations/0002_auto_20201220_1735.py new file mode 100644 index 0000000..b73e907 --- /dev/null +++ b/elections/migrations/0002_auto_20201220_1735.py @@ -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, + ), + ), + ] diff --git a/elections/migrations/0003_election_restricted.py b/elections/migrations/0003_election_restricted.py new file mode 100644 index 0000000..9f4372a --- /dev/null +++ b/elections/migrations/0003_election_restricted.py @@ -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" + ), + ), + ] diff --git a/elections/migrations/0004_auto_20201220_1847.py b/elections/migrations/0004_auto_20201220_1847.py new file mode 100644 index 0000000..549faf0 --- /dev/null +++ b/elections/migrations/0004_auto_20201220_1847.py @@ -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", + ), + ), + ] diff --git a/elections/mixins.py b/elections/mixins.py index 314d923..cd3d15a 100644 --- a/elections/mixins.py +++ b/elections/mixins.py @@ -35,7 +35,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): diff --git a/elections/models.py b/elections/models.py index c6a7d17..e99a4d2 100644 --- a/elections/models.py +++ b/elections/models.py @@ -1,16 +1,8 @@ +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 _ -# ############################################################################# -# Modification of the base User Model -# ############################################################################# - - -class User(AbstractUser): - pass - - # ############################################################################# # Models regarding an election # ############################################################################# @@ -24,8 +16,16 @@ 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 + ) + 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, ) results_public = models.BooleanField(_("résultats publics"), default=False) @@ -58,7 +58,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 @@ -66,3 +66,26 @@ 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, + ) + + def can_vote(self, election): + # Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections + # ouvertes à tou·te·s + if self.election is None: + return not election.restricted + # Pour les élections restreintes, il faut y être associé + return election.restricted and (self.election == election) diff --git a/elections/views.py b/elections/views.py index a960237..f43355b 100644 --- a/elections/views.py +++ b/elections/views.py @@ -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 @@ -16,7 +16,7 @@ from django.views.generic import ( ) from .forms import ElectionForm, OptionForm, OptionFormSet, QuestionForm -from .mixins import CreatorOnlyEditMixin, CreatorOnlyMixin, OpenElectionOnly +from .mixins import CreatorOnlyEditMixin, CreatorOnlyMixin, OpenElectionOnlyMixin from .models import Election, Option, Question # TODO: access control *everywhere* @@ -268,14 +268,6 @@ class ElectionView(DetailView): 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 def get_queryset(self): return ( @@ -286,7 +278,7 @@ class ElectionView(DetailView): ) -class VoteView(OpenElectionOnly, DetailView): +class VoteView(OpenElectionOnlyMixin, DetailView): model = Question template_name = "elections/vote.html" @@ -302,6 +294,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(question.election): + raise Http404 + return question + def get(self, request, *args, **kwargs): self.object = self.get_object() vote_form = OptionFormSet(instance=self.object) From fc695b9cc5e5eb9e90301013c3fd7891c6654c60 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Mon, 21 Dec 2020 00:07:07 +0100 Subject: [PATCH 03/23] =?UTF-8?q?D=C3=A9but=20de=20l'authentification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- elections/mixins.py | 7 +- elections/models.py | 9 ++ elections/templates/elections/election.html | 13 ++- elections/views.py | 15 ++- kadenios/settings_base.py | 6 + kadenios/urls.py | 1 + shared/auth/backends.py | 83 ++++++++++++++ shared/auth/forms.py | 48 ++++++++ shared/auth/urls.py | 10 ++ shared/auth/utils.py | 14 +++ shared/auth/views.py | 117 ++++++++++++++++++++ shared/templates/auth/login_select.html | 3 + shared/templates/auth/pwd_login.html | 2 + shared/templates/base.html | 17 ++- 14 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 shared/auth/backends.py create mode 100644 shared/auth/forms.py create mode 100644 shared/auth/urls.py create mode 100644 shared/auth/utils.py create mode 100644 shared/auth/views.py create mode 100644 shared/templates/auth/login_select.html create mode 100644 shared/templates/auth/pwd_login.html diff --git a/elections/mixins.py b/elections/mixins.py index cd3d15a..a54c583 100644 --- a/elections/mixins.py +++ b/elections/mixins.py @@ -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 @@ -51,9 +53,12 @@ class OpenElectionOnlyMixin(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 diff --git a/elections/models.py b/elections/models.py index e99a4d2..0eafee0 100644 --- a/elections/models.py +++ b/elections/models.py @@ -82,6 +82,10 @@ class User(AbstractUser): on_delete=models.CASCADE, ) + @property + def base_username(self): + return self.username.split("__")[-1] + def can_vote(self, election): # Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections # ouvertes à tou·te·s @@ -89,3 +93,8 @@ class User(AbstractUser): return not election.restricted # Pour les élections restreintes, il faut y être associé return election.restricted and (self.election == election) + + def connection_method(self): + if self.election is None: + return _("CAS") + return _("identifiants spécifiques") diff --git a/elections/templates/elections/election.html b/elections/templates/elections/election.html index f4338da..a726993 100644 --- a/elections/templates/elections/election.html +++ b/elections/templates/elections/election.html @@ -37,6 +37,17 @@
+{# Indications de connexion #} +
+
+ {% 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 %} +
+
+ {# Description de l'élection #}
{{ election.description|linebreaksbr }}
@@ -46,7 +57,7 @@ {% for q in election.questions.all %}
- {% 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 %} diff --git a/elections/views.py b/elections/views.py index f43355b..daecd29 100644 --- a/elections/views.py +++ b/elections/views.py @@ -265,9 +265,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) + 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( + context["election"] + ) + return context def get_queryset(self): return ( @@ -282,6 +290,9 @@ class VoteView(OpenElectionOnlyMixin, DetailView): model = Question template_name = "elections/vote.html" + def get_newt_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) diff --git a/kadenios/settings_base.py b/kadenios/settings_base.py index d1d201f..3f5a548 100644 --- a/kadenios/settings_base.py +++ b/kadenios/settings_base.py @@ -87,6 +87,12 @@ AUTH_PASSWORD_VALIDATORS = [ ] AUTH_USER_MODEL = "elections.User" +AUTHENTICATION_BACKENDS = [ + "shared.auth.backends.ENSCASBackend", + "shared.auth.backends.ElectionBackend", +] + +LOGIN_URL = "auth.cas" # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ diff --git a/kadenios/urls.py b/kadenios/urls.py index f547808..759e32b 100644 --- a/kadenios/urls.py +++ b/kadenios/urls.py @@ -8,6 +8,7 @@ urlpatterns = [ path("", HomeView.as_view(), name="kadenios"), path("admin/", admin.site.urls), path("elections/", include("elections.urls")), + path("auth/", include("shared.auth.urls")), ] if "debug_toolbar" in settings.INSTALLED_APPS: diff --git a/shared/auth/backends.py b/shared/auth/backends.py new file mode 100644 index 0000000..36b7654 --- /dev/null +++ b/shared/auth/backends.py @@ -0,0 +1,83 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend + +from .utils import get_cas_client + +UserModel = get_user_model() + + +class ENSCASBackend: + """ENS CAS authentication backend. + + Implement standard CAS v3 authentication + """ + + def authenticate(self, request, ticket=None): + cas_client = get_cas_client(request) + cas_login, attributes, _ = cas_client.verify_ticket(ticket) + + if cas_login is None: + # Authentication failed + return None + cas_login = self.clean_cas_login(cas_login) + + if request: + request.session["CASCONNECTED"] = True + + return self._get_or_create(cas_login, attributes) + + def clean_cas_login(self, cas_login): + return cas_login.strip().lower() + + def _get_or_create(self, cas_login, attributes): + """Handles account retrieval and creation for CAS authentication. + + - If no CAS account exists, create one; + - If a matching CAS account exists, retrieve it. + """ + + email = attributes.get("email") + + try: + user = UserModel.objects.get(username=cas_login) + except UserModel.DoesNotExist: + user = None + + if user is None: + user = UserModel.objects.create_user(username=cas_login, email=email) + return user + + # Django boilerplate. + def get_user(self, user_id): + try: + return UserModel.objects.get(pk=user_id) + except UserModel.DoesNotExist: + return None + + +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}") + except UserModel.DoesNotExist: + return None + + if user.check_password(password): + return user + return None + + # Django boilerplate. + def get_user(self, user_id): + try: + return UserModel.objects.get(pk=user_id) + except UserModel.DoesNotExist: + return None diff --git a/shared/auth/forms.py b/shared/auth/forms.py new file mode 100644 index 0000000..a4e8765 --- /dev/null +++ b/shared/auth/forms.py @@ -0,0 +1,48 @@ +from django import forms +from django.contrib.auth import authenticate +from django.contrib.auth import forms as auth_forms +from django.utils.translation import gettext_lazy as _ + + +class ElectionAuthForm(forms.Form): + """Adapts Django's AuthenticationForm to allow for OldCAS login.""" + + login = auth_forms.UsernameField(label=_("Identifiant"), max_length=255) + password = forms.CharField(label=_("Mot de passe"), strip=False) + 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("cas_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", + ) diff --git a/shared/auth/urls.py b/shared/auth/urls.py new file mode 100644 index 0000000..bac6f01 --- /dev/null +++ b/shared/auth/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("login/select", views.LoginSelectView.as_view(), name="auth.select"), + path("login/cas", views.CASLoginView.as_view(), name="auth.cas"), + path("login/pwd", views.PasswordLoginView.as_view(), name="auth.pwd"), + path("logout", views.LogoutView.as_view(), name="auth.logout"), +] diff --git a/shared/auth/utils.py b/shared/auth/utils.py new file mode 100644 index 0000000..0d92b97 --- /dev/null +++ b/shared/auth/utils.py @@ -0,0 +1,14 @@ +from urllib.parse import urlunparse + +from cas import CASClient + + +def get_cas_client(request): + """Return a CAS client configured for SPI's CAS.""" + return CASClient( + version=3, + service_url=urlunparse( + (request.scheme, request.get_host(), request.path, "", "", "") + ), + server_url="https://cas.eleves.ens.fr/", + ) diff --git a/shared/auth/views.py b/shared/auth/views.py new file mode 100644 index 0000000..e9b0986 --- /dev/null +++ b/shared/auth/views.py @@ -0,0 +1,117 @@ +from urllib.parse import urlparse, urlunparse + +from django.conf import settings +from django.contrib import auth +from django.contrib.auth import views as auth_views +from django.core.exceptions import PermissionDenied +from django.shortcuts import redirect +from django.utils.translation import gettext_lazy as _ +from django.views.generic import TemplateView, View + +from .forms import ElectionAuthForm +from .utils import get_cas_client + + +class NextPageMixin: + def get_next_url(self): + """Decide where to go after a successful login. + + Look for (in order): + - a `next` GET parameter; + - a `CASNEXT` session variable; + - the `LOGIN_REDIRECT_URL` django setting. + """ + request = self.request + next_url = request.GET.get("next") + if next_url is None and "CASNEXT" in request.session: + next_url = request.session["CASNEXT"] + del request.session["CASNEXT"] + if next_url is None: + next_url = settings.LOGIN_REDIRECT_URL + return next_url + + +class LoginSelectView(NextPageMixin, TemplateView): + """Simple page letting the user choose between password and CAS authentication.""" + + template_name = "auth/login_select.html" + http_method_names = ["get"] + + def get(self, request, *args, **kwargs): + if request.user.is_authenticated: + return redirect(self.get_next_url()) + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["next"] = self.get_next_url() + + return context + + +class CASLoginView(NextPageMixin, View): + """CAS authentication view. + + Implement the CAS authentication scheme: + + 1. We first redirect the user to the student CAS. + 2. The user comes back with a ticket, we validate it to make sure the user is legit + (validation is delegated to the ENSCASBackend). + 3. We redirect the user to the next page. + """ + + http_method_names = ["get"] + + def get(self, request, *args, **kwargs): + ticket = request.GET.get("ticket") + + if not ticket: + request.session["CASNEXT"] = self.get_next_url() + cas_client = get_cas_client(request) + return redirect(cas_client.get_login_url()) + + user = auth.authenticate(request, ticket=ticket) + if user is None: + raise PermissionDenied(_("Connexion échouée !")) + auth.login(request, user) + return redirect(self.get_next_url()) + + +class PasswordLoginView(auth_views.LoginView): + template_name = "auth/pwd_login.html" + authentication_form = ElectionAuthForm + + def get_initial(self): + return {"election_id": self.request.GET.get("election", None)} + + +class LogoutView(auth_views.LogoutView): + """Logout view. + + Tell Django to log the user out, then redirect to the CAS logout page if the user + logged in via CAS. + """ + + def setup(self, request): + super().setup(request) + if "CASCONNECTED" in request.session: + del request.session["CASCONNECTED"] + self.cas_connected = True + else: + self.cas_connected = False + + def get_next_page(self): + next_page = super().get_next_page() + if self.cas_connected: + cas_client = get_cas_client(self.request) + + # If the next_url is local (no hostname), make it absolute so that the user + # is correctly redirected from CAS. + if not urlparse(next_page).netloc: + request = self.request + next_page = urlunparse( + (request.scheme, request.get_host(), next_page, "", "", "") + ) + + next_page = cas_client.get_logout_url(redirect_url=next_page) + return next_page diff --git a/shared/templates/auth/login_select.html b/shared/templates/auth/login_select.html new file mode 100644 index 0000000..37d50df --- /dev/null +++ b/shared/templates/auth/login_select.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% load i18n %} + diff --git a/shared/templates/auth/pwd_login.html b/shared/templates/auth/pwd_login.html new file mode 100644 index 0000000..c4c0b0f --- /dev/null +++ b/shared/templates/auth/pwd_login.html @@ -0,0 +1,2 @@ +{% extends "base.html" %} +{% load i18n %} diff --git a/shared/templates/base.html b/shared/templates/base.html index 2c58435..c46d9eb 100644 --- a/shared/templates/base.html +++ b/shared/templates/base.html @@ -43,11 +43,24 @@
- {% block layout %} From 416efd52672387d98ad38af6661c1ce07ac2d739 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Mon, 21 Dec 2020 13:38:18 +0100 Subject: [PATCH 04/23] =?UTF-8?q?Authentification=20CAS=20par=20d=C3=A9fau?= =?UTF-8?q?t,=20permet=20de=20choisir=20entre=20CAS=20et=20mdp=20lorsque?= =?UTF-8?q?=20la=20variable=20`election`=20exite=20dans=20le=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- elections/mixins.py | 2 +- elections/models.py | 6 ++++ shared/auth/forms.py | 4 +-- shared/auth/views.py | 4 ++- shared/templates/auth/login_select.html | 44 +++++++++++++++++++++++++ shared/templates/auth/pwd_login.html | 38 +++++++++++++++++++++ shared/templates/base.html | 11 ++++++- 7 files changed, 104 insertions(+), 5 deletions(-) diff --git a/elections/mixins.py b/elections/mixins.py index a54c583..484d7da 100644 --- a/elections/mixins.py +++ b/elections/mixins.py @@ -76,7 +76,7 @@ class CreatorOnlyEditMixin(CreatorOnlyMixin, SingleObjectMixin): return filters -class AdministratorOnlyMixin: +class AdministratorOnlyMixin(LoginRequiredMixin): """Restreint l'accès aux admins""" diff --git a/elections/models.py b/elections/models.py index 0eafee0..58befe9 100644 --- a/elections/models.py +++ b/elections/models.py @@ -34,6 +34,12 @@ class Election(models.Model): 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"] diff --git a/shared/auth/forms.py b/shared/auth/forms.py index a4e8765..be55a01 100644 --- a/shared/auth/forms.py +++ b/shared/auth/forms.py @@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _ class ElectionAuthForm(forms.Form): - """Adapts Django's AuthenticationForm to allow for OldCAS login.""" + """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) @@ -17,7 +17,7 @@ class ElectionAuthForm(forms.Form): super().__init__(*args, **kwargs) def clean(self): - login = self.cleaned_data.get("cas_login") + login = self.cleaned_data.get("login") password = self.cleaned_data.get("password") election_id = self.cleaned_data.get("election_id") diff --git a/shared/auth/views.py b/shared/auth/views.py index e9b0986..831e42a 100644 --- a/shared/auth/views.py +++ b/shared/auth/views.py @@ -45,6 +45,8 @@ class LoginSelectView(NextPageMixin, TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["next"] = self.get_next_url() + context["method"] = self.request.GET.get("method", "CAS") + context["election_id"] = self.request.GET.get("election_id", None) return context @@ -82,7 +84,7 @@ class PasswordLoginView(auth_views.LoginView): authentication_form = ElectionAuthForm def get_initial(self): - return {"election_id": self.request.GET.get("election", None)} + return {"election_id": self.request.GET.get("election_id", None)} class LogoutView(auth_views.LogoutView): diff --git a/shared/templates/auth/login_select.html b/shared/templates/auth/login_select.html index 37d50df..50af8a0 100644 --- a/shared/templates/auth/login_select.html +++ b/shared/templates/auth/login_select.html @@ -1,3 +1,47 @@ {% extends "base.html" %} {% load i18n %} + +{% block content %} + +

{% trans "Choisissez la méthode de connexion" %}

+
+ +{# Indications de connexion #} +
+
+ {% if method == "PWD" %} + {% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide des identifiants reçus par mail. Choisissez la connexion par mot de passe." %} + {% else %} + {% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide du CAS élève." %} + {% endif %} +
+
+ + + + + +{% endblock %} diff --git a/shared/templates/auth/pwd_login.html b/shared/templates/auth/pwd_login.html index c4c0b0f..4819b09 100644 --- a/shared/templates/auth/pwd_login.html +++ b/shared/templates/auth/pwd_login.html @@ -1,2 +1,40 @@ {% extends "base.html" %} {% load i18n %} + + +{% block content %} + +

{% trans "Connexion par mot de passe" %}

+
+ +
+
+
+ {% csrf_token %} + {{ form.errors }} + + {% include "forms/form.html" with errors=True %} + +
+
+ +
+ +
+
+
+
+ +{% endblock %} diff --git a/shared/templates/base.html b/shared/templates/base.html index c46d9eb..4facd59 100644 --- a/shared/templates/base.html +++ b/shared/templates/base.html @@ -42,6 +42,7 @@

Kadenios

+
{% if user.is_authenticated %}
@@ -49,16 +50,24 @@ {% blocktrans with name=user.base_username connection=user.connection_method %}Connecté·e en tant que {{ name }} par {{ connection }}{% endblocktrans %}
+ {% else %} +
- + {% if election %} + + {% else %} + + + + {% endif %}
{% endif %} From a35e81a694998bb0a38a035c6fe06e03ce8a08a8 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Mon, 21 Dec 2020 13:47:56 +0100 Subject: [PATCH 05/23] Small tweaks --- elections/templates/elections/election.html | 2 ++ shared/templates/auth/login_select.html | 2 +- shared/templates/auth/pwd_login.html | 1 - shared/templates/forms/form.html | 3 --- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/elections/templates/elections/election.html b/elections/templates/elections/election.html index a726993..225e8a5 100644 --- a/elections/templates/elections/election.html +++ b/elections/templates/elections/election.html @@ -38,6 +38,7 @@
{# Indications de connexion #} +{% if election.start_date < current_time and election.end_date > current_time %}
{% if election.restricted %} @@ -47,6 +48,7 @@ {% endif %}
+{% endif %} {# Description de l'élection #}
diff --git a/shared/templates/auth/login_select.html b/shared/templates/auth/login_select.html index 50af8a0..eb98e9d 100644 --- a/shared/templates/auth/login_select.html +++ b/shared/templates/auth/login_select.html @@ -12,7 +12,7 @@
{% if method == "PWD" %} {% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide des identifiants reçus par mail. Choisissez la connexion par mot de passe." %} - {% else %} + {% elif method == "CAS" %} {% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide du CAS élève." %} {% endif %}
diff --git a/shared/templates/auth/pwd_login.html b/shared/templates/auth/pwd_login.html index 4819b09..669bf19 100644 --- a/shared/templates/auth/pwd_login.html +++ b/shared/templates/auth/pwd_login.html @@ -11,7 +11,6 @@
{% csrf_token %} - {{ form.errors }} {% include "forms/form.html" with errors=True %} diff --git a/shared/templates/forms/form.html b/shared/templates/forms/form.html index cc312aa..0ab9f16 100644 --- a/shared/templates/forms/form.html +++ b/shared/templates/forms/form.html @@ -1,9 +1,6 @@ {% if errors %} {% if form.non_field_errors %}
-
- -
{% for non_field_error in form.non_field_errors %} {{ non_field_error }} From 16d0d61e39242b90eb0480928f13a79282ad40c6 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Mon, 21 Dec 2020 14:35:12 +0100 Subject: [PATCH 06/23] On utilise un vrai champ mot de passe --- shared/auth/forms.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/shared/auth/forms.py b/shared/auth/forms.py index be55a01..99d972a 100644 --- a/shared/auth/forms.py +++ b/shared/auth/forms.py @@ -8,7 +8,11 @@ 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) + 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): From e7db47116e4be27eeed4c758584a01e41523da62 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Mon, 21 Dec 2020 21:14:52 +0100 Subject: [PATCH 07/23] Template tweaks --- kadenios/settings_base.py | 1 + shared/templates/auth/login_select.html | 6 +++++- shared/templates/auth/pwd_login.html | 2 ++ shared/templates/base.html | 8 ++++++-- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/kadenios/settings_base.py b/kadenios/settings_base.py index 3f5a548..7cb2b32 100644 --- a/kadenios/settings_base.py +++ b/kadenios/settings_base.py @@ -93,6 +93,7 @@ AUTHENTICATION_BACKENDS = [ ] LOGIN_URL = "auth.cas" +LOGIN_REDIRECT_URL = "kadenios" # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ diff --git a/shared/templates/auth/login_select.html b/shared/templates/auth/login_select.html index eb98e9d..af69662 100644 --- a/shared/templates/auth/login_select.html +++ b/shared/templates/auth/login_select.html @@ -2,12 +2,15 @@ {% load i18n %} +{% block auth %}{% endblock %} + {% block content %}

{% trans "Choisissez la méthode de connexion" %}


{# Indications de connexion #} +{% if method %}
{% if method == "PWD" %} @@ -17,6 +20,7 @@ {% endif %}
+{% endif %}
@@ -32,7 +36,7 @@
- + + {% endblock %} {% block layout %}
From 5c7e2238d83bb4a772568555e45dedd86d7318fa Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Tue, 22 Dec 2020 01:17:38 +0100 Subject: [PATCH 08/23] Tweaks on templates and foundation for uploading a file --- elections/forms.py | 4 ++ .../templates/elections/election_admin.html | 20 +++++-- .../templates/elections/upload_voters.html | 60 +++++++++++++++++++ elections/urls.py | 5 ++ elections/views.py | 28 ++++++++- shared/auth/views.py | 2 + shared/templates/auth/logout.html | 12 ++++ shared/templates/base.html | 2 +- shared/templates/forms/file.html | 25 ++++---- 9 files changed, 140 insertions(+), 18 deletions(-) create mode 100644 elections/templates/elections/upload_voters.html create mode 100644 shared/templates/auth/logout.html diff --git a/elections/forms.py b/elections/forms.py index bb04956..363dfbd 100644 --- a/elections/forms.py +++ b/elections/forms.py @@ -24,6 +24,10 @@ class ElectionForm(forms.ModelForm): fields = ["name", "description", "restricted", "start_date", "end_date"] +class UploadVotersForm(forms.Form): + csv_file = forms.FileField(label=_("Sélectionnez un fichier .csv")) + + class QuestionForm(forms.ModelForm): class Meta: model = Question diff --git a/elections/templates/elections/election_admin.html b/elections/templates/elections/election_admin.html index d5d668a..27554fd 100644 --- a/elections/templates/elections/election_admin.html +++ b/elections/templates/elections/election_admin.html @@ -22,9 +22,9 @@
- {% 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 %} + + {% if election.restricted %} + + {% endif %} + {% elif election.end_date < current_time %} - {% if not election.tallied %} {# Lien pour le dépouillement #} + {% if not election.tallied %} - {% else %} {# Lien pour la publication des résultats #} + {% else %}
diff --git a/elections/templates/elections/upload_voters.html b/elections/templates/elections/upload_voters.html new file mode 100644 index 0000000..8f3f8db --- /dev/null +++ b/elections/templates/elections/upload_voters.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% load i18n %} + + +{% block extra_head %} + +{% endblock %} + +{% block content %} + +

{% trans "Importer une liste de votant·e·s" %}

+
+ +
+
+ {% trans "Importez un fichier au format CSV, avec sur la première colonne les prénoms, sur la deuxième le nom et la troisième l'adresse email. Soit :

Prénom_1,Nom_1,mail_1@machin.test
Prénom_2,Nom_2,mail_2@bidule.test
...
" %} +
+
+ +
+ +{% endblock %} diff --git a/elections/urls.py b/elections/urls.py index 24ca25e..370b70c 100644 --- a/elections/urls.py +++ b/elections/urls.py @@ -7,6 +7,11 @@ urlpatterns = [ path("create/", views.ElectionCreateView.as_view(), name="election.create"), path("created/", views.ElectionListView.as_view(), name="election.created"), path("admin/", views.ElectionAdminView.as_view(), name="election.admin"), + path( + "upload-voters/", + views.ElectionUploadVotersView.as_view(), + name="election.upload-voters", + ), path("update/", views.ElectionUpdateView.as_view(), name="election.update"), path("tally/", views.ElectionTallyView.as_view(), name="election.tally"), path( diff --git a/elections/views.py b/elections/views.py index daecd29..3c6976c 100644 --- a/elections/views.py +++ b/elections/views.py @@ -10,12 +10,19 @@ 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 .forms import ( + ElectionForm, + OptionForm, + OptionFormSet, + QuestionForm, + UploadVotersForm, +) from .mixins import CreatorOnlyEditMixin, CreatorOnlyMixin, OpenElectionOnlyMixin from .models import Election, Option, Question @@ -63,11 +70,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 +85,19 @@ class ElectionAdminView(CreatorOnlyMixin, DetailView): return super().get_queryset().prefetch_related("questions__options") +class ElectionUploadVotersView(CreatorOnlyEditMixin, FormView): + model = Election + form_class = UploadVotersForm + template_name = "elections/upload_voters.html" + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + return super().get(request, *args, **kwargs) + + def form_valid(self, form): + pass + + class ElectionListView(CreatorOnlyMixin, ListView): model = Election template_name = "elections/election_list.html" @@ -290,7 +312,7 @@ class VoteView(OpenElectionOnlyMixin, DetailView): model = Question template_name = "elections/vote.html" - def get_newt_url(self): + def get_next_url(self): return reverse("election.view", args=[self.object.election.pk]) def get_success_url(self): diff --git a/shared/auth/views.py b/shared/auth/views.py index 831e42a..1dea011 100644 --- a/shared/auth/views.py +++ b/shared/auth/views.py @@ -94,6 +94,8 @@ class LogoutView(auth_views.LogoutView): logged in via CAS. """ + template_name = "auth/logout.html" + def setup(self, request): super().setup(request) if "CASCONNECTED" in request.session: diff --git a/shared/templates/auth/logout.html b/shared/templates/auth/logout.html new file mode 100644 index 0000000..bfaee74 --- /dev/null +++ b/shared/templates/auth/logout.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% load i18n %} + + +{% block auth %}{% endblock %} + +{% block content %} + +

{% trans "Vous avez bien été déconnecté." %}

+
+ +{% endblock %} diff --git a/shared/templates/base.html b/shared/templates/base.html index 9b1b857..3de407a 100644 --- a/shared/templates/base.html +++ b/shared/templates/base.html @@ -55,7 +55,7 @@
diff --git a/shared/templates/forms/file.html b/shared/templates/forms/file.html index fbabfd5..627fe19 100644 --- a/shared/templates/forms/file.html +++ b/shared/templates/forms/file.html @@ -1,22 +1,27 @@ {% load bulma_utils i18n %}
-

+{% endif %} {# Liste des votant·e·s #} {% if voters %} diff --git a/elections/urls.py b/elections/urls.py index 370b70c..2daf387 100644 --- a/elections/urls.py +++ b/elections/urls.py @@ -7,6 +7,11 @@ urlpatterns = [ path("create/", views.ElectionCreateView.as_view(), name="election.create"), path("created/", views.ElectionListView.as_view(), name="election.created"), path("admin/", views.ElectionAdminView.as_view(), name="election.admin"), + path( + "mail-voters/", + views.ElectionMailVotersView.as_view(), + name="election.mail-voters", + ), path( "upload-voters/", views.ElectionUploadVotersView.as_view(), diff --git a/elections/utils.py b/elections/utils.py index fea3789..8c59921 100644 --- a/elections/utils.py +++ b/elections/utils.py @@ -1,10 +1,15 @@ 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 @@ -72,8 +77,37 @@ def check_csv(csv_file): return errors -def send_mail(election): +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é. """ - pass + 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.base_username, + password=password, + ), + to=[v.email], + ) + ) + get_connection(fail_silently=False).send_messages(messages) + User.objects.bulk_update(voters, ["password"]) diff --git a/elections/views.py b/elections/views.py index 98b26c8..0076354 100644 --- a/elections/views.py +++ b/elections/views.py @@ -22,10 +22,12 @@ from .forms import ( OptionFormSet, QuestionForm, UploadVotersForm, + VoterMailForm, ) from .mixins import CreatorOnlyEditMixin, CreatorOnlyMixin, OpenElectionOnlyMixin from .models import Election, Option, Question -from .utils import create_users +from .staticdefs import MAIL_VOTERS +from .utils import create_users, send_mail # TODO: access control *everywhere* @@ -116,6 +118,33 @@ class ElectionUploadVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormVi 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_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" diff --git a/kadenios/settings_base.py b/kadenios/settings_base.py index 7cb2b32..510de1d 100644 --- a/kadenios/settings_base.py +++ b/kadenios/settings_base.py @@ -109,6 +109,11 @@ USE_L10N = True USE_TZ = True +# Mail configuration + +DEFAULT_FROM_EMAIL = "Kadenios " + + # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.2/howto/static-files/ From 1cafa2aedd747676c7a8fd99475f0212e519714d Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Thu, 24 Dec 2020 01:10:05 +0100 Subject: [PATCH 16/23] Small tweaks and filters --- elections/templates/elections/election_admin.html | 2 +- elections/templates/elections/upload_voters.html | 2 +- elections/views.py | 9 +++++++++ shared/templates/base.html | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/elections/templates/elections/election_admin.html b/elections/templates/elections/election_admin.html index 2449f06..acf2a8c 100644 --- a/elections/templates/elections/election_admin.html +++ b/elections/templates/elections/election_admin.html @@ -4,7 +4,7 @@ {% block content %} -
+
{# Titre de l'élection #}
diff --git a/elections/templates/elections/upload_voters.html b/elections/templates/elections/upload_voters.html index 6798bad..d7873c5 100644 --- a/elections/templates/elections/upload_voters.html +++ b/elections/templates/elections/upload_voters.html @@ -19,7 +19,7 @@ {% block content %} -
+

{% trans "Gestion de la liste de votant·e·s" %}

diff --git a/elections/views.py b/elections/views.py index 0076354..b3a800c 100644 --- a/elections/views.py +++ b/elections/views.py @@ -94,6 +94,10 @@ class ElectionUploadVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormVi 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]) @@ -124,6 +128,11 @@ class ElectionMailVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormView 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]) diff --git a/shared/templates/base.html b/shared/templates/base.html index 3de407a..e1624d6 100644 --- a/shared/templates/base.html +++ b/shared/templates/base.html @@ -79,7 +79,7 @@ {% block layout %}
-
+
{% if messages %} From c7e5d9ad6eaebcb4ce859717dd4399ceb3f430f9 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Thu, 24 Dec 2020 01:41:29 +0100 Subject: [PATCH 17/23] =?UTF-8?q?Enregistre=20les=20votes=20pour=20des=20?= =?UTF-8?q?=C3=A9lections=20et=20des=20questions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- elections/migrations/0007_election_voters.py | 21 ++++++++++++++++++++ elections/migrations/0008_question_voters.py | 21 ++++++++++++++++++++ elections/models.py | 11 +++++++++- elections/views.py | 11 +++++++++- 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 elections/migrations/0007_election_voters.py create mode 100644 elections/migrations/0008_question_voters.py diff --git a/elections/migrations/0007_election_voters.py b/elections/migrations/0007_election_voters.py new file mode 100644 index 0000000..454c60e --- /dev/null +++ b/elections/migrations/0007_election_voters.py @@ -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 + ), + ), + ] diff --git a/elections/migrations/0008_question_voters.py b/elections/migrations/0008_question_voters.py new file mode 100644 index 0000000..b61f737 --- /dev/null +++ b/elections/migrations/0008_question_voters.py @@ -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 + ), + ), + ] diff --git a/elections/models.py b/elections/models.py index 0bdaf03..27b926d 100644 --- a/elections/models.py +++ b/elections/models.py @@ -32,9 +32,13 @@ class Election(models.Model): 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) @@ -58,6 +62,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"] diff --git a/elections/views.py b/elections/views.py index b3a800c..10486ad 100644 --- a/elections/views.py +++ b/elections/views.py @@ -335,7 +335,7 @@ class DelOptionView(CreatorOnlyEditMixin, BackgroundUpdateView): # ############################################################################# -# Common Views +# Public Views # ############################################################################# @@ -364,6 +364,11 @@ class ElectionView(DetailView): ) +class ElectionVotersView(DetailView): + model = Election + template_name = "elections/election_voters.html" + + class VoteView(OpenElectionOnlyMixin, DetailView): model = Question template_name = "elections/vote.html" @@ -405,6 +410,10 @@ class VoteView(OpenElectionOnlyMixin, 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()) From c70fcefa866332d22e263255928ad5f85486b06f Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Thu, 24 Dec 2020 12:53:08 +0100 Subject: [PATCH 18/23] Template tweaks --- elections/forms.py | 2 + .../templates/elections/election_update.html | 46 ++++++++++--------- .../templates/elections/election_voters.html | 8 ++++ .../templates/elections/option_update.html | 46 ++++++++++--------- .../templates/elections/question_update.html | 46 ++++++++++--------- 5 files changed, 85 insertions(+), 63 deletions(-) create mode 100644 elections/templates/elections/election_voters.html diff --git a/elections/forms.py b/elections/forms.py index b25e0ab..9487a16 100644 --- a/elections/forms.py +++ b/elections/forms.py @@ -51,12 +51,14 @@ 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): diff --git a/elections/templates/elections/election_update.html b/elections/templates/elections/election_update.html index 6753b95..1cea140 100644 --- a/elections/templates/elections/election_update.html +++ b/elections/templates/elections/election_update.html @@ -31,29 +31,33 @@

{% trans "Modification d'une élection" %}


-
- {% csrf_token %} +
+
+ + {% csrf_token %} - {% include "forms/form.html" with errors=False %} + {% include "forms/form.html" with errors=False %} -
-
- -
- +
+
+ +
+ +
+
- +
{% endblock %} diff --git a/elections/templates/elections/election_voters.html b/elections/templates/elections/election_voters.html new file mode 100644 index 0000000..6473484 --- /dev/null +++ b/elections/templates/elections/election_voters.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% load i18n %} + + +{% block content %} + + +{% endblock %} diff --git a/elections/templates/elections/option_update.html b/elections/templates/elections/option_update.html index a7ebbb4..a5e5e92 100644 --- a/elections/templates/elections/option_update.html +++ b/elections/templates/elections/option_update.html @@ -13,29 +13,33 @@

{% trans "Modification d'une option" %}


-
- {% csrf_token %} +
+
+ + {% csrf_token %} - {% include "forms/form.html" with errors=False %} + {% include "forms/form.html" with errors=False %} -
-
- -
- +
+
+ +
+ +
+
- +
{% endblock %} diff --git a/elections/templates/elections/question_update.html b/elections/templates/elections/question_update.html index 265d416..bb1009d 100644 --- a/elections/templates/elections/question_update.html +++ b/elections/templates/elections/question_update.html @@ -13,29 +13,33 @@

{% trans "Modification d'une question" %}


-
- {% csrf_token %} +
+
+ + {% csrf_token %} - {% include "forms/form.html" with errors=False %} + {% include "forms/form.html" with errors=False %} -
-
- -
- +
+
+ +
+ +
+
- +
{% endblock %} From 6a59163deaa238369e4a0b7a6b4a48fbb4178a60 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Tue, 26 Jan 2021 14:26:35 +0100 Subject: [PATCH 19/23] On branche authens --- elections/models.py | 3 + kadenios/settings_base.py | 14 ++-- kadenios/urls.py | 1 + shared/auth/backends.py | 72 ++++++------------- shared/auth/forms.py | 11 +++ shared/templates/authens/login_switch.html | 53 ++++++++++++++ shared/templates/authens/pwd_login.html | 56 +++++++++++++++ shared/templates/authens/pwd_reset.html | 42 +++++++++++ .../templates/authens/pwd_reset_confirm.html | 33 +++++++++ shared/templates/base.html | 15 ++-- shared/templates/forms/input.html | 4 +- shared/templates/registration/logged_out.html | 42 +++++++++++ 12 files changed, 280 insertions(+), 66 deletions(-) create mode 100644 shared/templates/authens/login_switch.html create mode 100644 shared/templates/authens/pwd_login.html create mode 100644 shared/templates/authens/pwd_reset.html create mode 100644 shared/templates/authens/pwd_reset_confirm.html create mode 100644 shared/templates/registration/logged_out.html diff --git a/elections/models.py b/elections/models.py index 27b926d..849cb7f 100644 --- a/elections/models.py +++ b/elections/models.py @@ -116,5 +116,8 @@ class User(AbstractUser): def connection_method(self): if self.election is None: + if self.username.split("__")[0] == "pwd": + return _("mot de passe") return _("CAS") + return _("identifiants spécifiques") diff --git a/kadenios/settings_base.py b/kadenios/settings_base.py index 510de1d..8a115c5 100644 --- a/kadenios/settings_base.py +++ b/kadenios/settings_base.py @@ -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 = [ @@ -88,12 +91,15 @@ AUTH_PASSWORD_VALIDATORS = [ AUTH_USER_MODEL = "elections.User" AUTHENTICATION_BACKENDS = [ - "shared.auth.backends.ENSCASBackend", + "shared.auth.backends.PwdBackend", + "shared.auth.backends.CASBackend", "shared.auth.backends.ElectionBackend", ] -LOGIN_URL = "auth.cas" -LOGIN_REDIRECT_URL = "kadenios" +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/ diff --git a/kadenios/urls.py b/kadenios/urls.py index 759e32b..23c2a95 100644 --- a/kadenios/urls.py +++ b/kadenios/urls.py @@ -9,6 +9,7 @@ urlpatterns = [ 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: diff --git a/shared/auth/backends.py b/shared/auth/backends.py index 929b295..7ddbb24 100644 --- a/shared/auth/backends.py +++ b/shared/auth/backends.py @@ -1,62 +1,37 @@ +from authens.backends import ENSCASBackend + from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend -from .utils import get_cas_client - UserModel = get_user_model() -class ENSCASBackend: - """ENS CAS authentication backend. - - Implement standard CAS v3 authentication - """ - - def authenticate(self, request, ticket=None): - cas_client = get_cas_client(request) - cas_login, attributes, _ = cas_client.verify_ticket(ticket) - - if cas_login is None: - # Authentication failed - return None - cas_login = self.clean_cas_login(cas_login) - - if request: - request.session["CASCONNECTED"] = True - - return self._get_or_create(cas_login, attributes) +class CASBackend(ENSCASBackend): + """ENS CAS authentication backend, customized to get the full name at connection.""" def clean_cas_login(self, cas_login): - return cas_login.strip().lower() - - def _get_or_create(self, cas_login, attributes): - """Handles account retrieval and creation for CAS authentication. - - - If no CAS account exists, create one; - - If a matching CAS account exists, retrieve it. - """ + return f"cas__{cas_login.strip().lower()}" + def create_user(self, username, attributes): email = attributes.get("email") name = attributes.get("name") - try: - user = UserModel.objects.get(username=cas_login) - except UserModel.DoesNotExist: - user = None + return UserModel.objects.create_user( + username=username, email=email, full_name=name + ) - if user is None: - user = UserModel.objects.create_user( - username=cas_login, email=email, full_name=name - ) - return user - # Django boilerplate. - def get_user(self, user_id): - try: - return UserModel.objects.get(pk=user_id) - except UserModel.DoesNotExist: +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. @@ -70,17 +45,12 @@ class ElectionBackend(ModelBackend): return None try: - user = UserModel.objects.get(username=f"{election_id}__{login}") + 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 - - # Django boilerplate. - def get_user(self, user_id): - try: - return UserModel.objects.get(pk=user_id) - except UserModel.DoesNotExist: - return None diff --git a/shared/auth/forms.py b/shared/auth/forms.py index 99d972a..a36e046 100644 --- a/shared/auth/forms.py +++ b/shared/auth/forms.py @@ -1,8 +1,11 @@ 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.""" @@ -50,3 +53,11 @@ class ElectionAuthForm(forms.Form): ), 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") diff --git a/shared/templates/authens/login_switch.html b/shared/templates/authens/login_switch.html new file mode 100644 index 0000000..8925cd1 --- /dev/null +++ b/shared/templates/authens/login_switch.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% load i18n %} + + +{% block auth %}{% endblock %} + +{% block content %} + +

{% trans "Choisissez la méthode de connexion" %}

+
+ +{# Indications de connexion #} +{% comment %} +{% if method %} +
+
+ {% if method == "PWD" %} + {% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide des identifiants reçus par mail. Choisissez la connexion par mot de passe." %} + {% elif method == "CAS" %} + {% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide du CAS élève." %} + {% endif %} +
+
+{% endif %} +{% endcomment %} + + + + + +{% endblock %} diff --git a/shared/templates/authens/pwd_login.html b/shared/templates/authens/pwd_login.html new file mode 100644 index 0000000..0028a6b --- /dev/null +++ b/shared/templates/authens/pwd_login.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% load i18n %} + + +{% block auth %}{% endblock %} + +{% block content %} + +

{% trans "Connexion par mot de passe" %}

+
+ +
+
+
+ {% csrf_token %} + + {% include "forms/form.html" with errors=True %} + +
+
+ +
+ + +
+ +
+
+
+ {% trans "Mot de passe oublié :" %} + + {% trans "Réinitialiser mon mot de passe." %} + + + + +
+
+
+
+
+
+ +{% endblock %} diff --git a/shared/templates/authens/pwd_reset.html b/shared/templates/authens/pwd_reset.html new file mode 100644 index 0000000..ecfebda --- /dev/null +++ b/shared/templates/authens/pwd_reset.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% load i18n %} + + +{% block auth %}{% endblock %} + +{% block content %} + +

{% trans "Réinitialisation du mot de passe" %}

+
+ +
+
+
+ {% csrf_token %} + + {% include "forms/form.html" with errors=True %} + +
+
+ +
+ + +
+
+
+
+ +{% endblock %} diff --git a/shared/templates/authens/pwd_reset_confirm.html b/shared/templates/authens/pwd_reset_confirm.html new file mode 100644 index 0000000..77db956 --- /dev/null +++ b/shared/templates/authens/pwd_reset_confirm.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% load i18n %} + + +{% block auth %}{% endblock %} + +{% block content %} + +

{% trans "Réinitialisation du mot de passe" %}

+
+ +
+
+
+ {% csrf_token %} + + {% include "forms/form.html" with errors=True %} + +
+
+ +
+
+
+
+
+ +{% endblock %} diff --git a/shared/templates/base.html b/shared/templates/base.html index e1624d6..b32c4ea 100644 --- a/shared/templates/base.html +++ b/shared/templates/base.html @@ -55,22 +55,19 @@
{% else %}
- {% if election %} - - + + {% trans "Se connecter" %} + + + - {% else %} - - - - {% endif %}
{% endif %}
diff --git a/shared/templates/forms/input.html b/shared/templates/forms/input.html index adf01f6..500ffc2 100644 --- a/shared/templates/forms/input.html +++ b/shared/templates/forms/input.html @@ -12,8 +12,8 @@ {% endfor %} {% if field.help_text %} -

+

{{ field.help_text|safe }} -

+
{% endif %}
diff --git a/shared/templates/registration/logged_out.html b/shared/templates/registration/logged_out.html new file mode 100644 index 0000000..ffeece7 --- /dev/null +++ b/shared/templates/registration/logged_out.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% load i18n %} + + +{% block auth %}{% endblock %} + +{% block content %} + +

{% trans "Déconnexion réussie" %}

+
+ +
+
+
+ {% csrf_token %} + + {% include "forms/form.html" with errors=True %} + + +
+
+
+ +{% endblock %} From a4db79353bd3c7b688e1a8f597a101210ca96c9f Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Tue, 26 Jan 2021 15:35:10 +0100 Subject: [PATCH 20/23] =?UTF-8?q?On=20utilise=20get=5Fusername=20=C3=A0=20?= =?UTF-8?q?la=20place=20de=20base=5Fusername?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- elections/models.py | 5 ++--- elections/templates/elections/upload_voters.html | 2 +- elections/utils.py | 2 +- shared/templates/base.html | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/elections/models.py b/elections/models.py index 849cb7f..7b22daf 100644 --- a/elections/models.py +++ b/elections/models.py @@ -102,9 +102,8 @@ class User(AbstractUser): ) full_name = models.CharField(_("Nom et Prénom"), max_length=150, blank=True) - @property - def base_username(self): - return self.username.split("__")[-1] + def get_username(self): + return "__".join(self.username.split("__")[1:]) def can_vote(self, election): # Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections diff --git a/elections/templates/elections/upload_voters.html b/elections/templates/elections/upload_voters.html index d7873c5..ae7a12d 100644 --- a/elections/templates/elections/upload_voters.html +++ b/elections/templates/elections/upload_voters.html @@ -110,7 +110,7 @@ {% for v in voters %} - {{ v.base_username }} + {{ v.get_username }} {{ v.full_name }} {{ v.email }} diff --git a/elections/utils.py b/elections/utils.py index 8c59921..9beda7d 100644 --- a/elections/utils.py +++ b/elections/utils.py @@ -103,7 +103,7 @@ def send_mail(election, mail_form): body=mail_form.cleaned_data["message"].format( full_name=v.full_name, election_url=url, - username=v.base_username, + username=v.get_username, password=password, ), to=[v.email], diff --git a/shared/templates/base.html b/shared/templates/base.html index b32c4ea..dc74653 100644 --- a/shared/templates/base.html +++ b/shared/templates/base.html @@ -50,7 +50,7 @@ {% if user.is_authenticated %}
- {% blocktrans with name=user.base_username connection=user.connection_method %}Connecté·e en tant que {{ name }} par {{ connection }}{% endblocktrans %} + {% blocktrans with name=user.get_username connection=user.connection_method %}Connecté·e en tant que {{ name }} par {{ connection }}{% endblocktrans %}
From 92fe03d81c682ce87c434526809458e50755fb15 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Wed, 27 Jan 2021 13:52:35 +0100 Subject: [PATCH 21/23] =?UTF-8?q?Portail=20de=20connexion=20pour=20une=20?= =?UTF-8?q?=C3=A9lection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- elections/templates/elections/election.html | 30 ++++- shared/auth/urls.py | 9 +- shared/auth/utils.py | 14 -- shared/auth/views.py | 122 ++---------------- .../{pwd_login.html => election_login.html} | 2 +- shared/templates/auth/login_select.html | 51 -------- shared/templates/auth/logout.html | 12 -- shared/templates/authens/login_switch.html | 16 --- 8 files changed, 45 insertions(+), 211 deletions(-) delete mode 100644 shared/auth/utils.py rename shared/templates/auth/{pwd_login.html => election_login.html} (87%) delete mode 100644 shared/templates/auth/login_select.html delete mode 100644 shared/templates/auth/logout.html diff --git a/elections/templates/elections/election.html b/elections/templates/elections/election.html index 225e8a5..d5931a8 100644 --- a/elections/templates/elections/election.html +++ b/elections/templates/elections/election.html @@ -38,7 +38,7 @@
{# Indications de connexion #} -{% if election.start_date < current_time and election.end_date > current_time %} +{% if election.start_date < current_time and election.end_date > current_time and not can_vote %}
{% if election.restricted %} @@ -48,6 +48,34 @@ {% endif %}
+ +
+
+ +
+
{% endif %} {# Description de l'élection #} diff --git a/shared/auth/urls.py b/shared/auth/urls.py index bac6f01..a5c8cce 100644 --- a/shared/auth/urls.py +++ b/shared/auth/urls.py @@ -3,8 +3,9 @@ from django.urls import path from . import views urlpatterns = [ - path("login/select", views.LoginSelectView.as_view(), name="auth.select"), - path("login/cas", views.CASLoginView.as_view(), name="auth.cas"), - path("login/pwd", views.PasswordLoginView.as_view(), name="auth.pwd"), - path("logout", views.LogoutView.as_view(), name="auth.logout"), + path( + "election//login", + views.ElectionLoginView.as_view(), + name="auth.election", + ), ] diff --git a/shared/auth/utils.py b/shared/auth/utils.py deleted file mode 100644 index 0d92b97..0000000 --- a/shared/auth/utils.py +++ /dev/null @@ -1,14 +0,0 @@ -from urllib.parse import urlunparse - -from cas import CASClient - - -def get_cas_client(request): - """Return a CAS client configured for SPI's CAS.""" - return CASClient( - version=3, - service_url=urlunparse( - (request.scheme, request.get_host(), request.path, "", "", "") - ), - server_url="https://cas.eleves.ens.fr/", - ) diff --git a/shared/auth/views.py b/shared/auth/views.py index 1dea011..b9694a6 100644 --- a/shared/auth/views.py +++ b/shared/auth/views.py @@ -1,121 +1,19 @@ -from urllib.parse import urlparse, urlunparse - -from django.conf import settings -from django.contrib import auth from django.contrib.auth import views as auth_views -from django.core.exceptions import PermissionDenied -from django.shortcuts import redirect -from django.utils.translation import gettext_lazy as _ -from django.views.generic import TemplateView, View from .forms import ElectionAuthForm -from .utils import get_cas_client + +# ############################################################################# +# Election Specific Login +# ############################################################################# -class NextPageMixin: - def get_next_url(self): - """Decide where to go after a successful login. - - Look for (in order): - - a `next` GET parameter; - - a `CASNEXT` session variable; - - the `LOGIN_REDIRECT_URL` django setting. - """ - request = self.request - next_url = request.GET.get("next") - if next_url is None and "CASNEXT" in request.session: - next_url = request.session["CASNEXT"] - del request.session["CASNEXT"] - if next_url is None: - next_url = settings.LOGIN_REDIRECT_URL - return next_url - - -class LoginSelectView(NextPageMixin, TemplateView): - """Simple page letting the user choose between password and CAS authentication.""" - - template_name = "auth/login_select.html" - http_method_names = ["get"] - - def get(self, request, *args, **kwargs): - if request.user.is_authenticated: - return redirect(self.get_next_url()) - return super().get(request, *args, **kwargs) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["next"] = self.get_next_url() - context["method"] = self.request.GET.get("method", "CAS") - context["election_id"] = self.request.GET.get("election_id", None) - - return context - - -class CASLoginView(NextPageMixin, View): - """CAS authentication view. - - Implement the CAS authentication scheme: - - 1. We first redirect the user to the student CAS. - 2. The user comes back with a ticket, we validate it to make sure the user is legit - (validation is delegated to the ENSCASBackend). - 3. We redirect the user to the next page. - """ - - http_method_names = ["get"] - - def get(self, request, *args, **kwargs): - ticket = request.GET.get("ticket") - - if not ticket: - request.session["CASNEXT"] = self.get_next_url() - cas_client = get_cas_client(request) - return redirect(cas_client.get_login_url()) - - user = auth.authenticate(request, ticket=ticket) - if user is None: - raise PermissionDenied(_("Connexion échouée !")) - auth.login(request, user) - return redirect(self.get_next_url()) - - -class PasswordLoginView(auth_views.LoginView): - template_name = "auth/pwd_login.html" +class ElectionLoginView(auth_views.LoginView): + template_name = "auth/election_login.html" authentication_form = ElectionAuthForm def get_initial(self): - return {"election_id": self.request.GET.get("election_id", None)} + return {"election_id": self.kwargs.get("election_id")} - -class LogoutView(auth_views.LogoutView): - """Logout view. - - Tell Django to log the user out, then redirect to the CAS logout page if the user - logged in via CAS. - """ - - template_name = "auth/logout.html" - - def setup(self, request): - super().setup(request) - if "CASCONNECTED" in request.session: - del request.session["CASCONNECTED"] - self.cas_connected = True - else: - self.cas_connected = False - - def get_next_page(self): - next_page = super().get_next_page() - if self.cas_connected: - cas_client = get_cas_client(self.request) - - # If the next_url is local (no hostname), make it absolute so that the user - # is correctly redirected from CAS. - if not urlparse(next_page).netloc: - request = self.request - next_page = urlunparse( - (request.scheme, request.get_host(), next_page, "", "", "") - ) - - next_page = cas_client.get_logout_url(redirect_url=next_page) - return next_page + def get_context_data(self, **kwargs): + kwargs.update({"election_id": self.kwargs.get("election_id")}) + return super().get_context_data(**kwargs) diff --git a/shared/templates/auth/pwd_login.html b/shared/templates/auth/election_login.html similarity index 87% rename from shared/templates/auth/pwd_login.html rename to shared/templates/auth/election_login.html index ea4bf21..fb680f6 100644 --- a/shared/templates/auth/pwd_login.html +++ b/shared/templates/auth/election_login.html @@ -26,7 +26,7 @@
- + diff --git a/shared/templates/auth/login_select.html b/shared/templates/auth/login_select.html deleted file mode 100644 index af69662..0000000 --- a/shared/templates/auth/login_select.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} - - -{% block auth %}{% endblock %} - -{% block content %} - -

{% trans "Choisissez la méthode de connexion" %}

-
- -{# Indications de connexion #} -{% if method %} -
-
- {% if method == "PWD" %} - {% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide des identifiants reçus par mail. Choisissez la connexion par mot de passe." %} - {% elif method == "CAS" %} - {% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide du CAS élève." %} - {% endif %} -
-
-{% endif %} - - -
- - -{% endblock %} diff --git a/shared/templates/auth/logout.html b/shared/templates/auth/logout.html deleted file mode 100644 index bfaee74..0000000 --- a/shared/templates/auth/logout.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} - - -{% block auth %}{% endblock %} - -{% block content %} - -

{% trans "Vous avez bien été déconnecté." %}

-
- -{% endblock %} diff --git a/shared/templates/authens/login_switch.html b/shared/templates/authens/login_switch.html index 8925cd1..d873527 100644 --- a/shared/templates/authens/login_switch.html +++ b/shared/templates/authens/login_switch.html @@ -9,22 +9,6 @@

{% trans "Choisissez la méthode de connexion" %}


-{# Indications de connexion #} -{% comment %} -{% if method %} -
-
- {% if method == "PWD" %} - {% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide des identifiants reçus par mail. Choisissez la connexion par mot de passe." %} - {% elif method == "CAS" %} - {% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide du CAS élève." %} - {% endif %} -
-
-{% endif %} -{% endcomment %} - -
From fd80f23112cb9f0d6a032666ce6b82458b047ab5 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Wed, 27 Jan 2021 14:55:28 +0100 Subject: [PATCH 22/23] Abstract connection method --- elections/models.py | 10 ++++------ elections/staticdefs.py | 7 +++++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/elections/models.py b/elections/models.py index 7b22daf..065558f 100644 --- a/elections/models.py +++ b/elections/models.py @@ -3,6 +3,8 @@ from django.contrib.auth.models import AbstractUser from django.db import models from django.utils.translation import gettext_lazy as _ +from .staticdefs import CONNECTION_METHODS + # ############################################################################# # Models regarding an election # ############################################################################# @@ -114,9 +116,5 @@ class User(AbstractUser): return election.restricted and (self.election == election) def connection_method(self): - if self.election is None: - if self.username.split("__")[0] == "pwd": - return _("mot de passe") - return _("CAS") - - return _("identifiants spécifiques") + method = self.username.split("__")[0] + return CONNECTION_METHODS.get(method, _("identifiants spécifiques")) diff --git a/elections/staticdefs.py b/elections/staticdefs.py index c35708c..6699d54 100644 --- a/elections/staticdefs.py +++ b/elections/staticdefs.py @@ -1,3 +1,5 @@ +from django.utils.translation import gettext_lazy as _ + MAIL_VOTERS = ( "Dear {full_name},\n" "\n" @@ -10,3 +12,8 @@ MAIL_VOTERS = ( "-- \n" "Kadenios" ) + +CONNECTION_METHODS = { + "pwd": _("mot de passe"), + "cas": _("CAS"), +} From ffaf29145ebd52268bb3da219a451a5ad0954080 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Wed, 27 Jan 2021 20:54:23 +0100 Subject: [PATCH 23/23] =?UTF-8?q?Meilleure=20d=C3=A9tection=20de=20connexi?= =?UTF-8?q?on=20par=20CAS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- elections/models.py | 10 ++++++++-- elections/views.py | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/elections/models.py b/elections/models.py index 065558f..1116dd8 100644 --- a/elections/models.py +++ b/elections/models.py @@ -107,14 +107,20 @@ class User(AbstractUser): def get_username(self): return "__".join(self.username.split("__")[1:]) - def can_vote(self, election): + 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: - return not election.restricted + # 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")) diff --git a/elections/views.py b/elections/views.py index 10486ad..b04c7ed 100644 --- a/elections/views.py +++ b/elections/views.py @@ -351,7 +351,7 @@ class ElectionView(DetailView): context = super().get_context_data(**kwargs) context["current_time"] = timezone.now() context["can_vote"] = user.is_authenticated and user.can_vote( - context["election"] + self.request, context["election"] ) return context @@ -392,7 +392,7 @@ class VoteView(OpenElectionOnlyMixin, DetailView): 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(question.election): + if not self.request.user.can_vote(self.request, question.election): raise Http404 return question